@decocms/blocks-cli 7.20.5 → 7.20.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.20.5",
3
+ "version": "7.20.6",
4
4
  "type": "module",
5
5
  "description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
6
6
  "repository": {
@@ -30,7 +30,7 @@
30
30
  "lint:unused": "knip"
31
31
  },
32
32
  "dependencies": {
33
- "@decocms/blocks": "7.20.5",
33
+ "@decocms/blocks": "7.20.6",
34
34
  "ts-morph": "^27.0.0",
35
35
  "tsx": "^4.22.5"
36
36
  },
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isActionOrLoaderPath, scanFileForSecrets } from "./audit-secrets";
3
+
4
+ // All fixtures use a synthetic sentinel — never a real credential.
5
+ const FAKE = "FAKE0SECRET0SENTINEL0abcdef1234";
6
+
7
+ describe("isActionOrLoaderPath", () => {
8
+ it("matches Fresh- and TanStack-style action/loader paths", () => {
9
+ expect(isActionOrLoaderPath("actions/foo.ts")).toBe(true);
10
+ expect(isActionOrLoaderPath("src/actions/foo.ts")).toBe(true);
11
+ expect(isActionOrLoaderPath("src/loaders/bar/baz.ts")).toBe(true);
12
+ });
13
+ it("ignores non-action/loader paths", () => {
14
+ expect(isActionOrLoaderPath("src/sections/Hero.tsx")).toBe(false);
15
+ expect(isActionOrLoaderPath("src/components/actionsMenu.tsx")).toBe(false);
16
+ });
17
+ });
18
+
19
+ describe("scanFileForSecrets — hardcoded credential in an action", () => {
20
+ it("flags a hardcoded Bearer token", () => {
21
+ const src = `export default async function h() {
22
+ const headers = { Authorization: "Bearer ${FAKE}" };
23
+ return fetch("https://api.example.com", { headers });
24
+ }`;
25
+ const f = scanFileForSecrets("src/actions/simulate.ts", src);
26
+ expect(f).toHaveLength(1);
27
+ expect(f[0].id).toBe("hardcoded_bearer_token");
28
+ expect(f[0].severity).toBe("error");
29
+ expect(f[0].line).toBe(2);
30
+ });
31
+
32
+ it("flags a keyed secret literal (apiKey/token/secret)", () => {
33
+ const src = `const apiKey = "${FAKE}";\n`;
34
+ const f = scanFileForSecrets("actions/pay.ts", src);
35
+ expect(f.map((x) => x.id)).toContain("hardcoded_secret_literal");
36
+ });
37
+
38
+ it("does NOT flag env/context/interpolated reads", () => {
39
+ const src = `const headers = {
40
+ Authorization: \`Bearer \${process.env.API_TOKEN}\`,
41
+ apiKey: ctx.state.config.apiKey,
42
+ token: process.env.TOKEN,
43
+ };`;
44
+ expect(scanFileForSecrets("src/actions/ok.ts", src)).toHaveLength(0);
45
+ });
46
+
47
+ it("does NOT flag obvious placeholders", () => {
48
+ const src = `const token = "changeme";\nconst apiKey = "xxxxxxxxxxxx";\n`;
49
+ expect(scanFileForSecrets("src/actions/ph.ts", src)).toHaveLength(0);
50
+ });
51
+
52
+ it("does NOT scan hardcoded literals OUTSIDE actions/loaders", () => {
53
+ const src = `const apiKey = "${FAKE}";\n`;
54
+ expect(scanFileForSecrets("src/sections/Hero.tsx", src)).toHaveLength(0);
55
+ });
56
+ });
57
+
58
+ describe("scanFileForSecrets — server-only crypto in a client module", () => {
59
+ it('flags @decocms/blocks/sdk/crypto imported in a "use client" file', () => {
60
+ const src = `"use client";
61
+ import { resolveSecret } from "@decocms/blocks/sdk/crypto";
62
+ export function C() { return null; }`;
63
+ const f = scanFileForSecrets("src/components/C.tsx", src);
64
+ expect(f).toHaveLength(1);
65
+ expect(f[0].id).toBe("crypto_imported_in_client");
66
+ expect(f[0].severity).toBe("error");
67
+ });
68
+
69
+ it("does NOT flag crypto imported from a server module", () => {
70
+ const src = `import { resolveSecret } from "@decocms/blocks/sdk/crypto";
71
+ export function config() {}`;
72
+ expect(scanFileForSecrets("src/setup.ts", src)).toHaveLength(0);
73
+ });
74
+ });
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * @decocms/blocks-cli — hardcoded-secret audit
4
+ *
5
+ * Read-only source scanner. Catches the two ways a credential ends up
6
+ * publicly downloadable from a storefront's client assets:
7
+ *
8
+ * 1. A secret literal hardcoded in a site `action`/`loader` module. In a
9
+ * TanStack Start build these modules are registered in `.deco/loaders.gen.ts`
10
+ * behind a dynamic `import()`, and that file is reachable from the client
11
+ * entry (router -> setup -> commerce-loaders -> loaders.gen). Unless the
12
+ * framework stubs loaders.gen on the client (it now does — see
13
+ * packages/tanstack/src/vite/plugin.js), Vite emits each action/loader as a
14
+ * public chunk, baking the literal into the browser bundle. Even with the
15
+ * stub, a hardcoded credential in source is a committed-secret problem, so
16
+ * we flag it at author time.
17
+ *
18
+ * 2. An import of `@decocms/blocks/sdk/crypto` (the secret-decryption SDK)
19
+ * from a file marked `"use client"`. crypto is server-only; pulling it into
20
+ * a client module drags decryption code toward the browser bundle.
21
+ *
22
+ * The scanner intentionally only inspects `actions/` and `loaders/` directories
23
+ * for rule (1) — that's where request-time credentials live and where the
24
+ * bundle-leak path exists. Rule (2) applies to any file.
25
+ *
26
+ * CI-friendly: mirrors audit-observability-config.ts (Severity/Finding, --json,
27
+ * --mode warn|block, --github, exit 0/1/2).
28
+ *
29
+ * Usage (from a site or package directory):
30
+ * tsx audit-secrets.ts --source ./src
31
+ * tsx audit-secrets.ts --source ./src --mode block # exit 1 on error findings
32
+ * tsx audit-secrets.ts --json
33
+ * tsx audit-secrets.ts --github
34
+ *
35
+ * Exit codes:
36
+ * 0 — no findings, or `--mode warn` (default) regardless of findings
37
+ * 1 — `--mode block` and at least one `error`-severity finding
38
+ * 2 — source directory missing
39
+ */
40
+
41
+ import * as fs from "node:fs";
42
+ import * as path from "node:path";
43
+
44
+ export type Severity = "error" | "warn" | "info";
45
+
46
+ export interface Finding {
47
+ id: string;
48
+ severity: Severity;
49
+ /** Repo-relative file the finding is in. */
50
+ file: string;
51
+ /** 1-indexed line the finding anchors to. */
52
+ line: number;
53
+ message: string;
54
+ /** Suggested remediation. */
55
+ fix?: string;
56
+ }
57
+
58
+ export type GateMode = "warn" | "block";
59
+
60
+ // A file is in scope for the hardcoded-secret rule if it lives under an
61
+ // actions/ or loaders/ directory (Fresh `actions/foo.ts` or TanStack
62
+ // `src/actions/foo.ts` both match on the path segment).
63
+ export function isActionOrLoaderPath(relPath: string): boolean {
64
+ const norm = relPath.replaceAll("\\", "/");
65
+ return /(^|\/)(actions|loaders)\//.test(norm);
66
+ }
67
+
68
+ // Keys whose value being a plain string literal is a strong secret signal.
69
+ const SECRET_KEY =
70
+ "(?:api[_-]?key|token|secret|password|passwd|authorization|auth[_-]?token|access[_-]?token|client[_-]?secret|private[_-]?key)";
71
+
72
+ // A quoted literal with no `${` interpolation and a token-ish payload.
73
+ // Requires length >= 12 to avoid flagging short placeholders like "changeme".
74
+ const TOKENISH = "[A-Za-z0-9][A-Za-z0-9._\\-+/=]{11,}";
75
+
76
+ const BEARER_RE = new RegExp(`["'\`]\\s*Bearer\\s+(${TOKENISH})\\s*["'\`]`, "i");
77
+ const KEYED_SECRET_RE = new RegExp(`\\b${SECRET_KEY}\\b\\s*[:=]\\s*["'\`](${TOKENISH})["'\`]`, "i");
78
+
79
+ // Values that look like a real literal but are safe: obvious placeholders.
80
+ const PLACEHOLDER_RE =
81
+ /^(?:x+|y+|z+|changeme|placeholder|example|your[_-]?\w+|todo|xxx+|test|dummy|none|null|undefined)$/i;
82
+
83
+ /**
84
+ * Scan a single file's source for hardcoded credentials + client crypto import.
85
+ * Pure — exported for unit testing.
86
+ */
87
+ export function scanFileForSecrets(relPath: string, content: string): Finding[] {
88
+ const findings: Finding[] = [];
89
+ const lines = content.split("\n");
90
+
91
+ // Rule 2: crypto import in a "use client" file (any path).
92
+ const isClient = /^\s*["']use client["'];?\s*$/m.test(content);
93
+ if (isClient) {
94
+ lines.forEach((ln, i) => {
95
+ if (
96
+ /from\s+["']@decocms\/blocks\/sdk\/crypto["']/.test(ln) ||
97
+ /import\s*\(\s*["']@decocms\/blocks\/sdk\/crypto["']\s*\)/.test(ln)
98
+ ) {
99
+ findings.push({
100
+ id: "crypto_imported_in_client",
101
+ severity: "error",
102
+ file: relPath,
103
+ line: i + 1,
104
+ message:
105
+ "`@decocms/blocks/sdk/crypto` (server-only secret decryption) is imported from a " +
106
+ '"use client" module. Decrypt secrets server-side (in an app configure()/loader) and ' +
107
+ "never in client-bundled code.",
108
+ fix: "Move the secret resolution to a server-only module (action/loader/app config).",
109
+ });
110
+ }
111
+ });
112
+ }
113
+
114
+ // Rule 1: hardcoded secret literal in an action/loader.
115
+ if (isActionOrLoaderPath(relPath)) {
116
+ lines.forEach((ln, i) => {
117
+ for (const [id, re, label] of [
118
+ ["hardcoded_bearer_token", BEARER_RE, "a Bearer token"],
119
+ ["hardcoded_secret_literal", KEYED_SECRET_RE, "a credential"],
120
+ ] as const) {
121
+ const m = re.exec(ln);
122
+ if (!m) continue;
123
+ const value = m[1];
124
+ if (PLACEHOLDER_RE.test(value)) continue;
125
+ // The regexes only match a literal token (the charset excludes `${}`),
126
+ // so `Bearer ${t}` / `token: process.env.X` never reach here. Both rules
127
+ // therefore only fire on a genuinely hardcoded string.
128
+ findings.push({
129
+ id,
130
+ severity: "error",
131
+ file: relPath,
132
+ line: i + 1,
133
+ message:
134
+ `Hardcoded ${label} in an action/loader. In a TanStack build this source can be ` +
135
+ "emitted as a public client chunk, exposing the credential in the browser assets. " +
136
+ "It is also a committed secret regardless of bundling.",
137
+ fix: "Move the value to an environment variable / CMS Secret and read it via ctx or process.env; then rotate the exposed credential.",
138
+ });
139
+ }
140
+ });
141
+ }
142
+
143
+ return findings;
144
+ }
145
+
146
+ const SKIP_DIRS = new Set([
147
+ "node_modules",
148
+ ".git",
149
+ "dist",
150
+ "build",
151
+ ".deco",
152
+ ".vite",
153
+ ".cache",
154
+ "coverage",
155
+ ]);
156
+ const SCAN_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mts", ".cts"]);
157
+
158
+ function walk(dir: string, out: string[]): void {
159
+ let entries: fs.Dirent[];
160
+ try {
161
+ entries = fs.readdirSync(dir, { withFileTypes: true });
162
+ } catch {
163
+ return;
164
+ }
165
+ for (const e of entries) {
166
+ const full = path.join(dir, e.name);
167
+ if (e.isDirectory()) {
168
+ if (SKIP_DIRS.has(e.name)) continue;
169
+ walk(full, out);
170
+ } else if (e.isFile() && SCAN_EXT.has(path.extname(e.name))) {
171
+ // Don't scan test files or the scanner's own fixtures.
172
+ if (/\.test\.[cm]?[jt]sx?$/.test(e.name)) continue;
173
+ out.push(full);
174
+ }
175
+ }
176
+ }
177
+
178
+ /** Scan a directory tree. Pure-ish (reads the filesystem); returns findings. */
179
+ export function auditSourceDir(sourceDir: string): Finding[] {
180
+ const files: string[] = [];
181
+ walk(sourceDir, files);
182
+ const findings: Finding[] = [];
183
+ for (const full of files) {
184
+ const rel = path.relative(sourceDir, full);
185
+ let content: string;
186
+ try {
187
+ content = fs.readFileSync(full, "utf8");
188
+ } catch {
189
+ continue;
190
+ }
191
+ findings.push(...scanFileForSecrets(rel, content));
192
+ }
193
+ return findings;
194
+ }
195
+
196
+ interface CliOpts {
197
+ source: string;
198
+ json: boolean;
199
+ help: boolean;
200
+ mode: GateMode;
201
+ github: boolean;
202
+ }
203
+
204
+ function parseArgs(argv: string[]): CliOpts {
205
+ const opts: CliOpts = { source: ".", json: false, help: false, mode: "warn", github: false };
206
+ for (let i = 0; i < argv.length; i++) {
207
+ const flag = argv[i];
208
+ switch (flag) {
209
+ case "--source":
210
+ opts.source = argv[++i] ?? ".";
211
+ break;
212
+ case "--json":
213
+ opts.json = true;
214
+ break;
215
+ case "--mode": {
216
+ const value = argv[++i];
217
+ if (value !== "warn" && value !== "block") {
218
+ console.error(`audit-secrets: --mode must be "warn" or "block" (got "${value ?? ""}")`);
219
+ process.exit(2);
220
+ }
221
+ opts.mode = value;
222
+ break;
223
+ }
224
+ case "--github":
225
+ opts.github = true;
226
+ break;
227
+ case "--help":
228
+ case "-h":
229
+ opts.help = true;
230
+ break;
231
+ }
232
+ }
233
+ return opts;
234
+ }
235
+
236
+ function showHelp(): void {
237
+ console.log(`
238
+ @decocms/blocks-cli — hardcoded-secret audit
239
+
240
+ Read-only scan for credentials hardcoded in action/loader source and for
241
+ server-only crypto imported into "use client" modules.
242
+
243
+ Usage:
244
+ tsx audit-secrets.ts [options]
245
+
246
+ Options:
247
+ --source <dir> Directory to scan (default: .)
248
+ --json Emit findings as JSON
249
+ --mode <m> "warn" (default, exit 0) | "block" (exit 1 on errors)
250
+ --github Emit ::error::/::warning:: lines for GitHub Actions
251
+ --help, -h This message
252
+
253
+ Exit codes:
254
+ 0 no findings, OR --mode warn (default)
255
+ 1 --mode block AND at least one error-severity finding
256
+ 2 source directory missing
257
+ `);
258
+ }
259
+
260
+ function findingsToText(findings: Finding[]): string {
261
+ if (findings.length === 0) return "OK no hardcoded secrets found";
262
+ const lines = ["Hardcoded-secret findings:"];
263
+ for (const f of findings) {
264
+ lines.push(` [${f.severity.toUpperCase()}] ${f.id} — ${f.file}:${f.line}`);
265
+ lines.push(` ${f.message}`);
266
+ if (f.fix) lines.push(` fix: ${f.fix}`);
267
+ }
268
+ lines.push("");
269
+ return lines.join("\n");
270
+ }
271
+
272
+ function main(): void {
273
+ const opts = parseArgs(process.argv.slice(2));
274
+ if (opts.help) {
275
+ showHelp();
276
+ process.exit(0);
277
+ }
278
+
279
+ const dir = path.resolve(opts.source);
280
+ if (!fs.existsSync(dir)) {
281
+ console.error(`audit-secrets: ${dir} not found`);
282
+ process.exit(2);
283
+ }
284
+
285
+ const findings = auditSourceDir(dir);
286
+
287
+ if (opts.json) {
288
+ process.stdout.write(
289
+ JSON.stringify({ source: dir, mode: opts.mode, findings }, null, 2) + "\n",
290
+ );
291
+ } else {
292
+ process.stdout.write(findingsToText(findings) + "\n");
293
+ }
294
+
295
+ if (opts.github) {
296
+ for (const f of findings) {
297
+ const level =
298
+ opts.mode === "block" && f.severity === "error"
299
+ ? "error"
300
+ : f.severity === "info"
301
+ ? "notice"
302
+ : "warning";
303
+ const msg = `${f.message}${f.fix ? ` (fix: ${f.fix})` : ""}`;
304
+ const escaped = msg.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
305
+ process.stdout.write(`::${level} file=${f.file},line=${f.line},title=${f.id}::${escaped}\n`);
306
+ }
307
+ }
308
+
309
+ const shouldFail = opts.mode === "block" && findings.some((f) => f.severity === "error");
310
+ process.exit(shouldFail ? 1 : 0);
311
+ }
312
+
313
+ const isCjsEntry =
314
+ typeof require !== "undefined" && typeof module !== "undefined" && require.main === module;
315
+ let isEsmEntry = false;
316
+ try {
317
+ isEsmEntry =
318
+ typeof process !== "undefined" &&
319
+ Array.isArray(process.argv) &&
320
+ process.argv[1] !== undefined &&
321
+ import.meta.url === `file://${process.argv[1]}`;
322
+ } catch {
323
+ // ignore in CJS
324
+ }
325
+ if (isCjsEntry || isEsmEntry) {
326
+ main();
327
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ applyDepBumps,
4
+ isAffectedSite,
5
+ planDepBumps,
6
+ type SitePkg,
7
+ } from "./upgrade-loader-leak-fix";
8
+
9
+ function pkg(over: Partial<SitePkg> = {}): SitePkg {
10
+ return {
11
+ dependencies: {
12
+ "@decocms/blocks": "^7.20.2",
13
+ "@decocms/tanstack": "^7.20.2",
14
+ "@decocms/apps-vtex": "^7.20.2",
15
+ react: "19.0.0",
16
+ "@tanstack/react-start": "1.166.8",
17
+ },
18
+ devDependencies: {
19
+ "@decocms/blocks-cli": "^7.20.2",
20
+ typescript: "^5.9.0",
21
+ },
22
+ ...over,
23
+ };
24
+ }
25
+
26
+ describe("isAffectedSite", () => {
27
+ it("true when @decocms/tanstack is a dependency", () => {
28
+ expect(isAffectedSite(pkg())).toBe(true);
29
+ });
30
+ it("false for a non-deco or non-tanstack site", () => {
31
+ expect(isAffectedSite({ dependencies: { react: "19.0.0" } })).toBe(false);
32
+ expect(isAffectedSite({ dependencies: { "@decocms/nextjs": "^7.20.2" } })).toBe(false);
33
+ });
34
+ });
35
+
36
+ describe("planDepBumps", () => {
37
+ it("bumps every @decocms/* dep across deps + devDeps, leaving others alone", () => {
38
+ const bumps = planDepBumps(pkg(), "^7.21.0");
39
+ const names = bumps.map((b) => b.name).sort();
40
+ expect(names).toEqual([
41
+ "@decocms/apps-vtex",
42
+ "@decocms/blocks",
43
+ "@decocms/blocks-cli",
44
+ "@decocms/tanstack",
45
+ ]);
46
+ // non-@decocms deps are never touched
47
+ expect(names).not.toContain("react");
48
+ expect(names).not.toContain("@tanstack/react-start");
49
+ expect(names).not.toContain("typescript");
50
+ // devDeps are covered
51
+ expect(bumps.find((b) => b.name === "@decocms/blocks-cli")?.field).toBe("devDependencies");
52
+ });
53
+
54
+ it("returns nothing when already at the target spec (idempotent)", () => {
55
+ const bumped = applyDepBumps(pkg(), planDepBumps(pkg(), "^7.21.0"));
56
+ expect(planDepBumps(bumped, "^7.21.0")).toHaveLength(0);
57
+ });
58
+ });
59
+
60
+ describe("applyDepBumps", () => {
61
+ it("rewrites only @decocms/* specs and does not mutate the input", () => {
62
+ const original = pkg();
63
+ const next = applyDepBumps(original, planDepBumps(original, "^7.21.0"));
64
+ expect(next.dependencies!["@decocms/tanstack"]).toBe("^7.21.0");
65
+ expect(next.dependencies!["@decocms/apps-vtex"]).toBe("^7.21.0");
66
+ expect(next.devDependencies!["@decocms/blocks-cli"]).toBe("^7.21.0");
67
+ // untouched
68
+ expect(next.dependencies!.react).toBe("19.0.0");
69
+ // input unchanged
70
+ expect(original.dependencies!["@decocms/tanstack"]).toBe("^7.20.2");
71
+ });
72
+ });
@@ -0,0 +1,440 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * @decocms/blocks-cli — loader/action bundle-leak rollout
4
+ *
5
+ * Updates one or more already-in-prod TanStack Start sites so site
6
+ * loaders/actions (and any credential hardcoded in them) stop being emitted
7
+ * into the public client bundle.
8
+ *
9
+ * The actual fix ships in the framework: `@decocms/tanstack`'s vite plugin
10
+ * stubs `.deco/loaders.gen.ts` on the client, so once a site is on a fixed
11
+ * version its NEXT build is safe — no site source change is required. This
12
+ * script therefore just:
13
+ *
14
+ * 1. bumps every `@decocms/*` dependency to the fixed version,
15
+ * 2. installs (updates the lockfile),
16
+ * 3. audits the site source for credentials hardcoded in actions/loaders
17
+ * (these must be ROTATED by hand — a leaked secret stays compromised even
18
+ * after it stops shipping), and
19
+ * 4. optionally rebuilds and greps the client output for a known leaked
20
+ * value to confirm it's gone.
21
+ *
22
+ * It never deploys. After a green run, redeploy with your normal pipeline
23
+ * (e.g. `wrangler deploy`).
24
+ *
25
+ * Usage:
26
+ * tsx upgrade-loader-leak-fix.ts [siteDir...] [options]
27
+ * tsx upgrade-loader-leak-fix.ts ./site-a ./site-b --version 7.21.0
28
+ * tsx upgrade-loader-leak-fix.ts . --dry-run
29
+ * tsx upgrade-loader-leak-fix.ts . --build --verify-secret "$LEAKED_TOKEN"
30
+ *
31
+ * Options:
32
+ * --version <v> Target @decocms/* version (e.g. 7.21.0). Default: resolve
33
+ * the latest published @decocms/tanstack via `npm view`.
34
+ * --exact Pin exact ("7.21.0") instead of caret ("^7.21.0").
35
+ * --pm <bun|npm|pnpm> Package manager. Default: detect from lockfile.
36
+ * --no-install Don't run install after bumping package.json.
37
+ * --build Run the site's build script after install.
38
+ * --verify-secret <s> After --build, fail if <s> appears in the client output.
39
+ * Supply your known-leaked token at runtime; never commit it.
40
+ * --dry-run Show what would change; write/run nothing.
41
+ * --json Machine-readable summary.
42
+ * --help, -h This message.
43
+ *
44
+ * Exit codes:
45
+ * 0 every site updated cleanly (any hardcoded-secret findings are reported
46
+ * as warnings — rotation is a manual step this script can't do)
47
+ * 1 at least one site failed (install/build/verify error) OR --verify-secret
48
+ * was still present in a client build
49
+ * 2 bad arguments / no site found
50
+ */
51
+
52
+ import { execFileSync } from "node:child_process";
53
+ import * as fs from "node:fs";
54
+ import * as path from "node:path";
55
+ import { auditSourceDir, type Finding } from "./audit-secrets";
56
+
57
+ export interface SitePkg {
58
+ dependencies?: Record<string, string>;
59
+ devDependencies?: Record<string, string>;
60
+ scripts?: Record<string, string>;
61
+ [k: string]: unknown;
62
+ }
63
+
64
+ const DECO_SCOPE = /^@decocms\//;
65
+
66
+ /** A site is affected if it depends on the TanStack framework binding. */
67
+ export function isAffectedSite(pkg: SitePkg): boolean {
68
+ const all = { ...pkg.dependencies, ...pkg.devDependencies };
69
+ return Boolean(all["@decocms/tanstack"]);
70
+ }
71
+
72
+ export interface DepBump {
73
+ name: string;
74
+ field: "dependencies" | "devDependencies";
75
+ from: string;
76
+ to: string;
77
+ }
78
+
79
+ /**
80
+ * Compute the version-spec changes needed to move every `@decocms/*` dep to
81
+ * `spec`. Pure — exported for testing. Only returns entries that actually change.
82
+ */
83
+ export function planDepBumps(pkg: SitePkg, spec: string): DepBump[] {
84
+ const bumps: DepBump[] = [];
85
+ for (const field of ["dependencies", "devDependencies"] as const) {
86
+ const deps = pkg[field];
87
+ if (!deps) continue;
88
+ for (const [name, from] of Object.entries(deps)) {
89
+ if (!DECO_SCOPE.test(name)) continue;
90
+ if (from === spec) continue;
91
+ bumps.push({ name, field, from, to: spec });
92
+ }
93
+ }
94
+ return bumps;
95
+ }
96
+
97
+ /** Apply bumps in place on a cloned pkg. Pure — returns the new object. */
98
+ export function applyDepBumps(pkg: SitePkg, bumps: DepBump[]): SitePkg {
99
+ const next: SitePkg = JSON.parse(JSON.stringify(pkg));
100
+ for (const b of bumps) {
101
+ const deps = next[b.field] as Record<string, string> | undefined;
102
+ if (deps) deps[b.name] = b.to;
103
+ }
104
+ return next;
105
+ }
106
+
107
+ type PkgManager = "bun" | "npm" | "pnpm";
108
+
109
+ function detectPm(siteDir: string): PkgManager {
110
+ if (
111
+ fs.existsSync(path.join(siteDir, "bun.lock")) ||
112
+ fs.existsSync(path.join(siteDir, "bun.lockb"))
113
+ ) {
114
+ return "bun";
115
+ }
116
+ if (fs.existsSync(path.join(siteDir, "pnpm-lock.yaml"))) return "pnpm";
117
+ return "npm";
118
+ }
119
+
120
+ function run(cmd: string, args: string[], cwd: string): void {
121
+ execFileSync(cmd, args, { cwd, stdio: "inherit" });
122
+ }
123
+
124
+ /** Resolve the latest published version of a package via `npm view`. */
125
+ function resolveLatest(pkgName: string): string {
126
+ const out = execFileSync("npm", ["view", pkgName, "version"], {
127
+ encoding: "utf8",
128
+ }).trim();
129
+ if (!/^\d+\.\d+\.\d+/.test(out)) {
130
+ throw new Error(`could not resolve latest version of ${pkgName} (got "${out}")`);
131
+ }
132
+ return out;
133
+ }
134
+
135
+ // Directories a Vite/TanStack-Start + CF Workers build may emit client assets to.
136
+ const CLIENT_OUTPUT_DIRS = ["dist", ".output", "build", ".vercel/output", ".wrangler"];
137
+ const CLIENT_ASSET_EXT = new Set([".js", ".mjs", ".cjs", ".css", ".html", ".json", ".map"]);
138
+
139
+ /** Recursively collect built client asset files under a site dir. */
140
+ function collectClientAssets(siteDir: string): string[] {
141
+ const out: string[] = [];
142
+ const walk = (dir: string) => {
143
+ let entries: fs.Dirent[];
144
+ try {
145
+ entries = fs.readdirSync(dir, { withFileTypes: true });
146
+ } catch {
147
+ return;
148
+ }
149
+ for (const e of entries) {
150
+ const full = path.join(dir, e.name);
151
+ if (e.isDirectory()) {
152
+ if (e.name === "node_modules" || e.name === ".git") continue;
153
+ walk(full);
154
+ } else if (e.isFile() && CLIENT_ASSET_EXT.has(path.extname(e.name))) {
155
+ out.push(full);
156
+ }
157
+ }
158
+ };
159
+ for (const d of CLIENT_OUTPUT_DIRS) {
160
+ const full = path.join(siteDir, d);
161
+ if (fs.existsSync(full)) walk(full);
162
+ }
163
+ return out;
164
+ }
165
+
166
+ /** Grep built client assets for a literal. Returns matching file paths. */
167
+ export function findSecretInAssets(files: string[], secret: string): string[] {
168
+ const hits: string[] = [];
169
+ for (const f of files) {
170
+ let content: string;
171
+ try {
172
+ content = fs.readFileSync(f, "utf8");
173
+ } catch {
174
+ continue;
175
+ }
176
+ if (content.includes(secret)) hits.push(f);
177
+ }
178
+ return hits;
179
+ }
180
+
181
+ export interface SiteResult {
182
+ site: string;
183
+ affected: boolean;
184
+ bumps: DepBump[];
185
+ secretFindings: Finding[];
186
+ built: boolean;
187
+ leakedAssetFiles: string[];
188
+ error?: string;
189
+ }
190
+
191
+ interface CliOpts {
192
+ sites: string[];
193
+ version: string | null;
194
+ exact: boolean;
195
+ pm: PkgManager | null;
196
+ install: boolean;
197
+ build: boolean;
198
+ verifySecret: string | null;
199
+ dryRun: boolean;
200
+ json: boolean;
201
+ help: boolean;
202
+ }
203
+
204
+ function parseArgs(argv: string[]): CliOpts {
205
+ const opts: CliOpts = {
206
+ sites: [],
207
+ version: null,
208
+ exact: false,
209
+ pm: null,
210
+ install: true,
211
+ build: false,
212
+ verifySecret: null,
213
+ dryRun: false,
214
+ json: false,
215
+ help: false,
216
+ };
217
+ for (let i = 0; i < argv.length; i++) {
218
+ const a = argv[i];
219
+ switch (a) {
220
+ case "--version":
221
+ opts.version = argv[++i] ?? null;
222
+ break;
223
+ case "--exact":
224
+ opts.exact = true;
225
+ break;
226
+ case "--pm": {
227
+ const v = argv[++i];
228
+ if (v !== "bun" && v !== "npm" && v !== "pnpm") {
229
+ console.error(`upgrade: --pm must be bun|npm|pnpm (got "${v ?? ""}")`);
230
+ process.exit(2);
231
+ }
232
+ opts.pm = v;
233
+ break;
234
+ }
235
+ case "--no-install":
236
+ opts.install = false;
237
+ break;
238
+ case "--build":
239
+ opts.build = true;
240
+ break;
241
+ case "--verify-secret":
242
+ opts.verifySecret = argv[++i] ?? null;
243
+ break;
244
+ case "--dry-run":
245
+ opts.dryRun = true;
246
+ break;
247
+ case "--json":
248
+ opts.json = true;
249
+ break;
250
+ case "--help":
251
+ case "-h":
252
+ opts.help = true;
253
+ break;
254
+ default:
255
+ if (a.startsWith("--")) {
256
+ console.error(`upgrade: unknown option "${a}"`);
257
+ process.exit(2);
258
+ }
259
+ opts.sites.push(a);
260
+ }
261
+ }
262
+ if (opts.sites.length === 0) opts.sites.push(".");
263
+ return opts;
264
+ }
265
+
266
+ function showHelp(): void {
267
+ console.log(`
268
+ @decocms/blocks-cli — loader/action bundle-leak rollout
269
+
270
+ Bumps @decocms/* to the fixed framework version, installs, audits for
271
+ hardcoded credentials, and (optionally) rebuilds + verifies the client
272
+ output no longer contains a known leaked value. Never deploys.
273
+
274
+ Usage:
275
+ tsx upgrade-loader-leak-fix.ts [siteDir...] [options]
276
+
277
+ Options:
278
+ --version <v> Target @decocms/* version (default: latest published)
279
+ --exact Pin exact instead of caret
280
+ --pm <bun|npm|pnpm> Package manager (default: detect)
281
+ --no-install Skip install
282
+ --build Run the site build after install
283
+ --verify-secret <s> Fail if <s> appears in the client build (supply at runtime)
284
+ --dry-run Show changes; write/run nothing
285
+ --json Machine-readable summary
286
+ --help, -h This message
287
+ `);
288
+ }
289
+
290
+ function upgradeSite(siteDir: string, opts: CliOpts, spec: string): SiteResult {
291
+ const result: SiteResult = {
292
+ site: siteDir,
293
+ affected: false,
294
+ bumps: [],
295
+ secretFindings: [],
296
+ built: false,
297
+ leakedAssetFiles: [],
298
+ };
299
+
300
+ const pkgPath = path.join(siteDir, "package.json");
301
+ if (!fs.existsSync(pkgPath)) {
302
+ result.error = "no package.json";
303
+ return result;
304
+ }
305
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as SitePkg;
306
+
307
+ result.affected = isAffectedSite(pkg);
308
+ if (!result.affected) return result; // not a TanStack deco site — skip
309
+
310
+ // 1. Plan + apply dependency bumps.
311
+ result.bumps = planDepBumps(pkg, spec);
312
+ if (result.bumps.length > 0 && !opts.dryRun) {
313
+ const next = applyDepBumps(pkg, result.bumps);
314
+ fs.writeFileSync(pkgPath, JSON.stringify(next, null, 2) + "\n");
315
+ }
316
+
317
+ // 2. Install (updates the lockfile so the fixed framework is resolved).
318
+ const pm = opts.pm ?? detectPm(siteDir);
319
+ if (result.bumps.length > 0 && opts.install && !opts.dryRun) {
320
+ run(pm, ["install"], siteDir);
321
+ }
322
+
323
+ // 3. Audit source for hardcoded credentials — must be rotated manually.
324
+ // Scan the whole site tree (root actions/ + src/).
325
+ result.secretFindings = auditSourceDir(siteDir);
326
+
327
+ // 4. Optional rebuild + verify the leaked value is gone from the client output.
328
+ if (opts.build && !opts.dryRun) {
329
+ run(pm, ["run", "build"], siteDir);
330
+ result.built = true;
331
+ if (opts.verifySecret) {
332
+ const assets = collectClientAssets(siteDir);
333
+ result.leakedAssetFiles = findSecretInAssets(assets, opts.verifySecret);
334
+ }
335
+ }
336
+
337
+ return result;
338
+ }
339
+
340
+ function printReport(results: SiteResult[]): void {
341
+ for (const r of results) {
342
+ console.log(`\n── ${r.site} ──`);
343
+ if (r.error) {
344
+ console.log(` ERROR: ${r.error}`);
345
+ continue;
346
+ }
347
+ if (!r.affected) {
348
+ console.log(" skipped — not a @decocms/tanstack site");
349
+ continue;
350
+ }
351
+ if (r.bumps.length === 0) {
352
+ console.log(" deps already at target version");
353
+ } else {
354
+ console.log(` bumped ${r.bumps.length} @decocms/* dep(s):`);
355
+ for (const b of r.bumps) console.log(` ${b.name}: ${b.from} -> ${b.to}`);
356
+ }
357
+ if (r.built) {
358
+ console.log(" rebuilt client");
359
+ if (r.leakedAssetFiles.length > 0) {
360
+ console.log(` ✗ LEAK STILL PRESENT in ${r.leakedAssetFiles.length} asset(s):`);
361
+ for (const f of r.leakedAssetFiles) console.log(` ${f}`);
362
+ } else {
363
+ console.log(" ✓ verify-secret not found in client build");
364
+ }
365
+ }
366
+ const errors = r.secretFindings.filter((f) => f.severity === "error");
367
+ if (errors.length > 0) {
368
+ console.log(` ⚠ ${errors.length} hardcoded-credential finding(s) — ROTATE these:`);
369
+ for (const f of errors) console.log(` ${f.file}:${f.line} — ${f.id}`);
370
+ }
371
+ }
372
+ }
373
+
374
+ function main(): void {
375
+ const opts = parseArgs(process.argv.slice(2));
376
+ if (opts.help) {
377
+ showHelp();
378
+ process.exit(0);
379
+ }
380
+
381
+ // Resolve the target spec.
382
+ let version = opts.version;
383
+ if (!version) {
384
+ try {
385
+ version = resolveLatest("@decocms/tanstack");
386
+ console.log(`Resolved latest @decocms/tanstack: ${version}`);
387
+ } catch (e) {
388
+ console.error(`upgrade: ${(e as Error).message}. Pass --version explicitly.`);
389
+ process.exit(2);
390
+ }
391
+ }
392
+ const spec = opts.exact ? version : `^${version.replace(/^[\^~]/, "")}`;
393
+
394
+ const results: SiteResult[] = [];
395
+ for (const site of opts.sites) {
396
+ const dir = path.resolve(site);
397
+ try {
398
+ results.push(upgradeSite(dir, opts, spec));
399
+ } catch (e) {
400
+ results.push({
401
+ site: dir,
402
+ affected: true,
403
+ bumps: [],
404
+ secretFindings: [],
405
+ built: false,
406
+ leakedAssetFiles: [],
407
+ error: (e as Error).message,
408
+ });
409
+ }
410
+ }
411
+
412
+ if (opts.json) {
413
+ process.stdout.write(JSON.stringify({ spec, dryRun: opts.dryRun, results }, null, 2) + "\n");
414
+ } else {
415
+ printReport(results);
416
+ console.log(
417
+ "\nNext steps: rotate any flagged credential, then redeploy each site " +
418
+ "with your pipeline (e.g. `wrangler deploy`).",
419
+ );
420
+ }
421
+
422
+ const failed = results.some((r) => r.error || r.leakedAssetFiles.length > 0);
423
+ process.exit(failed ? 1 : 0);
424
+ }
425
+
426
+ const isCjsEntry =
427
+ typeof require !== "undefined" && typeof module !== "undefined" && require.main === module;
428
+ let isEsmEntry = false;
429
+ try {
430
+ isEsmEntry =
431
+ typeof process !== "undefined" &&
432
+ Array.isArray(process.argv) &&
433
+ process.argv[1] !== undefined &&
434
+ import.meta.url === `file://${process.argv[1]}`;
435
+ } catch {
436
+ // ignore in CJS
437
+ }
438
+ if (isCjsEntry || isEsmEntry) {
439
+ main();
440
+ }