@decocms/blocks-migrate 0.0.0-stage → 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/src/imports.ts ADDED
@@ -0,0 +1,335 @@
1
+ /**
2
+ * The import codemod: v7 `@decocms/*` imports in the site's `src/`.
3
+ *
4
+ * It rewrites only what has a v8 equivalent with the same meaning:
5
+ *
6
+ * - an import of a vendored module (`@decocms/apps-shopify/loaders/…`)
7
+ * points at the copy in `src/vendor`;
8
+ * - `createInstrumentedFetch` from `@decocms/blocks/sdk/instrumentedFetch`
9
+ * comes from `@decocms/blocks/fetch`, and `createInstrumentedFetch("x")`
10
+ * becomes `createInstrumentedFetch({ provider: "x" })`.
11
+ *
12
+ * Every other v7 import is reported with where its replacement lives; none
13
+ * is shimmed. Imports of the next major's documented entry points are left
14
+ * alone.
15
+ */
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import ts from "typescript";
19
+ import type { Report } from "./report";
20
+ import { relativeSpecifier, resolvePackageFile } from "./vendor";
21
+
22
+ /** The next major's documented API (/next/api-reference): imports of these stay. */
23
+ export const V8_API: Record<string, ReadonlySet<string> | "*"> = {
24
+ // The root's next-major exports (packages/blocks/src/index.ts, from ./v8).
25
+ "@decocms/blocks": new Set([
26
+ "Analytics",
27
+ "Block",
28
+ "BlockFunction",
29
+ "Blocks",
30
+ "Client",
31
+ "CMS",
32
+ "CMSError",
33
+ "createCMS",
34
+ "DRAFT_COOKIE",
35
+ "DraftPointer",
36
+ "draftCookie",
37
+ "draftPointer",
38
+ "formatDraftPointer",
39
+ "Lazy",
40
+ "ListOptions",
41
+ "Loader",
42
+ "Match",
43
+ "matchRoute",
44
+ "Page",
45
+ "parseDraftPointer",
46
+ "Redirect",
47
+ "Result",
48
+ "Route",
49
+ "remoteLoader",
50
+ "resetForTests",
51
+ "Secret",
52
+ "Seo",
53
+ "Snapshot",
54
+ "Telemetry",
55
+ "TelemetryConfig",
56
+ "Variant",
57
+ ]),
58
+ "@decocms/blocks/fetch": "*",
59
+ "@decocms/blocks/analytics": "*",
60
+ "@decocms/blocks/secrets": "*",
61
+ "@decocms/blocks/cli": "*",
62
+ "@decocms/tanstack": new Set(["kvLoader"]),
63
+ };
64
+
65
+ /** Where each v7 import's replacement lives, first match wins. */
66
+ const HINTS: [RegExp, string][] = [
67
+ [/^@decocms\/start(\/|$)/, "a 6.x import: upgrade the site to 7.x first"],
68
+ [
69
+ /^@decocms\/blocks\/sdk\/cachedLoader$/,
70
+ "cachedLoader is gone: upstream caching lives in the framework binding (/next/caching#upstream-data)",
71
+ ],
72
+ [
73
+ /(^|\/)(invoke|createInvoke)$|\/sdk\/invoke/,
74
+ "/deco/invoke is gone: call upstream clients from server functions or route handlers (/next/renames-and-migrations#loaders-actions-and-invoke)",
75
+ ],
76
+ [
77
+ /^@decocms\/blocks-admin(\/|$)/,
78
+ "next-major sites serve no admin endpoints; the site editor uses the content protocol (/next/studio-compatibility)",
79
+ ],
80
+ [
81
+ /^@decocms\/blocks-cli(\/|$)/,
82
+ "codegen is the deco CLI in @decocms/blocks: deco schema, deco content, deco check (/next/cli)",
83
+ ],
84
+ [
85
+ /^@decocms\/blocks\/(setup|cms)(\/|$)/,
86
+ "setup is createCMS from @decocms/blocks, with the block map in .deco/index.ts (/next/content#create-the-cms)",
87
+ ],
88
+ [
89
+ /^@decocms\/blocks\/sdk\/instrumentedFetch$/,
90
+ "createInstrumentedFetch({ provider, fetch?, retry?, circuitBreaker? }) from @decocms/blocks/fetch (/next/api-reference#createinstrumentedfetch-options)",
91
+ ],
92
+ [
93
+ /^@decocms\/blocks\/(sdk\/(otel|observability|logger)|middleware\/observability)/,
94
+ "telemetry is the telemetry option of createCMS (/next/telemetry)",
95
+ ],
96
+ [
97
+ /^@decocms\/blocks\/types\/widgets$/,
98
+ "type the field as a string with a @format tag (/next/schema#widgets)",
99
+ ],
100
+ [
101
+ /(OneDollarStats|Analytics)$|^@decocms\/blocks\/sdk\/analytics$/,
102
+ "AnalyticsScript and track from @decocms/blocks/analytics, with the built-in analytics block (/next/analytics)",
103
+ ],
104
+ [
105
+ /^@decocms\/apps-salesforce(\/|$)|^@decocms\/apps\/salesforce(\/|$)/,
106
+ "the Salesforce client is @decocms/apps-sfmc-personalization (/next/upstream-clients#what-a-client-is)",
107
+ ],
108
+ [
109
+ /^@decocms\/apps-commerce(\/|$)|^@decocms\/apps\/commerce(\/|$)/,
110
+ "converters, hooks and shared commerce types live in your platform template (/next/upstream-clients#what-a-client-is)",
111
+ ],
112
+ [
113
+ /^@decocms\/apps-website(\/|$)|^@decocms\/apps\/website(\/|$)/,
114
+ "website features (SEO, sitemaps, redirects) live in your platform template (/next/upstream-clients#what-a-client-is)",
115
+ ],
116
+ [
117
+ /^@decocms\/apps(-[a-z]+)?(\/|$)/,
118
+ "apps are thin upstream clients now; call the platform's client from your own code (/next/upstream-clients)",
119
+ ],
120
+ [
121
+ /^@decocms\/(tanstack|nextjs)(\/|$)/,
122
+ "the v7 binding: follow your framework's guide (/next/tanstack-start-descriptors, /next/nextjs)",
123
+ ],
124
+ ];
125
+ const DEFAULT_HINT = "no v8 equivalent";
126
+
127
+ const SOURCE = /\.(tsx?|mts|cts)$/;
128
+
129
+ function listSources(dir: string): string[] {
130
+ if (!fs.existsSync(dir)) return [];
131
+ return fs
132
+ .readdirSync(dir, { recursive: true, withFileTypes: true })
133
+ .filter((d) => d.isFile() && SOURCE.test(d.name) && !d.name.endsWith(".d.ts"))
134
+ .map((d) => path.join(d.parentPath, d.name))
135
+ .filter((f) => !f.split(path.sep).includes("node_modules"));
136
+ }
137
+
138
+ interface Found {
139
+ /** The string literal node holding the specifier. */
140
+ literal: ts.StringLiteral;
141
+ /** Named imports (`*` for a namespace or default import). */
142
+ names: string[];
143
+ declaration?: ts.ImportDeclaration;
144
+ }
145
+
146
+ function findImports(source: ts.SourceFile): Found[] {
147
+ const found: Found[] = [];
148
+ const visit = (node: ts.Node) => {
149
+ if (
150
+ (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
151
+ node.moduleSpecifier &&
152
+ ts.isStringLiteral(node.moduleSpecifier)
153
+ ) {
154
+ const names: string[] = [];
155
+ if (ts.isImportDeclaration(node)) {
156
+ const clause = node.importClause;
157
+ if (clause?.name) names.push("default");
158
+ const bindings = clause?.namedBindings;
159
+ if (bindings && ts.isNamespaceImport(bindings)) names.push("*");
160
+ if (bindings && ts.isNamedImports(bindings)) {
161
+ for (const el of bindings.elements) names.push((el.propertyName ?? el.name).text);
162
+ }
163
+ } else if (node.exportClause && ts.isNamedExports(node.exportClause)) {
164
+ for (const el of node.exportClause.elements) names.push((el.propertyName ?? el.name).text);
165
+ } else {
166
+ names.push("*");
167
+ }
168
+ found.push({
169
+ literal: node.moduleSpecifier,
170
+ names,
171
+ declaration: ts.isImportDeclaration(node) ? node : undefined,
172
+ });
173
+ } else if (
174
+ ts.isCallExpression(node) &&
175
+ node.expression.kind === ts.SyntaxKind.ImportKeyword &&
176
+ node.arguments[0] &&
177
+ ts.isStringLiteral(node.arguments[0])
178
+ ) {
179
+ found.push({ literal: node.arguments[0], names: ["*"] });
180
+ } else if (
181
+ ts.isImportTypeNode(node) &&
182
+ ts.isLiteralTypeNode(node.argument) &&
183
+ ts.isStringLiteral(node.argument.literal)
184
+ ) {
185
+ found.push({ literal: node.argument.literal, names: ["*"] });
186
+ }
187
+ ts.forEachChild(node, visit);
188
+ };
189
+ visit(source);
190
+ return found;
191
+ }
192
+
193
+ function isDocumented(specifier: string, names: string[]): boolean {
194
+ const api = V8_API[specifier];
195
+ if (!api) return false;
196
+ return api === "*" || names.every((n) => api.has(n));
197
+ }
198
+
199
+ function hintFor(specifier: string): string {
200
+ return HINTS.find(([pattern]) => pattern.test(specifier))?.[1] ?? DEFAULT_HINT;
201
+ }
202
+
203
+ /** `createInstrumentedFetch("x")` calls, or null when one has another argument shape. */
204
+ function instrumentedFetchCalls(source: ts.SourceFile, local: string): ts.CallExpression[] | null {
205
+ const calls: ts.CallExpression[] = [];
206
+ let convertible = true;
207
+ const visit = (node: ts.Node) => {
208
+ if (
209
+ ts.isCallExpression(node) &&
210
+ ts.isIdentifier(node.expression) &&
211
+ node.expression.text === local
212
+ ) {
213
+ const [arg] = node.arguments;
214
+ if (node.arguments.length === 1 && arg && ts.isStringLiteralLike(arg)) calls.push(node);
215
+ else convertible = false;
216
+ }
217
+ ts.forEachChild(node, visit);
218
+ };
219
+ visit(source);
220
+ return convertible ? calls : null;
221
+ }
222
+
223
+ interface Edit {
224
+ start: number;
225
+ end: number;
226
+ text: string;
227
+ }
228
+
229
+ /**
230
+ * Rewrite and report the `@decocms/*` imports of every source file in `src/`.
231
+ * `vendored` maps an installed source file to its copy in the site.
232
+ */
233
+ export function rewriteImports(root: string, report: Report, vendored: Map<string, string>): void {
234
+ const reported = new Map<string, { names: Set<string>; files: Set<string> }>();
235
+ let rewrittenFiles = 0;
236
+
237
+ for (const file of listSources(path.join(root, "src"))) {
238
+ const text = fs.readFileSync(file, "utf8");
239
+ if (!text.includes("@decocms/")) continue;
240
+ const source = ts.createSourceFile(
241
+ file,
242
+ text,
243
+ ts.ScriptTarget.Latest,
244
+ true,
245
+ file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
246
+ );
247
+ const edits: Edit[] = [];
248
+ const rel = path.relative(root, file).split(path.sep).join("/");
249
+
250
+ for (const found of findImports(source)) {
251
+ const specifier = found.literal.text;
252
+ if (!specifier.startsWith("@decocms/")) continue;
253
+ const replace = (to: string) =>
254
+ edits.push({
255
+ start: found.literal.getStart(source) + 1,
256
+ end: found.literal.getEnd() - 1,
257
+ text: to,
258
+ });
259
+
260
+ // 1. A vendored module: import the copy.
261
+ const target = resolvePackageFile(root, specifier);
262
+ const copy = target ? vendored.get(target) : undefined;
263
+ if (copy) {
264
+ replace(relativeSpecifier(file, copy));
265
+ continue;
266
+ }
267
+
268
+ // 2. createInstrumentedFetch moved to @decocms/blocks/fetch, options-only.
269
+ const decl = found.declaration;
270
+ const bindings = decl?.importClause?.namedBindings;
271
+ if (
272
+ specifier === "@decocms/blocks/sdk/instrumentedFetch" &&
273
+ decl &&
274
+ !decl.importClause?.isTypeOnly &&
275
+ !decl.importClause?.name &&
276
+ bindings &&
277
+ ts.isNamedImports(bindings) &&
278
+ bindings.elements.length === 1 &&
279
+ (bindings.elements[0].propertyName ?? bindings.elements[0].name).text ===
280
+ "createInstrumentedFetch"
281
+ ) {
282
+ const calls = instrumentedFetchCalls(source, bindings.elements[0].name.text);
283
+ if (calls) {
284
+ replace("@decocms/blocks/fetch");
285
+ for (const call of calls) {
286
+ const arg = call.arguments[0];
287
+ edits.push({
288
+ start: arg.getStart(source),
289
+ end: arg.getEnd(),
290
+ text: `{ provider: ${arg.getText(source)} }`,
291
+ });
292
+ }
293
+ continue;
294
+ }
295
+ }
296
+
297
+ // 3. The next major's documented API: nothing to do.
298
+ if (isDocumented(specifier, found.names)) continue;
299
+
300
+ // 4. Everything else: report.
301
+ const entry = reported.get(specifier) ?? { names: new Set(), files: new Set() };
302
+ for (const n of found.names) entry.names.add(n);
303
+ entry.files.add(
304
+ `${rel}:${source.getLineAndCharacterOfPosition(found.literal.getStart(source)).line + 1}`,
305
+ );
306
+ reported.set(specifier, entry);
307
+ }
308
+
309
+ if (edits.length > 0) {
310
+ let out = text;
311
+ for (const edit of edits.sort((a, b) => b.start - a.start)) {
312
+ out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
313
+ }
314
+ fs.writeFileSync(file, out);
315
+ rewrittenFiles++;
316
+ }
317
+ }
318
+
319
+ if (rewrittenFiles > 0) {
320
+ report.done.push({
321
+ step: "imports",
322
+ subject: "src/",
323
+ message: `rewrote imports in ${rewrittenFiles} files`,
324
+ });
325
+ }
326
+ for (const [specifier, { names, files }] of [...reported].sort(([a], [b]) => (a < b ? -1 : 1))) {
327
+ const list = [...files];
328
+ const where = `${list.slice(0, 3).join(", ")}${list.length > 3 ? `, +${list.length - 3} more` : ""}`;
329
+ report.manual.push({
330
+ step: "imports",
331
+ subject: `${specifier} {${[...names].join(", ")}}`,
332
+ message: `${hintFor(specifier)}; in ${where}`,
333
+ });
334
+ }
335
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Legacy type names the next major's alias table doesn't carry (spec:
3
+ * studio-compatibility › Well-known types and the alias table). v7 resolved
4
+ * these; the next major resolves only the names the site editor's screens
5
+ * look for, so the migration rewrites the others to one of those (or to the
6
+ * built-in's own name) in the saved content.
7
+ */
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { serializeBlock } from "@decocms/blocks/protocol/keys";
11
+ import type { Report } from "./report";
12
+ import { forEachBlock, readContent } from "./walk";
13
+
14
+ /** Old name → the name it's saved under after the migration. Same props, same behaviour. */
15
+ export const RENAMED_TYPES: Readonly<Record<string, string>> = {
16
+ "website/flags/multivariate/image.ts": "website/flags/multivariate.ts",
17
+ "website/flags/multivariate/message.ts": "website/flags/multivariate.ts",
18
+ "website/flags/multivariate/page.ts": "website/flags/multivariate.ts",
19
+ "$live/flags/multivariate.ts": "website/flags/multivariate.ts",
20
+ "$live/matchers/MatchAlways.ts": "website/matchers/always.ts",
21
+ "website/matchers/date.ts": "date",
22
+ "$live/matchers/MatchDate.ts": "date",
23
+ };
24
+
25
+ export function renameLegacyTypes(root: string, report: Report): void {
26
+ const { blocks, files } = readContent(root);
27
+ for (const [entry, block] of Object.entries(blocks)) {
28
+ const renamed = new Set<string>();
29
+ forEachBlock(block, (node) => {
30
+ const to = Object.hasOwn(RENAMED_TYPES, node.__resolveType)
31
+ ? RENAMED_TYPES[node.__resolveType]
32
+ : undefined;
33
+ if (to === undefined) return;
34
+ renamed.add(`${node.__resolveType} → ${to}`);
35
+ node.__resolveType = to;
36
+ });
37
+ if (renamed.size === 0) continue;
38
+ fs.writeFileSync(path.join(root, ".deco", "blocks", files[entry]!), serializeBlock(block));
39
+ report.done.push({
40
+ step: "content",
41
+ subject: files[entry]!,
42
+ message: `renamed ${[...renamed].join(", ")}`,
43
+ });
44
+ }
45
+ }
package/src/main.ts ADDED
@@ -0,0 +1,43 @@
1
+ /** The `blocks-migrate` bin's entry. */
2
+ import { parseArgs } from "node:util";
3
+ import { migrate } from "./migrate";
4
+ import { formatReport } from "./report";
5
+
6
+ const USAGE = `Usage: blocks-migrate [--root <dir>] [--decofile <file>]
7
+
8
+ Migrates a v7 Deco site to the next major, in place, and prints what is left
9
+ to do. Run it on a clean working tree with DECO_CRYPTO_KEY set and
10
+ .deco/secrets.pub committed (see /next/renames-and-migrations).
11
+
12
+ --root <dir> the app root, with the site's package.json (default: .)
13
+ --decofile <file> content to split into .deco/blocks when the site has none
14
+ (the JSON served at <site>/.decofile)`;
15
+
16
+ async function main(argv: string[]): Promise<number> {
17
+ const { values } = parseArgs({
18
+ args: argv,
19
+ options: {
20
+ root: { type: "string", default: "." },
21
+ decofile: { type: "string" },
22
+ help: { type: "boolean", short: "h" },
23
+ },
24
+ });
25
+ if (values.help) {
26
+ console.log(USAGE);
27
+ return 0;
28
+ }
29
+ const report = await migrate({ root: values.root!, decofile: values.decofile });
30
+ console.log(formatReport(report));
31
+ return 0;
32
+ }
33
+
34
+ main(process.argv.slice(2)).then(
35
+ (code) => {
36
+ process.exitCode = code;
37
+ },
38
+ (error) => {
39
+ console.error(error instanceof Error ? error.message : error);
40
+ console.error(USAGE);
41
+ process.exitCode = 1;
42
+ },
43
+ );
package/src/migrate.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `blocks-migrate`: moves a v7 site to the next major in one pass (spec:
3
+ * renames-and-migrations › Migrating from v7). The steps, in order:
4
+ *
5
+ * 1. content: saved blocks in `.deco/blocks`, v7 generated files removed,
6
+ * legacy type names outside the alias table renamed, and A/B tests keyed
7
+ * on a random matcher get its name as `experiment`;
8
+ * 2. secrets: v7 secrets re-encrypted with `.deco/secrets.pub`;
9
+ * 3. block map: `.deco/index.ts` with aliases under the v7 names, after
10
+ * vendoring the app loaders and actions the content calls;
11
+ * 4. imports: the codemod over `src/`;
12
+ * 5. scripts: `deco schema && deco content` before dev and build.
13
+ *
14
+ * It changes files in place, so run it on a clean working tree and review the
15
+ * diff. What it can't do is returned in the report.
16
+ */
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { writeBlockMap } from "./blockMap";
20
+ import { moveContent } from "./content";
21
+ import { copyExperimentIds } from "./experiments";
22
+ import { rewriteImports } from "./imports";
23
+ import { renameLegacyTypes } from "./legacyNames";
24
+ import { createReport, type Report } from "./report";
25
+ import { reencryptSecrets } from "./secrets";
26
+
27
+ interface MigrateOptions {
28
+ /** The app root: the folder with the site's package.json. */
29
+ root: string;
30
+ /** A decofile to split into `.deco/blocks` when the site has none, relative to the root. */
31
+ decofile?: string;
32
+ }
33
+
34
+ /** The scripts /next/cli#run-it-before-dev-and-build asks for. */
35
+ const SCRIPTS: Record<string, string> = {
36
+ predev: "deco schema && deco content",
37
+ prebuild: "deco schema && deco content && deco check",
38
+ };
39
+
40
+ function addScripts(root: string, report: Report): void {
41
+ const file = path.join(root, "package.json");
42
+ const manifest = JSON.parse(fs.readFileSync(file, "utf8"));
43
+ manifest.scripts ??= {};
44
+ const added: string[] = [];
45
+ for (const [name, command] of Object.entries(SCRIPTS)) {
46
+ const current = manifest.scripts[name];
47
+ if (current === undefined) {
48
+ manifest.scripts[name] = command;
49
+ added.push(name);
50
+ } else if (!current.includes(command)) {
51
+ report.manual.push({
52
+ step: "scripts",
53
+ subject: `package.json ${name}`,
54
+ message: `run ${command} in it (/next/cli#run-it-before-dev-and-build)`,
55
+ });
56
+ }
57
+ }
58
+ if (added.length > 0) {
59
+ fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
60
+ report.done.push({
61
+ step: "scripts",
62
+ subject: "package.json",
63
+ message: `added ${added.join(" and ")}`,
64
+ });
65
+ }
66
+ report.manual.push({
67
+ step: "scripts",
68
+ subject: "package.json",
69
+ message:
70
+ "depend on the next major of @decocms/blocks, drop the v7 codegen from build and the @decocms/blocks-admin and @decocms/blocks-cli dependencies once nothing imports them",
71
+ });
72
+ }
73
+
74
+ export async function migrate(options: MigrateOptions): Promise<Report> {
75
+ const root = path.resolve(options.root);
76
+ if (!fs.existsSync(path.join(root, "package.json"))) {
77
+ throw new Error(`no package.json in ${root}; pass the app root with --root`);
78
+ }
79
+ const report = createReport();
80
+ moveContent(root, report, { decofile: options.decofile });
81
+ renameLegacyTypes(root, report);
82
+ copyExperimentIds(root, report);
83
+ await reencryptSecrets(root, report);
84
+ const { vendored } = writeBlockMap(root, report);
85
+ rewriteImports(root, report, vendored);
86
+ addScripts(root, report);
87
+ return report;
88
+ }
package/src/report.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * What a migration did and what it left for a person: every step adds to one
3
+ * report, which the command prints at the end.
4
+ */
5
+ type Step = "content" | "secrets" | "block map" | "vendor" | "imports" | "scripts";
6
+
7
+ export interface Note {
8
+ step: Step;
9
+ /** What the note is about: a type name, a file, an import specifier. */
10
+ subject: string;
11
+ message: string;
12
+ }
13
+
14
+ export interface Report {
15
+ /** Changes the migration made. */
16
+ done: Note[];
17
+ /** Work left for a person: nothing was changed for these. */
18
+ manual: Note[];
19
+ }
20
+
21
+ export function createReport(): Report {
22
+ return { done: [], manual: [] };
23
+ }
24
+
25
+ function section(title: string, notes: Note[]): string[] {
26
+ if (notes.length === 0) return [];
27
+ const lines = [`${title} (${notes.length})`];
28
+ for (const step of new Set(notes.map((n) => n.step))) {
29
+ lines.push(` ${step}`);
30
+ for (const n of notes.filter((n) => n.step === step)) {
31
+ lines.push(` ${n.subject}: ${n.message}`);
32
+ }
33
+ }
34
+ return lines;
35
+ }
36
+
37
+ export function formatReport(report: Report): string {
38
+ const lines = [...section("Done", report.done), ...section("Left to do", report.manual)];
39
+ return lines.length > 0 ? lines.join("\n") : "Nothing to migrate.";
40
+ }
package/src/secrets.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Secrets (spec: renames-and-migrations › Secrets). v7 saved each credential
3
+ * as a `website/loaders/secret.ts` block holding `encrypted`, an AES-CBC
4
+ * value under `DECO_CRYPTO_KEY`. The next major's `secret` block holds
5
+ * `ciphertext`, encrypted with the site's public key (`.deco/secrets.pub`).
6
+ * This step decrypts each v7 secret (v7's format, read here so nothing from
7
+ * v7 is imported) and re-encrypts it with `encryptSecret`, in place, so the
8
+ * content commit carries the change.
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { LEGACY_ALIASES } from "@decocms/blocks/cli";
13
+ import { serializeBlock } from "@decocms/blocks/protocol/keys";
14
+ import { encryptSecret } from "@decocms/blocks/secrets";
15
+ import type { Report } from "./report";
16
+ import { forEachBlock, type JsonObject, readContent } from "./walk";
17
+
18
+ const DOCS = "/next/renames-and-migrations#secrets";
19
+
20
+ /** The v7 type names of a secret: every legacy alias of `secret`, with and without the extension. */
21
+ const LEGACY_SECRET_TYPES = new Set(
22
+ Object.entries(LEGACY_ALIASES)
23
+ .filter(([, target]) => target === "secret")
24
+ .flatMap(([name]) => [name, name.replace(/\.tsx?$/, "")]),
25
+ );
26
+
27
+ /**
28
+ * v7's secret format: `DECO_CRYPTO_KEY` is base64 JSON `{ key, iv }` (byte
29
+ * arrays) for AES-CBC, and `encrypted` is the ciphertext in hex. Returns null
30
+ * when the value doesn't decrypt with that key.
31
+ */
32
+ async function decryptV7(encryptedHex: string, cryptoKey: string): Promise<string | null> {
33
+ try {
34
+ const parsed = JSON.parse(atob(cryptoKey));
35
+ const bytes = (v: unknown) => new Uint8Array(Array.isArray(v) ? v : Object.values(v as object));
36
+ const key = await crypto.subtle.importKey("raw", bytes(parsed.key), "AES-CBC", false, [
37
+ "decrypt",
38
+ ]);
39
+ const data = Uint8Array.from(encryptedHex.match(/../g) ?? [], (h) => Number.parseInt(h, 16));
40
+ const plain = await crypto.subtle.decrypt({ name: "AES-CBC", iv: bytes(parsed.iv) }, key, data);
41
+ return new TextDecoder().decode(plain);
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /** Re-encrypt every v7 secret in `.deco/blocks`. Reads `DECO_CRYPTO_KEY` from the environment. */
48
+ export async function reencryptSecrets(root: string, report: Report): Promise<void> {
49
+ const { blocks, files } = readContent(root);
50
+ const found: { entry: string; block: JsonObject }[] = [];
51
+ for (const [entry, block] of Object.entries(blocks)) {
52
+ forEachBlock(block, (node) => {
53
+ if (!LEGACY_SECRET_TYPES.has(node.__resolveType)) return;
54
+ if (typeof node.encrypted === "string") {
55
+ found.push({ entry, block: node });
56
+ return;
57
+ }
58
+ const env = typeof node.name === "string" ? ` (it read env ${node.name})` : "";
59
+ report.manual.push({
60
+ step: "secrets",
61
+ subject: files[entry],
62
+ message: `a v7 secret with no encrypted value${env}: save it in the site editor (${DOCS})`,
63
+ });
64
+ });
65
+ }
66
+ if (found.length === 0) return;
67
+
68
+ const leave = (message: string) => {
69
+ for (const { entry } of found) {
70
+ report.manual.push({ step: "secrets", subject: files[entry], message });
71
+ }
72
+ };
73
+ const publicKeyFile = path.join(root, ".deco", "secrets.pub");
74
+ if (!fs.existsSync(publicKeyFile)) {
75
+ leave(
76
+ `a v7 secret: create the key pair (.deco/secrets.pub) and run again with DECO_CRYPTO_KEY set (${DOCS})`,
77
+ );
78
+ return;
79
+ }
80
+ const cryptoKey = process.env.DECO_CRYPTO_KEY;
81
+ if (!cryptoKey) {
82
+ leave(`a v7 secret: run again with DECO_CRYPTO_KEY set to re-encrypt it (${DOCS})`);
83
+ return;
84
+ }
85
+ const publicKey = fs.readFileSync(publicKeyFile, "utf8");
86
+
87
+ const changed = new Set<string>();
88
+ for (const { entry, block } of found) {
89
+ const name = typeof block.name === "string" ? block.name : undefined;
90
+ const value = await decryptV7(block.encrypted as string, cryptoKey);
91
+ if (value === null) {
92
+ report.manual.push({
93
+ step: "secrets",
94
+ subject: files[entry],
95
+ message: `secret${name ? ` ${name}` : ""} didn't decrypt with DECO_CRYPTO_KEY; save it again in the site editor`,
96
+ });
97
+ continue;
98
+ }
99
+ const { __resolveType, ciphertext } = await encryptSecret(publicKey, value);
100
+ // Keep fields this step doesn't know; drop the v7 ones.
101
+ delete block.encrypted;
102
+ delete block.name;
103
+ Object.assign(block, { __resolveType, ciphertext });
104
+ changed.add(entry);
105
+ report.done.push({
106
+ step: "secrets",
107
+ subject: files[entry],
108
+ message: `re-encrypted secret${name ? ` ${name}` : ""} with .deco/secrets.pub`,
109
+ });
110
+ }
111
+ for (const entry of changed) {
112
+ fs.writeFileSync(
113
+ path.join(root, ".deco", "blocks", files[entry]),
114
+ serializeBlock(blocks[entry]),
115
+ );
116
+ }
117
+ }