diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md index 08bfb5476..0f20045e6 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md @@ -52,7 +52,7 @@ Upload sourcemaps to Sentry | Argument | Description | |----------|-------------| -| `` | Directory containing sourcemaps | +| `` | Directory containing sourcemaps, or a glob like 'dist/**' | **Flags:** - `--release - Release version to associate with the upload` @@ -72,6 +72,9 @@ Upload sourcemaps to Sentry # Upload sourcemaps from dist/ sentry sourcemap upload ./dist +# Upload only the files a glob matches +sentry sourcemap upload '.next/static/**' + # Associate with a release sentry sourcemap upload ./dist --release 1.0.0 diff --git a/packages/cli/src/commands/sourcemap/upload.ts b/packages/cli/src/commands/sourcemap/upload.ts index 3739c5292..9a8849a61 100644 --- a/packages/cli/src/commands/sourcemap/upload.ts +++ b/packages/cli/src/commands/sourcemap/upload.ts @@ -37,6 +37,7 @@ import { discoverFilePairs, injectDirectory, type InjectResult, + resolveUploadTarget, } from "../../lib/sourcemap/inject.js"; /** Result type for the upload command. */ @@ -227,6 +228,10 @@ export const uploadCommand = buildCommand({ description: "Upload sourcemaps from dist/", command: "sentry sourcemap upload ./dist", }, + { + description: "Upload only the files a glob matches", + command: "sentry sourcemap upload '.next/static/**'", + }, { description: "Associate with a release", command: "sentry sourcemap upload ./dist --release 1.0.0", @@ -261,7 +266,7 @@ export const uploadCommand = buildCommand({ kind: "tuple", parameters: [ { - brief: "Directory containing sourcemaps", + brief: "Directory containing sourcemaps, or a glob like 'dist/**'", parse: parseDirectoryPath, placeholder: "directory", }, @@ -355,7 +360,8 @@ export const uploadCommand = buildCommand({ // Validate the directory and discover pairs read-only first so we // don't write debug IDs when the upload won't proceed (empty dir, // typoed path, missing credentials). - await assertDirectoryReadable(dir); + const target = await resolveUploadTarget(dir); + await assertDirectoryReadable(target.dir); const extensions = flags.ext?.split(",").map((e) => e.trim()); const extSet = extensions @@ -370,11 +376,19 @@ export const uploadCommand = buildCommand({ flags["ignore-file"], ); - const pairs = await discoverFilePairs(dir, extSet, ignoreMatcher); + const pairs = await discoverFilePairs( + target.dir, + extSet, + ignoreMatcher, + target.include, + ); if (pairs.length === 0) { if (!flags["allow-empty"]) { - const diag = await diagnoseEmptyDiscovery(dir, { extensions }); + const diag = await diagnoseEmptyDiscovery(target.dir, { + extensions, + include: target.include, + }); throw buildEmptyDiscoveryError(dir, diag); } // --allow-empty: nothing to upload, so don't require Sentry @@ -416,15 +430,16 @@ export const uploadCommand = buildCommand({ injected: false, debugId: "", })) - : await injectDirectory(dir, { + : await injectDirectory(target.dir, { extensions, ignoreMatcher, + include: target.include, }); const urlPrefix = flags["url-prefix"] ?? "~/"; // Build artifact file list with paths relative to the upload directory - const resolvedDir = resolve(dir); + const resolvedDir = resolve(target.dir); // Normalize --strip-prefix to end with "/" so it strips at directory // boundaries. Without this, "build" would strip from "build/app.js" // leaving "/app.js" instead of "app.js". diff --git a/packages/cli/src/lib/sourcemap/inject.ts b/packages/cli/src/lib/sourcemap/inject.ts index a8c405275..6972e6929 100644 --- a/packages/cli/src/lib/sourcemap/inject.ts +++ b/packages/cli/src/lib/sourcemap/inject.ts @@ -8,6 +8,7 @@ import { open, readFile, stat } from "node:fs/promises"; import { dirname, relative, resolve as resolvePath, sep } from "node:path"; import ignore from "ignore"; +import picomatch from "picomatch"; import { NODE_MODULES_DIRNAME } from "../constants.js"; import { ValidationError } from "../errors.js"; import { logger } from "../logger.js"; @@ -76,8 +77,74 @@ export type InjectDirectoryOptions = { ignorePatterns?: string[]; /** Pre-built ignore matcher (takes precedence over ignorePatterns). */ ignoreMatcher?: ReturnType; + /** Keep only pairs this filter accepts. See {@link UploadTarget.include}. */ + include?: PathFilter; }; +/** + * Predicate over a POSIX path relative to the scanned directory. + * Returns `true` to keep the file. + */ +export type PathFilter = (relativePath: string) => boolean; + +/** + * A directory to scan, resolved from a `sourcemap upload` target. + */ +export type UploadTarget = { + /** Directory to walk. */ + dir: string; + /** + * Set only when the target was a glob such as `.next/static/**`. A pair is + * kept when the glob matches its JS file or its external `.map` file, so + * both `dist/*.js` and `dist/*.map` select the same pairs. + * + * Hidden directories are walked and left to the glob, which matches a dot + * segment only when the pattern names it, like the `glob` package the + * bundler plugins expand `sourcemaps.assets` with. + */ + include?: PathFilter; +}; + +/** + * Resolve a `sourcemap upload` target into a directory plus an optional + * glob filter. + * + * Bundler plugins pass their `sourcemaps.assets` option straight through, + * and that option is documented as a glob (`.next/static/**`). The walk + * starts at the glob's static base (`.next/static`) and the rest of the + * pattern filters the pairs. + * + * A path that exists as a directory always wins, so a literal directory + * with glob characters in its name (Next.js `app/[locale]`) still works. + * + * @param target - Directory path or glob pattern from the command line + * @returns The directory to scan and, for globs, the filter to apply + */ +export async function resolveUploadTarget( + target: string, +): Promise { + const scan = picomatch.scan(target); + if (!scan.isGlob || (await isDirectory(target))) { + return { dir: target }; + } + return { dir: scan.base || ".", include: picomatch(scan.glob) }; +} + +/** Whether `path` exists and is a directory. */ +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch (err) { + log.debug(`could not stat upload target ${path}`, err); + return false; + } +} + +/** POSIX path of `path` relative to `absDir`, for glob and ignore matching. */ +function relativePosix(absDir: string, path: string): string { + return relative(absDir, path).replaceAll("\\", "/"); +} + /** * Scan a directory for JS + sourcemap pairs and inject debug IDs. * @@ -98,7 +165,12 @@ export async function injectDirectory( const ig = options.ignoreMatcher ?? (await buildIgnoreMatcher(options.ignorePatterns)); - const filePairs = await discoverFilePairs(dir, extensions, ig); + const filePairs = await discoverFilePairs( + dir, + extensions, + ig, + options.include, + ); const results: InjectResult[] = []; for (const { jsPath, map } of filePairs) { @@ -568,6 +640,7 @@ export async function discoverFilePairs( dir: string, extensions: Set = DEFAULT_EXTENSIONS, ignoreMatcher?: ReturnType, + include?: PathFilter, ): Promise { // `walkFiles` requires an absolute cwd. CLI callers pass // user-supplied positional args like `./dist` directly through to @@ -579,21 +652,26 @@ export async function discoverFilePairs( cwd: absDir, extensions, alwaysSkipDirs: SOURCEMAP_SKIP_DIRS, - hidden: false, + hidden: include !== undefined, respectGitignore: false, maxFileSize: Number.POSITIVE_INFINITY, })) { - if (ignoreMatcher) { - // Use POSIX relative path for gitignore-style matching - const rel = relative(absDir, entry.absolutePath).replaceAll("\\", "/"); - if (ignoreMatcher.ignores(rel)) { - continue; - } + const rel = relativePosix(absDir, entry.absolutePath); + if (ignoreMatcher?.ignores(rel)) { + continue; } const map = await findCompanionMap(entry.absolutePath); - if (map && isMapInsideDir(map, absDir, entry.absolutePath)) { - pairs.push({ jsPath: entry.absolutePath, map }); + if (!(map && isMapInsideDir(map, absDir, entry.absolutePath))) { + continue; + } + if ( + include && + !include(rel) && + !(map.kind === "external" && include(relativePosix(absDir, map.mapPath))) + ) { + continue; } + pairs.push({ jsPath: entry.absolutePath, map }); } return pairs; } @@ -691,11 +769,21 @@ export async function diagnoseEmptyDiscovery( cwd: absDir, extensions, alwaysSkipDirs: SOURCEMAP_SKIP_DIRS, - hidden: false, + hidden: options.include !== undefined, respectGitignore: false, maxFileSize: Number.POSITIVE_INFINITY, })) { - if (entry.absolutePath.endsWith(".map")) { + const isMap = entry.absolutePath.endsWith(".map"); + if (options.include) { + const rel = relativePosix(absDir, entry.absolutePath); + const matched = + options.include(rel) || + (isMap && options.include(rel.slice(0, -".map".length))); + if (!matched) { + continue; + } + } + if (isMap) { mapFiles += 1; } else { jsFiles += 1; diff --git a/packages/cli/test/commands/sourcemap/upload.test.ts b/packages/cli/test/commands/sourcemap/upload.test.ts index cfb2a1fc0..8dc6770ad 100644 --- a/packages/cli/test/commands/sourcemap/upload.test.ts +++ b/packages/cli/test/commands/sourcemap/upload.test.ts @@ -602,6 +602,97 @@ describe("sourcemap upload command — --allow-empty behavior", () => { } }); + describe("glob target", () => { + const emptyMap = JSON.stringify({ + version: 3, + sources: [], + names: [], + mappings: "", + }); + + /** Write `.js` and `.js.map` under `dir`. */ + async function writePair(rel: string): Promise { + await mkdir(join(dir, rel, ".."), { recursive: true }); + await writeFile(join(dir, `${rel}.js`), "console.log(1)\n"); + await writeFile(join(dir, `${rel}.js.map`), emptyMap); + } + + /** Run the upload and return the uploaded artifact URLs. */ + async function uploadedUrls(target: string): Promise { + const uploadSpy = vi + .spyOn(sourcemapsApi, "uploadSourcemaps") + .mockResolvedValue(undefined); + try { + await func.call(makeContext(), {}, target); + return (uploadSpy.mock.calls[0]?.[0]?.files ?? []).map( + (f) => f.url ?? "", + ); + } finally { + uploadSpy.mockRestore(); + } + } + + test("uploads every pair under the glob's base directory", async () => { + await writePair("static/chunks/app"); + await writePair("server/page"); + + const urls = await uploadedUrls(join(dir, "static", "**")); + + expect(urls.sort()).toEqual(["~/chunks/app.js", "~/chunks/app.js.map"]); + }); + + test("filters pairs by the glob, matching JS or map files", async () => { + await writePair("static/app"); + await writePair("static/vendor"); + + expect(await uploadedUrls(join(dir, "static", "app*.js"))).toEqual([ + "~/app.js", + "~/app.js.map", + ]); + expect(await uploadedUrls(join(dir, "static", "**", "app*.map"))).toEqual( + ["~/app.js", "~/app.js.map"], + ); + }); + + test("matches hidden directories only when the glob names them", async () => { + await writePair("app/.next/static/chunk"); + await writePair("app/dist/main"); + + expect( + (await uploadedUrls(join(dir, "**", ".next", "**"))).sort(), + ).toEqual([ + "~/app/.next/static/chunk.js", + "~/app/.next/static/chunk.js.map", + ]); + expect((await uploadedUrls(join(dir, "**", "*.js"))).sort()).toEqual([ + "~/app/dist/main.js", + "~/app/dist/main.js.map", + ]); + }); + + test("a directory whose name looks like a glob is used as-is", async () => { + await writePair("[locale]/page"); + + const urls = await uploadedUrls(join(dir, "[locale]")); + + expect(urls.sort()).toEqual(["~/page.js", "~/page.js.map"]); + }); + + test("missing glob base: reports the base directory", async () => { + await expect( + func.call(makeContext(), {}, join(dir, "missing", "**")), + ).rejects.toThrow(`Directory '${join(dir, "missing")}' does not exist.`); + }); + + test("glob that matches no pairs: throws the zero-pairs error", async () => { + await writePair("static/app"); + + await expect( + func.call(makeContext(), {}, join(dir, "static", "*.mjs")), + ).rejects.toThrow(/contains no JS or sourcemap files/); + }); + }); + test("--ignore-file with non-existent file: throws ValidationError", async () => { await writeFile(join(dir, "app.js"), "console.log(1)\n"); await writeFile(