@fixback/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # @fixback/cli
2
+
3
+ Fixback's build-time companion. Today it does one thing: **upload your
4
+ production build's sourcemaps** so the Issues Fixback captures on your site
5
+ arrive with a symbolicated **code-area pointer** — the original `file:line`
6
+ (with source context) where the error likely lives, not a minified frame.
7
+
8
+ ```bash
9
+ # after your production build, locally or in CI
10
+ FIXBACK_SECRET_KEY=sk_… npx @fixback/cli sourcemaps upload ./dist
11
+ ```
12
+
13
+ That's the whole integration. If a release has no uploaded sourcemaps, nothing
14
+ changes — the feature is simply off for it.
15
+
16
+ ## How it fits together
17
+
18
+ 1. **Build with sourcemaps.** e.g. Vite: `build: { sourcemap: true }` (or
19
+ `"hidden"` to keep maps out of the served site — the CLI reads them from
20
+ disk either way).
21
+ 2. **Upload them per release.** The CLI scans the build directory for
22
+ `*.js.map` files and uploads each, keyed by a **release** — `--release`,
23
+ else `FIXBACK_RELEASE`, else the current git commit SHA.
24
+ 3. **Tell the SDK the same release:**
25
+
26
+ ```js
27
+ Fixback.init({ key: "pk_…", release: "<the same release>" });
28
+ ```
29
+
30
+ Captured errors then carry that release, and Fixback's analysis
31
+ symbolicates their stack traces against the matching maps.
32
+
33
+ ## Auth
34
+
35
+ Uploads authenticate with your Project's **secret key** (Project settings →
36
+ API keys), via `FIXBACK_SECRET_KEY` or `--key`. It's the server-side
37
+ credential your CI already holds — never ship it in the page (that's what the
38
+ publishable `pk_…` key is for).
39
+
40
+ ## Options
41
+
42
+ | Flag | Default | |
43
+ |---|---|---|
44
+ | `--release <r>` | `$FIXBACK_RELEASE`, else git HEAD SHA | Build identifier (1–100 visible chars, no spaces/slashes) |
45
+ | `--url-prefix <p>` | `~/` | Where bundles are served relative to the site root, e.g. `~/static` |
46
+ | `--api-url <u>` | `$FIXBACK_API_URL`, else the hosted API | Self-hosted deployments point here |
47
+ | `--key <sk_…>` | `$FIXBACK_SECRET_KEY` | Project secret key |
48
+ | `--dry-run` | — | List what would upload; no network, no key needed |
49
+
50
+ ## Notes
51
+
52
+ - Re-running an upload for the same release is safe — artifacts upsert.
53
+ - Retention is bounded: Fixback keeps your newest releases and prunes the
54
+ oldest beyond the per-project cap.
55
+ - Only JS maps (`.js.map` / `.mjs.map` / `.cjs.map`) upload — CSS maps can't
56
+ symbolicate a stack trace.
package/dist/cli.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import { gitHeadSha, runCli } from "./main.js";
3
+ /**
4
+ * The `fixback` bin entry: real deps in, exit code out. All logic lives in
5
+ * `runCli` (tested); this file only binds the process edges.
6
+ */
7
+ runCli(process.argv.slice(2), {
8
+ env: process.env,
9
+ resolveGitSha: gitHeadSha,
10
+ fetch: globalThis.fetch,
11
+ log: (line) => console.log(line),
12
+ error: (line) => console.error(line),
13
+ }).then((code) => process.exit(code), (error) => {
14
+ console.error(error instanceof Error ? error.message : String(error));
15
+ process.exit(1);
16
+ });
package/dist/main.js ADDED
@@ -0,0 +1,127 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { normaliseRelease } from "./release.js";
4
+ import { uploadSourcemaps } from "./upload.js";
5
+ /**
6
+ * The `fixback` CLI (#117, ADR-0024) — Fixback's build-time companion, Sentry-
7
+ * style: run it after your production build (locally or in CI) to ship the
8
+ * build's sourcemaps, and errors captured on that release arrive with a
9
+ * symbolicated code-area pointer.
10
+ *
11
+ * FIXBACK_SECRET_KEY=sk_… npx fixback sourcemaps upload ./dist
12
+ *
13
+ * Everything is injectable (`CliDeps`) so the command is tested without a
14
+ * network, a repo, or real env.
15
+ */
16
+ /** The hosted API origin, mirrored by value from the SDK's `DEFAULT_API_URL`. */
17
+ const DEFAULT_API_URL = "https://api.fixback.dev";
18
+ const USAGE = `fixback — Fixback's build-time CLI
19
+
20
+ Usage:
21
+ fixback sourcemaps upload <dir> [options]
22
+
23
+ Uploads the JS sourcemaps a production build emitted so Fixback can
24
+ symbolicate captured stack traces into code-area pointers (set the same
25
+ release in the SDK: Fixback.init({ release: "<release>" })).
26
+
27
+ Options:
28
+ --release <r> Build identifier (default: $FIXBACK_RELEASE, else the git HEAD SHA)
29
+ --url-prefix <p> Abstract path prefix bundles are served under (default: ~/)
30
+ --api-url <u> Fixback API origin (default: $FIXBACK_API_URL, else ${DEFAULT_API_URL})
31
+ --key <sk_…> Project secret key (default: $FIXBACK_SECRET_KEY)
32
+ --dry-run List what would upload; no network, no key needed
33
+ --help Show this help
34
+ `;
35
+ /** The production `resolveGitSha`: `git rev-parse HEAD`, null on any failure. */
36
+ export async function gitHeadSha() {
37
+ try {
38
+ const { stdout } = await promisify(execFile)("git", ["rev-parse", "HEAD"]);
39
+ const sha = stdout.trim();
40
+ return sha.length > 0 ? sha : null;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ /** Parsed flags: every `--name value` pair (or bare `--flag`), plus positionals. */
47
+ function parseArgs(argv) {
48
+ const positionals = [];
49
+ const flags = new Map();
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const arg = argv[i];
52
+ if (arg.startsWith("--")) {
53
+ const name = arg.slice(2);
54
+ const next = argv[i + 1];
55
+ if (name === "dry-run" || name === "help" || next === undefined || next.startsWith("--")) {
56
+ flags.set(name, true);
57
+ }
58
+ else {
59
+ flags.set(name, next);
60
+ i++;
61
+ }
62
+ }
63
+ else {
64
+ positionals.push(arg);
65
+ }
66
+ }
67
+ return { positionals, flags };
68
+ }
69
+ /** Run the CLI; returns the process exit code. */
70
+ export async function runCli(argv, deps) {
71
+ const { positionals, flags } = parseArgs(argv);
72
+ if (flags.has("help") || positionals.length === 0) {
73
+ deps.log(USAGE);
74
+ return positionals.length === 0 && !flags.has("help") ? 1 : 0;
75
+ }
76
+ const [command, subcommand, dir] = positionals;
77
+ if (command !== "sourcemaps" || subcommand !== "upload") {
78
+ deps.error(`Unknown command: ${positionals.join(" ")}\n\n${USAGE}`);
79
+ return 1;
80
+ }
81
+ if (!dir) {
82
+ deps.error(`Missing <dir> — the build output directory to scan.\n\n${USAGE}`);
83
+ return 1;
84
+ }
85
+ // Release: flag > env > git HEAD SHA (Sentry's propose-version equivalent).
86
+ const flagRelease = flags.get("release");
87
+ const rawRelease = (typeof flagRelease === "string" ? flagRelease : undefined) ??
88
+ deps.env.FIXBACK_RELEASE ??
89
+ (await deps.resolveGitSha()) ??
90
+ undefined;
91
+ if (!rawRelease) {
92
+ deps.error("No release given and no git repository found. Pass --release <r> or set FIXBACK_RELEASE.");
93
+ return 1;
94
+ }
95
+ const release = normaliseRelease(rawRelease);
96
+ if (!release) {
97
+ deps.error(`Invalid release ${JSON.stringify(rawRelease)} — use 1-100 visible characters ` +
98
+ "with no whitespace or slashes (e.g. a git SHA or semver).");
99
+ return 1;
100
+ }
101
+ const dryRun = flags.get("dry-run") === true;
102
+ const flagKey = flags.get("key");
103
+ const secretKey = (typeof flagKey === "string" ? flagKey : undefined) ??
104
+ deps.env.FIXBACK_SECRET_KEY;
105
+ if (!secretKey && !dryRun) {
106
+ deps.error("No secret key. Set FIXBACK_SECRET_KEY (Project settings → API keys → secret key) or pass --key.");
107
+ return 1;
108
+ }
109
+ const flagApiUrl = flags.get("api-url");
110
+ const apiUrl = ((typeof flagApiUrl === "string" ? flagApiUrl : undefined) ??
111
+ deps.env.FIXBACK_API_URL ??
112
+ DEFAULT_API_URL).replace(/\/+$/, "");
113
+ const flagPrefix = flags.get("url-prefix");
114
+ const urlPrefix = typeof flagPrefix === "string" ? flagPrefix : "~/";
115
+ if (!urlPrefix.startsWith("~/") && urlPrefix !== "~") {
116
+ deps.error(`--url-prefix must start with ~/ (got ${JSON.stringify(urlPrefix)}).`);
117
+ return 1;
118
+ }
119
+ return uploadSourcemaps({
120
+ dir,
121
+ release,
122
+ urlPrefix,
123
+ apiUrl,
124
+ secretKey: secretKey ?? "",
125
+ dryRun,
126
+ }, deps);
127
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Release validation, mirrored **by value** from the server's shared rules
3
+ * (`@fixback/shared`'s `isValidRelease`) — the CLI publishes standalone and the
4
+ * shared package is workspace-private, so the slice it needs is vendored, the
5
+ * same rule the SDK follows. Keep in lock-step with the server: 1–100 visible
6
+ * characters, no whitespace / control characters / path separators, not a
7
+ * reserved name.
8
+ */
9
+ const RELEASE_MAX_LENGTH = 100;
10
+ const RESERVED_RELEASES = new Set([".", "..", "latest"]);
11
+ /** Trim + validate a release; `undefined` when unusable. */
12
+ export function normaliseRelease(value) {
13
+ const trimmed = value.trim();
14
+ if (trimmed.length === 0 || trimmed.length > RELEASE_MAX_LENGTH)
15
+ return undefined;
16
+ if (RESERVED_RELEASES.has(trimmed))
17
+ return undefined;
18
+ // eslint-disable-next-line no-control-regex
19
+ if (/[\s/\\\u0000-\u001f\u007f]/.test(trimmed))
20
+ return undefined;
21
+ return trimmed;
22
+ }
package/dist/upload.js ADDED
@@ -0,0 +1,104 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join, relative, sep } from "node:path";
3
+ /**
4
+ * Sourcemap discovery + upload (#117, ADR-0024) — the working half of
5
+ * `fixback sourcemaps upload`. Discovers the `.js.map` files a production build
6
+ * emitted and posts each to the Fixback API (`POST /api/sourcemaps`), keyed by
7
+ * (release, `~/`-abstract bundle path) and authenticated with the Project's
8
+ * secret key. One request per artifact: bounded, retryable, idempotent — a
9
+ * re-run of the same CI job upserts rather than duplicates.
10
+ */
11
+ /** The sourcemap suffixes worth uploading — JS bundles only (CSS maps can't symbolicate a stack). */
12
+ const MAP_SUFFIXES = [".js.map", ".mjs.map", ".cjs.map"];
13
+ /**
14
+ * Walk a build directory for JS sourcemaps. The bundle path is the map's
15
+ * relative path minus `.map` (a `.js.map` maps the `.js` beside it), normalised
16
+ * to POSIX separators — ready to be joined onto the `~/` prefix.
17
+ */
18
+ export async function discoverSourcemaps(dir) {
19
+ const entries = await readdir(dir, { recursive: true, withFileTypes: true });
20
+ const found = [];
21
+ for (const entry of entries) {
22
+ if (!entry.isFile())
23
+ continue;
24
+ if (!MAP_SUFFIXES.some((suffix) => entry.name.endsWith(suffix)))
25
+ continue;
26
+ const filePath = join(entry.parentPath, entry.name);
27
+ const bundleRelative = relative(dir, filePath).split(sep).join("/");
28
+ found.push({
29
+ filePath,
30
+ bundlePath: bundleRelative.slice(0, -".map".length),
31
+ });
32
+ }
33
+ return found.sort((a, b) => a.bundlePath.localeCompare(b.bundlePath));
34
+ }
35
+ /** Join the `~/` URL prefix and a bundle-relative path into the abstract path. */
36
+ export function toArtifactPath(urlPrefix, bundlePath) {
37
+ const prefix = urlPrefix.endsWith("/") ? urlPrefix : `${urlPrefix}/`;
38
+ return `${prefix}${bundlePath}`;
39
+ }
40
+ /** Pull a useful message out of an API error response, best-effort. */
41
+ async function apiErrorMessage(response) {
42
+ try {
43
+ const body = (await response.json());
44
+ if (typeof body.message === "string")
45
+ return body.message;
46
+ if (Array.isArray(body.message))
47
+ return body.message.join("; ");
48
+ }
49
+ catch {
50
+ /* fall through to the status line */
51
+ }
52
+ return response.statusText || `HTTP ${response.status}`;
53
+ }
54
+ /**
55
+ * Upload every discovered map for one release. Returns a process exit code:
56
+ * `0` when everything (or a clean dry run) succeeded, `1` when nothing was
57
+ * found or any upload failed — CI should fail loudly rather than silently
58
+ * shipping a release without symbolication.
59
+ */
60
+ export async function uploadSourcemaps(options, deps) {
61
+ const maps = await discoverSourcemaps(options.dir);
62
+ if (maps.length === 0) {
63
+ deps.error(`No JS sourcemaps found under ${options.dir}. ` +
64
+ "Enable them in your build (e.g. Vite: build.sourcemap = true) and re-run.");
65
+ return 1;
66
+ }
67
+ let failed = 0;
68
+ for (const map of maps) {
69
+ const path = toArtifactPath(options.urlPrefix, map.bundlePath);
70
+ if (options.dryRun) {
71
+ deps.log(`would upload ${map.bundlePath}.map as ${path}`);
72
+ continue;
73
+ }
74
+ const body = new FormData();
75
+ body.set("payload", JSON.stringify({ release: options.release, path }));
76
+ body.set("map", new Blob([await readFile(map.filePath)], { type: "application/json" }), `${map.bundlePath.split("/").pop() ?? "bundle.js"}.map`);
77
+ const response = await deps.fetch(`${options.apiUrl}/api/sourcemaps`, {
78
+ method: "POST",
79
+ headers: { Authorization: `Bearer ${options.secretKey}` },
80
+ body,
81
+ });
82
+ if (response.ok) {
83
+ deps.log(`uploaded ${map.bundlePath}.map as ${path}`);
84
+ }
85
+ else {
86
+ failed += 1;
87
+ deps.error(`failed ${map.bundlePath}.map (${response.status}): ${await apiErrorMessage(response)}`);
88
+ // 401 means every remaining upload will fail identically — stop early.
89
+ if (response.status === 401)
90
+ break;
91
+ }
92
+ }
93
+ if (options.dryRun) {
94
+ deps.log(`Dry run: ${maps.length} sourcemap(s) would upload for release ${options.release}.`);
95
+ return 0;
96
+ }
97
+ if (failed > 0) {
98
+ deps.error(`${failed} upload(s) failed for release ${options.release}.`);
99
+ return 1;
100
+ }
101
+ deps.log(`Uploaded ${maps.length} sourcemap(s) for release ${options.release} — ` +
102
+ "code-area pointers are enabled for errors captured on this release.");
103
+ return 0;
104
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@fixback/cli",
3
+ "version": "0.1.0",
4
+ "description": "Fixback's build-time CLI — upload sourcemaps per release so Issues gain symbolicated code-area pointers.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "fixback": "./dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20.12"
16
+ },
17
+ "devDependencies": {
18
+ "@types/node": "^22.18.13",
19
+ "typescript": "5.9.3",
20
+ "vitest": "^4.1.10"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.build.json",
24
+ "lint": "eslint .",
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "vitest run"
27
+ }
28
+ }