@thomas.f/prnext 0.1.13-alpha → 0.1.16-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.
@@ -0,0 +1,100 @@
1
+ import { constants } from 'node:fs';
2
+ import { copyFile, lstat, mkdir, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
4
+ import path from 'node:path';
5
+ import { rename } from '../runtime/fs.mjs';
6
+ import { OUTPUT_POINTER, prepareBuildDirectory, readBuildDirectory, validateDistDir } from '../runtime/build-directory.mjs';
7
+ import { writeBuildOutput } from './output-contract.mjs';
8
+ import { precompressBuild } from './precompress.mjs';
9
+ import { withBuildLock } from './build-lock.mjs';
10
+
11
+ const MAX_FILES = 20000, MAX_BYTES = 512 * 1024 * 1024;
12
+ const inside = (root, file) => file.startsWith(root + path.sep);
13
+ const urlFor = file => '/' + file.split('/').map(encodeURIComponent).join('/');
14
+
15
+ /** Import an already-built public directory. Never evaluate its config or code. */
16
+ export async function buildStatic(directory, { from = 'dist', spa = false, distDir, dev = false } = {}) {
17
+ return withBuildLock(directory, root => importStatic(root, { from, spa, distDir, dev }));
18
+ }
19
+
20
+ async function importStatic(directory, { from, spa, distDir, dev }) {
21
+ if (dev) throw new Error('The static adapter imports production files; use the source framework for development.');
22
+ if (typeof spa !== 'boolean') throw new TypeError('spa must be a boolean');
23
+ const root = await realpath(directory);
24
+ // Reuse the output-directory restrictions to keep imports within this project
25
+ // and away from source/dependency/repository trees.
26
+ const relative = validateDistDir(from);
27
+ const output = distDir === undefined ? await readBuildDirectory(root) : validateDistDir(distDir);
28
+ const source = path.join(root, relative), destination = await prepareBuildDirectory(root, output);
29
+ if (source === destination || inside(source, destination) || inside(destination, source)) throw new Error('Static input and PRNext output directories must not overlap.');
30
+ let ancestor = root;
31
+ for (const part of relative.split('/')) {
32
+ ancestor = path.join(ancestor, part);
33
+ const entry = await lstat(ancestor);
34
+ if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error('Static input must use real directories, not symbolic links.');
35
+ }
36
+ if (!inside(root, await realpath(source))) throw new Error('Static input must stay inside the project.');
37
+ const index = await lstat(path.join(source, 'index.html')).catch(() => null);
38
+ if (!index?.isFile() || index.isSymbolicLink()) throw new Error('Static input needs index.html. Run your framework build first and select its public output with --from.');
39
+
40
+ const id = randomUUID(), stage = path.join(root, `.prnext-build-${id}`);
41
+ const backup = path.join(root, `.prnext-backup-${id}`), pointer = path.join(root, `.prnext-output-${id}.tmp`);
42
+ const manifest = { version: 1, buildId: id, config: { staticOnly: true, staticFallback: spa, compress: true, poweredByHeader: true }, routes: [], prerendered: [] };
43
+ const paths = new Set();
44
+ let count = 0, bytes = 0, previous = false, published = false;
45
+ const add = (url, file) => {
46
+ if (paths.has(url)) throw new Error(`Ambiguous static URL ${url}; remove the conflicting file or HTML alias.`);
47
+ paths.add(url);
48
+ manifest.prerendered.push({ path: url, file: `static/${file}`, revalidate: false });
49
+ };
50
+ async function copy(folder = '', depth = 0) {
51
+ if (depth > 64) throw new Error('Static input exceeds 64 directory levels.');
52
+ for (const name of (await readdir(path.join(source, folder))).sort()) {
53
+ if (name.includes('\\') || /[\x00-\x1f\x7f:]/.test(name) ||
54
+ name.startsWith('.') && !(folder === '' && name === '.well-known') ||
55
+ ['node_modules', '_prnext', '_next'].includes(name) || folder === '' && name === 'server') {
56
+ throw new Error(`Static input contains a private, reserved or server path: ${folder ? folder + '/' : ''}${name}. Select only browser-ready files.`);
57
+ }
58
+ const file = folder ? `${folder}/${name}` : name;
59
+ const entry = await lstat(path.join(source, file));
60
+ if (entry.isSymbolicLink()) throw new Error(`Static input cannot contain symbolic links: ${file}`);
61
+ if (entry.isDirectory()) { await copy(file, depth + 1); continue; }
62
+ if (!entry.isFile()) throw new Error(`Static input must contain regular files: ${file}`);
63
+ if (++count > MAX_FILES || (bytes += entry.size) > MAX_BYTES) throw new Error('Static input exceeds 20,000 files or 512 MiB.');
64
+ const target = path.join(stage, 'static', file);
65
+ await mkdir(path.dirname(target), { recursive: true });
66
+ await copyFile(path.join(source, file), target, constants.COPYFILE_EXCL);
67
+ const after = await lstat(path.join(source, file));
68
+ if (!after.isFile() || after.size !== entry.size || after.mtimeMs !== entry.mtimeMs || (await stat(target)).size !== entry.size) throw new Error(`Static input changed while being imported: ${file}. Retry after its build finishes.`);
69
+ add(urlFor(file), file);
70
+ if (file === 'index.html') add('/', file);
71
+ else if (file.endsWith('/index.html')) add(urlFor(file.slice(0, -11)), file);
72
+ else if (file.endsWith('.html')) add(urlFor(file.slice(0, -5)), file);
73
+ }
74
+ }
75
+ try {
76
+ await mkdir(stage);
77
+ await copy();
78
+ await writeFile(path.join(stage, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
79
+ await writeBuildOutput(stage, manifest, { adapter: 'static', spa });
80
+ await precompressBuild(stage, manifest, { cacheDirectory: path.join(root, '.prnext-cache/precompress-v1') });
81
+ // A failed import leaves the previously published build and pointer intact.
82
+ previous = await lstat(destination).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; });
83
+ await writeFile(pointer, JSON.stringify({ distDir: output }) + '\n', { flag: 'wx' });
84
+ if (previous) await rename(destination, backup);
85
+ try {
86
+ await rename(stage, destination);
87
+ published = true;
88
+ await rename(pointer, path.join(root, OUTPUT_POINTER));
89
+ } catch (error) {
90
+ if (published) await rename(destination, stage);
91
+ if (previous) await rename(backup, destination);
92
+ throw error;
93
+ }
94
+ if (previous) await rm(backup, { recursive: true, force: true });
95
+ return { ...manifest, outputDirectory: destination, files: count, bytes };
96
+ } finally {
97
+ await rm(pointer, { force: true });
98
+ await rm(stage, { recursive: true, force: true });
99
+ }
100
+ }
package/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { rename } from './runtime/fs.mjs';
3
3
  import { spawn } from 'node:child_process';
4
- import { readFile, writeFile, rm } from 'node:fs/promises';
4
+ import { readFile, writeFile, rm, mkdir, readdir } from 'node:fs/promises';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import path from 'node:path';
7
7
  import { resolveNativeBinary, packageManifest } from './native/resolve.mjs';
@@ -16,6 +16,9 @@ const help = `PRNext ${packageManifest.version}
16
16
  Use prnext for portable scripts; prn is a shortcut on compatible shells.
17
17
 
18
18
  prnext build [directory] Build React/TypeScript and static HTML
19
+ prnext build [directory] --adapter static [--from dist] [--spa]
20
+ Import an existing public build directory
21
+ prnext output [directory] [--json] Read the versioned build output contract
19
22
  prnext start [directory] [options] Start native Rust production server
20
23
  prnext pstart [directory] [options] Start production persistently in the background
21
24
  prnext prestart [name] Replace a persistent app after a health check
@@ -27,7 +30,7 @@ Use prnext for portable scripts; prn is a shortcut on compatible shells.
27
30
  prnext dev [directory] [options] Rebuild and restart on source changes
28
31
  prnext routes [directory] Display compiled routes
29
32
  prnext check [directory] [--json] Check an existing Next project before building
30
- prnext inspect [directory] [--json] Explain routes, cache decisions and timings
33
+ prnext inspect [directory] [--json|--otlp] Explain routes, health and sampled timings
31
34
  prnext host [prnext.host.json] Host built apps by hostname; wake on demand
32
35
  prnext host [config] --check Validate hosting configuration and builds
33
36
  prnext host [config] --status [--json] Show memory, process and idle status
@@ -52,7 +55,7 @@ Memory favors lower RAM under concurrency, accepting more waiting and CPU per re
52
55
  Without an option: PRNEXT_PROFILE, legacy PRNEXT_MEMORY_PROFILE=compact, then balanced.
53
56
  Classic preserves the former standard settings; standard remains an alias. Legacy compact remains accepted.
54
57
  Development includes React Fast Refresh, stylesheet updates and an error overlay.
55
- Requires Node >=22. Published packages include the native server for supported platforms.
58
+ Requires Node 22.23.4+, 24.22.1+ or 26.12.1+. Published packages include the native server for supported platforms.
56
59
  Rust is only needed when building the framework from source.
57
60
  `;
58
61
 
@@ -147,19 +150,36 @@ async function main() {
147
150
  if (!report.ok) process.exitCode = 1;
148
151
  return;
149
152
  }
150
- if (!['build', 'start', 'dev', 'routes', 'check', 'inspect'].includes(command)) throw new Error(`Unknown command: ${command}\n${help}`);
153
+ if (!['build', 'start', 'dev', 'routes', 'check', 'inspect', 'output'].includes(command)) throw new Error(`Unknown command: ${command}\n${help}`);
151
154
  const args = [...argv];
152
155
  const root = path.resolve(args[0] && !args[0].startsWith('-') ? args.shift() : '.');
156
+ if (command === 'output') {
157
+ if (args.some(arg => arg !== '--json')) throw new Error('output accepts only [directory] and --json');
158
+ const { readBuildOutput } = await import('./build/output-contract.mjs');
159
+ const output = await readBuildOutput(path.join(root, await readBuildDirectory(root)));
160
+ if (args.includes('--json')) console.log(JSON.stringify(output, null, 2));
161
+ else console.log(`PRNext output v${output.version}: ${output.adapter}\nBuild: ${output.buildId}\nManifest: ${output.manifest}\nAssets: ${output.assets}\nServer: ${output.server?.entry || 'none'}\nSPA fallback: ${output.fallback || 'disabled'}`);
162
+ return;
163
+ }
153
164
  if (command === 'inspect') {
154
- if (args.some(arg => arg !== '--json')) throw new Error('inspect accepts only [directory] and --json');
155
- const { inspectProject, printInspection } = await import('./build/inspect.mjs');
165
+ if (args.some(arg => !['--json', '--otlp'].includes(arg)) || args.includes('--json') && args.includes('--otlp')) throw new Error('inspect accepts [directory] and either --json or --otlp');
166
+ const { inspectProject, printInspection, inspectionMetrics } = await import('./build/inspect.mjs');
156
167
  const report = await inspectProject(root);
157
- if (args.includes('--json')) console.log(JSON.stringify(report, null, 2)); else printInspection(report);
168
+ if (args.includes('--otlp')) console.log(JSON.stringify(inspectionMetrics(report), null, 2));
169
+ else if (args.includes('--json')) console.log(JSON.stringify(report, null, 2)); else printInspection(report);
158
170
  return;
159
171
  }
160
172
  if (['start', 'dev'].includes(command) && args.includes('--inspect')) {
161
173
  args.splice(args.indexOf('--inspect'), 1);
162
174
  process.env.PRNEXT_INSPECT_DIR = path.join(root, '.prnext-cache/inspect');
175
+ await mkdir(process.env.PRNEXT_INSPECT_DIR, {recursive:true, mode:0o700});
176
+ process.env.PRNEXT_METRICS_FILE ||= path.join(process.env.PRNEXT_INSPECT_DIR, `metrics-${process.pid}.jsonl`);
177
+ const old = (await readdir(process.env.PRNEXT_INSPECT_DIR)).filter(name => /^metrics-\d+\.jsonl$/.test(name));
178
+ for (const name of old.slice(0, Math.max(0, old.length - 4))) {
179
+ const pid = Number(name.slice(8,-6));
180
+ try { process.kill(pid, 0); }
181
+ catch (error) { if (error.code === 'ESRCH') await rm(path.join(process.env.PRNEXT_INSPECT_DIR,name),{force:true}); }
182
+ }
163
183
  }
164
184
  if (command === 'build' || command === 'dev') {
165
185
  // Build plugins such as Contentlayer read cwd/INIT_CWD rather than webpack's
@@ -183,13 +203,30 @@ async function main() {
183
203
  return;
184
204
  }
185
205
  if (command === 'build') {
186
- const dev = args.includes('--dev');
187
- if (args.some(arg => arg !== '--dev')) throw new Error('build accepts only [directory] and --dev');
206
+ const options = {};
207
+ for (let i = 0; i < args.length; i++) {
208
+ const flag = args[i];
209
+ if (flag === '--dev') options.dev = true;
210
+ else if (flag === '--spa') options.spa = true;
211
+ else if (flag === '--adapter' || flag === '--from') {
212
+ const value = args[++i];
213
+ if (!value || value.startsWith('-')) throw new Error(`${flag} requires a value`);
214
+ const key = flag.slice(2);
215
+ if (options[key] !== undefined) throw new Error(`Duplicate build option: ${flag}`);
216
+ options[key] = value;
217
+ } else throw new Error(`Unknown build option: ${flag}`);
218
+ }
219
+ const dev = !!options.dev;
188
220
  const envMode = process.env.NODE_ENV === 'test' ? 'test' : undefined;
189
221
  process.env.NODE_ENV = dev ? 'development' : 'production';
190
- const { build } = await import('./build/index.mjs');
191
222
  const started = performance.now();
192
- await build(root, { dev, envMode });
223
+ if (options.adapter === 'static') {
224
+ const { buildStatic } = await import('./build/static-adapter.mjs');
225
+ await buildStatic(root, options);
226
+ } else {
227
+ const { build } = await import('./build/index.mjs');
228
+ await build(root, { ...options, dev, envMode });
229
+ }
193
230
  console.log(`PRNext built ${root} in ${((performance.now() - started) / 1000).toFixed(2)}s`);
194
231
  return;
195
232
  }
@@ -4,6 +4,10 @@ const directory = process.env.PRNEXT_INSPECT_DIR;
4
4
  const { threadId } = require('node:worker_threads');
5
5
  let queue = [], scheduled = false, writing = false, size;
6
6
  const limit = 1024 * 1024;
7
+ if (directory) setInterval(() => {
8
+ const {rss, heapUsed} = process.memoryUsage();
9
+ record('process', {rssBytes:rss, heapUsedBytes:heapUsed});
10
+ }, 5000).unref();
7
11
  function record(type, fields = {}) {
8
12
  if (!directory || queue.length >= 256) return;
9
13
  const line = JSON.stringify({ time: Date.now(), pid: process.pid, type, ...fields }) + '\n';
package/compat/index.d.ts CHANGED
@@ -168,4 +168,33 @@ export interface NextConfig {
168
168
  rewrites?: () => Rewrite[] | { beforeFiles?: Rewrite[]; afterFiles?: Rewrite[]; fallback?: Rewrite[] } | Promise<Rewrite[] | { beforeFiles?: Rewrite[]; afterFiles?: Rewrite[]; fallback?: Rewrite[] }>;
169
169
  }
170
170
  export type PRNextConfig = NextConfig;
171
+ /** Public build envelope; internal manifest fields are adapter-specific. */
172
+ export type BuildOutput = {
173
+ version: 1;
174
+ buildId: string;
175
+ manifest: 'manifest.json';
176
+ } & ({
177
+ adapter: 'react';
178
+ assets: 'assets';
179
+ server: { entry: 'runtime/worker.mjs'; runtimeContract: 'runtime-contract.json' };
180
+ fallback: null;
181
+ } | {
182
+ adapter: 'static';
183
+ assets: 'static';
184
+ server: null;
185
+ fallback: null | 'index.html';
186
+ });
187
+ export interface StaticBuildOptions {
188
+ from?: string;
189
+ spa?: boolean;
190
+ distDir?: string;
191
+ dev?: false;
192
+ }
193
+ export interface BuildResult { buildId: string; outputDirectory: string; }
194
+ export interface StaticBuildResult extends BuildResult { files: number; bytes: number; }
195
+ export function build(projectRoot: string, options: StaticBuildOptions & { adapter: 'static' }): Promise<StaticBuildResult>;
196
+ export function build(projectRoot: string, options?: { adapter?: 'react'; dev?: boolean; envMode?: 'production' | 'development' | 'test' }): Promise<BuildResult>;
197
+ export function buildStatic(projectRoot: string, options?: StaticBuildOptions): Promise<StaticBuildResult>;
198
+ export function readBuildOutput(buildDirectory: string): Promise<BuildOutput>;
199
+ export function validateBuildOutput(value: unknown): BuildOutput;
171
200
  export type { DocumentContext, DocumentInitialProps, DocumentProps } from './document.cjs';
package/index.mjs CHANGED
@@ -1 +1,3 @@
1
1
  export { build } from './build/index.mjs';
2
+ export { buildStatic } from './build/static-adapter.mjs';
3
+ export { readBuildOutput, validateBuildOutput } from './build/output-contract.mjs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thomas.f/prnext",
3
- "version": "0.1.13-alpha",
3
+ "version": "0.1.16-alpha",
4
4
  "license": "MIT",
5
5
  "description": "Experimental Rust-powered runtime for Next.js apps, React and npm",
6
6
  "homepage": "https://prnext.dev",
@@ -111,7 +111,6 @@
111
111
  "@babel/types": "^7.29.0",
112
112
  "@edge-runtime/vm": "5.0.0",
113
113
  "@vercel/nft": "1.11.0",
114
- "@vercel/og": "1.0.3",
115
114
  "dotenv": "16.6.1",
116
115
  "dotenv-expand": "11.0.7",
117
116
  "esbuild": "^0.28.2",
@@ -128,19 +127,19 @@
128
127
  "webpack": "5.111.1"
129
128
  },
130
129
  "peerDependencies": {
131
- "react": "19.3.0",
132
- "react-dom": "19.3.0",
133
- "react-server-dom-webpack": "19.3.0"
130
+ "react": "19.3.1",
131
+ "react-dom": "19.3.1",
132
+ "react-server-dom-webpack": "19.3.1"
134
133
  },
135
134
  "optionalDependencies": {
136
- "prnext-darwin-arm64": "0.1.13-alpha",
137
- "prnext-darwin-x64": "0.1.13-alpha",
138
- "prnext-linux-arm64-gnu": "0.1.13-alpha",
139
- "prnext-linux-x64-gnu": "0.1.13-alpha",
140
- "@thomas.f/prnext-win32-x64-msvc": "0.1.13-alpha",
141
- "@thomas.f/prnext-win32-arm64-msvc": "0.1.13-alpha"
135
+ "prnext-darwin-arm64": "0.1.16-alpha",
136
+ "prnext-darwin-x64": "0.1.16-alpha",
137
+ "prnext-linux-arm64-gnu": "0.1.16-alpha",
138
+ "prnext-linux-x64-gnu": "0.1.16-alpha",
139
+ "@thomas.f/prnext-win32-x64-msvc": "0.1.16-alpha",
140
+ "@thomas.f/prnext-win32-arm64-msvc": "0.1.16-alpha"
142
141
  },
143
142
  "engines": {
144
- "node": ">=22"
143
+ "node": "^22.23.4 || ^24.22.1 || >=26.12.1"
145
144
  }
146
145
  }
@@ -16,12 +16,18 @@ export async function verifyPackage() {
16
16
  }
17
17
  const rsc = pkg.peerDependencies['react-server-dom-webpack'];
18
18
  if (pkg.peerDependencies.react !== rsc || pkg.peerDependencies['react-dom'] !== rsc) throw new Error('React, React DOM and the RSC runtime must have the same exact version.');
19
+ // Repository advisories may precede npm audit's database. Do not publish the
20
+ // known-vulnerable RSC line even if all three peers have matching versions.
21
+ const reactVersion = /^(\d+)\.(\d+)\.(\d+)$/.exec(rsc)?.slice(1).map(Number);
22
+ if (!reactVersion || reactVersion[0] < 19 || (reactVersion[0] === 19 && (reactVersion[1] < 3 || (reactVersion[1] === 3 && reactVersion[2] < 1)))) throw new Error('React Server Components must include the October 14, 2026 fixes (19.3.1 or later).');
19
23
  async function exported(value) {
20
24
  if (typeof value === 'string') { await access(path.join(packageRoot, value)); return; }
21
25
  for (const target of Object.values(value)) await exported(target);
22
26
  }
23
27
  await exported(pkg.exports);
24
28
  await access(path.join(packageRoot, 'runtime/profiles.json'));
29
+ for (const name of ['index.node.mjs', 'resvg.wasm', 'hb.wasm', 'Geist-Regular.ttf', 'LICENSE', 'LICENSE.fflate', 'LICENSE.Geist', 'LICENSE.harfbuzzjs', 'LICENSE.satori', 'NOTICE.txt']) await access(path.join(packageRoot, 'runtime/og', name));
30
+ if (pkg.dependencies['@vercel/og'] || (await readFile(path.join(packageRoot, 'runtime/og/index.node.mjs'), 'utf8')).includes('fflate@0.7.3')) throw new Error('OG renderer still depends on vulnerable upstream fflate. Run scripts/prepare-og.mjs.');
25
31
  const source = sourceCheckout();
26
32
  if (source) {
27
33
  const cargo = await readFile(path.join(source, 'crates/prnext/Cargo.toml'), 'utf8');
package/runtime/api.mjs CHANGED
@@ -131,7 +131,7 @@ async function runStreamingPagesApi(handler, request, { method, signal, timeoutM
131
131
  const timer = setTimeout(() => cancelRequest(apiTimeoutError('response headers', timeoutMs)), timeoutMs);
132
132
  timer.unref();
133
133
  if (controller.signal.aborted) cancelRequest(abortError(controller.signal.reason));
134
- else trackRequestWork(Promise.resolve().then(() => handler(request, response)), signal).then(result => {
134
+ else trackRequestWork(Promise.resolve().then(() => handler(request, response)), signal, { timeoutMs }).then(result => {
135
135
  if (result !== undefined && result !== response && !response.writableEnded) {
136
136
  throw new Error('Pages API handlers must send their response with res.send(), res.json(), or res.end()');
137
137
  }
@@ -255,7 +255,10 @@ async function runApiInner({ modulePath, route = {}, manifest = {}, production =
255
255
  if (lifecycle) pendingResponse.then(({ result }) => {
256
256
  if (lifecycle.controller.signal.aborted) result?.body?.cancel(lifecycle.controller.signal.reason).catch(() => {});
257
257
  }, () => {});
258
- trackRequestWork(pendingResponse, signal);
258
+ // A client disconnect does not cancel arbitrary npm/database promises.
259
+ // Let them settle within this route's existing deadline before retiring the
260
+ // shared worker; the shorter orphan guard would interrupt other visitors.
261
+ trackRequestWork(pendingResponse, signal, { timeoutMs });
259
262
  let result, changedCookies, runInContext;
260
263
  try {
261
264
  ({ result, changedCookies, runInContext } = await withSignal(pendingResponse, lifecycle?.controller.signal));
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { deflateRawSync } from 'node:zlib';
4
4
  import { createHash } from 'node:crypto';
5
5
  import picomatch from 'picomatch';
6
+ import { prospectivePath } from './state.mjs';
6
7
 
7
8
  const MiB = 1048576;
8
9
  const alwaysExcluded = (name) =>
@@ -96,7 +97,7 @@ export async function archiveProject(
96
97
  ) {
97
98
  const root = await realpath(directory);
98
99
  if (outputFile) {
99
- const relative = path.relative(root,path.resolve(outputFile));
100
+ const relative = path.relative(root,await prospectivePath(outputFile));
100
101
  if (!relative || relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) throw new Error('Temporary archive must be outside the project.');
101
102
  }
102
103
  const output = outputFile ? await open(outputFile, 'wx', 0o600) : null;
@@ -10,6 +10,7 @@ import { detectEnvFiles, readDeploymentEnv, confirmEnvImport } from './environme
10
10
  import {
11
11
  cloudOrigin,
12
12
  credentialPath,
13
+ prospectivePath,
13
14
  linkPath,
14
15
  readState,
15
16
  writeState,
@@ -35,24 +36,6 @@ Source excludes secrets/build output and respects root .gitignore/.prnextignore.
35
36
  `;
36
37
  const clean = (value) =>
37
38
  String(value || '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
38
-
39
- async function resolveCredentialDirectory(directory) {
40
- let existing = directory;
41
- const missing = [];
42
- for (;;) {
43
- try {
44
- return path.join(await realpath(existing), ...missing);
45
- } catch (error) {
46
- const parent = path.dirname(existing);
47
- if (error.code !== 'ENOENT' || parent === existing) throw error;
48
- // Resolve existing ancestors even when the credential directory has not
49
- // been created yet (macOS /var aliases, Windows short paths, symlinks).
50
- missing.unshift(path.basename(existing));
51
- existing = parent;
52
- }
53
- }
54
- }
55
-
56
39
  export function parseCloudArgs(command, argv) {
57
40
  const options = { directory: '.', timeout: 600 };
58
41
  const flags = new Map([
@@ -582,7 +565,7 @@ export async function cloudCommand(
582
565
  try {
583
566
  if (['deploy', 'update'].includes(command)) {
584
567
  const requestedHome = path.dirname(credentialPath(origin, env));
585
- const actualHome = await resolveCredentialDirectory(requestedHome);
568
+ const actualHome = await prospectivePath(requestedHome);
586
569
  const credentialsRelative = path.relative(directory, actualHome);
587
570
  if (
588
571
  !credentialsRelative ||
@@ -1,9 +1,24 @@
1
- import { mkdir, lstat, readFile, writeFile, rm, chmod } from 'node:fs/promises';
1
+ import { mkdir, lstat, readFile, realpath, writeFile, rm, chmod } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { createHash, randomBytes } from 'node:crypto';
5
5
  import { rename } from '../fs.mjs';
6
6
 
7
+ // Resolve existing parents too when a destination has not been created yet.
8
+ // On macOS, for example, /var and /private/var refer to the same directory.
9
+ export async function prospectivePath(value) {
10
+ let parent = path.resolve(value);
11
+ const missing = [];
12
+ for (;;) {
13
+ try { return path.join(await realpath(parent), ...missing); }
14
+ catch (error) {
15
+ if (error.code !== 'ENOENT' || path.dirname(parent) === parent) throw error;
16
+ missing.unshift(path.basename(parent));
17
+ parent = path.dirname(parent);
18
+ }
19
+ }
20
+ }
21
+
7
22
  export function cloudOrigin(value = 'https://app.prnext.dev') {
8
23
  let url;
9
24
  try {
Binary file