@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.
package/src/scan.js ADDED
@@ -0,0 +1,443 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import ts from "typescript";
4
+
5
+ import {
6
+ REMOVED_CONFIG_KEYS,
7
+ REMOVED_EXPORTS,
8
+ REMOVED_OPTIONS,
9
+ REMOVED_PAGE_STATE_FIELDS,
10
+ RULES,
11
+ } from "./rules.js";
12
+
13
+ const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"]);
14
+ const SKIP_DIRS = new Set(["node_modules", ".next", ".git", "dist", "build", "out", "coverage", ".vercel", ".turbo"]);
15
+ const SDK = "@uniformdev/next-app-router";
16
+ const RESOLVERS = new Set(["resolveRouteFromCode", "resolvePlaygroundRoute"]);
17
+ const LITE_MIDDLEWARE = new Set(["uniformMiddleware", "handleUniformRoute"]);
18
+ const MIN_NEXT = [16, 0, 7];
19
+
20
+ /**
21
+ * Scans the project at `root` and returns { project, findings }.
22
+ * `contents` maps absolute file paths to text that replaces the file on disk. The migration uses it
23
+ * to scan its result before it writes the files.
24
+ */
25
+ export function scanProject(root, { uniformDataDir, contents } = {}) {
26
+ const findings = [];
27
+ const add = (rule, details) => findings.push({ rule, ...RULES[rule], ...details });
28
+ const read = (file) => contents?.get(file) ?? fs.readFileSync(file, "utf8");
29
+
30
+ const pkg = readPackageJson(root, read);
31
+ const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
32
+ const project = {
33
+ root,
34
+ name: pkg?.name,
35
+ sdkVersion: deps[SDK],
36
+ usesV1: Boolean(deps["@uniformdev/canvas-next-rsc"]),
37
+ filesScanned: 0,
38
+ compositionsWithPersonalization: 0,
39
+ };
40
+
41
+ checkPackageJson(pkg, deps, add);
42
+
43
+ const files = listSourceFiles(root);
44
+ project.filesScanned = files.length;
45
+ const state = { usesLiteMiddleware: false, importsVercelFunctions: [] };
46
+ for (const file of files) {
47
+ scanSourceFile(root, file, add, state, read);
48
+ }
49
+
50
+ if (state.importsVercelFunctions.length && !deps["@vercel/functions"]) {
51
+ for (const where of state.importsVercelFunctions) {
52
+ add("vercel-functions-transitive", {
53
+ ...where,
54
+ message: "The app imports @vercel/functions, but package.json does not list it. Version 20.82 installed it as a dependency of the SDK. The developer preview makes it an optional peer dependency.",
55
+ fix: "Add @vercel/functions to the dependencies in package.json.",
56
+ });
57
+ }
58
+ }
59
+
60
+ scanUniformData(root, uniformDataDir, project, add);
61
+ scanNotFoundRoutes(root, files, add);
62
+
63
+ if (state.usesLiteMiddleware) {
64
+ const count = project.compositionsWithPersonalization;
65
+ add("lite-mode-flicker", {
66
+ ...state.liteMiddlewareLocation,
67
+ message:
68
+ "Version 20.82 chose personalization and test variants in the middleware. With uniformMiddleware or handleUniformRoute, the developer preview renders the default variants on the server, and the browser changes them after hydration." +
69
+ (count ? ` The uniform-data folder has ${count} composition(s) with a personalization or a test.` : ""),
70
+ fix: "No code change is required. To keep the first paint correct, change to vercelUniformEdgeMiddleware (edge mode).",
71
+ });
72
+ }
73
+
74
+ if (project.sdkVersion) {
75
+ add("not-found-html", {
76
+ message:
77
+ "Version 20.82 rewrote missing routes to /404 in the middleware. The developer preview calls notFound() while the page renders. The response is still 404, but Next.js sends an empty HTML shell, and the 404 content (also app/not-found.tsx) shows only after hydration. Tested with Next.js 16.2.10 and 16.4.0.",
78
+ fix: "No code change is required. Visitors without JavaScript see an empty 404 page.",
79
+ });
80
+ add("redirect-status", {
81
+ message: "The page applies Uniform redirects. A 301 or 308 redirect gives a 308, and other redirects give a 307. A missing route gives notFound().",
82
+ fix: "No change is required.",
83
+ });
84
+ }
85
+
86
+ return { project, findings };
87
+ }
88
+
89
+ function readPackageJson(root, read = (file) => fs.readFileSync(file, "utf8")) {
90
+ try {
91
+ return JSON.parse(read(path.join(root, "package.json")));
92
+ } catch {
93
+ return undefined;
94
+ }
95
+ }
96
+
97
+ function parseVersion(range) {
98
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(range ?? "");
99
+ return match ? match.slice(1, 4).map(Number) : undefined;
100
+ }
101
+
102
+ function isLower(a, b) {
103
+ for (let i = 0; i < 3; i++) {
104
+ if (a[i] !== b[i]) return a[i] < b[i];
105
+ }
106
+ return false;
107
+ }
108
+
109
+ function checkPackageJson(pkg, deps, add) {
110
+ if (!pkg) return;
111
+ const where = { file: "package.json" };
112
+
113
+ const next = parseVersion(deps.next);
114
+ if (next && isLower(next, MIN_NEXT)) {
115
+ add("next-version", {
116
+ ...where,
117
+ message: `package.json has next ${deps.next}. The developer preview requires Next.js 16.0.7 or later.`,
118
+ fix: "Upgrade Next.js to 16.0.7 or later.",
119
+ });
120
+ }
121
+
122
+ // @uniformdev/toolbar-* and @uniformdev/context-edge have their own version lines.
123
+ const uniform = Object.entries(deps).filter(
124
+ ([name]) => name.startsWith("@uniformdev/") && !name.startsWith("@uniformdev/toolbar") && name !== "@uniformdev/context-edge"
125
+ );
126
+ const versions = new Set(uniform.map(([, version]) => version));
127
+ if (versions.size > 1) {
128
+ add("uniform-version-mismatch", {
129
+ ...where,
130
+ message: `The @uniformdev packages have different versions: ${uniform.map(([n, v]) => `${n}@${v}`).join(", ")}. Different versions can install two copies of @uniformdev/context.`,
131
+ fix: "Install all @uniformdev packages with the same version.",
132
+ });
133
+ }
134
+
135
+ if (deps["@uniformdev/toolbar-react"] && !pkg.overrides?.["@uniformdev/context"]) {
136
+ add("prerelease-peer-range", {
137
+ ...where,
138
+ message: "@uniformdev/toolbar-react has the peer range @uniformdev/context ^20.72. That range does not accept prerelease versions, so npm shows ERESOLVE for a developer preview version. A stable release is not affected.",
139
+ fix: 'For a prerelease version, add "overrides": { "@uniformdev/context": "$@uniformdev/context" } to package.json.',
140
+ });
141
+ }
142
+
143
+ if (deps["@uniformdev/context-edge"]) {
144
+ add("context-edge-dependency", {
145
+ ...where,
146
+ message: "The edge and vercel entry points of @uniformdev/next-app-router contain @uniformdev/context-edge.",
147
+ fix: "You can remove @uniformdev/context-edge from package.json, if the app does not import it.",
148
+ });
149
+ }
150
+ }
151
+
152
+ export function listSourceFiles(root) {
153
+ const out = [];
154
+ const walk = (dir) => {
155
+ let entries;
156
+ try {
157
+ entries = fs.readdirSync(dir, { withFileTypes: true });
158
+ } catch {
159
+ return;
160
+ }
161
+ for (const entry of entries) {
162
+ if (entry.isDirectory()) {
163
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) walk(path.join(dir, entry.name));
164
+ } else if (SOURCE_EXTENSIONS.has(path.extname(entry.name)) && !entry.name.endsWith(".d.ts")) {
165
+ out.push(path.join(dir, entry.name));
166
+ }
167
+ }
168
+ };
169
+ walk(root);
170
+ return out.sort();
171
+ }
172
+
173
+ export function scriptKind(file) {
174
+ const ext = path.extname(file);
175
+ if (ext === ".tsx") return ts.ScriptKind.TSX;
176
+ if (ext === ".jsx") return ts.ScriptKind.JSX;
177
+ if (ext === ".ts" || ext === ".mts" || ext === ".cts") return ts.ScriptKind.TS;
178
+ return ts.ScriptKind.JS;
179
+ }
180
+
181
+ function scanSourceFile(root, file, add, state, read) {
182
+ const text = read(file);
183
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind(file));
184
+ const rel = path.relative(root, file);
185
+ const at = (node) => ({ file: rel, line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1 });
186
+
187
+ const isConfigFile = /^uniform\.server\.config\.[cm]?[jt]s$/.test(path.basename(file));
188
+ const isMiddlewareFile = /^(src\/)?(middleware|proxy)\.[cm]?[jt]s$/.test(rel.split(path.sep).join("/"));
189
+
190
+ // Local names of SDK imports: local name -> { module, imported }.
191
+ const sdkLocals = new Map();
192
+ const sdkNamespaces = new Map();
193
+ let importsSdk = false;
194
+
195
+ for (const statement of sf.statements) {
196
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
197
+ const module = statement.moduleSpecifier.text;
198
+
199
+ if (module === "@vercel/functions" || module.startsWith("@vercel/functions/")) {
200
+ state.importsVercelFunctions.push(at(statement));
201
+ }
202
+ if (!module.startsWith(SDK)) continue;
203
+ importsSdk = true;
204
+
205
+ if (module === `${SDK}/cache`) {
206
+ add("cached-resolver", {
207
+ ...at(statement),
208
+ message: "The resolvers of @uniformdev/next-app-router/cache now use 'use cache' only for published content. Draft and editor requests are not cached.",
209
+ fix: "No change is required.",
210
+ });
211
+ }
212
+
213
+ const bindings = statement.importClause?.namedBindings;
214
+ if (bindings && ts.isNamespaceImport(bindings)) {
215
+ sdkNamespaces.set(bindings.name.text, module);
216
+ }
217
+ if (bindings && ts.isNamedImports(bindings)) {
218
+ for (const element of bindings.elements) {
219
+ const imported = (element.propertyName ?? element.name).text;
220
+ sdkLocals.set(element.name.text, { module, imported });
221
+ checkImportedName(module, imported, element, add, at, state);
222
+ }
223
+ }
224
+ }
225
+
226
+ if (isConfigFile) importsSdk = true;
227
+
228
+ const resultVariables = new Set();
229
+
230
+ const visit = (node) => {
231
+ // ns.removedExport
232
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
233
+ const module = sdkNamespaces.get(node.expression.text);
234
+ if (module) checkImportedName(module, node.name.text, node, add, at, state);
235
+ }
236
+
237
+ // Removed options: { dataClient } and { pathPatternsWithVariations }.
238
+ if (importsSdk && (ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node))) {
239
+ const name = propertyName(node.name);
240
+ if (name && REMOVED_OPTIONS[name]) {
241
+ add("removed-option", { ...at(node), message: `The option "${name}" is removed.`, fix: REMOVED_OPTIONS[name] });
242
+ }
243
+ if (name && REMOVED_CONFIG_KEYS[name] && (isConfigFile || isServerConfigContext(node))) {
244
+ add("removed-config-key", { ...at(node), message: `The server configuration option "${name}" is removed.`, fix: REMOVED_CONFIG_KEYS[name] });
245
+ }
246
+ if (name === "defaultConsent" && isMiddlewareFile && !isBooleanLiteral(node)) {
247
+ add("per-request-consent", {
248
+ ...at(node),
249
+ message: "The middleware writes the default consent into the page code. Each value makes a different cached page.",
250
+ fix: "If you use createUniformStaticParams, list each value in its defaultConsent option.",
251
+ });
252
+ }
253
+ }
254
+
255
+ // <UniformComposition dataClient={...} />
256
+ if (ts.isJsxAttribute(node) && importsSdk) {
257
+ const name = node.name.getText(sf);
258
+ if (REMOVED_OPTIONS[name]) {
259
+ add("removed-option", { ...at(node), message: `The prop "${name}" is removed.`, fix: REMOVED_OPTIONS[name] });
260
+ }
261
+ }
262
+
263
+ // pageState.components and the other removed fields.
264
+ if (ts.isPropertyAccessExpression(node) && REMOVED_PAGE_STATE_FIELDS.includes(node.name.text)) {
265
+ if (endsWithPageState(node.expression)) {
266
+ add("removed-page-state-field", {
267
+ ...at(node),
268
+ message: `pageState.${node.name.text} is removed. The page code no longer contains evaluation results.`,
269
+ fix: node.name.text === "quirks" ? "Read quirks in the browser with useQuirks()." : "No replacement.",
270
+ });
271
+ }
272
+ }
273
+ if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name) && node.initializer && endsWithPageState(node.initializer)) {
274
+ for (const element of node.name.elements) {
275
+ const name = (element.propertyName ?? element.name).getText(sf);
276
+ if (REMOVED_PAGE_STATE_FIELDS.includes(name)) {
277
+ add("removed-page-state-field", { ...at(element), message: `pageState.${name} is removed.`, fix: "No replacement." });
278
+ }
279
+ }
280
+ }
281
+
282
+ // const result = await resolveRouteFromCode(...); result.code
283
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
284
+ const call = unwrapAwait(node.initializer);
285
+ if (call && ts.isCallExpression(call) && ts.isIdentifier(call.expression)) {
286
+ const local = sdkLocals.get(call.expression.text);
287
+ if (local && RESOLVERS.has(local.imported)) resultVariables.add(node.name.text);
288
+ }
289
+ }
290
+ const isResolverCall = (expression) => {
291
+ const call = unwrapAwait(expression);
292
+ if (!call || !ts.isCallExpression(call) || !ts.isIdentifier(call.expression)) return false;
293
+ return RESOLVERS.has(sdkLocals.get(call.expression.text)?.imported);
294
+ };
295
+ if (
296
+ ts.isPropertyAccessExpression(node) &&
297
+ node.name.text === "code" &&
298
+ ((ts.isIdentifier(node.expression) && resultVariables.has(node.expression.text)) || isResolverCall(node.expression))
299
+ ) {
300
+ add("result-code", { ...at(node), message: "ResolvedRouteResult no longer has a code field.", fix: "Keep the code from the [code] route parameter." });
301
+ }
302
+
303
+ // result.route.compositionApiResponse without a type check.
304
+ if (importsSdk && ts.isPropertyAccessExpression(node) && node.name.text === "compositionApiResponse") {
305
+ const target = node.expression;
306
+ if (ts.isPropertyAccessExpression(target) && target.name.text === "route" && !isNarrowedToComposition(node)) {
307
+ add("route-union", {
308
+ ...at(node),
309
+ message: "result.route can now also be a redirect, so TypeScript does not accept route.compositionApiResponse without a check.",
310
+ fix: 'Check result.route?.type === "composition" first, or call requireComposition(result).',
311
+ });
312
+ }
313
+ }
314
+
315
+ // Hard-coded old page codes.
316
+ if ((ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) && /(^|\/)2~\d+~/.test(node.text)) {
317
+ add("old-page-code", { ...at(node), message: `The string "${node.text.slice(0, 60)}" has a page code in the old format. Old codes do not work.`, fix: "Link to the path of the page, not to /uniform/<code>." });
318
+ }
319
+
320
+ // Lite middleware.
321
+ if (isMiddlewareFile && ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
322
+ const local = sdkLocals.get(node.expression.text);
323
+ if (local && local.module === `${SDK}/middleware` && LITE_MIDDLEWARE.has(local.imported)) {
324
+ state.usesLiteMiddleware = true;
325
+ state.liteMiddlewareLocation ??= at(node);
326
+ }
327
+ }
328
+
329
+ ts.forEachChild(node, visit);
330
+ };
331
+ visit(sf);
332
+ }
333
+
334
+ function checkImportedName(module, imported, node, add, at) {
335
+ const removed = REMOVED_EXPORTS[module]?.[imported];
336
+ if (removed) {
337
+ add("removed-export", { ...at(node), message: `"${imported}" is no longer exported from ${module}.`, fix: removed });
338
+ }
339
+ if ((imported === "PersonalizeProps" || imported === "TestProps") && module.startsWith(SDK)) {
340
+ add("placement-props", {
341
+ ...at(node),
342
+ message: `${imported} changed: indexes is now defaultIndexes, index is now defaultIndex, and PersonalizeProps has a new personalization prop.`,
343
+ fix: "Update the custom placement component.",
344
+ });
345
+ }
346
+ if (imported === "createUniformStaticParams" || imported === "createUniformPlaygroundStaticParams") {
347
+ add("static-params", {
348
+ ...at(node),
349
+ message: `${imported} now makes one code for each path (and each defaultConsent and edgeMode value), not one for each variant combination.`,
350
+ fix: "No change is required. With edge mode, use edgeMode: [true, false].",
351
+ });
352
+ }
353
+ }
354
+
355
+ function propertyName(name) {
356
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
357
+ return undefined;
358
+ }
359
+
360
+ function isBooleanLiteral(node) {
361
+ return ts.isPropertyAssignment(node) && (node.initializer.kind === ts.SyntaxKind.TrueKeyword || node.initializer.kind === ts.SyntaxKind.FalseKeyword);
362
+ }
363
+
364
+ function isServerConfigContext(node) {
365
+ for (let parent = node.parent; parent; parent = parent.parent) {
366
+ if (ts.isVariableDeclaration(parent) && parent.type && /UniformServerConfig/.test(parent.type.getText())) return true;
367
+ if (ts.isSatisfiesExpression?.(parent) && /UniformServerConfig/.test(parent.type.getText())) return true;
368
+ }
369
+ return false;
370
+ }
371
+
372
+ function endsWithPageState(expression) {
373
+ if (ts.isIdentifier(expression)) return expression.text === "pageState";
374
+ if (ts.isPropertyAccessExpression(expression)) return expression.name.text === "pageState";
375
+ return false;
376
+ }
377
+
378
+ function unwrapAwait(expression) {
379
+ let current = expression;
380
+ while (current && (ts.isAwaitExpression(current) || ts.isParenthesizedExpression(current))) current = current.expression;
381
+ return current;
382
+ }
383
+
384
+ // True when an enclosing if statement or conditional checks `.type === "composition"`.
385
+ function isNarrowedToComposition(node) {
386
+ for (let parent = node.parent; parent; parent = parent.parent) {
387
+ const condition = ts.isIfStatement(parent) || ts.isConditionalExpression(parent) ? parent.expression ?? parent.condition : undefined;
388
+ if (condition && /\.type\s*===?\s*["']composition["']/.test(condition.getText())) return true;
389
+ if (ts.isFunctionLike(parent)) {
390
+ const body = parent.body?.getText() ?? "";
391
+ if (/requireComposition\(/.test(body) || /\.type\s*!==?\s*["']composition["']/.test(body)) return true;
392
+ }
393
+ }
394
+ return false;
395
+ }
396
+
397
+ function scanUniformData(root, uniformDataDir, project, add) {
398
+ const dir = path.resolve(root, uniformDataDir ?? "uniform-data");
399
+ if (!fs.existsSync(dir)) return;
400
+
401
+ const readYaml = (sub) => {
402
+ const folder = path.join(dir, sub);
403
+ if (!fs.existsSync(folder)) return [];
404
+ return fs
405
+ .readdirSync(folder)
406
+ .filter((f) => /\.(ya?ml|json)$/.test(f))
407
+ .map((f) => ({ file: path.relative(root, path.join(folder, f)), text: fs.readFileSync(path.join(folder, f), "utf8") }));
408
+ };
409
+
410
+ for (const { file, text } of readYaml("projectMapNode")) {
411
+ if (/queryStrings\s*:/.test(text) && !/queryStrings\s*:\s*\[\s*\]/.test(text)) {
412
+ add("project-map-query-strings", {
413
+ file,
414
+ message: "This project map node declares query strings. The developer preview middleware no longer reads them, and removes all query strings from the route path.",
415
+ fix: "List the query strings in the queryStrings option of the middleware.",
416
+ });
417
+ }
418
+ }
419
+
420
+ let withViz = 0;
421
+ for (const { text } of [...readYaml("composition"), ...readYaml("compositionPattern"), ...readYaml("componentPattern")]) {
422
+ if (/\$personalization|\$test\b/.test(text)) project.compositionsWithPersonalization++;
423
+ if (/\$viz/.test(text)) withViz++;
424
+ }
425
+ if (withViz) {
426
+ add("visibility-rules", {
427
+ file: path.relative(root, dir),
428
+ message: `${withViz} composition(s) or pattern(s) use visibility rules. These components render only in the browser, so they are not in the server HTML.`,
429
+ fix: "Do not put content for search engines in components with visibility rules.",
430
+ });
431
+ }
432
+ }
433
+
434
+ function scanNotFoundRoutes(root, files, add) {
435
+ const notFoundRoutes = files.filter((f) => /(^|\/)app\/(.*\/)?404\/page\.[jt]sx?$/.test(path.relative(root, f).split(path.sep).join("/")));
436
+ for (const file of notFoundRoutes) {
437
+ add("not-found-page", {
438
+ file: path.relative(root, file),
439
+ message: "Version 20.82 rewrote missing routes to /404. The developer preview calls notFound() on the page, so this route no longer renders for missing pages.",
440
+ fix: "Move the 404 content to app/not-found.tsx.",
441
+ });
442
+ }
443
+ }
package/src/session.js ADDED
@@ -0,0 +1,194 @@
1
+ // The migration flow, shared by `migrate` and the interactive mode: plan, show, check, write.
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ import { gitState, unifiedDiff, validate, writeAll } from "./apply.js";
7
+ import { PROVIDERS } from "./edge/index.js";
8
+ import { planMigration, uniformDependencies } from "./migrate.js";
9
+ import { publishedAt } from "./registry.js";
10
+ import { TARGET_VERSION } from "./report.js";
11
+ import { scanProject } from "./scan.js";
12
+
13
+ export const NAME = "uniform-next-app-router-upgrade";
14
+
15
+ export function colors(enabled) {
16
+ return (code, s) => (enabled ? `\u001b[${code}m${s}\u001b[0m` : s);
17
+ }
18
+
19
+ /** Phase 1, and phase 2 when `edge` names a provider, as one plan. */
20
+ export async function buildPlan(root, { edge, uniformData } = {}) {
21
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
22
+ const published = await publishedAt(uniformDependencies(pkg), TARGET_VERSION);
23
+ const phase1 = planMigration(root, { published });
24
+ let changes = phase1.changes.map((change) => ({ ...change, phase: 1 }));
25
+ let contents = phase1.contents;
26
+ let steps = [...phase1.manual];
27
+ let reminders = [];
28
+
29
+ if (edge) {
30
+ const phase2 = PROVIDERS[edge].plan(root, { base: phase1.contents });
31
+ changes = [...changes, ...phase2.changes.map((change) => ({ ...change, phase: 2 }))];
32
+ contents = new Map([...phase1.contents, ...phase2.contents]);
33
+ steps = [...steps, ...phase2.manual];
34
+ reminders = phase2.reminders ?? [];
35
+ }
36
+
37
+ const after = scanProject(root, { uniformDataDir: uniformData, contents });
38
+ return {
39
+ changes,
40
+ contents,
41
+ steps,
42
+ reminders,
43
+ manual: after.findings.filter((f) => f.severity === "error"),
44
+ reviews: after.findings.filter((f) => f.severity === "warning"),
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Runs the migration and returns an exit code: 0 no code changes left, 1 manual changes left,
50
+ * 2 nothing was written.
51
+ * `ask(question, defaultYes)` returns a promise of true or false. Without it, the flow uses the
52
+ * options only.
53
+ */
54
+ export async function executeMigration({ root, edge, dryRun, yes, allowDirty, color, uniformData, ask, interactive = false, diff = true }) {
55
+ const c = colors(color);
56
+ const plan = await buildPlan(root, { edge, uniformData });
57
+ const { changes, contents, steps, reminders, manual, reviews } = plan;
58
+
59
+ printChanges(root, changes, c);
60
+ if (changes.length && (diff === true || (diff === "ask" && ask && (await ask("Show the diff?", true))))) {
61
+ for (const [file, text] of contents) {
62
+ console.log(unifiedDiff(path.relative(root, file), fs.readFileSync(file, "utf8"), text, { color }));
63
+ console.log("");
64
+ }
65
+ }
66
+ printManual(manual, reviews, steps, c);
67
+ if (reminders.length) {
68
+ console.log(c("36;1", "Reminders:"));
69
+ for (const reminder of reminders) console.log(` ${c("36", "•")} ${reminder}`);
70
+ console.log("");
71
+ }
72
+
73
+ const exitCode = manual.length || steps.length ? 1 : 0;
74
+ if (!changes.length || !contents.size) return exitCode;
75
+
76
+ const problems = validate(contents);
77
+ if (problems.length) {
78
+ console.error(c("31;1", "Nothing was written. A change makes a file that does not parse:"));
79
+ for (const problem of problems) console.error(` ${path.relative(root, problem.file)}: ${problem.message}`);
80
+ console.error("Please report this problem to Uniform, with the file.");
81
+ return 2;
82
+ }
83
+
84
+ if (dryRun) {
85
+ console.log(c("1", "Dry run: no files were changed."));
86
+ return exitCode;
87
+ }
88
+
89
+ const git = gitState(root, [...contents.keys()]);
90
+ if (!allowDirty && (git.notGit || git.dirty.length)) {
91
+ if (git.notGit) {
92
+ console.error(c("31;1", "The app is not in a git repository, so you cannot undo the changes with git."));
93
+ } else {
94
+ console.error(c("31;1", "These files contain changes that are not committed:"));
95
+ for (const file of git.dirty) console.error(` ${file}`);
96
+ }
97
+ // Only the interactive mode asks. With options, --allow-dirty is the only way past this check.
98
+ const proceed = interactive && ask ? await ask("Write the changes anyway?", false) : false;
99
+ if (!proceed) {
100
+ console.error(interactive ? "Nothing was written." : "Nothing was written. Commit or stash the changes first, or use --allow-dirty.");
101
+ return 2;
102
+ }
103
+ }
104
+
105
+ if (!yes) {
106
+ let approved = false;
107
+ if (ask) approved = await ask(`Write ${changes.length} change(s) to ${contents.size} file(s)?`, false);
108
+ else {
109
+ console.error("Nothing was written. To write the changes in a shell without a terminal, use --yes.");
110
+ return 2;
111
+ }
112
+ if (!approved) {
113
+ console.log("Nothing was written.");
114
+ return 2;
115
+ }
116
+ }
117
+
118
+ try {
119
+ writeAll(contents);
120
+ } catch (error) {
121
+ console.error(c("31;1", `Nothing was written: ${error.message}`));
122
+ return 2;
123
+ }
124
+
125
+ console.log(c("32;1", `Wrote ${changes.length} change(s) to ${contents.size} file(s).`));
126
+ console.log("");
127
+ console.log("Next steps:");
128
+ const next = [
129
+ "Install the new versions: npm install (or pnpm install, or yarn).",
130
+ ...(manual.length + steps.length ? [`Do the ${manual.length + steps.length} manual change(s) above.`] : []),
131
+ `Run the check again: ${NAME} check`,
132
+ edge ? "Build and start the app, and do the edge mode test of the docs: next build && next start" : "Build and test the app: next build",
133
+ "Read the changes: git diff",
134
+ ];
135
+ next.forEach((step, i) => console.log(` ${i + 1}. ${step}`));
136
+ if (edge) console.log(c("2", ` Docs: ${PROVIDERS[edge].docs}`));
137
+ console.log("");
138
+ return exitCode;
139
+ }
140
+
141
+ function printChanges(root, changes, c) {
142
+ if (!changes.length) {
143
+ console.log("No automatic changes are necessary.");
144
+ console.log("");
145
+ return;
146
+ }
147
+ for (const phase of [1, 2]) {
148
+ const items = changes.filter((change) => change.phase === phase);
149
+ if (!items.length) continue;
150
+ const title = phase === 1 ? "Phase 1: upgrade the SDK" : "Phase 2: turn on edge mode";
151
+ console.log(c("1", `${title} (${items.length} automatic change(s)):`));
152
+ for (const change of items) console.log(` ${c("32", "•")} ${change.file}${change.line ? `:${change.line}` : ""} ${change.description}`);
153
+ console.log("");
154
+ }
155
+ }
156
+
157
+ function printManual(manual, reviews, steps, c) {
158
+ if (manual.length || steps.length) {
159
+ console.log(c("31;1", `Manual changes (${manual.length + steps.length}). The migration does not change this code, because the correct change depends on your app:`));
160
+ for (const step of steps) console.log(` ${c("31", "•")} ${step}`);
161
+ for (const f of manual) {
162
+ console.log(` ${c("31", "•")} ${f.file ? `${f.file}${f.line ? `:${f.line}` : ""} ` : ""}${f.message}`);
163
+ console.log(` Fix: ${f.fix}`);
164
+ }
165
+ console.log("");
166
+ } else {
167
+ console.log(c("32", "No manual code changes are necessary."));
168
+ console.log("");
169
+ }
170
+ if (reviews.length) {
171
+ console.log(c("33;1", `Behavior changes to review (${reviews.length}). For the details, run: ${NAME} check`));
172
+ for (const f of reviews) console.log(` ${c("33", "•")} ${f.title}${f.file ? c("2", ` (${f.file}${f.line ? `:${f.line}` : ""})`) : ""}`);
173
+ console.log("");
174
+ }
175
+ }
176
+
177
+ /** A short status of the project, for the interactive mode. */
178
+ export function projectStatus(root, { uniformData } = {}) {
179
+ const scan = scanProject(root, { uniformDataDir: uniformData });
180
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
181
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
182
+ const phase1 = planMigration(root);
183
+ const text = (file) => (fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "");
184
+ const middleware = ["middleware.ts", "middleware.js", "src/middleware.ts", "src/middleware.js", "proxy.ts", "src/proxy.ts"]
185
+ .map((file) => text(path.join(root, file)))
186
+ .join("\n");
187
+ return {
188
+ scan,
189
+ sdkVersion: deps["@uniformdev/next-app-router"],
190
+ atTarget: deps["@uniformdev/next-app-router"] === TARGET_VERSION,
191
+ phase1Pending: phase1.changes.length,
192
+ edgeMode: /vercelUniformEdgeMiddleware/.test(middleware) ? "vercel" : /uniformEdgeMiddleware|handleUniformEdgeRoute/.test(middleware) ? "custom" : undefined,
193
+ };
194
+ }