@decocms/blocks-cli 8.0.0 → 8.1.0-next.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/package.json +5 -3
- package/scripts/analyze-traces.mjs +1 -0
- package/scripts/cdn-rules.test.ts +105 -0
- package/scripts/cdn-rules.ts +192 -0
- package/scripts/deco-migrate-cli.ts +6 -7
- package/scripts/fast-deploy-kv.test.ts +172 -0
- package/scripts/generate-blocks.test.ts +11 -0
- package/scripts/generate-blocks.ts +0 -10
- package/scripts/generate-invoke.test.ts +2 -2
- package/scripts/generate-invoke.ts +9 -21
- package/scripts/generate-loaders.test.ts +2 -2
- package/scripts/generate-loaders.ts +0 -10
- package/scripts/generate-schema-loader-aliases.test.ts +186 -0
- package/scripts/generate-schema-workspaces.test.ts +180 -0
- package/scripts/generate-schema.test.ts +68 -29
- package/scripts/generate-schema.ts +101 -89
- package/scripts/generate-sections.test.ts +104 -35
- package/scripts/generate-sections.ts +57 -11
- package/scripts/generate-workspaces.test.ts +179 -0
- package/scripts/generate.test.ts +1 -2
- package/scripts/generate.ts +43 -45
- package/scripts/lib/blocks-dedupe.test.ts +1 -1
- package/scripts/lib/cf-kv-rest.ts +49 -1
- package/scripts/lib/installedPackages.ts +42 -0
- package/scripts/lib/invokeSource.ts +18 -0
- package/scripts/lib/kv-snapshot.ts +44 -0
- package/scripts/lib/read-decofile.ts +13 -2
- package/scripts/lib/upgrade-6-to-7.ts +1 -1
- package/scripts/lib/wrangler-config.test.ts +49 -0
- package/scripts/lib/wrangler-config.ts +31 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +6 -6
- package/scripts/migrate/analyzers/tailwind-config.ts +95 -1
- package/scripts/migrate/config.ts +2 -2
- package/scripts/migrate/delete-sets.ts +75 -0
- package/scripts/migrate/fast-deploy-scaffold.test.ts +130 -0
- package/scripts/migrate/icon-casing.test.ts +63 -0
- package/scripts/migrate/phase-analyze.test.ts +66 -4
- package/scripts/migrate/phase-analyze.ts +79 -60
- package/scripts/migrate/phase-cleanup.test.ts +3 -1
- package/scripts/migrate/phase-cleanup.ts +1 -1
- package/scripts/migrate/phase-compile.test.ts +3 -1
- package/scripts/migrate/phase-report.test.ts +2 -0
- package/scripts/migrate/phase-report.ts +3 -4
- package/scripts/migrate/phase-scaffold.ts +150 -45
- package/scripts/migrate/phase-transform.ts +107 -2
- package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +13 -0
- package/scripts/migrate/phase-verify.test.ts +3 -1
- package/scripts/migrate/phase-verify.ts +87 -33
- package/scripts/migrate/post-cleanup/rules.ts +29 -11
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +1 -1
- package/scripts/migrate/post-cleanup/shim-classify.ts +2 -2
- package/scripts/migrate/source-layout.test.ts +43 -0
- package/scripts/migrate/source-layout.ts +32 -5
- package/scripts/migrate/templates/app-css.test.ts +24 -0
- package/scripts/migrate/templates/app-css.ts +27 -0
- package/scripts/migrate/templates/ci-workflows.test.ts +179 -0
- package/scripts/migrate/templates/ci-yml.ts +182 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +3 -3
- package/scripts/migrate/templates/hooks.test.ts +3 -1
- package/scripts/migrate/templates/lib-utils.ts +3 -3
- package/scripts/migrate/templates/main-push-guard-yml.ts +45 -0
- package/scripts/migrate/templates/package-json.ts +10 -0
- package/scripts/migrate/templates/parity-yml.ts +136 -0
- package/scripts/migrate/templates/perf-yml.ts +449 -0
- package/scripts/migrate/templates/playwright-yml.ts +127 -0
- package/scripts/migrate/templates/react-doctor-yml.ts +46 -0
- package/scripts/migrate/templates/routes.test.ts +31 -0
- package/scripts/migrate/templates/routes.ts +19 -9
- package/scripts/migrate/templates/section-loaders.ts +3 -3
- package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +19 -0
- package/scripts/migrate/templates/server-entry.ts +19 -14
- package/scripts/migrate/templates/setup.ts +11 -3
- package/scripts/migrate/templates/sync-blocks-bot-yml.ts +201 -0
- package/scripts/migrate/templates/ui-components.ts +3 -0
- package/scripts/migrate/transforms/ctx-compat.test.ts +37 -0
- package/scripts/migrate/transforms/ctx-compat.ts +93 -1
- package/scripts/migrate/transforms/htmx-on-events.test.ts +1 -1
- package/scripts/migrate/transforms/htmx-on-events.ts +2 -2
- package/scripts/migrate/transforms/jsx-class-type.test.ts +28 -0
- package/scripts/migrate/transforms/jsx.ts +10 -0
- package/scripts/migrate/transforms/picture.test.ts +56 -0
- package/scripts/migrate/transforms/picture.ts +95 -0
- package/scripts/migrate/transforms/tailwind-renames.test.ts +29 -0
- package/scripts/migrate/transforms/tailwind-renames.ts +34 -0
- package/scripts/migrate/transforms/timer-types.test.ts +30 -0
- package/scripts/migrate/transforms/timer-types.ts +48 -0
- package/scripts/migrate/transforms/use-script-handlers.test.ts +37 -0
- package/scripts/migrate/transforms/use-script-handlers.ts +36 -0
- package/scripts/migrate/types.ts +13 -1
- package/scripts/migrate-blocks-to-kv.ts +16 -2
- package/scripts/migrate-to-cf-observability.test.ts +5 -5
- package/scripts/migrate-to-cf-observability.ts +1 -1
- package/scripts/migrate.ts +244 -6
- package/scripts/reconcile.test.ts +162 -0
- package/scripts/reconcile.ts +303 -0
- package/scripts/sync-blocks-bot.test.ts +276 -0
- package/scripts/sync-blocks-bot.ts +567 -0
- package/scripts/sync-blocks-to-kv.ts +20 -2
- package/scripts/upgrade-6-to-7.ts +0 -0
- package/scripts/lib/legacyArtifact.ts +0 -20
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Snapshot reconcile — produces a per-file diff of everything that landed on the
|
|
4
|
+
* Fresh/Deno SOURCE repo since the migration cut, plus the context an agent needs
|
|
5
|
+
* to port each change into the already-migrated TanStack TARGET repo.
|
|
6
|
+
*
|
|
7
|
+
* Migrations take weeks. During that window the source team keeps shipping and
|
|
8
|
+
* the migration team hand-fixes what the codemod got wrong. Reconciling is a
|
|
9
|
+
* REBASE, not a re-migration: re-running `deco-migrate` over the tree overwrites
|
|
10
|
+
* the hand-fixes. So this script makes NO judgement and writes NOTHING to the
|
|
11
|
+
* target — it emits one patch per file and lets the agent (see the
|
|
12
|
+
* `deco-reconcile-snapshot` skill) work them one at a time.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* npx -p @decocms/blocks-cli deco-reconcile \
|
|
16
|
+
* --source ../site-fresh --target ../site-tanstack --snapshot <cut sha>
|
|
17
|
+
*
|
|
18
|
+
* Options:
|
|
19
|
+
* --source <dir> Fresh/Deno repo checkout (required)
|
|
20
|
+
* --target <dir> Migrated TanStack repo checkout (required)
|
|
21
|
+
* --snapshot <sha> Last reconciled source commit — the cut (required)
|
|
22
|
+
* --target-snapshot <sha> The migration commit on the target. Defaults to the
|
|
23
|
+
* commit that added MIGRATION_REPORT.md, else HEAD.
|
|
24
|
+
* Everything after it counts as a hand-fix, so a wrong
|
|
25
|
+
* value silently empties the collision list.
|
|
26
|
+
* --out <dir> Output dir (default: <target>/.reconcile/<sourceHead:7>)
|
|
27
|
+
* --verbose Log every file
|
|
28
|
+
* --help, -h Show this help
|
|
29
|
+
*
|
|
30
|
+
* Output: <out>/manifest.json (machine, also the resume state via `done`),
|
|
31
|
+
* <out>/INDEX.md (human), <out>/patches/NNN-<slug>.patch (one per file).
|
|
32
|
+
*
|
|
33
|
+
* Exit codes: 0 ok; 2 bad args / git failure.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { execFileSync } from "node:child_process";
|
|
37
|
+
import * as fs from "node:fs";
|
|
38
|
+
import { isDeletedByMigration } from "./migrate/delete-sets";
|
|
39
|
+
import * as path from "node:path";
|
|
40
|
+
|
|
41
|
+
/** Paths whose upstream changes never reconcile into the target. */
|
|
42
|
+
const SKIP = [
|
|
43
|
+
/^\.deco\//, // CMS content — syncs through its own channel
|
|
44
|
+
/^\.github\//, // target has its own scaffolded workflows
|
|
45
|
+
/^_fresh\//,
|
|
46
|
+
/(^|\/)(deno\.lock|package-lock\.json|bun\.lock|bun\.lockb|yarn\.lock)$/,
|
|
47
|
+
/\.(png|jpe?g|gif|webp|avif|ico|woff2?|ttf|eot|mp4|webm|pdf|zip)$/i,
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export interface ReconcileFile {
|
|
51
|
+
/** git --name-status letter: A/M/D/R… */
|
|
52
|
+
status: string;
|
|
53
|
+
sourcePath: string;
|
|
54
|
+
/** Previous path, for renames only. */
|
|
55
|
+
oldPath?: string;
|
|
56
|
+
/** Patch file, relative to the output dir. */
|
|
57
|
+
patch: string;
|
|
58
|
+
/** Where this MIGHT live on the target. Candidates, not a verdict. */
|
|
59
|
+
targetCandidates: string[];
|
|
60
|
+
/** Target commits touching a candidate since the migration commit. */
|
|
61
|
+
collision: string[];
|
|
62
|
+
done: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ReconcileManifest {
|
|
66
|
+
source: string;
|
|
67
|
+
target: string;
|
|
68
|
+
snapshot: string;
|
|
69
|
+
/** Feed this back as --snapshot on the next round. */
|
|
70
|
+
sourceHead: string;
|
|
71
|
+
targetSnapshot: string;
|
|
72
|
+
files: ReconcileFile[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function git(cwd: string, args: string[]): string {
|
|
76
|
+
return execFileSync("git", ["-C", cwd, ...args], {
|
|
77
|
+
encoding: "utf8",
|
|
78
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function slug(p: string): string {
|
|
83
|
+
return p.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 80);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Parse `git diff --name-status -M` output. Renames carry two tab-separated paths. */
|
|
87
|
+
export function parseNameStatus(
|
|
88
|
+
out: string,
|
|
89
|
+
): Array<{ status: string; path: string; oldPath?: string }> {
|
|
90
|
+
return out.split("\n").filter(Boolean).map((line) => {
|
|
91
|
+
const [status, a, b] = line.split("\t");
|
|
92
|
+
return b ? { status, path: b, oldPath: a } : { status, path: a };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function isSkipped(relPath: string): boolean {
|
|
97
|
+
return SKIP.some((re) => re.test(relPath)) || isDeletedByMigration(relPath);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Guess where a source file lives on the target. The migration moved things
|
|
102
|
+
* around (islands/→src/components/, static/→public/, sections stay under src/),
|
|
103
|
+
* so this is basename matching plus the conventional `src/` prefix — deliberately
|
|
104
|
+
* a shortlist for the agent to confirm against the target tree, not a mapping.
|
|
105
|
+
*/
|
|
106
|
+
export function targetCandidates(
|
|
107
|
+
relPath: string,
|
|
108
|
+
byBasename: Map<string, string[]>,
|
|
109
|
+
): string[] {
|
|
110
|
+
const guesses = [
|
|
111
|
+
`src/${relPath}`,
|
|
112
|
+
`src/${relPath.replace(/^islands\//, "components/")}`,
|
|
113
|
+
relPath.replace(/^static\//, "public/"),
|
|
114
|
+
];
|
|
115
|
+
const found = byBasename.get(path.basename(relPath)) ?? [];
|
|
116
|
+
// Only real target paths survive; the guesses just rank the likely one first.
|
|
117
|
+
return [...new Set([...guesses.filter((g) => found.includes(g)), ...found])];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function usage(msg?: string): never {
|
|
121
|
+
if (msg) console.error(`error: ${msg}\n`);
|
|
122
|
+
console.error(
|
|
123
|
+
"usage: deco-reconcile --source <dir> --target <dir> --snapshot <sha> [--target-snapshot <sha>] [--out <dir>] [--verbose]",
|
|
124
|
+
);
|
|
125
|
+
process.exit(msg ? 2 : 0);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The migration commit on the target — everything after it is a hand-fix.
|
|
130
|
+
* `deco-migrate` leaves no provenance except MIGRATION_REPORT.md, so the commit
|
|
131
|
+
* that added it is the marker. No marker: fall back to HEAD, which makes every
|
|
132
|
+
* collision range empty — loudly, because a silent empty list reads as "nobody
|
|
133
|
+
* touched anything" and that is exactly the wrong thing to believe.
|
|
134
|
+
*/
|
|
135
|
+
export function detectTargetSnapshot(log: string): string | undefined {
|
|
136
|
+
return log.split("\n").filter(Boolean).pop();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function main() {
|
|
140
|
+
const argv = process.argv.slice(2);
|
|
141
|
+
if (argv.includes("--help") || argv.includes("-h")) usage();
|
|
142
|
+
const flag = (name: string) => {
|
|
143
|
+
const i = argv.indexOf(`--${name}`);
|
|
144
|
+
return i === -1 ? undefined : argv[i + 1];
|
|
145
|
+
};
|
|
146
|
+
const verbose = argv.includes("--verbose");
|
|
147
|
+
|
|
148
|
+
const source = flag("source");
|
|
149
|
+
const target = flag("target");
|
|
150
|
+
const snapshot = flag("snapshot");
|
|
151
|
+
if (!source || !target || !snapshot) {
|
|
152
|
+
usage("--source, --target and --snapshot are all required");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const targetSnapshot = flag("target-snapshot") ??
|
|
156
|
+
detectTargetSnapshot(
|
|
157
|
+
git(target, [
|
|
158
|
+
"log",
|
|
159
|
+
"--diff-filter=A",
|
|
160
|
+
"--format=%H",
|
|
161
|
+
"--",
|
|
162
|
+
"MIGRATION_REPORT.md",
|
|
163
|
+
]),
|
|
164
|
+
) ?? git(target, ["rev-parse", "HEAD"]).trim();
|
|
165
|
+
if (!flag("target-snapshot")) {
|
|
166
|
+
console.log(
|
|
167
|
+
`--target-snapshot not given, using ${targetSnapshot.slice(0, 7)} (${
|
|
168
|
+
git(target, ["log", "-1", "--format=%s", targetSnapshot]).trim()
|
|
169
|
+
})`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const sourceHead = git(source, ["rev-parse", "HEAD"]).trim();
|
|
174
|
+
if (sourceHead === git(source, ["rev-parse", snapshot]).trim()) {
|
|
175
|
+
console.log("Nothing to reconcile — source HEAD is already the snapshot.");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const outDir = flag("out") ??
|
|
180
|
+
path.join(target, ".reconcile", sourceHead.slice(0, 7));
|
|
181
|
+
fs.mkdirSync(path.join(outDir, "patches"), { recursive: true });
|
|
182
|
+
|
|
183
|
+
const byBasename = new Map<string, string[]>();
|
|
184
|
+
for (const p of git(target, ["ls-files"]).split("\n").filter(Boolean)) {
|
|
185
|
+
const key = path.basename(p);
|
|
186
|
+
byBasename.set(key, [...(byBasename.get(key) ?? []), p]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Submodules (mode 160000) diff as a one-line gitlink bump — no source to port.
|
|
190
|
+
const submodules = new Set(
|
|
191
|
+
git(source, ["ls-files", "--stage"]).split("\n")
|
|
192
|
+
.filter((l) => l.startsWith("160000"))
|
|
193
|
+
.map((l) => l.split("\t")[1]),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const changes = parseNameStatus(
|
|
197
|
+
git(source, ["diff", "--name-status", "-M", `${snapshot}..${sourceHead}`]),
|
|
198
|
+
);
|
|
199
|
+
const files: ReconcileFile[] = [];
|
|
200
|
+
|
|
201
|
+
for (const change of changes) {
|
|
202
|
+
if (isSkipped(change.path) || submodules.has(change.path)) {
|
|
203
|
+
if (verbose) console.log(` skip ${change.path}`);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const n = String(files.length + 1).padStart(3, "0");
|
|
207
|
+
const patch = `patches/${n}-${slug(change.path)}.patch`;
|
|
208
|
+
fs.writeFileSync(
|
|
209
|
+
path.join(outDir, patch),
|
|
210
|
+
git(source, [
|
|
211
|
+
"diff",
|
|
212
|
+
"-M",
|
|
213
|
+
`${snapshot}..${sourceHead}`,
|
|
214
|
+
"--",
|
|
215
|
+
...(change.oldPath ? [change.oldPath, change.path] : [change.path]),
|
|
216
|
+
]),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const candidates = targetCandidates(change.path, byBasename);
|
|
220
|
+
// A target file touched since the migration commit is a hand-fix. The agent
|
|
221
|
+
// must reconcile hunk by hunk instead of applying the upstream change whole.
|
|
222
|
+
// Deduped by SHA: one commit touching two candidates (the island/section
|
|
223
|
+
// re-export pair) is one hand-fix, not two — an inflated count misreads the
|
|
224
|
+
// "does this fit one human review sitting" gate.
|
|
225
|
+
const bySha = new Map<string, string>();
|
|
226
|
+
for (const c of candidates) {
|
|
227
|
+
for (
|
|
228
|
+
const line of git(target, [
|
|
229
|
+
"log",
|
|
230
|
+
"--format=%h %s",
|
|
231
|
+
`${targetSnapshot}..HEAD`,
|
|
232
|
+
"--",
|
|
233
|
+
c,
|
|
234
|
+
]).split("\n").filter(Boolean)
|
|
235
|
+
) {
|
|
236
|
+
const sha = line.split(" ")[0];
|
|
237
|
+
if (!bySha.has(sha)) bySha.set(sha, `${c}: ${line}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const collision = [...bySha.values()];
|
|
241
|
+
|
|
242
|
+
files.push({
|
|
243
|
+
status: change.status,
|
|
244
|
+
sourcePath: change.path,
|
|
245
|
+
oldPath: change.oldPath,
|
|
246
|
+
patch,
|
|
247
|
+
targetCandidates: candidates,
|
|
248
|
+
collision,
|
|
249
|
+
done: false,
|
|
250
|
+
});
|
|
251
|
+
if (verbose) {
|
|
252
|
+
console.log(
|
|
253
|
+
` ${change.status.padEnd(4)} ${change.path} → ${
|
|
254
|
+
candidates.join(", ") || "(no target match — new file?)"
|
|
255
|
+
}${collision.length ? ` [COLLISION x${collision.length}]` : ""}`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const manifest: ReconcileManifest = {
|
|
261
|
+
source,
|
|
262
|
+
target,
|
|
263
|
+
snapshot,
|
|
264
|
+
sourceHead,
|
|
265
|
+
targetSnapshot,
|
|
266
|
+
files,
|
|
267
|
+
};
|
|
268
|
+
fs.writeFileSync(
|
|
269
|
+
path.join(outDir, "manifest.json"),
|
|
270
|
+
JSON.stringify(manifest, null, 2) + "\n",
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const collided = files.filter((f) => f.collision.length);
|
|
274
|
+
fs.writeFileSync(
|
|
275
|
+
path.join(outDir, "INDEX.md"),
|
|
276
|
+
[
|
|
277
|
+
`# Reconcile ${snapshot.slice(0, 7)}..${sourceHead.slice(0, 7)}`,
|
|
278
|
+
"",
|
|
279
|
+
`SOURCE_HEAD \`${sourceHead}\` — feed this back as \`--snapshot\` next round.`,
|
|
280
|
+
"",
|
|
281
|
+
`Target snapshot \`${targetSnapshot}\` — commits after it count as hand-fixes.`,
|
|
282
|
+
"",
|
|
283
|
+
`${files.length} files, ${collided.length} with collisions.`,
|
|
284
|
+
"",
|
|
285
|
+
"| # | status | source | target candidates | collision |",
|
|
286
|
+
"|---|---|---|---|---|",
|
|
287
|
+
...files.map((f, i) =>
|
|
288
|
+
`| ${i + 1} | ${f.status} | \`${f.sourcePath}\` | ${
|
|
289
|
+
f.targetCandidates.map((c) => `\`${c}\``).join("<br>") || "—"
|
|
290
|
+
} | ${f.collision.length || ""} |`
|
|
291
|
+
),
|
|
292
|
+
"",
|
|
293
|
+
].join("\n"),
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
console.log(
|
|
297
|
+
`${files.length} files (${collided.length} colliding) → ${outDir}\nSOURCE_HEAD ${sourceHead}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// realpath: under npx/bunx argv[1] is the `.bin/deco-reconcile` symlink, which
|
|
302
|
+
// the regex alone never matched — the CLI exited 0 without running (#515).
|
|
303
|
+
if (process.argv[1] && /reconcile\.ts$/.test(fs.realpathSync(process.argv[1]))) main();
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_DENY,
|
|
8
|
+
fetchDecofile,
|
|
9
|
+
findPlaintextSecrets,
|
|
10
|
+
hasEncryptedSecretRef,
|
|
11
|
+
matchesGlob,
|
|
12
|
+
writeDecofileToDir,
|
|
13
|
+
} from "./sync-blocks-bot";
|
|
14
|
+
|
|
15
|
+
function tmpBlocksDir(files: Record<string, unknown> = {}): string {
|
|
16
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sync-blocks-bot-"));
|
|
17
|
+
const out = path.join(dir, ".deco", "blocks");
|
|
18
|
+
fs.mkdirSync(out, { recursive: true });
|
|
19
|
+
for (const [file, content] of Object.entries(files)) {
|
|
20
|
+
fs.writeFileSync(path.join(out, file), `${JSON.stringify(content, null, 2)}\n`);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ls = (dir: string) => fs.readdirSync(dir).sort();
|
|
26
|
+
const read = (dir: string, file: string) =>
|
|
27
|
+
JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8")) as Record<string, unknown>;
|
|
28
|
+
|
|
29
|
+
describe("matchesGlob", () => {
|
|
30
|
+
it("matches whole keys with a * wildcard", () => {
|
|
31
|
+
expect(matchesGlob("Site", "Site")).toBe(true);
|
|
32
|
+
expect(matchesGlob("Sitemap", "Site")).toBe(false);
|
|
33
|
+
expect(matchesGlob("pages-Home", "pages-*")).toBe(true);
|
|
34
|
+
expect(matchesGlob("deco-vtex", "deco-*")).toBe(true);
|
|
35
|
+
// dots are literal, not "any char"
|
|
36
|
+
expect(matchesGlob("axb", "a.b")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("hasEncryptedSecretRef", () => {
|
|
41
|
+
it("finds a {name, encrypted} ref at any depth", () => {
|
|
42
|
+
expect(hasEncryptedSecretRef({ appKey: { name: "BANANA", encrypted: "3a714de1" } })).toBe(true);
|
|
43
|
+
expect(hasEncryptedSecretRef({ a: [{ b: { name: "X", encrypted: "y" } }] })).toBe(true);
|
|
44
|
+
expect(hasEncryptedSecretRef({ name: "X", encrypted: "" })).toBe(false);
|
|
45
|
+
expect(hasEncryptedSecretRef({ sections: [{ __resolveType: "site/x.tsx" }] })).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("findPlaintextSecrets", () => {
|
|
50
|
+
it("flags credential-shaped props holding a raw string", () => {
|
|
51
|
+
expect(findPlaintextSecrets({ appToken: "abcdefghijklmnop" })).toEqual(["appToken"]);
|
|
52
|
+
expect(findPlaintextSecrets({ nested: { api_key: "abcdefghijklmnop" } })).toEqual([
|
|
53
|
+
"nested.api_key",
|
|
54
|
+
]);
|
|
55
|
+
expect(findPlaintextSecrets({ list: [{ password: "abcdefghijklmnop" }] })).toEqual([
|
|
56
|
+
"list[0].password",
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("ignores a bare `key` prop — CMS content is full of them", () => {
|
|
61
|
+
// real shape: every VTEX PLP loader block carries selectedFacets[].key
|
|
62
|
+
expect(
|
|
63
|
+
findPlaintextSecrets({ selectedFacets: [{ key: "productClusterIds", value: "140" }] }),
|
|
64
|
+
).toEqual([]);
|
|
65
|
+
// …but a qualified key still counts
|
|
66
|
+
expect(findPlaintextSecrets({ apiKey: "abcdefghijklmnop" })).toEqual(["apiKey"]);
|
|
67
|
+
expect(findPlaintextSecrets({ "private-key": "abcdefghijklmnop" })).toEqual(["private-key"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("does not flag encrypted refs, urls, prose or short values", () => {
|
|
71
|
+
expect(findPlaintextSecrets({ appKey: { name: "X", encrypted: "abcdefghijklmnop" } })).toEqual(
|
|
72
|
+
[],
|
|
73
|
+
);
|
|
74
|
+
expect(findPlaintextSecrets({ tokenUrl: "https://x.com/very/long/path" })).toEqual([]);
|
|
75
|
+
expect(findPlaintextSecrets({ secret: "short" })).toEqual([]);
|
|
76
|
+
expect(findPlaintextSecrets({ password: "uma frase com espacos" })).toEqual([]);
|
|
77
|
+
expect(findPlaintextSecrets({ apiKey: "{{ FROM_ENV_VAR_HERE }}" })).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("writeDecofileToDir", () => {
|
|
82
|
+
it("writes one single-encoded file per block and skips unchanged ones", () => {
|
|
83
|
+
const out = tmpBlocksDir();
|
|
84
|
+
const remote = {
|
|
85
|
+
"pages-Home": { name: "Home", path: "/", sections: [] },
|
|
86
|
+
"pages-A B": { name: "A B", path: "/a-b", sections: [] },
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const first = writeDecofileToDir(remote, { out });
|
|
90
|
+
expect(first.added.sort()).toEqual(["pages-A B", "pages-Home"]);
|
|
91
|
+
expect(ls(out)).toEqual(["pages-A%20B.json", "pages-Home.json"]);
|
|
92
|
+
expect(read(out, "pages-Home.json").path).toBe("/");
|
|
93
|
+
|
|
94
|
+
const second = writeDecofileToDir(remote, { out });
|
|
95
|
+
expect(second.unchanged).toBe(2);
|
|
96
|
+
expect(second.added).toEqual([]);
|
|
97
|
+
expect(second.updated).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("compares content, not bytes — minified or reordered locals are not churn", () => {
|
|
101
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sync-blocks-bot-"));
|
|
102
|
+
const out = path.join(dir, "blocks");
|
|
103
|
+
fs.mkdirSync(out, { recursive: true });
|
|
104
|
+
// how the Studio daemon / old sync bot write it: one minified line, no newline
|
|
105
|
+
fs.writeFileSync(path.join(out, "pages-Home.json"), '{"sections":[],"path":"/","name":"Home"}');
|
|
106
|
+
|
|
107
|
+
const report = writeDecofileToDir(
|
|
108
|
+
{ "pages-Home": { name: "Home", path: "/", sections: [] } },
|
|
109
|
+
{ out },
|
|
110
|
+
);
|
|
111
|
+
expect(report.unchanged).toBe(1);
|
|
112
|
+
expect(report.updated).toEqual([]);
|
|
113
|
+
// untouched: no reformat commit
|
|
114
|
+
expect(fs.readFileSync(path.join(out, "pages-Home.json"), "utf-8")).toBe(
|
|
115
|
+
'{"sections":[],"path":"/","name":"Home"}',
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// a real content change does get written, pretty-printed
|
|
119
|
+
const changed = writeDecofileToDir({ "pages-Home": { name: "Home v2", path: "/" } }, { out });
|
|
120
|
+
expect(changed.updated).toEqual(["pages-Home"]);
|
|
121
|
+
expect(fs.readFileSync(path.join(out, "pages-Home.json"), "utf-8")).toContain('\n "name"');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("denies keys by glob, defaulting to the Site block", () => {
|
|
125
|
+
const out = tmpBlocksDir({ "Site.json": { seo: { title: "local" } } });
|
|
126
|
+
const report = writeDecofileToDir(
|
|
127
|
+
{ Site: { seo: { title: "prod" } }, "pages-Home": { path: "/" } },
|
|
128
|
+
{ out },
|
|
129
|
+
);
|
|
130
|
+
expect(DEFAULT_DENY).toContain("Site");
|
|
131
|
+
expect(report.denied).toEqual(["Site"]);
|
|
132
|
+
expect(read(out, "Site.json").seo).toEqual({ title: "local" });
|
|
133
|
+
|
|
134
|
+
const custom = writeDecofileToDir({ "pages-Home": { path: "/x" } }, { out, deny: ["pages-*"] });
|
|
135
|
+
expect(custom.denied).toEqual(["pages-Home"]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("never overwrites a block that carries an encrypted secret", () => {
|
|
139
|
+
const out = tmpBlocksDir({
|
|
140
|
+
"deco-vtex.json": { account: "loja", appKey: { name: "KEY", encrypted: "local" } },
|
|
141
|
+
});
|
|
142
|
+
const report = writeDecofileToDir(
|
|
143
|
+
{ "deco-vtex": { account: "outra", appKey: { name: "KEY", encrypted: "prod" } } },
|
|
144
|
+
{ out },
|
|
145
|
+
);
|
|
146
|
+
expect(report.protectedSecretBlocks).toEqual(["deco-vtex"]);
|
|
147
|
+
expect(read(out, "deco-vtex.json").account).toBe("loja");
|
|
148
|
+
|
|
149
|
+
const forced = writeDecofileToDir(
|
|
150
|
+
{ "deco-vtex": { account: "outra", appKey: { name: "KEY", encrypted: "prod" } } },
|
|
151
|
+
{ out, allowSecretBlocks: true },
|
|
152
|
+
);
|
|
153
|
+
expect(forced.updated).toEqual(["deco-vtex"]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("overwrites a legacy double-encoded filename in place instead of duplicating it", () => {
|
|
157
|
+
const out = tmpBlocksDir({ "pages-A%2520B.json": { name: "old", path: "/a-b" } });
|
|
158
|
+
const report = writeDecofileToDir({ "pages-A B": { name: "new", path: "/a-b" } }, { out });
|
|
159
|
+
expect(report.updated).toEqual(["pages-A B"]);
|
|
160
|
+
expect(ls(out)).toEqual(["pages-A%2520B.json"]);
|
|
161
|
+
expect(read(out, "pages-A%2520B.json").name).toBe("new");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("collapses colliding encodings onto the canonical name", () => {
|
|
165
|
+
const out = tmpBlocksDir({
|
|
166
|
+
"pages-A%2520B.json": { name: "stale", path: "/a-b" },
|
|
167
|
+
"pages-A%20B.json": { name: "also stale", path: "/a-b" },
|
|
168
|
+
});
|
|
169
|
+
const report = writeDecofileToDir({ "pages-A B": { name: "new", path: "/a-b" } }, { out });
|
|
170
|
+
expect(report.updated).toEqual(["pages-A B"]);
|
|
171
|
+
expect(ls(out)).toEqual(["pages-A%20B.json"]);
|
|
172
|
+
expect(read(out, "pages-A%20B.json").name).toBe("new");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("prunes blocks absent upstream, but keeps denied and secret-bearing ones", () => {
|
|
176
|
+
const out = tmpBlocksDir({
|
|
177
|
+
"pages-Gone.json": { name: "gone", path: "/gone" },
|
|
178
|
+
"pages-Home.json": { name: "home", path: "/" },
|
|
179
|
+
"Site.json": { seo: {} },
|
|
180
|
+
"deco-vtex.json": { appKey: { name: "KEY", encrypted: "local" } },
|
|
181
|
+
});
|
|
182
|
+
const report = writeDecofileToDir(
|
|
183
|
+
{ "pages-Home": { name: "home", path: "/" } },
|
|
184
|
+
{
|
|
185
|
+
out,
|
|
186
|
+
prune: true,
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
expect(report.removed).toEqual(["pages-Gone"]);
|
|
190
|
+
expect(report.denied).toEqual(["Site"]);
|
|
191
|
+
expect(report.protectedSecretBlocks).toEqual(["deco-vtex"]);
|
|
192
|
+
expect(ls(out)).toEqual(["Site.json", "deco-vtex.json", "pages-Home.json"]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("--dry-run touches nothing", () => {
|
|
196
|
+
const out = tmpBlocksDir({ "pages-Gone.json": { path: "/gone" } });
|
|
197
|
+
const report = writeDecofileToDir(
|
|
198
|
+
{ "pages-Home": { path: "/" } },
|
|
199
|
+
{
|
|
200
|
+
out,
|
|
201
|
+
prune: true,
|
|
202
|
+
dryRun: true,
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
expect(report.added).toEqual(["pages-Home"]);
|
|
206
|
+
expect(report.removed).toEqual(["pages-Gone"]);
|
|
207
|
+
expect(ls(out)).toEqual(["pages-Gone.json"]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("skips non-object payload values and reports plaintext secrets", () => {
|
|
211
|
+
const out = tmpBlocksDir();
|
|
212
|
+
const report = writeDecofileToDir(
|
|
213
|
+
{ broken: "not a block", leak: { appToken: "abcdefghijklmnop" } },
|
|
214
|
+
{ out },
|
|
215
|
+
);
|
|
216
|
+
expect(report.skipped).toEqual(["broken"]);
|
|
217
|
+
expect(report.plaintextSecrets).toEqual(["leak.appToken"]);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("fetchDecofile", () => {
|
|
222
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
223
|
+
|
|
224
|
+
const stub = (body: string, init: { status?: number; headers?: Record<string, string> } = {}) => {
|
|
225
|
+
vi.stubGlobal(
|
|
226
|
+
"fetch",
|
|
227
|
+
vi.fn(
|
|
228
|
+
async () =>
|
|
229
|
+
new Response(body, {
|
|
230
|
+
status: init.status ?? 200,
|
|
231
|
+
headers: { "content-type": "application/json", ...init.headers },
|
|
232
|
+
}),
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
it("returns the parsed decofile plus the ETag revision", async () => {
|
|
238
|
+
stub(JSON.stringify({ "pages-Home": { path: "/" } }), { headers: { etag: '"abc123"' } });
|
|
239
|
+
const result = await fetchDecofile("https://x.com/.decofile");
|
|
240
|
+
expect(result.blocks).toEqual({ "pages-Home": { path: "/" } });
|
|
241
|
+
expect(result.revision).toBe('"abc123"');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("rejects a non-200, a non-JSON content-type, a non-object payload and an oversized body", async () => {
|
|
245
|
+
stub("{}", { status: 500 });
|
|
246
|
+
await expect(fetchDecofile("https://x.com/.decofile")).rejects.toThrow(/responded 500/);
|
|
247
|
+
|
|
248
|
+
stub("<html>", { headers: { "content-type": "text/html" } });
|
|
249
|
+
await expect(fetchDecofile("https://x.com/.decofile")).rejects.toThrow(/expected JSON/);
|
|
250
|
+
|
|
251
|
+
stub("[]");
|
|
252
|
+
await expect(fetchDecofile("https://x.com/.decofile")).rejects.toThrow(/an array/);
|
|
253
|
+
|
|
254
|
+
stub(JSON.stringify({ a: { b: 1 } }));
|
|
255
|
+
await expect(fetchDecofile("https://x.com/.decofile", { maxBytes: 4 })).rejects.toThrow(
|
|
256
|
+
/over the 4 byte cap/,
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("CLI entrypoint", () => {
|
|
262
|
+
// Regression: invoked through the package `bin`, argv[1] is the
|
|
263
|
+
// node_modules/.bin symlink while import.meta.url is the real file. A string
|
|
264
|
+
// compare of the two fails, main() never runs, and the CLI exits 0 having
|
|
265
|
+
// printed nothing — a silently green CI job. Exercise the symlink path.
|
|
266
|
+
it("runs when invoked through a bin symlink", () => {
|
|
267
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sync-blocks-bin-"));
|
|
268
|
+
const link = path.join(dir, "deco-sync-blocks-bot");
|
|
269
|
+
fs.symlinkSync(path.join(__dirname, "sync-blocks-bot.ts"), link);
|
|
270
|
+
|
|
271
|
+
const out = execFileSync(process.execPath, [require.resolve("tsx/cli"), link, "--help"], {
|
|
272
|
+
encoding: "utf-8",
|
|
273
|
+
});
|
|
274
|
+
expect(out).toContain("--fail-on-plaintext-secret");
|
|
275
|
+
});
|
|
276
|
+
});
|