@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.
|
|
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.
|
|
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
|
+
}
|