@volter/world-runtime 2.0.21 → 2.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/branch.d.ts +1 -0
- package/dist/src/branch.js +1 -1
- package/dist/src/changeset.js +1 -1
- package/dist/src/cli.js +21 -14
- package/dist/src/env-output.d.ts +14 -0
- package/dist/src/env-output.js +67 -0
- package/dist/src/init.js +1 -1
- package/dist/src/reflect.js +1 -1
- package/dist/src/runtime.d.ts +2 -0
- package/dist/src/runtime.js +9 -4
- package/package.json +7 -7
- package/src/branch.ts +2 -2
- package/src/changeset.ts +1 -1
- package/src/cli.ts +18 -14
- package/src/env-output.ts +57 -0
- package/src/init.ts +1 -1
- package/src/reflect.ts +1 -1
- package/src/runtime.ts +12 -4
package/dist/src/branch.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/branch.js
CHANGED
|
@@ -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
|
package/dist/src/changeset.js
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
323
|
+
const envFile = optionValue(rest, '--env-out');
|
|
322
324
|
if (!envFile)
|
|
323
|
-
throw new Error('volter-world up: missing required --env-
|
|
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-
|
|
744
|
-
const envFile = optionValue(rest, '--env-
|
|
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
|
|
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-
|
|
1163
|
-
const name = optionValue(
|
|
1164
|
-
const mode = optionValue(
|
|
1165
|
-
const result = await runWorld(subject, rest.slice(split + 1), { root, name, mode, envFile,
|
|
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-
|
|
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}`,
|
package/dist/src/reflect.js
CHANGED
|
@@ -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-
|
|
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')
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/src/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|
|
3
|
+
"version": "2.0.23",
|
|
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",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"url": "git+https://github.com/volter-ai/twin.git",
|
|
36
36
|
"directory": "packages/world-runtime"
|
|
37
37
|
},
|
|
38
|
-
"homepage": "https://
|
|
38
|
+
"homepage": "https://world.volter.ai/docs/reference/cli",
|
|
39
39
|
"type": "module",
|
|
40
40
|
"exports": {
|
|
41
41
|
".": {
|
|
@@ -67,14 +67,14 @@
|
|
|
67
67
|
"node": ">=22.3"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@volter/world-core": "2.0.
|
|
71
|
-
"@volter/world-console": "2.0.
|
|
70
|
+
"@volter/world-core": "2.0.15",
|
|
71
|
+
"@volter/world-console": "2.0.10"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@electric-sql/pglite": "0.5.8",
|
|
75
75
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
76
|
-
"@volter/world-access": "2.0.
|
|
77
|
-
"@volter/world-core": "2.0.
|
|
76
|
+
"@volter/world-access": "2.0.2",
|
|
77
|
+
"@volter/world-core": "2.0.15",
|
|
78
78
|
"pg-gateway": "0.3.0-beta.4",
|
|
79
79
|
"smol-toml": "^1.8.0"
|
|
80
80
|
},
|
|
@@ -84,6 +84,6 @@
|
|
|
84
84
|
}
|
|
85
85
|
},
|
|
86
86
|
"optionalDependencies": {
|
|
87
|
-
"@volter/twin-mongodb": "0.1.
|
|
87
|
+
"@volter/twin-mongodb": "0.1.15"
|
|
88
88
|
}
|
|
89
89
|
}
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
303
|
-
if (!envFile) throw new Error('volter-world up: missing required --env-
|
|
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-
|
|
667
|
-
const envFile = optionValue(rest, '--env-
|
|
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
|
|
1025
|
-
if (
|
|
1026
|
-
const
|
|
1027
|
-
|
|
1028
|
-
const
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|