@thomas.f/prnext 0.1.16-alpha → 0.1.17-alpha

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.
Files changed (126) hide show
  1. package/AI.md +63 -0
  2. package/README.md +384 -9
  3. package/build/ADAPTERS.md +27 -2
  4. package/build/actions.mjs +5 -0
  5. package/build/analyze.mjs +184 -0
  6. package/build/app-config.mjs +7 -8
  7. package/build/app-static.mjs +12 -7
  8. package/build/app.mjs +23 -10
  9. package/build/build-lock.mjs +6 -6
  10. package/build/bundle-runtime.mjs +20 -5
  11. package/build/check.mjs +56 -11
  12. package/build/compiler.mjs +25 -1
  13. package/build/concurrent.mjs +12 -0
  14. package/build/config.mjs +21 -13
  15. package/build/css-modules.mjs +15 -4
  16. package/build/dev-compiler-health.mjs +11 -0
  17. package/build/dev-css-update.mjs +83 -0
  18. package/build/dev-dependencies.mjs +44 -0
  19. package/build/dev-discovery.mjs +28 -0
  20. package/build/dev-worker.mjs +14 -5
  21. package/build/dev.mjs +55 -4
  22. package/build/edge.mjs +3 -2
  23. package/build/framework-imports.mjs +40 -0
  24. package/build/incremental.mjs +8 -4
  25. package/build/index.mjs +96 -101
  26. package/build/inspect.mjs +3 -0
  27. package/build/inspection-summary.mjs +37 -4
  28. package/build/loader-cache.mjs +53 -26
  29. package/build/metadata-routes.mjs +2 -3
  30. package/build/migrate.mjs +60 -57
  31. package/build/npm-packages.mjs +3 -18
  32. package/build/package-manager.mjs +308 -0
  33. package/build/persistent-cache.mjs +97 -0
  34. package/build/precompress.mjs +62 -30
  35. package/build/prerender-pages.mjs +90 -0
  36. package/build/prerender-worker.mjs +10 -0
  37. package/build/production-builder.mjs +80 -0
  38. package/build/scan.mjs +4 -4
  39. package/build/source-analysis.mjs +42 -0
  40. package/build/static-adapter.mjs +13 -5
  41. package/build/static-input.mjs +48 -0
  42. package/build/styles.mjs +15 -3
  43. package/build/transform-cache.mjs +37 -4
  44. package/build/type-command.mjs +41 -0
  45. package/build/typed-routes.mjs +91 -0
  46. package/build/typescript-dev-worker.mjs +18 -0
  47. package/build/typescript-dev.mjs +22 -0
  48. package/build/typescript-watch.mjs +22 -0
  49. package/build/typescript.mjs +5 -4
  50. package/build/webpack-graph.mjs +164 -27
  51. package/build/worker-preload.mjs +9 -0
  52. package/cli.mjs +105 -121
  53. package/compat/index.d.ts +21 -7
  54. package/compat/link.d.cts +4 -3
  55. package/compat/navigation.d.cts +4 -3
  56. package/compat/router.d.cts +4 -3
  57. package/compat/typed-route.d.cts +17 -0
  58. package/docs/ai/compatibility.md +106 -0
  59. package/docs/ai/data.md +128 -0
  60. package/docs/ai/deployment.md +111 -0
  61. package/docs/ai/manifest.json +11 -0
  62. package/docs/ai/quickstart.md +146 -0
  63. package/docs/ai/routing.md +132 -0
  64. package/docs/ai/troubleshooting.md +120 -0
  65. package/index.mjs +2 -0
  66. package/native/diagnostics.mjs +34 -0
  67. package/native/resolve.mjs +7 -2
  68. package/package.json +25 -7
  69. package/release/ai-docs.mjs +48 -0
  70. package/release/verify.mjs +2 -0
  71. package/runtime/ai-docs.mjs +202 -0
  72. package/runtime/app-client.mjs +17 -6
  73. package/runtime/app-render.mjs +12 -0
  74. package/runtime/cloud/archive.mjs +5 -6
  75. package/runtime/compile-cache.mjs +8 -0
  76. package/runtime/dev-ai-brands.mjs +26 -0
  77. package/runtime/dev-ai-style.mjs +99 -0
  78. package/runtime/dev-build-reasons.mjs +25 -0
  79. package/runtime/dev-cache-control.mjs +53 -0
  80. package/runtime/dev-client.mjs +239 -45
  81. package/runtime/dev-console-copy.mjs +19 -0
  82. package/runtime/dev-console.mjs +106 -0
  83. package/runtime/dev-dependency-graph.mjs +40 -0
  84. package/runtime/dev-element-picker-style.mjs +27 -0
  85. package/runtime/dev-element-picker.mjs +213 -0
  86. package/runtime/dev-error-overlay-style.mjs +41 -0
  87. package/runtime/dev-error-overlay.mjs +167 -0
  88. package/runtime/dev-error-report.mjs +121 -0
  89. package/runtime/dev-file-changes.mjs +1 -1
  90. package/runtime/dev-gateway.mjs +44 -0
  91. package/runtime/dev-host-relay.mjs +66 -0
  92. package/runtime/dev-indicator.mjs +186 -0
  93. package/runtime/dev-issue-motion.mjs +214 -0
  94. package/runtime/dev-lazy-tool.mjs +18 -0
  95. package/runtime/dev-logo.mjs +2 -0
  96. package/runtime/dev-notification.mjs +81 -0
  97. package/runtime/dev-popout.mjs +34 -0
  98. package/runtime/dev-position.mjs +107 -0
  99. package/runtime/dev-preview-touch.mjs +122 -0
  100. package/runtime/dev-runtime-pool.mjs +45 -0
  101. package/runtime/dev-runtime-spare.mjs +57 -0
  102. package/runtime/dev-tools-client.mjs +155 -0
  103. package/runtime/dev-tools-mcp.mjs +126 -0
  104. package/runtime/dev-tools-metrics.mjs +31 -0
  105. package/runtime/dev-tools-model.mjs +42 -0
  106. package/runtime/dev-tools-panel.mjs +341 -0
  107. package/runtime/dev-tools-result.mjs +37 -0
  108. package/runtime/dev-tools-server.mjs +256 -0
  109. package/runtime/dev-tools-source.mjs +104 -0
  110. package/runtime/dev-tooltip.mjs +94 -0
  111. package/runtime/dev-ui-icons.mjs +8 -0
  112. package/runtime/dev-workbench-style.mjs +106 -0
  113. package/runtime/dev-workbench.mjs +428 -0
  114. package/runtime/dev-worker-logs.mjs +31 -0
  115. package/runtime/development.mjs +307 -0
  116. package/runtime/env.mjs +2 -1
  117. package/runtime/pages-client.mjs +10 -1
  118. package/runtime/pages-navigation.mjs +6 -1
  119. package/runtime/persistent/client.mjs +6 -5
  120. package/runtime/persistent/daemon.mjs +66 -56
  121. package/runtime/persistent/native-proxy.mjs +63 -0
  122. package/runtime/preload.mjs +12 -0
  123. package/runtime/rsc-worker.mjs +7 -0
  124. package/runtime/typed-route.mjs +19 -0
  125. package/runtime/watch-project.mjs +12 -5
  126. package/runtime/worker.mjs +36 -2
