From c8d042783c5dbe65a0b3de49e6d6b610b8c80e14 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:42:17 +0000 Subject: [PATCH 1/2] fix(cli): Accept glob patterns as the sourcemap upload target Bundler plugins pass sourcemaps.assets straight to `sourcemap upload`, and that option is documented as a glob. A target like '.next/static/**' failed with "Directory '.next/static/**' does not exist." (CLI-1FN). Walk the glob's static base and keep the pairs whose JS or map file the rest of the pattern matches. An existing directory is still used as-is, so names like '[locale]' keep working. Co-Authored-By: Miguel Betegon --- .../skills/sentry-cli/references/sourcemap.md | 5 +- packages/cli/src/commands/sourcemap/upload.ts | 27 ++++- packages/cli/src/lib/sourcemap/inject.ts | 104 ++++++++++++++++-- .../test/commands/sourcemap/upload.test.ts | 75 +++++++++++++ 4 files changed, 194 insertions(+), 17 deletions(-) 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..4fc6771e9 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,70 @@ 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. + */ + 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 +161,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 +636,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 @@ -583,17 +652,22 @@ export async function discoverFilePairs( 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; } @@ -695,7 +769,17 @@ export async function diagnoseEmptyDiscovery( 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..52b1cc31d 100644 --- a/packages/cli/test/commands/sourcemap/upload.test.ts +++ b/packages/cli/test/commands/sourcemap/upload.test.ts @@ -602,6 +602,81 @@ 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("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( From a1cdf0c89c7d0294a08f074cc89f254a673a90d2 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:57:30 +0000 Subject: [PATCH 2/2] fix(cli): Walk hidden directories for glob upload targets A glob whose wildcard comes before a dot segment, like '**/.next/**', could never match: its base is '.', and the walk skipped hidden directories before the glob saw them. Walk them for glob targets and let the glob decide, so dot segments match only when the pattern names them, the same as the glob package the bundler plugins use. --- packages/cli/src/lib/sourcemap/inject.ts | 8 ++++++-- .../cli/test/commands/sourcemap/upload.test.ts | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lib/sourcemap/inject.ts b/packages/cli/src/lib/sourcemap/inject.ts index 4fc6771e9..6972e6929 100644 --- a/packages/cli/src/lib/sourcemap/inject.ts +++ b/packages/cli/src/lib/sourcemap/inject.ts @@ -97,6 +97,10 @@ export type UploadTarget = { * 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; }; @@ -648,7 +652,7 @@ export async function discoverFilePairs( cwd: absDir, extensions, alwaysSkipDirs: SOURCEMAP_SKIP_DIRS, - hidden: false, + hidden: include !== undefined, respectGitignore: false, maxFileSize: Number.POSITIVE_INFINITY, })) { @@ -765,7 +769,7 @@ export async function diagnoseEmptyDiscovery( cwd: absDir, extensions, alwaysSkipDirs: SOURCEMAP_SKIP_DIRS, - hidden: false, + hidden: options.include !== undefined, respectGitignore: false, maxFileSize: Number.POSITIVE_INFINITY, })) { diff --git a/packages/cli/test/commands/sourcemap/upload.test.ts b/packages/cli/test/commands/sourcemap/upload.test.ts index 52b1cc31d..8dc6770ad 100644 --- a/packages/cli/test/commands/sourcemap/upload.test.ts +++ b/packages/cli/test/commands/sourcemap/upload.test.ts @@ -654,6 +654,22 @@ describe("sourcemap upload command — --allow-empty behavior", () => { ); }); + 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");