Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Upload sourcemaps to Sentry

| Argument | Description |
|----------|-------------|
| `<directory>` | Directory containing sourcemaps |
| `<directory>` | Directory containing sourcemaps, or a glob like 'dist/**' |

**Flags:**
- `--release <value> - Release version to associate with the upload`
Expand All @@ -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

Expand Down
27 changes: 21 additions & 6 deletions packages/cli/src/commands/sourcemap/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
discoverFilePairs,
injectDirectory,
type InjectResult,
resolveUploadTarget,
} from "../../lib/sourcemap/inject.js";

/** Result type for the upload command. */
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
},
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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".
Expand Down
112 changes: 100 additions & 12 deletions packages/cli/src/lib/sourcemap/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -76,8 +77,74 @@ export type InjectDirectoryOptions = {
ignorePatterns?: string[];
/** Pre-built ignore matcher (takes precedence over ignorePatterns). */
ignoreMatcher?: ReturnType<typeof ignore>;
/** 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<UploadTarget> {
const scan = picomatch.scan(target);
if (!scan.isGlob || (await isDirectory(target))) {
return { dir: target };
}
return { dir: scan.base || ".", include: picomatch(scan.glob) };
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

/** Whether `path` exists and is a directory. */
async function isDirectory(path: string): Promise<boolean> {
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.
*
Expand All @@ -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) {
Expand Down Expand Up @@ -568,6 +640,7 @@ export async function discoverFilePairs(
dir: string,
extensions: Set<string> = DEFAULT_EXTENSIONS,
ignoreMatcher?: ReturnType<typeof ignore>,
include?: PathFilter,
): Promise<FilePair[]> {
// `walkFiles` requires an absolute cwd. CLI callers pass
// user-supplied positional args like `./dist` directly through to
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
91 changes: 91 additions & 0 deletions packages/cli/test/commands/sourcemap/upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,97 @@ describe("sourcemap upload command — --allow-empty behavior", () => {
}
});

describe("glob target", () => {
const emptyMap = JSON.stringify({
version: 3,
sources: [],
names: [],
mappings: "",
});

/** Write `<rel>.js` and `<rel>.js.map` under `dir`. */
async function writePair(rel: string): Promise<void> {
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<string[]> {
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(
Expand Down
Loading