@@ -1,27 +1,33 @@
1
1
  import { rename } from '../runtime/fs.mjs';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { createRequire } from 'node:module';
4
- import { readFile, writeFile, mkdir, rm, readdir, stat } from 'node:fs/promises';
4
+ import { readFile, open, unlink, readdir, stat, lstat, realpath } from 'node:fs/promises';
5
5
  import path from 'node:path';
6
+ import {privateCacheDirectory,readCacheRecord} from './persistent-cache.mjs';
6
7
  const require = createRequire(import.meta.url);
7
8
  const hash = value => createHash('sha256').update(value).digest('hex');
8
9
  const MAX_ENTRY = 16 * 1024 * 1024, MAX_TOTAL = 64 * 1024 * 1024;
10
+ const MAX_DEPENDENCIES=10000;
9
11
  async function fileHash(file) {
10
12
  try { if ((await stat(file)).size > MAX_ENTRY) return null; return hash(await readFile(file)); }
11
13
  catch(error) { if(error.code === 'ENOENT') return 'missing'; throw error; }
12
14
  }
13
15
  async function directoryHash(root) {
14
- const records = []; let count = 0;
16
+ try{if(!(await stat(root)).isDirectory())return null;}catch(error){if(error.code==='ENOENT')return 'missing';throw error;}
17
+ const records = [],seen=new Set(); let count = 0;
15
18
  async function walk(directory) {
19
+ const canonical=await realpath(directory);if(seen.has(canonical))throw new Error('Cyclic loader context dependency');seen.add(canonical);
16
20
  for (const name of (await readdir(directory)).sort()) {
17
- if (++count > 10000) throw new Error('Loader context dependency exceeds 10000 files');
21
+ if (++count > MAX_DEPENDENCIES) throw new Error('Loader context dependency exceeds 10000 files');
18
22
  const file=path.join(directory,name), metadata=await stat(file);
19
- records.push([path.relative(root,file),metadata.isDirectory()?'directory':await fileHash(file)]);
23
+ const digest=metadata.isDirectory()?'directory':await fileHash(file);
24
+ if(digest===null||digest==='missing')throw new Error('Unstable or excessive loader context dependency');
25
+ records.push([path.relative(root,file),digest]);
20
26
  if(metadata.isDirectory())await walk(file);
21
27
  }
28
+ seen.delete(canonical);
22
29
  }
23
- try { await walk(root); return hash(JSON.stringify(records)); }
24
- catch(error) { if(error.code==='ENOENT')return 'missing'; throw error; }
30
+ await walk(root);return hash(JSON.stringify(records));
25
31
  }
