@uniformdev/next-app-router-upgrade 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,360 @@
1
+ // Phase 2 on Vercel: turn on edge mode with vercelUniformEdgeMiddleware.
2
+ // The steps follow "Set up edge mode on Vercel" in the docs.
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import ts from "typescript";
7
+
8
+ import { addToBlock } from "../json-text.js";
9
+ import { applyEdits } from "../migrate.js";
10
+ import { listSourceFiles, scriptKind } from "../scan.js";
11
+
12
+ const SDK = "@uniformdev/next-app-router";
13
+ const VERCEL = `${SDK}/vercel`;
14
+ const EDGE_HEADER = "x-uniform-edge-origin";
15
+ const VERCEL_FUNCTIONS_RANGE = "^3.7.2";
16
+ const DEFAULT_SOURCE = "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)";
17
+ const MISSING_RULE = `[{ type: "header", key: "${EDGE_HEADER}" }]`;
18
+ const LITE = new Set(["uniformMiddleware"]);
19
+
20
+ /**
21
+ * Returns { changes, contents, manual, reminders } for phase 2 on Vercel. Reminders are not code changes.
22
+ * `base` maps absolute paths to the text after phase 1, so that the two phases make one plan.
23
+ */
24
+ export function planVercelEdge(root, { base = new Map() } = {}) {
25
+ const read = (file) => base.get(file) ?? fs.readFileSync(file, "utf8");
26
+ const changes = [];
27
+ const contents = new Map();
28
+ const manual = [];
29
+ const save = (file, text, fileChanges) => {
30
+ if (text !== read(file)) contents.set(file, text);
31
+ changes.push(...fileChanges);
32
+ };
33
+
34
+ // Step 1: @vercel/functions.
35
+ const pkgFile = path.join(root, "package.json");
36
+ const pkgText = read(pkgFile);
37
+ const pkg = JSON.parse(pkgText);
38
+ if (!pkg.dependencies?.["@vercel/functions"] && !pkg.devDependencies?.["@vercel/functions"]) {
39
+ save(pkgFile, addToBlock(pkgText, "dependencies", "@vercel/functions", VERCEL_FUNCTIONS_RANGE), [
40
+ { file: "package.json", rule: "edge-vercel-functions", description: `Add @vercel/functions ${VERCEL_FUNCTIONS_RANGE}. vercelUniformEdgeMiddleware needs it.` },
41
+ ]);
42
+ }
43
+
44
+ // Step 2: the middleware.
45
+ const middleware = findMiddlewareFile(root);
46
+ if (!middleware) {
47
+ manual.push("No middleware.ts was found. Add middleware.ts with vercelUniformEdgeMiddleware, as in the docs.");
48
+ } else if (/^proxy\./.test(path.basename(middleware))) {
49
+ manual.push(
50
+ `${path.relative(root, middleware)}: edge mode needs middleware.ts with runtime "experimental-edge" (vercel/next.js#82344). Rename the file to middleware.ts, and then run the migration again.`
51
+ );
52
+ } else {
53
+ const result = migrateMiddleware(root, middleware, read(middleware));
54
+ save(middleware, result.text, result.changes);
55
+ manual.push(...result.manual);
56
+ }
57
+
58
+ // Steps 3 and 4: static params and the preview route, in all source files.
59
+ for (const file of listSourceFiles(root)) {
60
+ if (file === middleware) continue;
61
+ const text = read(file);
62
+ if (!text.includes(SDK)) continue;
63
+ const result = migrateRouteFile(root, file, text);
64
+ if (result.changes.length || result.manual.length) {
65
+ save(file, result.text, result.changes);
66
+ manual.push(...result.manual);
67
+ }
68
+ }
69
+
70
+ const reminders = [
71
+ "In Uniform, make sure that a webhook sends manifest.published, composition.*, entry.*, projectmap.node.* and redirect.* to /api/preview. The migration cannot check this.",
72
+ ];
73
+ return { changes, contents, manual, reminders };
74
+ }
75
+
76
+ function findMiddlewareFile(root) {
77
+ for (const dir of ["", "src"]) {
78
+ for (const name of ["middleware", "proxy"]) {
79
+ for (const ext of [".ts", ".js", ".mts", ".mjs", ".tsx", ".jsx"]) {
80
+ const file = path.join(root, dir, name + ext);
81
+ if (fs.existsSync(file)) return file;
82
+ }
83
+ }
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ function parse(file, text) {
89
+ return ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind(file));
90
+ }
91
+
92
+ function sdkImports(sf) {
93
+ const locals = new Map();
94
+ for (const statement of sf.statements) {
95
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
96
+ const module = statement.moduleSpecifier.text;
97
+ if (!module.startsWith(SDK)) continue;
98
+ const bindings = statement.importClause?.namedBindings;
99
+ if (bindings && ts.isNamedImports(bindings)) {
100
+ for (const element of bindings.elements) {
101
+ locals.set(element.name.text, { module, imported: (element.propertyName ?? element.name).text, element, statement });
102
+ }
103
+ }
104
+ }
105
+ return locals;
106
+ }
107
+
108
+ // The middleware file
109
+
110
+ export function migrateMiddleware(root, file, text) {
111
+ const sf = parse(file, text);
112
+ const rel = path.relative(root, file);
113
+ const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
114
+ const edits = [];
115
+ const changes = [];
116
+ const manual = [];
117
+ const record = (node, description) => changes.push({ file: rel, line: node ? lineOf(node) : undefined, rule: "edge-middleware", description });
118
+
119
+ const locals = sdkImports(sf);
120
+ const usesEdge = [...locals.values()].some((info) => info.module === VERCEL && info.imported === "vercelUniformEdgeMiddleware");
121
+ const lite = [...locals.entries()].find(([, info]) => info.module === `${SDK}/middleware` && LITE.has(info.imported));
122
+ const handle = [...locals.entries()].find(([, info]) => info.module === `${SDK}/middleware` && info.imported === "handleUniformRoute");
123
+
124
+ if (!usesEdge && lite) {
125
+ const [local, info] = lite;
126
+ // Swap the factory only when each reference is a call: uniformMiddleware({ ... }).
127
+ const references = [];
128
+ let other = false;
129
+ const visit = (node) => {
130
+ if (ts.isIdentifier(node) && node.text === local && node !== info.element.name && !ts.isImportSpecifier(node.parent)) {
131
+ if (ts.isCallExpression(node.parent) && node.parent.expression === node) references.push(node);
132
+ else other = true;
133
+ }
134
+ ts.forEachChild(node, visit);
135
+ };
136
+ visit(sf);
137
+
138
+ if (other || !references.length) {
139
+ manual.push(`${rel}: ${local} is used in a way that the migration cannot change. Replace it with vercelUniformEdgeMiddleware from ${VERCEL}.`);
140
+ } else {
141
+ for (const reference of references) {
142
+ edits.push({ start: reference.getStart(sf), end: reference.end, text: "vercelUniformEdgeMiddleware" });
143
+ record(reference, `Replace ${local}(...) with vercelUniformEdgeMiddleware(...). It accepts the same options.`);
144
+ }
145
+ edits.push(...replaceImport(sf, info, "vercelUniformEdgeMiddleware", VERCEL));
146
+ }
147
+ } else if (!usesEdge && handle) {
148
+ manual.push(
149
+ `${rel}: the middleware calls handleUniformRoute in a custom function. Replace it with vercelUniformEdgeMiddleware(options)(request, event), or with handleUniformEdgeRoute. The migration does not change custom middleware.`
150
+ );
151
+ } else if (!usesEdge) {
152
+ manual.push(`${rel}: the middleware does not use uniformMiddleware. Add vercelUniformEdgeMiddleware, as in the docs.`);
153
+ }
154
+
155
+ // export const config = { matcher, runtime }
156
+ const config = findExportedConst(sf, "config");
157
+ if (!config) {
158
+ const block = `\nexport const config = {\n matcher: [\n {\n source: "${DEFAULT_SOURCE}",\n missing: ${MISSING_RULE},\n },\n ],\n runtime: "experimental-edge",\n};\n`;
159
+ edits.push({ start: text.length, end: text.length, text: (text.endsWith("\n") ? "" : "\n") + block });
160
+ record(undefined, "Add the middleware config: the default matcher of the docs with the missing header rule, and runtime \"experimental-edge\". Before, the middleware ran on all paths.");
161
+ } else if (!ts.isObjectLiteralExpression(config)) {
162
+ manual.push(`${rel}: the middleware config is not an object literal. Add missing: ${MISSING_RULE} to each matcher, and runtime: "experimental-edge".`);
163
+ } else {
164
+ const matcher = findProperty(config, "matcher");
165
+ if (!matcher) {
166
+ edits.push(insertProperty(sf, config, `matcher: [{ source: "${DEFAULT_SOURCE}", missing: ${MISSING_RULE} }]`));
167
+ record(config, "Add a matcher with the missing header rule.");
168
+ } else if (ts.isPropertyAssignment(matcher)) {
169
+ const value = matcher.initializer;
170
+ if (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)) {
171
+ edits.push({ start: value.getStart(sf), end: value.end, text: `[{ source: ${value.getText(sf)}, missing: ${MISSING_RULE} }]` });
172
+ record(matcher, "Change the matcher to an object with the missing header rule.");
173
+ } else if (ts.isArrayLiteralExpression(value)) {
174
+ for (const element of value.elements) {
175
+ if (ts.isStringLiteral(element) || ts.isNoSubstitutionTemplateLiteral(element)) {
176
+ edits.push({ start: element.getStart(sf), end: element.end, text: `{ source: ${element.getText(sf)}, missing: ${MISSING_RULE} }` });
177
+ record(element, "Add the missing header rule to the matcher.");
178
+ } else if (ts.isObjectLiteralExpression(element)) {
179
+ const missing = findProperty(element, "missing");
180
+ if (!missing) {
181
+ edits.push(insertProperty(sf, element, `missing: ${MISSING_RULE}`));
182
+ record(element, "Add the missing header rule to the matcher.");
183
+ } else if (!missing.getText(sf).includes(EDGE_HEADER)) {
184
+ if (ts.isPropertyAssignment(missing) && ts.isArrayLiteralExpression(missing.initializer)) {
185
+ edits.push(insertArrayElement(sf, missing.initializer, `{ type: "header", key: "${EDGE_HEADER}" }`));
186
+ record(missing, "Add the edge origin header to the missing rules of the matcher.");
187
+ } else {
188
+ manual.push(`${rel}:${lineOf(missing)}: add { type: "header", key: "${EDGE_HEADER}" } to the missing rules.`);
189
+ }
190
+ }
191
+ } else {
192
+ manual.push(`${rel}:${lineOf(element)}: add missing: ${MISSING_RULE} to this matcher.`);
193
+ }
194
+ }
195
+ } else {
196
+ manual.push(`${rel}:${lineOf(matcher)}: the matcher is not a literal. Add missing: ${MISSING_RULE} to each matcher.`);
197
+ }
198
+ }
199
+
200
+ const runtime = findProperty(config, "runtime");
201
+ if (!runtime) {
202
+ edits.push(insertProperty(sf, config, 'runtime: "experimental-edge"'));
203
+ record(config, 'Add runtime: "experimental-edge". The edge middleware needs the edge runtime.');
204
+ } else if (!/["'`](experimental-)?edge["'`]/.test(runtime.getText(sf))) {
205
+ manual.push(`${rel}:${lineOf(runtime)}: set runtime to "experimental-edge". The edge middleware needs the edge runtime (vercel/next.js#82344).`);
206
+ }
207
+ }
208
+
209
+ return { text: applyEdits(text, mergeInsertions(edits)), changes, manual };
210
+ }
211
+
212
+ function findExportedConst(sf, name) {
213
+ for (const statement of sf.statements) {
214
+ if (!ts.isVariableStatement(statement)) continue;
215
+ if (!statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) continue;
216
+ for (const declaration of statement.declarationList.declarations) {
217
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === name) {
218
+ let value = declaration.initializer;
219
+ while (value && (ts.isSatisfiesExpression?.(value) || ts.isAsExpression(value) || ts.isParenthesizedExpression(value))) value = value.expression;
220
+ return value;
221
+ }
222
+ }
223
+ }
224
+ return undefined;
225
+ }
226
+
227
+ function findProperty(object, name) {
228
+ return object.properties.find((p) => p.name && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && p.name.text === name);
229
+ }
230
+
231
+ // Route files: createUniformStaticParams and createPreviewPOSTRouteHandler
232
+
233
+ export function migrateRouteFile(root, file, text) {
234
+ const sf = parse(file, text);
235
+ const rel = path.relative(root, file);
236
+ const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
237
+ const edits = [];
238
+ const changes = [];
239
+ const manual = [];
240
+ const locals = sdkImports(sf);
241
+ const isCall = (node, imported) =>
242
+ ts.isCallExpression(node) && ts.isIdentifier(node.expression) && locals.get(node.expression.text)?.imported === imported;
243
+ let needsExpireImport = false;
244
+ const hasExpireImport = [...locals.values()].some((info) => info.imported === "expireVercelRuntimeCacheTags");
245
+
246
+ const visit = (node) => {
247
+ if (isCall(node, "createUniformStaticParams")) {
248
+ const options = node.arguments[0];
249
+ if (options && ts.isObjectLiteralExpression(options)) {
250
+ const edgeMode = findProperty(options, "edgeMode");
251
+ if (!edgeMode) {
252
+ edits.push(insertProperty(sf, options, "edgeMode: [true, false]"));
253
+ changes.push({ file: rel, line: lineOf(node), rule: "edge-static-params", description: "Add edgeMode: [true, false], so that the build prebuilds both edge mode values." });
254
+ } else if (!/\btrue\b/.test(edgeMode.getText(sf))) {
255
+ manual.push(`${rel}:${lineOf(edgeMode)}: add true to the edgeMode option of createUniformStaticParams.`);
256
+ }
257
+ } else {
258
+ manual.push(`${rel}:${lineOf(node)}: add edgeMode: [true, false] to the options of createUniformStaticParams.`);
259
+ }
260
+ }
261
+
262
+ if (isCall(node, "createPreviewPOSTRouteHandler")) {
263
+ const options = node.arguments[0];
264
+ if (!options) {
265
+ const open = sf.getFullText().indexOf("(", node.expression.end);
266
+ edits.push({ start: open + 1, end: node.arguments.end, text: "{ onRevalidateTags: expireVercelRuntimeCacheTags }" });
267
+ changes.push({ file: rel, line: lineOf(node), rule: "edge-preview-route", description: "Expire the Vercel runtime cache when content is published: onRevalidateTags: expireVercelRuntimeCacheTags." });
268
+ needsExpireImport = true;
269
+ } else if (ts.isObjectLiteralExpression(options)) {
270
+ const existing = findProperty(options, "onRevalidateTags");
271
+ if (!existing) {
272
+ edits.push(insertProperty(sf, options, "onRevalidateTags: expireVercelRuntimeCacheTags"));
273
+ changes.push({ file: rel, line: lineOf(node), rule: "edge-preview-route", description: "Expire the Vercel runtime cache when content is published: onRevalidateTags: expireVercelRuntimeCacheTags." });
274
+ needsExpireImport = true;
275
+ } else if (!existing.getText(sf).includes("expireVercelRuntimeCacheTags")) {
276
+ manual.push(`${rel}:${lineOf(existing)}: you have your own onRevalidateTags. Also call expireVercelRuntimeCacheTags(tags) from ${VERCEL} in it.`);
277
+ }
278
+ } else {
279
+ manual.push(`${rel}:${lineOf(node)}: add onRevalidateTags: expireVercelRuntimeCacheTags to the options of createPreviewPOSTRouteHandler.`);
280
+ }
281
+ }
282
+ ts.forEachChild(node, visit);
283
+ };
284
+ visit(sf);
285
+
286
+ if (needsExpireImport && !hasExpireImport) edits.push(...addNamedImport(sf, "expireVercelRuntimeCacheTags", VERCEL));
287
+ return { text: applyEdits(text, mergeInsertions(edits)), changes, manual };
288
+ }
289
+
290
+ // Text edits
291
+
292
+ /** Inserts `property` as the last property of `object`, in the style of the object. */
293
+ function insertProperty(sf, object, property) {
294
+ const text = sf.getFullText();
295
+ const props = object.properties;
296
+ if (!props.length) {
297
+ return { start: object.getStart(sf) + 1, end: object.end - 1, text: ` ${property} ` };
298
+ }
299
+ const last = props[props.length - 1];
300
+ let at = last.end;
301
+ const comma = /^\s*,/.exec(text.slice(at));
302
+ const trailingComma = Boolean(comma);
303
+ if (comma) at += comma[0].length;
304
+ const multiline = text.slice(object.getStart(sf), object.end).includes("\n");
305
+ if (!multiline) return { start: at, end: at, text: `${trailingComma ? "" : ","} ${property}${trailingComma ? "," : ""}` };
306
+ const indent = /([ \t]*)$/.exec(text.slice(0, last.getStart(sf)))[1];
307
+ return { start: at, end: at, text: `${trailingComma ? "" : ","}\n${indent}${property}${trailingComma ? "," : ""}` };
308
+ }
309
+
310
+ function insertArrayElement(sf, array, element) {
311
+ const fake = { properties: array.elements, getStart: (s) => array.getStart(s), end: array.end };
312
+ return insertProperty(sf, fake, element);
313
+ }
314
+
315
+ /** Replaces the import of `info` with `name` from `module`. */
316
+ function replaceImport(sf, info, name, module) {
317
+ const statement = info.statement;
318
+ const named = statement.importClause.namedBindings;
319
+ const quote = statement.moduleSpecifier.getText(sf)[0];
320
+ const line = `import { ${name} } from ${quote}${module}${quote};`;
321
+ if (named.elements.length === 1 && !statement.importClause.name) {
322
+ return [{ start: statement.getStart(sf), end: statement.end, text: line }];
323
+ }
324
+ // Remove the specifier, and add the new import after the statement.
325
+ const elements = [...named.elements];
326
+ const i = elements.indexOf(info.element);
327
+ const removal =
328
+ i < elements.length - 1
329
+ ? { start: info.element.getStart(sf), end: elements[i + 1].getStart(sf), text: "" }
330
+ : { start: elements[i - 1].end, end: info.element.end, text: "" };
331
+ return [removal, { start: statement.end, end: statement.end, text: `\n${line}` }];
332
+ }
333
+
334
+ /** Adds `import { name } from module`, or adds `name` to an existing import from the module. */
335
+ function addNamedImport(sf, name, module) {
336
+ const imports = sf.statements.filter(ts.isImportDeclaration);
337
+ const existing = imports.find((s) => ts.isStringLiteral(s.moduleSpecifier) && s.moduleSpecifier.text === module && s.importClause?.namedBindings && ts.isNamedImports(s.importClause.namedBindings));
338
+ if (existing) {
339
+ const elements = existing.importClause.namedBindings.elements;
340
+ const last = elements[elements.length - 1];
341
+ return [{ start: last.end, end: last.end, text: `, ${name}` }];
342
+ }
343
+ const quote = imports[0]?.moduleSpecifier.getText(sf)[0] ?? '"';
344
+ const line = `import { ${name} } from ${quote}${module}${quote};`;
345
+ const after = imports[imports.length - 1];
346
+ if (after) return [{ start: after.end, end: after.end, text: `\n${line}` }];
347
+ return [{ start: 0, end: 0, text: `${line}\n` }];
348
+ }
349
+
350
+ /** Joins insertions at the same position, so that applyEdits accepts them. */
351
+ function mergeInsertions(edits) {
352
+ const out = [];
353
+ for (const edit of [...edits].sort((a, b) => a.start - b.start || a.end - b.end)) {
354
+ const previous = out[out.length - 1];
355
+ if (previous && previous.start === edit.start && previous.end === previous.start && edit.end === edit.start) {
356
+ previous.text += edit.text;
357
+ } else out.push({ ...edit });
358
+ }
359
+ return out;
360
+ }
@@ -0,0 +1,111 @@
1
+ // The interactive mode: a guide through the check, phase 1 (the SDK upgrade) and phase 2 (edge mode).
2
+
3
+ import { spawnSync } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { createInterface } from "node:readline/promises";
7
+
8
+ import { PROVIDERS } from "./edge/index.js";
9
+ import { formatText, TARGET_VERSION, verdict } from "./report.js";
10
+ import { colors, executeMigration, NAME, projectStatus } from "./session.js";
11
+
12
+ export async function runInteractive({ root, color, uniformData }) {
13
+ const c = colors(color);
14
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
15
+ const ask = async (question, defaultYes = false) => {
16
+ const answer = (await rl.question(`${c("36", "?")} ${question} ${c("2", defaultYes ? "[Y/n]" : "[y/N]")} `)).trim();
17
+ return answer ? /^y(es)?$/i.test(answer) : defaultYes;
18
+ };
19
+ const choose = async (question, options) => {
20
+ console.log(`${c("36", "?")} ${c("1", question)}`);
21
+ options.forEach((option, i) => {
22
+ const label = option.disabled ? c("2", `${option.label} (${option.hint})`) : `${option.label}${option.hint ? c("2", ` ${option.hint}`) : ""}`;
23
+ console.log(` ${c("36", String(i + 1))}) ${label}`);
24
+ });
25
+ for (;;) {
26
+ const answer = Number((await rl.question(` Select 1-${options.length}: `)).trim());
27
+ const option = options[answer - 1];
28
+ if (option) {
29
+ console.log("");
30
+ return option;
31
+ }
32
+ }
33
+ };
34
+
35
+ try {
36
+ for (;;) {
37
+ const status = projectStatus(root, { uniformData });
38
+ printHeader(root, status, c);
39
+
40
+ const phase1Hint = status.phase1Pending ? `${status.phase1Pending} automatic change(s) to do` : "done";
41
+ const phase2Hint = status.edgeMode ? `edge mode is on (${status.edgeMode})` : "lite mode now";
42
+ const action = await choose("What do you want to do?", [
43
+ { value: "check", label: "Check compatibility", hint: "a full report, no changes" },
44
+ { value: "phase1", label: "Phase 1: upgrade the SDK", hint: phase1Hint },
45
+ { value: "phase2", label: "Phase 2: turn on edge mode", hint: phase2Hint },
46
+ { value: "exit", label: "Exit" },
47
+ ]);
48
+
49
+ if (action.value === "exit") return 0;
50
+
51
+ if (action.value === "check") {
52
+ console.log(formatText(status.scan, verdict(status.scan.findings), { color }));
53
+ console.log("");
54
+ await rl.question(c("2", "Press Enter to go back to the menu. "));
55
+ continue;
56
+ }
57
+
58
+ let edge;
59
+ if (action.value === "phase2") {
60
+ const host = await choose("Which host serves your app?", [
61
+ ...Object.entries(PROVIDERS).map(([key, provider]) => ({
62
+ value: key,
63
+ label: provider.name,
64
+ hint: provider.available ? provider.summary : "coming soon",
65
+ disabled: !provider.available,
66
+ })),
67
+ { value: "back", label: "Back" },
68
+ ]);
69
+ if (host.value === "back") continue;
70
+ if (host.disabled) {
71
+ console.log(`${PROVIDERS[host.value].name} is not in this CLI yet. To set up edge mode by hand, follow the docs:`);
72
+ console.log(c("36", ` ${PROVIDERS[host.value].docs}`));
73
+ console.log("");
74
+ continue;
75
+ }
76
+ edge = host.value;
77
+ if (status.phase1Pending) {
78
+ console.log(c("33", `Phase 1 is not done. The plan includes its ${status.phase1Pending} change(s) too.`));
79
+ console.log("");
80
+ }
81
+ }
82
+
83
+ const code = await executeMigration({ root, edge, color, uniformData, ask, interactive: true, diff: "ask" });
84
+ if (code !== 2 && fs.existsSync(path.join(root, "package.json"))) await offerInstall(root, ask, c);
85
+ await rl.question(c("2", "Press Enter to go back to the menu. "));
86
+ }
87
+ } finally {
88
+ rl.close();
89
+ }
90
+ }
91
+
92
+ function printHeader(root, status, c) {
93
+ const { scan } = status;
94
+ const result = verdict(scan.findings);
95
+ console.log("");
96
+ console.log(c("1", "Uniform Next.js App Router SDK upgrade"));
97
+ console.log(` Project ${scan.project.name ?? path.basename(root)} ${c("2", `(${root})`)}`);
98
+ console.log(` SDK ${status.sdkVersion}${status.atTarget ? c("32", " (target version)") : c("2", ` → ${TARGET_VERSION}`)}`);
99
+ console.log(` Check ${c(result.safe ? "32" : "31", result.label)} ${c("2", `(${result.errors} error(s), ${result.warnings} warning(s))`)}`);
100
+ console.log(` Mode ${status.edgeMode ? c("32", `edge mode (${status.edgeMode})`) : "lite mode: the browser chooses the variants after hydration"}`);
101
+ console.log("");
102
+ }
103
+
104
+ async function offerInstall(root, ask, c) {
105
+ const manager = fs.existsSync(path.join(root, "pnpm-lock.yaml")) ? "pnpm" : fs.existsSync(path.join(root, "yarn.lock")) ? "yarn" : "npm";
106
+ if (!(await ask(`Run "${manager} install" now?`, false))) return;
107
+ const result = spawnSync(manager, ["install"], { cwd: root, stdio: "inherit", shell: process.platform === "win32" });
108
+ console.log(result.status === 0 ? c("32", "Install done.") : c("31", `${manager} install failed. Run it yourself, and read the error.`));
109
+ console.log(c("2", `Then run: ${NAME} check`));
110
+ console.log("");
111
+ }
@@ -0,0 +1,95 @@
1
+ // Small edits to JSON text that keep the formatting of all other lines.
2
+
3
+ /** Returns [start, end] of the object value of a top-level key, from "{" to "}" inclusive. */
4
+ export function findTopLevelObject(text, key) {
5
+ let depth = 0;
6
+ for (let i = 0; i < text.length; i++) {
7
+ const ch = text[i];
8
+ if (ch === '"') {
9
+ const end = endOfString(text, i);
10
+ if (depth === 1) {
11
+ const name = JSON.parse(text.slice(i, end + 1));
12
+ const rest = /^\s*:\s*/.exec(text.slice(end + 1));
13
+ if (name === key && rest && text[end + 1 + rest[0].length] === "{") {
14
+ const start = end + 1 + rest[0].length;
15
+ return [start, matchBrace(text, start)];
16
+ }
17
+ }
18
+ i = end;
19
+ } else if (ch === "{" || ch === "[") depth++;
20
+ else if (ch === "}" || ch === "]") depth--;
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ function endOfString(text, start) {
26
+ for (let i = start + 1; i < text.length; i++) {
27
+ if (text[i] === "\\") i++;
28
+ else if (text[i] === '"') return i;
29
+ }
30
+ throw new Error("Unterminated string in JSON");
31
+ }
32
+
33
+ function matchBrace(text, start) {
34
+ let depth = 0;
35
+ for (let i = start; i < text.length; i++) {
36
+ if (text[i] === '"') i = endOfString(text, i);
37
+ else if (text[i] === "{" || text[i] === "[") depth++;
38
+ else if (text[i] === "}" || text[i] === "]") {
39
+ depth--;
40
+ if (depth === 0) return i;
41
+ }
42
+ }
43
+ throw new Error("Unbalanced JSON");
44
+ }
45
+
46
+ const quote = (s) => JSON.stringify(s);
47
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
48
+
49
+ /** Changes the value of `name` inside the top-level object `block`. Returns the new text, or undefined. */
50
+ export function setValueInBlock(text, block, name, value) {
51
+ const range = findTopLevelObject(text, block);
52
+ if (!range) return undefined;
53
+ const inner = text.slice(range[0], range[1] + 1);
54
+ const re = new RegExp(`(${escapeRe(quote(name))}\\s*:\\s*)"(?:[^"\\\\]|\\\\.)*"`);
55
+ if (!re.test(inner)) return undefined;
56
+ return text.slice(0, range[0]) + inner.replace(re, `$1${quote(value)}`) + text.slice(range[1] + 1);
57
+ }
58
+
59
+ /**
60
+ * Adds `name: value` to the top-level object `block`, in alphabetical order when the keys are in
61
+ * alphabetical order. Adds the block when it does not exist.
62
+ */
63
+ export function addToBlock(text, block, name, value) {
64
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
65
+ const unit = /^([ \t]+)"/m.exec(text)?.[1] ?? " ";
66
+ const range = findTopLevelObject(text, block);
67
+
68
+ if (!range) {
69
+ // Add the block before the last "}" of the file.
70
+ const close = text.lastIndexOf("}");
71
+ const before = text.slice(0, close).replace(/\s*$/, "");
72
+ const entry = `${unit}${quote(block)}: {${eol}${unit}${unit}${quote(name)}: ${quote(value)}${eol}${unit}}`;
73
+ return `${before},${eol}${entry}${eol}${text.slice(close)}`;
74
+ }
75
+
76
+ const [start, end] = range;
77
+ const body = text.slice(start + 1, end);
78
+ const keys = [...body.matchAll(/"((?:[^"\\]|\\.)*)"\s*:/g)].map((m) => ({ name: JSON.parse(`"${m[1]}"`), index: start + 1 + m.index }));
79
+ if (!keys.length) return text.slice(0, start) + `{ ${quote(name)}: ${quote(value)} }` + text.slice(end + 1);
80
+
81
+ const indent = /([ \t]*)$/.exec(text.slice(0, keys[0].index))[1];
82
+ const multiline = body.includes("\n");
83
+ const sorted = keys.every((key, i) => i === 0 || keys[i - 1].name.localeCompare(key.name) <= 0);
84
+ const next = sorted ? keys.find((key) => key.name.localeCompare(name) > 0) : undefined;
85
+
86
+ if (next) {
87
+ const entry = multiline ? `${quote(name)}: ${quote(value)},${eol}${indent}` : `${quote(name)}: ${quote(value)}, `;
88
+ return text.slice(0, next.index) + entry + text.slice(next.index);
89
+ }
90
+ // Add after the last value.
91
+ const lastValueEnd = start + 1 + body.replace(/\s*$/, "").length;
92
+ const hasComma = text[lastValueEnd - 1] === ",";
93
+ const entry = multiline ? `${hasComma ? "" : ","}${eol}${indent}${quote(name)}: ${quote(value)}` : `${hasComma ? "" : ","} ${quote(name)}: ${quote(value)}`;
94
+ return text.slice(0, lastValueEnd) + entry + text.slice(lastValueEnd);
95
+ }