@volter/world-runtime 2.0.21 → 2.0.22

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.
@@ -2,6 +2,7 @@ import type { WorldInstance } from './schema.js';
2
2
  export type BranchOptions = {
3
3
  root?: string;
4
4
  envFile?: string;
5
+ forceEnvOutput?: boolean;
5
6
  now?: Date; /** branch from a point in the base's history: an instant, or a position per twin (contract "Just like Neon", 3) */
6
7
  at?: {
7
8
  instant?: string;
@@ -58,7 +58,7 @@ export async function branchWorld(base, name, options = {}) {
58
58
  const began = Date.now();
59
59
  let instance;
60
60
  try {
61
- instance = await upWorld(existsSync(from.configPath) ? from.configPath : from.config, { name, root, mode: from.mode, envFile });
61
+ instance = await upWorld(existsSync(from.configPath) ? from.configPath : from.config, { name, root, mode: from.mode, envFile, forceEnvOutput: options.forceEnvOutput });
62
62
  }
63
63
  catch (error) {
64
64
  // only a record made during this call is ours to remove, and it is removed by its own createdAt, as the fork's
@@ -43,7 +43,7 @@ function requireWorld(name, root) {
43
43
  if (!/World instance not found/.test(String(error.message)))
44
44
  throw error;
45
45
  const known = listWorlds(root).map((world) => world.name);
46
- throw new Error(`World "${name}" not found in ${root}${known.length ? ` (known worlds: ${known.join(', ')})` : ' (no worlds here)'} — boot it with \`volter-world up <config> --env-file <path> --name ${name}\``);
46
+ throw new Error(`World "${name}" not found in ${root}${known.length ? ` (known worlds: ${known.join(', ')})` : ' (no worlds here)'} — boot it with \`volter-world up <config> --env-out <path> --name ${name}\``);
47
47
  }
48
48
  }
49
49
  /** Every state service a world service currently records actions under (often exactly one). */
package/dist/src/cli.js CHANGED
@@ -70,7 +70,7 @@ function printHelp() {
70
70
  # emission. Exit 0 = ready to \`up\`; exit 1 = the honest worklist.
71
71
  # Deterministic: same repo + same pack catalog => identical bytes.
72
72
  # init DIAGNOSES; it never boots — it prints the exact next commands
73
- volter-world up <config> --env-file=<path> [--owner <label>] [--name <name>] [--mode local|share|sealed] [--isolation process|colocated|worker] [--keep-state] [--root <repo>]
73
+ volter-world up <config> --env-out=<path> [--owner <label>] [--name <name>] [--mode local|share|sealed] [--isolation process|colocated|worker] [--keep-state] [--root <repo>]
74
74
  volter-world outdated <world> [--json] — each mounted twin: pinned / mounted / catalog-current version and protocol standing (R17)
75
75
  volter-world audit <world> [--json] — the catalog's upkeep verdicts for the mounted twins: matrix debt, census (R17)
76
76
  volter-world down <name> [--grace-ms <ms>] [--purge] [--root <repo>] # SIGTERM, then SIGKILL survivors after the grace (default 5000ms)
@@ -120,7 +120,7 @@ function printHelp() {
120
120
  # window open, so the defaults land as observed state (diff empty, nothing pending)
121
121
  volter-world reset <world> [--entry <file>] [--cwd <dir>] [--json] [--root <repo>]
122
122
  # back to the defaults: down --purge, up, seed (refuses a referenced parent)
123
- volter-world branch <world> <name> [--env-file=<path>] [--json] [--root <repo>]
123
+ volter-world branch <world> <name> [--env-out=<path>] [--json] [--root <repo>]
124
124
  # a new world over the base's mirrors (its own log; the base's unpushed changes stay)
125
125
  volter-world checkout <name> [--json] [--root <repo>] # a stopped world back up, state intact
126
126
  volter-world fetch <world> [--origin <twins url> --namespace <org>/<world>] --key <key> [--full] [--service <id>]... [--json] [--root <repo>]
@@ -168,7 +168,7 @@ function printHelp() {
168
168
  # attachers: volter-world attach <http://host:manifest-port> -- <command...>
169
169
  volter-world activate <name> [--root <repo>] # eval "$(volter-world activate <name>)" — a virtualenv for vendor APIs
170
170
  volter-world shell <name> [--root <repo>] # drop into a subshell with the world active (any CLI hits the twins)
171
- volter-world run <config> --env-file=<path> [--name <name>] [--mode local|share|sealed] [--keep] [--verbose] -- <command...>
171
+ volter-world run <config> --env-out=<path> [--name <name>] [--mode local|share|sealed] [--keep] [--verbose] -- <command...>
172
172
  volter-world share <name> [--service app] [--verify /health|--no-verify] [--provider cloudflare-quick|command] [--command <cmd> [-- <args…>]]
173
173
  # {url} in command args = the service's local URL;
174
174
  # --provider cloudflare-quick selects raw cloudflared and clears a configured custom command
@@ -316,11 +316,13 @@ async function main() {
316
316
  return;
317
317
  }
318
318
  if (cmd === 'up') {
319
+ if (rest.some(arg => arg === '--env-file' || arg.startsWith('--env-file=')))
320
+ throw new Error('--env-file is not an input for this command. Use --env-out=<new-output-path>; never pass credentials or custody as an output.');
319
321
  if (!subject)
320
322
  throw new Error('volter-world up: missing config');
321
- const envFile = optionValue(rest, '--env-file');
323
+ const envFile = optionValue(rest, '--env-out');
322
324
  if (!envFile)
323
- throw new Error('volter-world up: missing required --env-file=<path>');
325
+ throw new Error('volter-world up: missing required --env-out=<path>');
324
326
  const name = optionValue(rest, '--name') || undefined;
325
327
  const mode = optionValue(rest, '--mode', 'local');
326
328
  const isolationArg = optionValue(rest, '--isolation');
@@ -332,7 +334,7 @@ async function main() {
332
334
  // --keep-state: boot over the state the World's directory already holds (a World carried somewhere else, e.g. a
333
335
  // browser tab's image, comes up over its data instead of starting empty, as a fresh `up` does)
334
336
  const keepState = rest.includes('--keep-state');
335
- const instance = await superviseWorldUp(subject, { name, root, mode, envFile, owner: ownerOption(rest), ...(isolation ? { isolation } : {}), ...(keepState ? { keepState: true } : {}) });
337
+ const instance = await superviseWorldUp(subject, { name, root, mode, envFile, forceEnvOutput: rest.includes('--force'), owner: ownerOption(rest), ...(isolation ? { isolation } : {}), ...(keepState ? { keepState: true } : {}) });
336
338
  printReady(instance);
337
339
  return;
338
340
  }
@@ -738,11 +740,13 @@ async function main() {
738
740
  return;
739
741
  }
740
742
  if (cmd === 'branch') {
743
+ if (rest.some(arg => arg === '--env-file' || arg.startsWith('--env-file=')))
744
+ throw new Error('--env-file is not an input for this command. Use --env-out=<new-output-path>; never pass credentials or custody as an output.');
741
745
  const [name] = positionalArgs(rest);
742
746
  if (!subject || !name)
743
- throw new Error('volter-world branch: usage: volter-world branch <world> <name> [--env-file=<path>]');
744
- const envFile = optionValue(rest, '--env-file') || undefined;
745
- const { instance, forked } = await branchWorld(subject, name, { root, ...(envFile ? { envFile } : {}) });
747
+ throw new Error('volter-world branch: usage: volter-world branch <world> <name> [--env-out=<path>]');
748
+ const envFile = optionValue(rest, '--env-out') || undefined;
749
+ const { instance, forked } = await branchWorld(subject, name, { root, forceEnvOutput: rest.includes('--force'), ...(envFile ? { envFile } : {}) });
746
750
  if (rest.includes('--json'))
747
751
  process.stdout.write(`${JSON.stringify({ name: instance.name, base: subject, forked }, null, 2)}\n`);
748
752
  else {
@@ -1157,12 +1161,15 @@ async function main() {
1157
1161
  const split = rest.indexOf('--');
1158
1162
  if (split < 0)
1159
1163
  throw new Error('volter-world run: pass command after --');
1160
- const envFile = optionValue(rest, '--env-file');
1164
+ const worldArgs = rest.slice(0, split);
1165
+ if (worldArgs.some(arg => arg === '--env-file' || arg.startsWith('--env-file=')))
1166
+ throw new Error('--env-file is not an input for this command. Use --env-out=<new-output-path>; never pass credentials or custody as an output.');
1167
+ const envFile = optionValue(worldArgs, '--env-out');
1161
1168
  if (!envFile)
1162
- throw new Error('volter-world run: missing required --env-file=<path>');
1163
- const name = optionValue(rest, '--name') || undefined;
1164
- const mode = optionValue(rest, '--mode', 'local');
1165
- const result = await runWorld(subject, rest.slice(split + 1), { root, name, mode, envFile, owner: ownerOption(rest.slice(0, split)), keep: rest.slice(0, split).includes('--keep'), verbose: rest.slice(0, split).includes('--verbose') });
1169
+ throw new Error('volter-world run: missing required --env-out=<path>');
1170
+ const name = optionValue(worldArgs, '--name') || undefined;
1171
+ const mode = optionValue(worldArgs, '--mode', 'local');
1172
+ const result = await runWorld(subject, rest.slice(split + 1), { root, name, mode, envFile, forceEnvOutput: worldArgs.includes('--force'), owner: ownerOption(worldArgs), keep: worldArgs.includes('--keep'), verbose: worldArgs.includes('--verbose') });
1166
1173
  if (result.exitCode !== 0) {
1167
1174
  const detail = result.outcome.error ? `could not start (${result.outcome.error})`
1168
1175
  : result.outcome.signal ? `was terminated by ${result.outcome.signal}`
@@ -0,0 +1,14 @@
1
+ type PriorOutput = {
2
+ path: string;
3
+ contents: string;
4
+ };
5
+ export type EnvOutputClaim = {
6
+ path: string;
7
+ contents?: Buffer;
8
+ force: boolean;
9
+ backupDir: string;
10
+ };
11
+ /** Refuse a custody/input file before any World service or lifecycle mutation starts. */
12
+ export declare function claimEnvOutput(path: string, backupDir: string, prior?: PriorOutput, force?: boolean): EnvOutputClaim;
13
+ export declare function publishEnvOutput(claim: EnvOutputClaim, contents: string): void;
14
+ export {};
@@ -0,0 +1,67 @@
1
+ import { constants, closeSync, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, writeFileSync, writeSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ function inspect(path) {
5
+ try {
6
+ const info = lstatSync(path);
7
+ if (!info.isFile() || info.nlink !== 1)
8
+ throw new Error('World env output must be a regular file with one link; symlinks are refused');
9
+ return readFileSync(path);
10
+ }
11
+ catch (error) {
12
+ if (error.code === 'ENOENT')
13
+ return undefined;
14
+ throw error;
15
+ }
16
+ }
17
+ /** Refuse a custody/input file before any World service or lifecycle mutation starts. */
18
+ export function claimEnvOutput(path, backupDir, prior, force = false) {
19
+ const contents = inspect(path);
20
+ if (contents !== undefined && !force && !(prior?.path === path && Buffer.from(prior.contents).equals(contents))) {
21
+ throw new Error(`World refuses existing --env-out file it does not own: ${path}. Choose a new output path; never use a custody file. --force explicitly replaces a file with a private backup.`);
22
+ }
23
+ if (force && contents !== undefined) {
24
+ mkdirSync(backupDir, { recursive: true, mode: 0o700 });
25
+ const hash = createHash('sha256').update(path).update('\0').update(contents).digest('hex');
26
+ const backup = join(backupDir, hash);
27
+ if (existsSync(backup)) {
28
+ if (!readFileSync(backup).equals(contents))
29
+ throw new Error('World env backup differs; refusing replacement');
30
+ }
31
+ else
32
+ writeFileSync(backup, contents, { mode: 0o600, flag: 'wx' });
33
+ }
34
+ return { path, contents, force, backupDir };
35
+ }
36
+ export function publishEnvOutput(claim, contents) {
37
+ mkdirSync(dirname(claim.path), { recursive: true });
38
+ const current = inspect(claim.path);
39
+ if (current === undefined) {
40
+ const fd = openSync(claim.path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
41
+ try {
42
+ writeFileSync(fd, contents);
43
+ }
44
+ finally {
45
+ closeSync(fd);
46
+ }
47
+ return;
48
+ }
49
+ if (claim.contents === undefined || !current.equals(claim.contents))
50
+ throw new Error('World env output changed during boot; refusing to overwrite it');
51
+ const fd = openSync(claim.path, constants.O_RDWR | constants.O_NOFOLLOW);
52
+ try {
53
+ const opened = fstatSync(fd);
54
+ const named = lstatSync(claim.path);
55
+ if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== named.dev || opened.ino !== named.ino || !readFileSync(fd).equals(claim.contents)) {
56
+ throw new Error('World env output identity changed during boot; refusing to overwrite it');
57
+ }
58
+ ftruncateSync(fd, 0);
59
+ const bytes = Buffer.from(contents);
60
+ let offset = 0;
61
+ while (offset < bytes.length)
62
+ offset += writeSync(fd, bytes, offset, bytes.length - offset, offset);
63
+ }
64
+ finally {
65
+ closeSync(fd);
66
+ }
67
+ }
package/dist/src/init.js CHANGED
@@ -1046,7 +1046,7 @@ export function initWorld(name, repoPath, options = {}) {
1046
1046
  'volter world down',
1047
1047
  ]
1048
1048
  : [
1049
- `volter-world up ${plan.configPath} --name ${plan.name} --env-file=${resolve(plan.out, 'world.env')} # up WRITES the live env (twin URLs) there; app.env is init's static preview of the same map`,
1049
+ `volter-world up ${plan.configPath} --name ${plan.name} --env-out=${resolve(plan.out, 'world.env')} # up WRITES the live env (twin URLs) there; app.env is init's static preview of the same map`,
1050
1050
  ...(plan.seedEntry === null ? [] : [`volter-world seed ${plan.name} --cwd ${plan.repo} # load the default data AFTER every up: it lands as data that was already there, the log empty; reset = down, up, seed`]),
1051
1051
  `volter-world env ${plan.name} -- <your app's dev command> # run the app inside the world`,
1052
1052
  `volter-world down ${plan.name}`,
@@ -358,7 +358,7 @@ export function composeOverrideForReflect(manifest, services, worldName) {
358
358
  }
359
359
  // docker compose's global options that take a value — the override's -f must land after the consumer's own
360
360
  // files and before the subcommand, so these are stepped over to find it.
361
- const COMPOSE_VALUED_OPTIONS = new Set(['-f', '--file', '-p', '--project-name', '--profile', '--env-file', '--project-directory', '--progress', '--parallel', '--ansi']);
361
+ const COMPOSE_VALUED_OPTIONS = new Set(['-f', '--file', '-p', '--project-name', '--profile', '--env-out', '--project-directory', '--progress', '--parallel', '--ansi']);
362
362
  /** Split `docker [docker flags] compose [compose flags] <subcommand…>`; undefined when it is not a compose command. */
363
363
  export function splitDockerComposeArgs(command) {
364
364
  if (command[0] !== 'docker')
@@ -9,7 +9,9 @@ export type UpWorldOptions = {
9
9
  mode?: WorldMode;
10
10
  /** Overrides the config's `isolation` for this boot (e.g. force 'process' for a share world). */
11
11
  isolation?: WorldIsolation;
12
+ /** Output path for generated World exports; never an input credentials file. */
12
13
  envFile?: string;
14
+ forceEnvOutput?: boolean;
13
15
  share?: Omit<ShareWorldOptions, 'root' | 'service' | 'verifyPath'> & {
14
16
  verifyPath?: string | false;
15
17
  };
@@ -1,3 +1,4 @@
1
+ import { claimEnvOutput, publishEnvOutput } from "./env-output.js";
1
2
  import { offSignal } from "./signals.js";
2
3
  import { assertWorldStateRemovable, removeWorldState } from "./ancestry.js";
3
4
  import { packCli, packEntry } from "./catalog.js";
@@ -1400,6 +1401,10 @@ export async function upWorld(configId, options = {}, abort) {
1400
1401
  const pidsFile = join(instance, 'pids');
1401
1402
  const envFile = resolve(root, options.envFile ?? join(instance, 'world.env'));
1402
1403
  const instanceFile = join(instance, 'instance.json');
1404
+ const priorOutputInstance = existsSync(instanceFile) ? JSON.parse(readFileSync(instanceFile, 'utf8')) : undefined;
1405
+ const priorOutput = priorOutputInstance?.name === name && resolve(priorOutputInstance.root) === root
1406
+ ? { path: resolve(priorOutputInstance.envFile), contents: envFileContents(priorOutputInstance.env) } : undefined;
1407
+ const envOutputClaim = claimEnvOutput(envFile, join(root, stateDirName(), 'env-output-backups'), priorOutput, options.forceEnvOutput);
1403
1408
  if (loaded.config.resources)
1404
1409
  process.stderr.write(`World "${name}": config resources is deprecated; declarations are not reservations or enforced limits. Configure limits in the execution backend.\n`);
1405
1410
  checkedOwner(options.owner);
@@ -1639,7 +1644,7 @@ export async function upWorld(configId, options = {}, abort) {
1639
1644
  mkdirSync(dirname(envFile), { recursive: true });
1640
1645
  // Config env and discovered credentials: a file this writes is owner-only; a file the caller already made keeps
1641
1646
  // the mode the caller chose (a shared env file may be read under another uid).
1642
- writeFileSync(envFile, envFileContents(env), { mode: 0o600 });
1647
+ publishEnvOutput(envOutputClaim, envFileContents(env));
1643
1648
  writePidsFromInstance(worldInstance, Object.values(worldInstance.services).map((service) => service.pid).filter((pid) => pid > 0));
1644
1649
  saveWorldInstance(worldInstance);
1645
1650
  const servicePids = [...new Set(Object.values(services).map((service) => service.pid).filter((pid) => pid > 0))];
@@ -2840,7 +2845,7 @@ export function urlsWorld(name, root = process.cwd()) {
2840
2845
  export function urlWorld(name, options = {}) {
2841
2846
  const status = statusWorld(name, options.root ?? process.cwd());
2842
2847
  if (!status.running) {
2843
- throw new Error(`World ${name} is not running; start it with: volter-world up ${status.config} --env-file <path> --name ${name}`);
2848
+ throw new Error(`World ${name} is not running; start it with: volter-world up ${status.config} --env-out <path> --name ${name}`);
2844
2849
  }
2845
2850
  if (options.service === undefined) {
2846
2851
  return Object.values(status.services)
@@ -3252,7 +3257,7 @@ async function runWithWorldEnvLogged(name, command, root, options = {}) {
3252
3257
  export function worldShellEnv(name, root = process.cwd()) {
3253
3258
  const status = statusWorld(name, root);
3254
3259
  if (!status.running)
3255
- throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-file <path> --name ${name}`);
3260
+ throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-out <path> --name ${name}`);
3256
3261
  return {
3257
3262
  shell: status.env.SHELL || admittedEnv(stripEnvOf(status.env)).SHELL || '/bin/bash',
3258
3263
  cwd: resolve(root),
@@ -3560,7 +3565,7 @@ function stopWorldProxy(root, name) {
3560
3565
  export function activateScript(name, root = process.cwd()) {
3561
3566
  const status = statusWorld(name, root);
3562
3567
  if (!status.running)
3563
- throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-file <path> --name ${name}`);
3568
+ throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-out <path> --name ${name}`);
3564
3569
  const baseEnv = status.env ?? {};
3565
3570
  // Bring up the ambient redirect proxy (best-effort) and fold its env in so the activated shell
3566
3571
  // routes https through the twins too — not just the Node injector / cliRedirect endpoint vars.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "2.0.21",
3
+ "version": "2.0.22",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
package/src/branch.ts CHANGED
@@ -15,7 +15,7 @@ import type { WorldInstance } from './schema.ts';
15
15
  import { parseInfraDefinition, pgliteDown, pgliteUp } from './pglite-backing.ts';
16
16
  import { redisTwinCli, redisTwinDown, redisTwinUp } from './redis-backing.ts';
17
17
 
18
- export type BranchOptions = { root?: string; envFile?: string; now?: Date; /** branch from a point in the base's history: an instant, or a position per twin (contract "Just like Neon", 3) */ at?: { instant?: string; positions?: Record<string, number>; views?: Record<string, string> } };
18
+ export type BranchOptions = { root?: string; envFile?: string; forceEnvOutput?: boolean; now?: Date; /** branch from a point in the base's history: an instant, or a position per twin (contract "Just like Neon", 3) */ at?: { instant?: string; positions?: Record<string, number>; views?: Record<string, string> } };
19
19
 
20
20
  function stateServices(controlRoot: string): string[] {
21
21
  const stateRoot = worldStateRoot(controlRoot);
@@ -54,7 +54,7 @@ export async function branchWorld(base: string, name: string, options: BranchOpt
54
54
  const began = Date.now();
55
55
  let instance: WorldInstance;
56
56
  try {
57
- instance = await upWorld(existsSync(from.configPath) ? from.configPath : from.config, { name, root, mode: from.mode, envFile });
57
+ instance = await upWorld(existsSync(from.configPath) ? from.configPath : from.config, { name, root, mode: from.mode, envFile, forceEnvOutput: options.forceEnvOutput });
58
58
  } catch (error) {
59
59
  // only a record made during this call is ours to remove, and it is removed by its own createdAt, as the fork's
60
60
  // cleanup below does: a concurrent `branch` of the same name that recorded first keeps its World
package/src/changeset.ts CHANGED
@@ -67,7 +67,7 @@ function requireWorld(name: string, root: string): WorldInstance {
67
67
  } catch (error) {
68
68
  if (!/World instance not found/.test(String((error as Error).message))) throw error;
69
69
  const known = listWorlds(root).map((world) => world.name);
70
- throw new Error(`World "${name}" not found in ${root}${known.length ? ` (known worlds: ${known.join(', ')})` : ' (no worlds here)'} — boot it with \`volter-world up <config> --env-file <path> --name ${name}\``);
70
+ throw new Error(`World "${name}" not found in ${root}${known.length ? ` (known worlds: ${known.join(', ')})` : ' (no worlds here)'} — boot it with \`volter-world up <config> --env-out <path> --name ${name}\``);
71
71
  }
72
72
  }
73
73
 
package/src/cli.ts CHANGED
@@ -71,7 +71,7 @@ function printHelp(): void {
71
71
  # emission. Exit 0 = ready to \`up\`; exit 1 = the honest worklist.
72
72
  # Deterministic: same repo + same pack catalog => identical bytes.
73
73
  # init DIAGNOSES; it never boots — it prints the exact next commands
74
- volter-world up <config> --env-file=<path> [--owner <label>] [--name <name>] [--mode local|share|sealed] [--isolation process|colocated|worker] [--keep-state] [--root <repo>]
74
+ volter-world up <config> --env-out=<path> [--owner <label>] [--name <name>] [--mode local|share|sealed] [--isolation process|colocated|worker] [--keep-state] [--root <repo>]
75
75
  volter-world outdated <world> [--json] — each mounted twin: pinned / mounted / catalog-current version and protocol standing (R17)
76
76
  volter-world audit <world> [--json] — the catalog's upkeep verdicts for the mounted twins: matrix debt, census (R17)
77
77
  volter-world down <name> [--grace-ms <ms>] [--purge] [--root <repo>] # SIGTERM, then SIGKILL survivors after the grace (default 5000ms)
@@ -121,7 +121,7 @@ function printHelp(): void {
121
121
  # window open, so the defaults land as observed state (diff empty, nothing pending)
122
122
  volter-world reset <world> [--entry <file>] [--cwd <dir>] [--json] [--root <repo>]
123
123
  # back to the defaults: down --purge, up, seed (refuses a referenced parent)
124
- volter-world branch <world> <name> [--env-file=<path>] [--json] [--root <repo>]
124
+ volter-world branch <world> <name> [--env-out=<path>] [--json] [--root <repo>]
125
125
  # a new world over the base's mirrors (its own log; the base's unpushed changes stay)
126
126
  volter-world checkout <name> [--json] [--root <repo>] # a stopped world back up, state intact
127
127
  volter-world fetch <world> [--origin <twins url> --namespace <org>/<world>] --key <key> [--full] [--service <id>]... [--json] [--root <repo>]
@@ -169,7 +169,7 @@ function printHelp(): void {
169
169
  # attachers: volter-world attach <http://host:manifest-port> -- <command...>
170
170
  volter-world activate <name> [--root <repo>] # eval "$(volter-world activate <name>)" — a virtualenv for vendor APIs
171
171
  volter-world shell <name> [--root <repo>] # drop into a subshell with the world active (any CLI hits the twins)
172
- volter-world run <config> --env-file=<path> [--name <name>] [--mode local|share|sealed] [--keep] [--verbose] -- <command...>
172
+ volter-world run <config> --env-out=<path> [--name <name>] [--mode local|share|sealed] [--keep] [--verbose] -- <command...>
173
173
  volter-world share <name> [--service app] [--verify /health|--no-verify] [--provider cloudflare-quick|command] [--command <cmd> [-- <args…>]]
174
174
  # {url} in command args = the service's local URL;
175
175
  # --provider cloudflare-quick selects raw cloudflared and clears a configured custom command
@@ -298,9 +298,10 @@ async function main(): Promise<void> {
298
298
  }
299
299
 
300
300
  if (cmd === 'up') {
301
+ if (rest.some(arg => arg === '--env-file' || arg.startsWith('--env-file='))) throw new Error('--env-file is not an input for this command. Use --env-out=<new-output-path>; never pass credentials or custody as an output.');
301
302
  if (!subject) throw new Error('volter-world up: missing config');
302
- const envFile = optionValue(rest, '--env-file');
303
- if (!envFile) throw new Error('volter-world up: missing required --env-file=<path>');
303
+ const envFile = optionValue(rest, '--env-out');
304
+ if (!envFile) throw new Error('volter-world up: missing required --env-out=<path>');
304
305
  const name = optionValue(rest, '--name') || undefined;
305
306
  const mode = optionValue(rest, '--mode', 'local') as 'local' | 'share' | 'sealed';
306
307
  const isolationArg = optionValue(rest, '--isolation');
@@ -312,7 +313,7 @@ async function main(): Promise<void> {
312
313
  // --keep-state: boot over the state the World's directory already holds (a World carried somewhere else, e.g. a
313
314
  // browser tab's image, comes up over its data instead of starting empty, as a fresh `up` does)
314
315
  const keepState = rest.includes('--keep-state');
315
- const instance = await superviseWorldUp(subject, { name, root, mode, envFile, owner: ownerOption(rest), ...(isolation ? { isolation } : {}), ...(keepState ? { keepState: true } : {}) });
316
+ const instance = await superviseWorldUp(subject, { name, root, mode, envFile, forceEnvOutput: rest.includes('--force'), owner: ownerOption(rest), ...(isolation ? { isolation } : {}), ...(keepState ? { keepState: true } : {}) });
316
317
  printReady(instance);
317
318
  return;
318
319
  }
@@ -662,10 +663,11 @@ async function main(): Promise<void> {
662
663
  }
663
664
 
664
665
  if (cmd === 'branch') {
666
+ if (rest.some(arg => arg === '--env-file' || arg.startsWith('--env-file='))) throw new Error('--env-file is not an input for this command. Use --env-out=<new-output-path>; never pass credentials or custody as an output.');
665
667
  const [name] = positionalArgs(rest);
666
- if (!subject || !name) throw new Error('volter-world branch: usage: volter-world branch <world> <name> [--env-file=<path>]');
667
- const envFile = optionValue(rest, '--env-file') || undefined;
668
- const { instance, forked } = await branchWorld(subject, name, { root, ...(envFile ? { envFile } : {}) });
668
+ if (!subject || !name) throw new Error('volter-world branch: usage: volter-world branch <world> <name> [--env-out=<path>]');
669
+ const envFile = optionValue(rest, '--env-out') || undefined;
670
+ const { instance, forked } = await branchWorld(subject, name, { root, forceEnvOutput: rest.includes('--force'), ...(envFile ? { envFile } : {}) });
669
671
  if (rest.includes('--json')) process.stdout.write(`${JSON.stringify({ name: instance.name, base: subject, forked }, null, 2)}\n`);
670
672
  else { process.stdout.write(`Branched world ${name} from ${subject}\n${Object.entries(forked).map(([s, states]) => ` ${s}: mirror forked (${states.join(', ')})`).join('\n')}\n`); printReady(instance); }
671
673
  return;
@@ -1021,11 +1023,13 @@ async function main(): Promise<void> {
1021
1023
  if (!subject) throw new Error('volter-world run: missing config');
1022
1024
  const split = rest.indexOf('--');
1023
1025
  if (split < 0) throw new Error('volter-world run: pass command after --');
1024
- const envFile = optionValue(rest, '--env-file');
1025
- if (!envFile) throw new Error('volter-world run: missing required --env-file=<path>');
1026
- const name = optionValue(rest, '--name') || undefined;
1027
- const mode = optionValue(rest, '--mode', 'local') as 'local' | 'share' | 'sealed';
1028
- const result = await runWorld(subject, rest.slice(split + 1), { root, name, mode, envFile, owner: ownerOption(rest.slice(0, split)), keep: rest.slice(0, split).includes('--keep'), verbose: rest.slice(0, split).includes('--verbose') });
1026
+ const worldArgs = rest.slice(0, split);
1027
+ if (worldArgs.some(arg => arg === '--env-file' || arg.startsWith('--env-file='))) throw new Error('--env-file is not an input for this command. Use --env-out=<new-output-path>; never pass credentials or custody as an output.');
1028
+ const envFile = optionValue(worldArgs, '--env-out');
1029
+ if (!envFile) throw new Error('volter-world run: missing required --env-out=<path>');
1030
+ const name = optionValue(worldArgs, '--name') || undefined;
1031
+ const mode = optionValue(worldArgs, '--mode', 'local') as 'local' | 'share' | 'sealed';
1032
+ const result = await runWorld(subject, rest.slice(split + 1), { root, name, mode, envFile, forceEnvOutput: worldArgs.includes('--force'), owner: ownerOption(worldArgs), keep: worldArgs.includes('--keep'), verbose: worldArgs.includes('--verbose') });
1029
1033
  if (result.exitCode !== 0) {
1030
1034
  const detail = result.outcome.error ? `could not start (${result.outcome.error})`
1031
1035
  : result.outcome.signal ? `was terminated by ${result.outcome.signal}`
@@ -0,0 +1,57 @@
1
+ import { constants, closeSync, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, writeFileSync, writeSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+
5
+ type PriorOutput = { path: string; contents: string };
6
+ export type EnvOutputClaim = { path: string; contents?: Buffer; force: boolean; backupDir: string };
7
+
8
+ function inspect(path: string): Buffer | undefined {
9
+ try {
10
+ const info = lstatSync(path);
11
+ if (!info.isFile() || info.nlink !== 1) throw new Error('World env output must be a regular file with one link; symlinks are refused');
12
+ return readFileSync(path);
13
+ } catch (error) {
14
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
15
+ throw error;
16
+ }
17
+ }
18
+
19
+ /** Refuse a custody/input file before any World service or lifecycle mutation starts. */
20
+ export function claimEnvOutput(path: string, backupDir: string, prior?: PriorOutput, force = false): EnvOutputClaim {
21
+ const contents = inspect(path);
22
+ if (contents !== undefined && !force && !(prior?.path === path && Buffer.from(prior.contents).equals(contents))) {
23
+ throw new Error(`World refuses existing --env-out file it does not own: ${path}. Choose a new output path; never use a custody file. --force explicitly replaces a file with a private backup.`);
24
+ }
25
+ if (force && contents !== undefined) {
26
+ mkdirSync(backupDir, { recursive: true, mode: 0o700 });
27
+ const hash = createHash('sha256').update(path).update('\0').update(contents).digest('hex');
28
+ const backup = join(backupDir, hash);
29
+ if (existsSync(backup)) {
30
+ if (!readFileSync(backup).equals(contents)) throw new Error('World env backup differs; refusing replacement');
31
+ } else writeFileSync(backup, contents, { mode: 0o600, flag: 'wx' });
32
+ }
33
+ return { path, contents, force, backupDir };
34
+ }
35
+
36
+ export function publishEnvOutput(claim: EnvOutputClaim, contents: string): void {
37
+ mkdirSync(dirname(claim.path), { recursive: true });
38
+ const current = inspect(claim.path);
39
+ if (current === undefined) {
40
+ const fd = openSync(claim.path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
41
+ try { writeFileSync(fd, contents); } finally { closeSync(fd); }
42
+ return;
43
+ }
44
+ if (claim.contents === undefined || !current.equals(claim.contents)) throw new Error('World env output changed during boot; refusing to overwrite it');
45
+ const fd = openSync(claim.path, constants.O_RDWR | constants.O_NOFOLLOW);
46
+ try {
47
+ const opened = fstatSync(fd);
48
+ const named = lstatSync(claim.path);
49
+ if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== named.dev || opened.ino !== named.ino || !readFileSync(fd).equals(claim.contents)) {
50
+ throw new Error('World env output identity changed during boot; refusing to overwrite it');
51
+ }
52
+ ftruncateSync(fd, 0);
53
+ const bytes = Buffer.from(contents);
54
+ let offset = 0;
55
+ while (offset < bytes.length) offset += writeSync(fd, bytes, offset, bytes.length - offset, offset);
56
+ } finally { closeSync(fd); }
57
+ }
package/src/init.ts CHANGED
@@ -1245,7 +1245,7 @@ export function initWorld(name: string, repoPath: string, options: InitOptions =
1245
1245
  'volter world down',
1246
1246
  ]
1247
1247
  : [
1248
- `volter-world up ${plan.configPath} --name ${plan.name} --env-file=${resolve(plan.out, 'world.env')} # up WRITES the live env (twin URLs) there; app.env is init's static preview of the same map`,
1248
+ `volter-world up ${plan.configPath} --name ${plan.name} --env-out=${resolve(plan.out, 'world.env')} # up WRITES the live env (twin URLs) there; app.env is init's static preview of the same map`,
1249
1249
  ...(plan.seedEntry === null ? [] : [`volter-world seed ${plan.name} --cwd ${plan.repo} # load the default data AFTER every up: it lands as data that was already there, the log empty; reset = down, up, seed`]),
1250
1250
  `volter-world env ${plan.name} -- <your app's dev command> # run the app inside the world`,
1251
1251
  `volter-world down ${plan.name}`,
package/src/reflect.ts CHANGED
@@ -413,7 +413,7 @@ export function composeOverrideForReflect(manifest: ReflectManifest, services: s
413
413
 
414
414
  // docker compose's global options that take a value — the override's -f must land after the consumer's own
415
415
  // files and before the subcommand, so these are stepped over to find it.
416
- const COMPOSE_VALUED_OPTIONS = new Set(['-f', '--file', '-p', '--project-name', '--profile', '--env-file', '--project-directory', '--progress', '--parallel', '--ansi']);
416
+ const COMPOSE_VALUED_OPTIONS = new Set(['-f', '--file', '-p', '--project-name', '--profile', '--env-out', '--project-directory', '--progress', '--parallel', '--ansi']);
417
417
 
418
418
  /** Split `docker [docker flags] compose [compose flags] <subcommand…>`; undefined when it is not a compose command. */
419
419
  export function splitDockerComposeArgs(command: string[]): { head: string[]; composeFlags: string[]; tail: string[] } | undefined {
package/src/runtime.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { claimEnvOutput, publishEnvOutput } from './env-output.ts';
1
2
  import { offSignal } from './signals.ts';
2
3
  import { assertWorldStateRemovable, removeWorldState } from './ancestry.ts';
3
4
  import { packCli, packEntry } from './catalog.ts';
@@ -168,7 +169,9 @@ export type UpWorldOptions = {
168
169
  mode?: WorldMode;
169
170
  /** Overrides the config's `isolation` for this boot (e.g. force 'process' for a share world). */
170
171
  isolation?: WorldIsolation;
172
+ /** Output path for generated World exports; never an input credentials file. */
171
173
  envFile?: string;
174
+ forceEnvOutput?: boolean;
172
175
  share?: Omit<ShareWorldOptions, 'root' | 'service' | 'verifyPath'> & { verifyPath?: string | false };
173
176
  /** `checkout`: bring a stopped world back with its state — the data dir, marks, changesets and
174
177
  * episodes survive the boot; logs and the pid file start clean. Never the default: `up` starts
@@ -1420,6 +1423,11 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1420
1423
  const pidsFile = join(instance, 'pids');
1421
1424
  const envFile = resolve(root, options.envFile ?? join(instance, 'world.env'));
1422
1425
  const instanceFile = join(instance, 'instance.json');
1426
+ const priorOutputInstance = existsSync(instanceFile) ? JSON.parse(readFileSync(instanceFile, 'utf8')) as WorldInstance : undefined;
1427
+ const priorOutput = priorOutputInstance?.name === name && resolve(priorOutputInstance.root) === root
1428
+ ? { path: resolve(priorOutputInstance.envFile), contents: envFileContents(priorOutputInstance.env) } : undefined;
1429
+ const envOutputClaim = claimEnvOutput(envFile, join(root, stateDirName(), 'env-output-backups'), priorOutput, options.forceEnvOutput);
1430
+
1423
1431
  if (loaded.config.resources) process.stderr.write(`World "${name}": config resources is deprecated; declarations are not reservations or enforced limits. Configure limits in the execution backend.\n`);
1424
1432
  checkedOwner(options.owner);
1425
1433
  let resourcesClaimed = false;
@@ -1638,7 +1646,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1638
1646
  mkdirSync(dirname(envFile), { recursive: true });
1639
1647
  // Config env and discovered credentials: a file this writes is owner-only; a file the caller already made keeps
1640
1648
  // the mode the caller chose (a shared env file may be read under another uid).
1641
- writeFileSync(envFile, envFileContents(env), { mode: 0o600 });
1649
+ publishEnvOutput(envOutputClaim, envFileContents(env));
1642
1650
  writePidsFromInstance(worldInstance, Object.values(worldInstance.services).map((service) => service.pid).filter((pid) => pid > 0));
1643
1651
  saveWorldInstance(worldInstance);
1644
1652
  const servicePids = [...new Set(Object.values(services).map((service) => service.pid).filter((pid) => pid > 0))];
@@ -2817,7 +2825,7 @@ export function urlsWorld(name: string, root = process.cwd()): WorldUrlInfo {
2817
2825
  export function urlWorld(name: string, options: { service?: string; root?: string } = {}): Array<{ id: string; url: string }> {
2818
2826
  const status = statusWorld(name, options.root ?? process.cwd());
2819
2827
  if (!status.running) {
2820
- throw new Error(`World ${name} is not running; start it with: volter-world up ${status.config} --env-file <path> --name ${name}`);
2828
+ throw new Error(`World ${name} is not running; start it with: volter-world up ${status.config} --env-out <path> --name ${name}`);
2821
2829
  }
2822
2830
  if (options.service === undefined) {
2823
2831
  return Object.values(status.services)
@@ -3156,7 +3164,7 @@ async function runWithWorldEnvLogged(name: string, command: string[], root: stri
3156
3164
  * since starting a server is async; this pure helper stays synchronous + side-effect-free for tests. */
3157
3165
  export function worldShellEnv(name: string, root = process.cwd()): { shell: string; cwd: string; env: Record<string, string> } {
3158
3166
  const status = statusWorld(name, root);
3159
- if (!status.running) throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-file <path> --name ${name}`);
3167
+ if (!status.running) throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-out <path> --name ${name}`);
3160
3168
  return {
3161
3169
  shell: status.env.SHELL || admittedEnv(stripEnvOf(status.env)).SHELL || '/bin/bash',
3162
3170
  cwd: resolve(root),
@@ -3413,7 +3421,7 @@ function stopWorldProxy(root: string, name: string): void {
3413
3421
  */
3414
3422
  export function activateScript(name: string, root = process.cwd()): string {
3415
3423
  const status = statusWorld(name, root);
3416
- if (!status.running) throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-file <path> --name ${name}`);
3424
+ if (!status.running) throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-out <path> --name ${name}`);
3417
3425
  const baseEnv = status.env ?? {};
3418
3426
  // Bring up the ambient redirect proxy (best-effort) and fold its env in so the activated shell
3419
3427
  // routes https through the twins too — not just the Node injector / cliRedirect endpoint vars.