26
32
  function loaderModules(loaders) {
27
33
  const files=new Set();
@@ -30,41 +36,62 @@ function loaderModules(loaders) {
30
36
  return [...files];
31
37
  }
32
38
  async function snapshot(files,contexts,missing) {
39
+ if(files.length+contexts.length+missing.length>MAX_DEPENDENCIES)throw new Error('Excessive loader dependencies');
33
40
  return {files:await Promise.all([...new Set([...files,...missing])].sort().map(async file=>[file,await fileHash(file)])),
34
41
  contexts:await Promise.all([...new Set(contexts)].sort().map(async file=>[file,await directoryHash(file)]))};
35
42
  }
36
43
  async function valid(snapshot) {
37
- if(snapshot.files.some(([,digest])=>digest===null))return false;
44
+ if(!validSnapshot(snapshot))return false;
38
45
  for(const [file,digest] of snapshot.files)if(await fileHash(file)!==digest)return false;
39
46
  for(const [file,digest] of snapshot.contexts)if(await directoryHash(file)!==digest)return false;
40
47
  return true;
41
48
  }
42
49
  async function prune(directory) {
43
- const entries = await Promise.all((await readdir(directory)).filter(name=>name.endsWith('.json')).map(async name=>({name,...await stat(path.join(directory,name))})));
50
+ const entries=[];
51
+ for(const name of await readdir(directory))if(/^[a-f0-9]{64}\.json$/.test(name)){
52
+ const info=await lstat(path.join(directory,name)).catch(()=>null);
53
+ if(info?.isFile()&&!info.isSymbolicLink())entries.push({name,size:info.size,mtimeMs:info.mtimeMs});
54
+ }
44
55
  entries.sort((a,b)=>b.mtimeMs-a.mtimeMs);let bytes=0;
45
- for(let index=0;index<entries.length;index++){bytes+=entries[index].size;if(index>=256||bytes>MAX_TOTAL)await rm(path.join(directory,entries[index].name),{force:true});}
56
+ for(let index=0;index<entries.length;index++){bytes+=entries[index].size;if(index>=256||bytes>MAX_TOTAL)await unlink(path.join(directory,entries[index].name)).catch(()=>{});}
57
+ }
58
+ const validFile=value=>typeof value==='string'&&path.isAbsolute(value)&&!value.includes('\0')&&value.length<=32768;
59
+ const validFiles=value=>Array.isArray(value)&&value.length<=MAX_DEPENDENCIES&&value.every(validFile);
60
+ const digest=value=>value==='missing'||typeof value==='string'&&/^[a-f0-9]{64}$/.test(value);
61
+ function validSnapshot(value){return !!value&&typeof value==='object'&&['files','contexts'].every(key=>Array.isArray(value[key])&&value[key].length<=MAX_DEPENDENCIES&&value[key].every(item=>Array.isArray(item)&&item.length===2&&validFile(item[0])&&digest(item[1])));}
62
+ function validResult(value){
63
+ return !!value&&typeof value==='object'&&typeof value.source==='string'&&value.source.length<=MAX_ENTRY&&
64
+ ['dependencies','contexts','missing'].every(key=>validFiles(value[key]))&&
65
+ Array.isArray(value.warnings)&&value.warnings.length<=MAX_DEPENDENCIES&&value.warnings.every(item=>typeof item==='string')&&
66
+ Array.isArray(value.assets)&&value.assets.length<=4096&&value.assets.every(item=>item&&typeof item.name==='string'&&!path.isAbsolute(item.name)&&!/[\\\0?#]/.test(item.name)&&item.name.split('/').every(part=>part&&part!=='.'&&part!=='..')&&typeof item.data==='string'&&item.data.length<=MAX_ENTRY&&item.data.length%4===0&&/^[A-Za-z0-9+/]*={0,2}$/.test(item.data))&&
67
+ (value.map===undefined||value.map&&typeof value.map==='object'&&!Array.isArray(value.map)&&value.map.version===3)&&
68
+ (value.cacheable===undefined||typeof value.cacheable==='boolean');
69
+ }
70
+ async function publish(directory,key,value){
71
+ const payload=JSON.stringify(value),body=JSON.stringify({version:1,key,checksum:hash(payload),payload});
72
+ if(Buffer.byteLength(body)>MAX_ENTRY)return;
73
+ const destination=path.join(directory,key+'.json'),temporary=destination+'.'+randomUUID()+'.tmp';
74
+ try{const file=await open(temporary,'wx',0o600);try{await file.writeFile(body);}finally{await file.close();}await rename(temporary,destination);await prune(directory);}
75
+ finally{await unlink(temporary).catch(()=>{});}
46
76
  }
47
77
  /** Cache only loaders which opt into dependency tracking; no render output enters this cache. */
48
78
  export async function cachedLoader(options,run) {
79
+ const execute=()=>{for(const file of loaderModules(options.loaders))delete require.cache[file];return run();};
80
+ if(process.env.PRNEXT_BUILD_CACHE==='0')return execute();
49
81
  let serializable = true;
50
82
  try { JSON.stringify(options,(_key,value)=>{if(typeof value==='function'||typeof value==='symbol'||typeof value==='bigint'||value instanceof RegExp)serializable=false;return value;}); } catch { serializable=false; }
51
- if (!serializable) return run();
52
- const directory=path.join(options.root,'.prnext-cache','loaders-v1');
53
- const key=hash(JSON.stringify({...options,env:hash(JSON.stringify(Object.entries(process.env).sort()))}));
54
- const file=path.join(directory,key+'.json');
83
+ if (!serializable) return execute();
84
+ let directory,key;
55
85
  try {
56
- if((await stat(file)).size<=MAX_ENTRY){const cached=JSON.parse(await readFile(file,'utf8'));if(cached.version===1&&await valid(cached.snapshot))return cached.result;}
57
- } catch(error) { if(!['ENOENT','ENOTDIR'].includes(error.code) && !(error instanceof SyntaxError)) throw error; }
58
- for (const file of loaderModules(options.loaders)) delete require.cache[file];
59
- const result=await run();
60
- if(result.cacheable===false)return result;
61
- const dependencies=[...new Set([options.file,...result.dependencies,...loaderModules(options.loaders),...['package.json','package-lock.json','pnpm-lock.yaml','yarn.lock'].map(name=>path.join(options.root,name))])];
62
- const current=await snapshot(dependencies,result.contexts,result.missing);
63
- const body=JSON.stringify({version:1,snapshot:current,result:{...result,dependencies}});
64
- if(Buffer.byteLength(body)>MAX_ENTRY || current.files.some(([,value])=>value===null))return result;
65
- await mkdir(directory,{recursive:true});
66
- const temporary=file+'.'+randomUUID()+'.tmp';
67
- try {await writeFile(temporary,body);await rename(temporary,file);}finally{await rm(temporary,{force:true});}
68
- await prune(directory);
86
+ directory=await privateCacheDirectory(options.root,'loaders-v2');
87
+ if(directory){key=hash(JSON.stringify({...options,node:process.version,env:hash(JSON.stringify(Object.entries(process.env).sort()))}));const cached=await readCacheRecord(directory,key,MAX_ENTRY);if(cached?.format===2&&validResult(cached.result)&&await valid(cached.snapshot))return cached.result;}
88
+ } catch{/* Cache availability and malformed records never prevent a loader run. */}
89
+ const result=await execute();
90
+ if(!directory||!key||result?.cacheable===false||!validResult(result))return result;
91
+ try{
92
+ const dependencies=[...new Set([options.file,...result.dependencies,...loaderModules(options.loaders),...['package.json','package-lock.json','npm-shrinkwrap.json','pnpm-lock.yaml','yarn.lock','bun.lock','bun.lockb'].map(name=>path.join(options.root,name))])];
93
+ const current=await snapshot(dependencies,result.contexts,result.missing);
94
+ if(validSnapshot(current))await publish(directory,key,{format:2,snapshot:current,result:{...result,dependencies}});
95
+ }catch{/* Successful loader output remains usable when snapshot/publication fails. */}
69
96
  return result;
70
97
  }
@@ -1,7 +1,7 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
- import { parse } from '@babel/parser';
4
+ import {analyzeSource} from './source-analysis.mjs';
5
5
  import { validateAppConfig, mergeAppConfig } from './app-config.mjs';
6
6
  import { scanMetadataImages } from './metadata-images.mjs';
7
7
  import { pageExtensionPattern, validatePageSource } from './page-extensions.mjs';
@@ -50,8 +50,7 @@ export async function scanMetadataRoutes({ root, appRoot, files, routes, pattern
50
50
  }
51
51
 
52
52
  export function metadataExportNames(source, file) {
53
- const ast = parse(source, { sourceType: 'module', plugins: ['jsx', ...(/\.tsx?$/.test(file) ? ['typescript'] : [])] });
54
- return ast.program.body.flatMap(node => node.type === 'ExportNamedDeclaration' ? [node.declaration?.id?.name, ...(node.declaration?.declarations || []).map(item => item.id.name), ...node.specifiers.map(item => item.exported?.name)] : []);
53
+ return analyzeSource(source,file).program.body.flatMap(node => node.type === 'ExportNamedDeclaration' && node.exportKind !== 'type' ? [node.declaration?.id?.name, ...(node.declaration?.declarations || []).map(item => item.id.name), ...node.specifiers.filter(item=>item.exportKind!=='type').map(item => item.exported?.name ?? item.exported?.value)] : []).filter(Boolean);
55
54
  }
56
55
 
57
56
  export async function prepareMetadataRoutes(project, stage) {
package/build/migrate.mjs CHANGED
@@ -2,23 +2,16 @@ import { rename } from '../runtime/fs.mjs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { spawn } from 'node:child_process';
6
- import { constants } from 'node:fs';
7
- import { readFile, writeFile, rm, lstat, realpath, access } from 'node:fs/promises';
5
+ import { readFile, writeFile, rm, lstat, realpath } from 'node:fs/promises';
8
6
  import { checkProject } from './check.mjs';
9
7
  import { sourceCheckout } from '../native/resolve.mjs';
8
+ import { migrationInstallContext, regularMigrationFile, installWithManager, executeManager } from './package-manager.mjs';
10
9
 
11
10
  const packageRoot = fileURLToPath(new URL('../', import.meta.url));
12
11
  const dependencyFields = ['dependencies', 'devDependencies', 'optionalDependencies'];
13
- const locks = ['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'];
14
12
  const own = (object, key) => Object.hasOwn(object, key);
15
13
  const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
16
14
 
17
- async function optionalFile(file) {
18
- try { return await readFile(file); }
19
- catch (error) { if (error.code === 'ENOENT') return null; throw error; }
20
- }
21
-
22
15
  export function parseMigrationArgs(args) {
23
16
  const options = { dryRun: false, install: true, json: false };
24
17
  let directory;
@@ -172,31 +165,14 @@ export function migrationPackage(input, framework, spec) {
172
165
  return { package: output, changes, notes };
173
166
  }
174
167
 
175
- async function installContext(root, pkg) {
176
- const present = [];
177
- for (const name of locks) if (await optionalFile(path.join(root, name))) present.push(name);
178
- const manager = pkg.packageManager?.split('@')[0];
179
- if (manager && manager !== 'npm') return { allowed: false, reason: `This project uses ${manager}. Use --no-install, then install with ${manager}.`, locks: present };
180
- if (present.some(name => !['package-lock.json', 'npm-shrinkwrap.json'].includes(name))) {
181
- return { allowed: false, reason: 'A non-npm lockfile is present. Use --no-install and the existing package manager to preserve its lockfile.', locks: present };
182
- }
183
- for (let directory = root; ; directory = path.dirname(directory)) {
184
- const contents = directory === root ? null : await optionalFile(path.join(directory, 'package.json'));
185
- let ancestor;
186
- try { ancestor = directory === root ? pkg : contents && JSON.parse(contents); } catch { ancestor = null; }
187
- if (ancestor?.workspaces) return { allowed: false, reason: 'A workspace package manager must be run from its workspace root. Use --no-install, then install from that root.', locks: present };
188
- if (directory === path.dirname(directory)) break;
189
- }
190
- return { allowed: true, locks: present };
191
- }
192
-
193
168
  async function backup(file, contents) {
194
169
  for (let index = 0; index < 1000; index++) {
195
170
  const destination = `${file}.prnext-backup${index ? '.' + index : ''}`;
196
171
  try { await writeFile(destination, contents, { flag: 'wx', mode: 0o600 }); return destination; }
197
172
  catch (error) {
198
173
  if (error.code !== 'EEXIST') throw error;
199
- if ((await readFile(destination)).equals(contents)) return destination;
174
+ const existing = await regularMigrationFile(destination);
175
+ if (existing?.contents.equals(contents)) return destination;
200
176
  }
201
177
  }
202
178
  throw new Error(`Too many migration backups for ${file}.`);
@@ -206,34 +182,23 @@ async function replacePackage(file, original, text, mode) {
206
182
  const temporary = `${file}.prnext-${randomUUID()}.tmp`;
207
183
  try {
208
184
  await writeFile(temporary, text, { flag: 'wx', mode });
185
+ if (!(await lstat(file)).isFile()) throw new Error('package.json is no longer a regular file.');
209
186
  if (!(await readFile(file)).equals(original)) throw new Error('package.json changed during migration. Retry after the other edit finishes.');
210
187
  await rename(temporary, file);
211
188
  } finally { await rm(temporary, { force: true }); }
212
189
  }
213
190
 
214
- export async function installMigrationDependencies(root, { json = false, local = false } = {}) {
215
- const npm = process.env.npm_execpath;
216
- // Never use a shell to interpolate a project path or a package specification.
217
- const args = ['install', '--workspaces=false', ...(local ? ['--install-links=false'] : [])];
218
- const command = npm && /(?:^|[/\\])npm(?:-cli)?\.(?:c?js)$/.test(npm) ? process.execPath : process.platform === 'win32' ? 'npm.cmd' : 'npm';
219
- const argv = command === process.execPath ? [npm, ...args] : args;
220
- await new Promise((resolve, reject) => {
221
- // Windows .cmd launchers require a shell; argv contains only fixed flags,
222
- // never project data. npx/npm invocations use npm-cli.js directly instead.
223
- const child = spawn(command, argv, { cwd: root, stdio: json ? ['inherit', 2, 2] : 'inherit', shell: process.platform === 'win32' && command === 'npm.cmd' });
224
- child.once('error', reject);
225
- child.once('exit', (code, signal) => code === 0 ? resolve() : reject(new Error(`npm install failed (${signal || code}). Backups are available; fix the dependency conflict and retry. No --force or --legacy-peer-deps override was used.`)));
226
- });
227
- for (const name of ['prn', 'prnext']) {
228
- const executable = path.join(root, 'node_modules/.bin', name + (process.platform === 'win32' ? '.cmd' : ''));
229
- try { await access(executable, process.platform === 'win32' ? constants.F_OK : constants.X_OK); }
230
- catch { throw new Error(`npm did not install the ${name} executable. Check npm's bin-links setting and the local PRNext dependency path, then retry.`); }
231
- }
191
+ export async function installMigrationDependencies(root, { json = false, local = false, context } = {}) {
192
+ context ||= await migrationInstallContext(root, JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')));
193
+ if (!context.allowed) throw new Error(context.reason);
194
+ return installWithManager(context, { json, local });
232
195
  }
233
196
 
234
197
  export async function migrateProject(directory, { dryRun = false, install = true, json = false } = {}, services = {}) {
235
198
  const root = path.resolve(directory), file = path.join(root, 'package.json');
236
199
  const report = { root, ok: false, status: 'checking', dryRun, changes: [], backups: [], notes: [], preflight: null };
200
+ const snapshots = new Map();
201
+ let appliedText, physicalFile, installationStarted = false;
237
202
  try {
238
203
  const stat = await lstat(file);
239
204
  if (!stat.isFile()) throw new Error('Migration requires a regular package.json file, not a symlink or directory.');
@@ -244,15 +209,23 @@ export async function migrateProject(directory, { dryRun = false, install = true
244
209
  const framework = JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8'));
245
210
  if ([framework.name, 'prnext', 'prnext-monorepo'].includes(input.name)) throw new Error('Choose the Next.js application directory, not the PRNext framework repository.');
246
211
  const local = sourceCheckout() !== null;
212
+ const context = await migrationInstallContext(root, input);
247
213
  // npm resolves file dependencies from the physical working directory. A
248
214
  // symlink such as macOS /var -> /private/var otherwise changes ../ depth.
249
215
  const relative = path.relative(await realpath(root), await realpath(packageRoot)).split(path.sep).join('/') || '.';
250
- const spec = local ? `file:${relative}` : framework.version;
216
+ // Preserve a live checkout for local candidates: file: is copied into
217
+ // pnpm/Yarn stores and would lose the repository's native build context.
218
+ const protocol = context.manager === 'npm' ? 'file' : context.manager === 'yarn' && context.berry ? 'portal' : 'link';
219
+ const spec = local ? `${protocol}:${relative}` : framework.version;
251
220
  const plan = migrationPackage(input, framework, spec);
252
221
  report.changes = plan.changes;
253
222
  report.notes = plan.notes;
254
223
  if (local) report.notes.push(`This unpublished checkout uses a local PRNext dependency (${spec}); keep this checkout available. Rust is required on the first native build.`);
255
- const context = await installContext(root, input);
224
+ physicalFile = path.join(context.target, 'package.json');
225
+ report.installation = { manager: context.manager, version: context.version, root: context.root, workspace: context.workspace,
226
+ target: context.workspaceTarget, command: context.command };
227
+ report.notes.push(...context.notes);
228
+ for (const stateFile of context.stateFiles) snapshots.set(stateFile, await regularMigrationFile(stateFile));
256
229
  if (!context.allowed) {
257
230
  report.notes.push(context.reason);
258
231
  if (install && !dryRun) throw new Error(context.reason);
@@ -264,11 +237,8 @@ export async function migrateProject(directory, { dryRun = false, install = true
264
237
  if (dryRun) { report.ok = true; report.status = 'preview'; return report; }
265
238
  if (plan.changes.length || install) {
266
239
  report.backups.push(await backup(file, original));
267
- for (const name of context.locks) {
268
- const lockFile = path.join(root, name);
269
- const stat = await lstat(lockFile);
270
- if (!stat.isFile()) throw new Error(`Migration requires a regular ${name} file.`);
271
- report.backups.push(await backup(lockFile, await readFile(lockFile)));
240
+ for (const lockFile of context.locks) {
241
+ report.backups.push(await backup(lockFile, snapshots.get(lockFile).contents));
272
242
  }
273
243
  }
274
244
  if (plan.changes.length) {
@@ -276,23 +246,56 @@ export async function migrateProject(directory, { dryRun = false, install = true
276
246
  const newline = source.includes('\r\n') ? '\r\n' : '\n';
277
247
  const text = (source.startsWith('\uFEFF') ? '\uFEFF' : '') + JSON.stringify(plan.package, null, indent).replaceAll('\n', newline) + (/\r?\n$/.test(source) ? newline : '');
278
248
  await replacePackage(file, original, text, stat.mode & 0o777);
249
+ appliedText = Buffer.from(text);
279
250
  }
280
251
  report.status = plan.changes.length ? 'prepared' : 'unchanged';
281
252
  if (!install) {
282
253
  report.ok = true;
283
- report.notes.push('Dependency installation and installed-version validation are pending. Install with your package manager, then run prn check and prn build.');
254
+ report.notes.push(`Dependency installation and installed-version validation are pending. Run ${context.command.name} ${context.command.args.map(value => /\s/.test(value) ? JSON.stringify(value) : value).join(' ')} from ${context.root}, then run prnext check and prnext build.`);
284
255
  return report;
285
256
  }
286
257
  report.status = 'installing';
287
- await (services.install || installMigrationDependencies)(root, { json, local });
288
- report.preflight = await check(root);
258
+ // A concurrent editor must not have its manifest or lockfile silently
259
+ // replaced by an install planned from an earlier snapshot.
260
+ for (const [stateFile, snapshot] of snapshots) {
261
+ const current = await regularMigrationFile(stateFile), expected = stateFile === physicalFile && appliedText ? appliedText : snapshot?.contents;
262
+ if (expected ? !current?.contents.equals(expected) : current !== null) throw new Error(`Migration file changed before installation: ${stateFile}`);
263
+ }
264
+ if (await realpath(context.target) !== context.target || await realpath(context.root) !== context.root) throw new Error('Workspace directories changed during migration.');
265
+ installationStarted = true;
266
+ const installed = await (services.install || installMigrationDependencies)(root, { json, local, context });
267
+ if (installed?.pnp && !services.check) {
268
+ const result = await executeManager(context, ['node', path.join(packageRoot, 'cli.mjs'), 'check', context.target, '--json'], { cwd: context.target });
269
+ report.preflight = JSON.parse(result.stdout);
270
+ } else report.preflight = await check(root);
289
271
  if (!report.preflight.ok) throw new Error(`Dependencies were installed, but final preflight failed: ${report.preflight.errors.join(' ')}`);
290
272
  report.ok = true;
291
273
  report.status = 'migrated';
292
- report.notes.push('Migration prepared and dependencies checked. Run npm run build and test your application before deploying.');
274
+ report.notes.push(`Migration prepared and dependencies checked. Run ${context.manager} run build and test your application before deploying.`);
293
275
  return report;
294
276
  } catch (error) {
295
277
  report.error = error.message;
278
+ if (appliedText || report.status === 'installing') {
279
+ report.rollback = { ok: true, restored: [], errors: [] };
280
+ for (const [stateFile, snapshot] of snapshots) {
281
+ try {
282
+ if (await realpath(path.dirname(stateFile)) !== path.dirname(stateFile)) throw new Error('Migration directory became a symbolic link; preserved the current file.');
283
+ const current = await regularMigrationFile(stateFile);
284
+ if (stateFile !== physicalFile && !installationStarted) continue;
285
+ if (stateFile === physicalFile && current && !current.contents.equals(appliedText || snapshot.contents) && !current.contents.equals(snapshot.contents)) throw new Error('package.json was edited after migration; the newer edit was preserved.');
286
+ if (stateFile !== physicalFile && path.basename(stateFile) === 'package.json' && current && snapshot && !current.contents.equals(snapshot.contents)) throw new Error('Workspace package.json changed outside the selected application; the newer edit was preserved.');
287
+ if (snapshot) {
288
+ if (!current?.contents.equals(snapshot.contents)) {
289
+ const temporary = stateFile + `.prnext-${randomUUID()}.tmp`;
290
+ try { await writeFile(temporary, snapshot.contents, { flag: 'wx', mode: snapshot.mode }); await rename(temporary, stateFile); }
291
+ finally { await rm(temporary, { force: true }); }
292
+ report.rollback.restored.push(stateFile);
293
+ }
294
+ } else if (current) { await rm(stateFile); report.rollback.restored.push(stateFile); }
295
+ } catch (rollbackError) { report.rollback.ok = false; report.rollback.errors.push(`${stateFile}: ${rollbackError.message}`); }
296
+ }
297
+ report.notes.push(report.rollback.ok ? 'Restored the selected manifest and lockfiles after the failed migration. Dependency directories/caches may contain partial installation results; retry the same package manager.' : 'Some rollback files changed or became unsafe. Preserved those files; use the exact backups to review recovery.');
298
+ }
296
299
  if (report.status === 'checking') report.status = 'blocked';
297
300
  else report.status = 'incomplete';
298
301
  return report;
@@ -1,8 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { builtinModules, createRequire } from 'node:module';
4
- import { parse } from '@babel/parser';
5
- import { VISITOR_KEYS } from '@babel/types';
4
+ import {analyzeSource} from './source-analysis.mjs';
6
5
  import { createNativeDiscovery } from './native-discovery.mjs';
7
6
  import {frameworkImportPattern} from './framework-imports.mjs';
8
7
 
@@ -26,22 +25,8 @@ export function npmPackagesPlugin({ pages = false, projectRoot, transpilePackage
26
25
  sourceBytes += Buffer.byteLength(source);
27
26
  if (sourceBytes > 64 * 1024 * 1024) throw new Error('npm compilation analysis exceeded 64 MiB of source. Separate large runtime dependencies using serverExternalPackages.');
28
27
  if (Buffer.byteLength(source) > 8 * 1024 * 1024) throw new Error(`npm source ${file} exceeds the 8 MiB analysis limit. Configure serverExternalPackages for packages that do not use Next APIs.`);
29
- const ast = parse(source, { sourceType: 'unambiguous', sourceFilename: file, allowReturnOutsideFunction: true, plugins: ['jsx', ...(/\.tsx?$/.test(file) ? ['typescript'] : [])] });
30
- const result = { imports: [], computedRequire: false, frameworkBoundary: ast.program.directives.some(item => ['use client', 'use server'].includes(item.value.value)) };
31
- function visit(node) {
32
- if (!node || typeof node !== 'object') return;
33
- if (['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration'].includes(node.type) && node.source && node.importKind !== 'type' && node.exportKind !== 'type') result.imports.push({ path: node.source.value, kind: 'import-statement' });
34
- if (node.type === 'CallExpression' && (node.callee.type === 'Import' || node.callee.type === 'Identifier' && node.callee.name === 'require')) {
35
- const argument = node.arguments[0];
36
- if (argument?.type === 'StringLiteral') result.imports.push({ path: argument.value, kind: node.callee.type === 'Import' ? 'dynamic-import' : 'require-call' });
37
- else if (node.callee.type !== 'Import') result.computedRequire = true;
38
- }
39
- for (const key of VISITOR_KEYS[node.type] || []) {
40
- if (Array.isArray(node[key])) node[key].forEach(visit); else visit(node[key]);
41
- }
42
- }
43
- visit(ast.program);
44
- return result;
28
+ const {imports,computedRequire,frameworkBoundary}=analyzeSource(source,file);
29
+ return {imports,computedRequire,frameworkBoundary};
45
30
  })());
46
31
  return sources.get(file);
47
32
  }