@volter/world 2.0.57 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/skills/volter-world/AGENTS.md +4 -4
- package/dist/skills/volter-world/SKILL.md +1 -1
- package/dist/src/agents.js +1 -1
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +34 -9
- package/dist/src/locate.js +1 -1
- package/dist/src/mcp.js +1 -1
- package/dist/src/world.d.ts +11 -5
- package/dist/src/world.js +39 -11
- package/package.json +6 -6
- package/skills/volter-world/AGENTS.md +4 -4
- package/skills/volter-world/SKILL.md +1 -1
- package/src/agents.ts +1 -1
- package/src/cli.ts +24 -11
- package/src/handlers-in-repo.test.ts +12 -5
- package/src/journeys/kit.ts +2 -2
- package/src/journeys/tutorial.ts +10 -3
- package/src/locate.ts +1 -1
- package/src/mcp.ts +1 -1
- package/src/sdk.test.ts +2 -2
- package/src/world.ts +33 -14
package/README.md
CHANGED
|
@@ -22,6 +22,6 @@ console.log(world.log());
|
|
|
22
22
|
await world.down();
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
- [Getting started](https://github.com/volter-ai/twin/blob/main/docs/getting-started.md)
|
|
26
|
-
- [CLI reference](https://github.com/volter-ai/twin/blob/main/docs/reference/cli.md)
|
|
27
|
-
- [SDK reference](https://github.com/volter-ai/twin/blob/main/docs/reference/sdk.md)
|
|
25
|
+
- [Getting started](https://github.com/volter-ai/twin-world/blob/main/docs/getting-started.md)
|
|
26
|
+
- [CLI reference](https://github.com/volter-ai/twin-world/blob/main/docs/reference/cli.md)
|
|
27
|
+
- [SDK reference](https://github.com/volter-ai/twin-world/blob/main/docs/reference/sdk.md)
|
|
@@ -7,13 +7,13 @@ environment injection, a Node preload and a scoped HTTPS proxy route configured
|
|
|
7
7
|
The state model is **Neon for every SaaS**: a log, checkpoints and branches over a parent position.
|
|
8
8
|
Git-inspired verbs describe operations; there is no staging or commit step. Push moves entries
|
|
9
9
|
between Worlds. Deploy performs them against a vendor at a real-system root under its policy.
|
|
10
|
-
The canonical [model](https://github.com/volter-ai/twin/blob/main/docs/concepts/the-model.md),
|
|
11
|
-
[lifecycle](https://github.com/volter-ai/twin/blob/main/docs/concepts/worlds.md) and
|
|
12
|
-
[CLI reference](https://github.com/volter-ai/twin/blob/main/docs/reference/cli.md) own the details.
|
|
10
|
+
The canonical [model](https://github.com/volter-ai/twin-world/blob/main/docs/concepts/the-model.md),
|
|
11
|
+
[lifecycle](https://github.com/volter-ai/twin-world/blob/main/docs/concepts/worlds.md) and
|
|
12
|
+
[CLI reference](https://github.com/volter-ai/twin-world/blob/main/docs/reference/cli.md) own the details.
|
|
13
13
|
|
|
14
14
|
## Set up a World for an app
|
|
15
15
|
|
|
16
|
-
In the app's folder (the [guide](https://github.com/volter-ai/twin/blob/main/docs/guides/use-with-a-coding-agent.md)):
|
|
16
|
+
In the app's folder (the [guide](https://github.com/volter-ai/twin-world/blob/main/docs/guides/use-with-a-coding-agent.md)):
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
19
|
npm install -g @volter/world # once; `volter agents install` then adds this skill and the MCP server to your agents
|
|
@@ -10,5 +10,5 @@ operator instruction source shipped with this skill: setting a World up for an a
|
|
|
10
10
|
to a platform, the `volter mcp` tools, and the lifecycle distinction between the product command
|
|
11
11
|
`volter world` and the explicit operator command `volter-world`.
|
|
12
12
|
|
|
13
|
-
The [documentation map](https://github.com/volter-ai/twin/blob/main/docs/README.md) links the
|
|
13
|
+
The [documentation map](https://github.com/volter-ai/twin-world/blob/main/docs/README.md) links the
|
|
14
14
|
canonical model, task guides and command reference. Do not maintain a second copy here.
|
package/dist/src/agents.js
CHANGED
|
@@ -84,7 +84,7 @@ const writeJson = (path, value) => writeAtomic(path, `${JSON.stringify(value, nu
|
|
|
84
84
|
/** A command run as itself; on Windows, where `claude` is a .cmd that runs only in cmd, one line to cmd with each argument
|
|
85
85
|
* quoted (a path with a space stays one argument), as cross-spawn does. */
|
|
86
86
|
const runTool = (bin, args) => process.platform === 'win32'
|
|
87
|
-
? spawnSync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', `"${[bin, ...args].map((a) => `"${a.
|
|
87
|
+
? spawnSync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', `"${[bin, ...args].map((a) => `"${a.replaceAll('"', '""')}"`).join(' ')}"`], { windowsHide: true, encoding: 'utf8', windowsVerbatimArguments: true })
|
|
88
88
|
: spawnSync(bin, args, { windowsHide: true, encoding: 'utf8' });
|
|
89
89
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
90
90
|
/** Our entry under `key` in a JSON config's server map: added, or already there; someone else's `volter` is left alone. */
|
package/dist/src/cli.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
export declare const HELP = "volter \u2014 run your app against a world of twins\n\nworld \u2014 the twins your app needs, running together (the world of the current directory)\n volter world init [--install] [--name <world>] [--allow-unknown] detect the app's vendors (installing their twins when none are), write .volter/world.json\n volter world up [--sandbox] [--no-seed] start the twins on this branch (loads the default data the first time)\n volter world run [--verbose] -- <command...> run the app or its tests inside the world\n volter world activate eval \"$(volter world activate)\": vendor CLIs and curl in this shell reach the twins\n volter world shell a subshell with the world active\n volter world init --bare <org>/<world> --twins a,b a world with no app: a shared world for a team, or one standing in for a vendor\n volter world serve [--port <p>] [--console-port <c>] [--origin <url>] serve this world on a URL under /<org>/<world>/ for apps; prints the token and the console (on loopback: view's front, no token in the browser)\n volter world view [--port <p>] [--host <h>] [--no-open] [--no-origins] [--origin <url>] step into this world in a browser: its twins' own UIs, timeline, clock, branches (a non-loopback --host serves them on one origin)\n volter world console [<https://host/org/world>] [--port <p>] the console on this machine for a World served elsewhere (default: this world's origin); needs @volter/world-console\n volter world status world, branch, origin, unpushed changes, what is running\n volter world log [--receipts] [--json] every write the app made; --receipts adds each change's receipt\n volter world diff [--json] what changed since the default data (or the last mark)\n volter world seed load the default data\n volter world reset back to the default data\n volter world down [--purge] stop the twins (--purge forgets this branch's state)\n volter world branch [<name>] list branches, or make one from here and check it out\n volter world checkout <name> switch branches\n volter world replay <changeset> --into <branch> feed a changeset's changes into another branch's twins\n volter world clock show | set <iso> | advance <N s|m|h|d> | shift <N s|m|h|d> | clear the world's clock: set, not observed\n volter world clone <url> --token <token> [--admin-token <t>] connect this world to a remote and fetch its history (the admin token copies the whole tree at once)\n volter world fetch what the remote observed since the last fetch\n volter world origin where this world clones from and pushes to\n volter world changeset [<name>] -m \"<message>\" cut the unpushed changes into a reviewable changeset\n volter world pull [--token <token>] fetch, then move this branch onto what came in (git's pull)\n volter world push [<name>] [--token <token>] [--force] push to the remote, which deploys and answers with receipts\n volter world rebase [<changeset>] rebase this branch onto its moved base, or a changeset onto the moved
|
|
2
|
+
export declare const HELP = "volter \u2014 run your app against a world of twins\n\nworld \u2014 the twins your app needs, running together (the world of the current directory)\n volter world init [--install] [--name <world>] [--allow-unknown] detect the app's vendors (installing their twins when none are), write .volter/world.json\n volter world up [--sandbox] [--no-seed] start the twins on this branch (loads the default data the first time)\n volter world run [--verbose] -- <command...> run the app or its tests inside the world\n volter world activate eval \"$(volter world activate)\": vendor CLIs and curl in this shell reach the twins\n volter world shell a subshell with the world active\n volter world init --bare <org>/<world> --twins a,b a world with no app: a shared world for a team, or one standing in for a vendor\n volter world serve [--port <p>] [--console-port <c>] [--origin <url>] serve this world on a URL under /<org>/<world>/ for apps; prints the token and the console (on loopback: view's front, no token in the browser)\n volter world view [--port <p>] [--host <h>] [--no-open] [--no-origins] [--origin <url>] step into this world in a browser: its twins' own UIs, timeline, clock, branches (a non-loopback --host serves them on one origin)\n volter world console [<https://host/org/world>] [--port <p>] the console on this machine for a World served elsewhere (default: this world's origin); needs @volter/world-console\n volter world status world, branch, origin, unpushed changes, what is running\n volter world log [--receipts] [--json] every write the app made; --receipts adds each change's receipt\n volter world diff [--json] what changed since the default data (or the last mark)\n volter world seed load the default data\n volter world reset back to the default data\n volter world down [--purge] stop the twins (--purge forgets this branch's state)\n volter world branch [<name>] list branches, or make one from here and check it out\n volter world checkout <name> switch branches\n volter world replay <changeset> --into <branch> feed a changeset's changes into another branch's twins\n volter world clock show | set <iso> | advance <N s|m|h|d> | shift <N s|m|h|d> | clear the world's clock: set, not observed\n volter world clone <url> --token <token> [--admin-token <t>] connect this world to a remote and fetch its history (the admin token copies the whole tree at once)\n volter world fetch what the remote observed since the last fetch\n volter world origin where this world clones from and pushes to\n volter world changeset [<name>] -m \"<message>\" cut the unpushed changes into a reviewable changeset\n volter world pull [--token <token>] fetch, then move this branch onto what came in (git's pull)\n volter world push [<name>] [--token <token>] [--force] push to the remote, which deploys and answers with receipts\n volter world rebase [<changeset>] rebase this branch onto its moved base, or a changeset onto the moved remote\n volter world verify <changeset> run the world's checks over a changeset and record the result\n volter world approve <changeset> --as <principal> sign a changeset's current hash\n volter world deploy [<changeset>] perform landed changes against each twin's root, by its policy\n\ntwin \u2014 one twin: in this world, or on its own\n volter twin <vendor> the twin's URL, root, deploy policy, whether a credential is sealed\n volter twin <vendor> root <url> [--deploy auto|gated|hold] [--scope <path>] the vendor's real account behind this twin (--none clears it)\n volter twin <vendor> credential seal a credential read from stdin beside the world; never readable back\n volter twin <vendor> refresh observe the root now\n volter twin <vendor> serve [--port <p>] [--read-only] serve the twin; your SDK talks to it at http://127.0.0.1:<p>\n\nagents \u2014 your coding agent, driving the same verbs\n volter agents install [--claude] [--cursor] [--vscode] [--codex] [--yes] put the volter-world skill and the MCP server into the agents found here\n volter mcp the MCP server (stdio) an agent starts: the verbs above as tools\n\nremote \u2014 the worlds this one pushes to and fetches from, by name\n volter login <platform url> [--token <personal token>] sign the CLI into the hosted platform: approve it in the browser (or a token made under Account, for CI)\n volter whoami the platform the CLI is signed into, as whom, and its orgs\n volter logout revoke the CLI's token at the platform and forget it here\n volter remote add <name> <url|path|org/world> [--token <token>] [--create] name a remote; org/world (or world, in one org) resolves through the platform you logged into; --create makes it there from this world's twins\n volter open [<org>/<world>] [--no-open] a World's dashboard: a hosted one through the platform, else this world's (as view)\n volter completion bash|zsh|fish|powershell print a completion script for your shell\n volter remote remove <name> forget it\n volter remote list every remote, with its URL and whether a token is stored\n (the hosting product's serve answers here for one release: volter remote serve)\n\n --world <dir> the world root, when the cwd is not inside it\n --branch <name> act on that branch instead of the checked-out one\n --json machine-readable output where offered\n --version the command's version\n";
|
package/dist/src/cli.js
CHANGED
|
@@ -64,7 +64,7 @@ world — the twins your app needs, running together (the world of the current
|
|
|
64
64
|
volter world changeset [<name>] -m "<message>" cut the unpushed changes into a reviewable changeset
|
|
65
65
|
volter world pull [--token <token>] fetch, then move this branch onto what came in (git's pull)
|
|
66
66
|
volter world push [<name>] [--token <token>] [--force] push to the remote, which deploys and answers with receipts
|
|
67
|
-
volter world rebase [<changeset>] rebase this branch onto its moved base, or a changeset onto the moved
|
|
67
|
+
volter world rebase [<changeset>] rebase this branch onto its moved base, or a changeset onto the moved remote
|
|
68
68
|
volter world verify <changeset> run the world's checks over a changeset and record the result
|
|
69
69
|
volter world approve <changeset> --as <principal> sign a changeset's current hash
|
|
70
70
|
volter world deploy [<changeset>] perform landed changes against each twin's root, by its policy
|
|
@@ -75,8 +75,6 @@ twin — one twin: in this world, or on its own
|
|
|
75
75
|
volter twin <vendor> credential seal a credential read from stdin beside the world; never readable back
|
|
76
76
|
volter twin <vendor> refresh observe the root now
|
|
77
77
|
volter twin <vendor> serve [--port <p>] [--read-only] serve the twin; your SDK talks to it at http://127.0.0.1:<p>
|
|
78
|
-
volter twin <vendor> mirror [--port <p>] the twin's UI mirror
|
|
79
|
-
volter twin <vendor> conformance check the twin against the vendor's spec
|
|
80
78
|
|
|
81
79
|
agents — your coding agent, driving the same verbs
|
|
82
80
|
volter agents install [--claude] [--cursor] [--vscode] [--codex] [--yes] put the volter-world skill and the MCP server into the agents found here
|
|
@@ -96,6 +94,7 @@ remote — the worlds this one pushes to and fetches from, by name
|
|
|
96
94
|
--world <dir> the world root, when the cwd is not inside it
|
|
97
95
|
--branch <name> act on that branch instead of the checked-out one
|
|
98
96
|
--json machine-readable output where offered
|
|
97
|
+
--version the command's version
|
|
99
98
|
`;
|
|
100
99
|
function flag(args, name) { return args.includes(name); }
|
|
101
100
|
function value(args, name) {
|
|
@@ -236,6 +235,17 @@ async function worldCommand(verb, args) {
|
|
|
236
235
|
const command = afterDashes(args);
|
|
237
236
|
if (command.length === 0)
|
|
238
237
|
throw new Error('volter world run: give the command after -- (volter world run -- npm test)');
|
|
238
|
+
// a stopped World is started first, as `volter world up` does (the default data the first time), and left running
|
|
239
|
+
let running = false;
|
|
240
|
+
try {
|
|
241
|
+
running = world.instance()?.running ?? false;
|
|
242
|
+
}
|
|
243
|
+
catch { /* never started */ }
|
|
244
|
+
if (!running) {
|
|
245
|
+
process.stderr.write(`${world.name} is not running: starting it (volter world up)
|
|
246
|
+
`);
|
|
247
|
+
await world.up({ seed: true });
|
|
248
|
+
}
|
|
239
249
|
process.exitCode = await world.run(command, { verbose: flag(args.slice(0, args.indexOf('--')), '--verbose') });
|
|
240
250
|
return;
|
|
241
251
|
}
|
|
@@ -446,7 +456,7 @@ async function worldCommand(verb, args) {
|
|
|
446
456
|
process.exit(0); // success follows confirmed shutdown; failures reach main's error path
|
|
447
457
|
return;
|
|
448
458
|
}
|
|
449
|
-
// the console mounts when it is installed beside the CLI; without it the World serves its doors and
|
|
459
|
+
// the console mounts when it is installed beside the CLI; without it the World serves its doors and the twins
|
|
450
460
|
const console = await (async () => {
|
|
451
461
|
try {
|
|
452
462
|
const mod = await import('@volter/world-console');
|
|
@@ -582,7 +592,7 @@ async function worldCommand(verb, args) {
|
|
|
582
592
|
throw new Error(`volter world: unknown verb "${verb}"\n\n${HELP}`);
|
|
583
593
|
}
|
|
584
594
|
}
|
|
585
|
-
/** `volter twin <vendor> <verb…>`: the twin's own CLI (serve
|
|
595
|
+
/** `volter twin <vendor> <verb…>`: the twin's own CLI (serve), found the way a world finds it. */
|
|
586
596
|
async function twinCommand(args) {
|
|
587
597
|
const [vendor, ...rest] = args;
|
|
588
598
|
if (!vendor || vendor.startsWith('-'))
|
|
@@ -620,7 +630,10 @@ async function twinCommand(args) {
|
|
|
620
630
|
out(`${vendor} credential from ${found.from}`);
|
|
621
631
|
input = found.input;
|
|
622
632
|
}
|
|
623
|
-
await world.sealTwinCredential(vendor, input);
|
|
633
|
+
const sealed = await world.sealTwinCredential(vendor, input);
|
|
634
|
+
// the root runbook: the ingest door's secret, shown once
|
|
635
|
+
if (sealed.mintedSigningSecret)
|
|
636
|
+
out(`${vendor} signing secret ${sealed.mintedSigningSecret} (enter it at the vendor as the webhook's secret; it is not shown again. A vendor that issues its own seals it instead: {"headers": {...}, "signingSecret": "<the vendor's>"})`);
|
|
624
637
|
}
|
|
625
638
|
else if (verb === 'refresh') {
|
|
626
639
|
const r = await world.refreshTwin(vendor, { force: flag(rest, '--force') });
|
|
@@ -633,13 +646,16 @@ async function twinCommand(args) {
|
|
|
633
646
|
return;
|
|
634
647
|
}
|
|
635
648
|
out(`${vendor} ${t.root ? `root ${t.root.url}${t.root.scope ? `/${t.root.scope}` : ''} deploy ${t.root.deploy}` : 'no root'} ${t.credential ? 'credential sealed' : 'no credential'}${t.url ? ` ${t.url}` : ''}`);
|
|
649
|
+
// the root runbook: where the vendor's webhooks go
|
|
650
|
+
if (t.root)
|
|
651
|
+
out(t.ingest ? `${vendor} ingest ${t.ingest} (register it at the vendor as the webhook URL, with the signing secret sealed with the credential)` : `${vendor} ingest: a served World's door (volter world serve, or a remote) is the webhook URL to register at the vendor`);
|
|
636
652
|
return;
|
|
637
653
|
}
|
|
638
654
|
const dir = resolveCatalog(process.cwd()).packDir(vendor);
|
|
639
655
|
if (dir === undefined)
|
|
640
656
|
throw new Error(`No twin "${vendor}" here — \`bun add -d @volter/twin-${vendor}\`, or run from a checkout that has it`);
|
|
641
657
|
if (rest.length === 0 || rest[0].startsWith('-'))
|
|
642
|
-
throw new Error(`volter twin ${vendor}: which verb? serve |
|
|
658
|
+
throw new Error(`volter twin ${vendor}: which verb? serve | root | credential | refresh`);
|
|
643
659
|
const cli = packCli(dir);
|
|
644
660
|
if (cli === undefined)
|
|
645
661
|
throw new Error(`No cli for twin "${vendor}" (its manifest names no bin)`);
|
|
@@ -683,7 +699,12 @@ async function loginCommand(args) {
|
|
|
683
699
|
throw new Error(`${origin} answered ${res.status}: ${body.error ?? 'not signed in'}`);
|
|
684
700
|
storeToken(origin, token);
|
|
685
701
|
storePlatform(origin, undefined, body.person?.email ?? body.person?.id);
|
|
686
|
-
|
|
702
|
+
// a teammate's orgs already hold Worlds: the next step links one; a first person's makes one
|
|
703
|
+
const held = (body.orgs ?? []).flatMap((o) => (o.worlds ?? []).map((w) => w.name));
|
|
704
|
+
const next = held.length
|
|
705
|
+
? `in your app's folder: volter remote add origin ${held[0]}, then volter world pull${held.length > 1 ? ` (your orgs' Worlds: ${held.slice(0, 6).join(', ')}${held.length > 6 ? ', …' : ''})` : ''}`
|
|
706
|
+
: `in your app's folder: volter world init, then volter remote add origin ${(body.orgs ?? [])[0]?.slug ?? '<org>'}/<world> --create`;
|
|
707
|
+
out(`signed in ${origin} as ${body.person?.email ?? body.person?.id ?? 'you'}\norgs ${(body.orgs ?? []).map((o) => o.slug ?? o.name).join(', ') || '(none yet)'}\nnext ${next}`);
|
|
687
708
|
}
|
|
688
709
|
/** `volter whoami`: which platform the command is signed into, as whom, and the orgs it reaches. */
|
|
689
710
|
async function whoamiCommand() {
|
|
@@ -696,7 +717,7 @@ async function whoamiCommand() {
|
|
|
696
717
|
}
|
|
697
718
|
const res = await fetch(`${origin}/-/orgs`, { headers: { authorization: `Bearer ${token}` } }).catch(() => null);
|
|
698
719
|
if (!res)
|
|
699
|
-
throw new Error(`${origin} did not answer`);
|
|
720
|
+
throw new Error(`${origin} did not answer: the platform this command is signed into is unreachable (volter login <url> signs it into another)`);
|
|
700
721
|
const body = (await res.json().catch(() => ({})));
|
|
701
722
|
// a token the platform no longer takes is signed out; one it takes but that may not read orgs is still signed in
|
|
702
723
|
if (res.status === 403) {
|
|
@@ -1023,6 +1044,10 @@ async function dispatch(noun, verb, args) {
|
|
|
1023
1044
|
out(HELP);
|
|
1024
1045
|
return;
|
|
1025
1046
|
}
|
|
1047
|
+
if (noun === '--version' || noun === '-v' || noun === 'version') {
|
|
1048
|
+
out(`volter ${ownVersion()}`);
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1026
1051
|
switch (noun) {
|
|
1027
1052
|
case 'world': {
|
|
1028
1053
|
if (!verb)
|
package/dist/src/locate.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// `World.open()` start here, the way `git` starts from the nearest `.git`.
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { dirname, join, resolve } from 'node:path';
|
|
7
|
-
import { stateDirName } from '@volter/world-core';
|
|
7
|
+
import { stateDirName } from '@volter/world-core/runtime';
|
|
8
8
|
import { loadWorldConfig } from '@volter/world-runtime';
|
|
9
9
|
export const WORLD_FILE = 'world.json';
|
|
10
10
|
/** `<root>/.volter` */
|
package/dist/src/mcp.js
CHANGED
|
@@ -78,7 +78,7 @@ Work in the app's folder (each tool takes "dir", defaulting to where this server
|
|
|
78
78
|
- Look: world_log and world_diff say what the app changed at each vendor; world_view starts the dashboard and returns its link for the person.
|
|
79
79
|
- Branch and reset like a database: world_branch, world_checkout, world_reset.
|
|
80
80
|
- Share with a team: platform_login returns a link and a code the PERSON must approve in their browser (you cannot approve it); after they do, remote_link links this World to <org>/<world> on the platform (create: true makes it there from this World's vendors); world_changeset cuts the changes with a message, and world_push sends them.
|
|
81
|
-
The guide: https://github.com/volter-ai/twin/blob/main/docs/guides/use-with-a-coding-agent.md`;
|
|
81
|
+
The guide: https://github.com/volter-ai/twin-world/blob/main/docs/guides/use-with-a-coding-agent.md`;
|
|
82
82
|
export async function serveMcp(version) {
|
|
83
83
|
const server = new McpServer({ name: 'volter', title: 'Volter World', version }, { instructions: INSTRUCTIONS });
|
|
84
84
|
const here = (dir) => resolve(dir ?? process.cwd());
|
package/dist/src/world.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { rebaseChangeset, worldBootMarker, type Changeset, type LedgerDelta, type TwinAction, type WorldMarker, type ApplyReceipt, applyTwinWrite, type TwinResource } from '@volter/world-core';
|
|
1
|
+
import { rebaseChangeset, worldBootMarker, type Changeset, type LedgerDelta, type TwinAction, type WorldMarker, type ApplyReceipt, applyTwinWrite, type TwinResource } from '@volter/world-core/runtime';
|
|
2
2
|
import { approveWorldChangeset, createWorldChangeset, downWorld, fetchFromOrigin, pushWorldChangeset, rebaseWorldChangeset, replayWorldChangeset, resetWorld, seedWorld, statusWorldChangeset, verifyWorldChangeset, type ServedWorld, type ViewConsole, type WorldView, rootCredentialInput, type DeployTwinOutcome, type ChangesetLocation, type InitOptions, type InitResult, type WorldInstance } from '@volter/world-runtime';
|
|
3
|
-
import { rebaseBranch, type Receipt, type RootConfig } from '@volter/world-core';
|
|
3
|
+
import { rebaseBranch, type Receipt, type RootConfig } from '@volter/world-core/runtime';
|
|
4
4
|
export type WorldRef = {
|
|
5
5
|
name?: string;
|
|
6
6
|
root?: string;
|
|
@@ -53,7 +53,7 @@ export type WorldStatus = {
|
|
|
53
53
|
};
|
|
54
54
|
services: Record<string, {
|
|
55
55
|
url?: string;
|
|
56
|
-
running: boolean; /** the pack's protocol major and its standing (
|
|
56
|
+
running: boolean; /** the pack's protocol major and its standing (the kernel serves its own major and refuses any other) */
|
|
57
57
|
protocol?: {
|
|
58
58
|
major: number;
|
|
59
59
|
standing: string;
|
|
@@ -201,7 +201,7 @@ export declare class World {
|
|
|
201
201
|
console: string | null;
|
|
202
202
|
}) => void;
|
|
203
203
|
}): Promise<ServedWorld>;
|
|
204
|
-
/** `volter world view`: this World, its
|
|
204
|
+
/** `volter world view`: this World, its twins' screens, its branches and the console on one origin (docs/contributing/architecture.md, "Viewing a World"). */
|
|
205
205
|
view(opts?: {
|
|
206
206
|
port?: number;
|
|
207
207
|
host?: string;
|
|
@@ -224,6 +224,9 @@ export declare class World {
|
|
|
224
224
|
url: string;
|
|
225
225
|
namespace: string;
|
|
226
226
|
} | null;
|
|
227
|
+
/** The origin the instance recorded, or null for a world that has never run (only world.json names it then);
|
|
228
|
+
* any other failure to read the instance is said as it is. */
|
|
229
|
+
private recordedOrigin;
|
|
227
230
|
/** The origin this world clones from and pushes to, or null: the default data is its only origin. */
|
|
228
231
|
origin(): WorldInstance['origin'] | null;
|
|
229
232
|
/**
|
|
@@ -245,7 +248,8 @@ export declare class World {
|
|
|
245
248
|
fetched: Awaited<ReturnType<typeof fetchFromOrigin>>;
|
|
246
249
|
rebased: ReturnType<World['rebaseBranch']>;
|
|
247
250
|
}>;
|
|
248
|
-
/** Fetch what the origin observed since the last fetch.
|
|
251
|
+
/** Fetch what the origin observed since the last fetch. A world that has never run (a teammate's checkout, linked
|
|
252
|
+
* with `remote add`) is booted as a clone boots it, with no story, and brings the origin's whole log in. */
|
|
249
253
|
fetch(opts?: {
|
|
250
254
|
token?: string;
|
|
251
255
|
services?: string[];
|
|
@@ -284,6 +288,7 @@ export declare class World {
|
|
|
284
288
|
twin(vendor: string): {
|
|
285
289
|
vendor: string;
|
|
286
290
|
url?: string;
|
|
291
|
+
ingest?: string;
|
|
287
292
|
root: RootConfig | null;
|
|
288
293
|
credential: {
|
|
289
294
|
placedAt: string;
|
|
@@ -296,6 +301,7 @@ export declare class World {
|
|
|
296
301
|
sealTwinCredential(vendor: string, input: string): Promise<{
|
|
297
302
|
placedAt: string;
|
|
298
303
|
fingerprint: string;
|
|
304
|
+
mintedSigningSecret?: string;
|
|
299
305
|
}>;
|
|
300
306
|
/** A root's credential when none is piped in: the signed-in vault, else the repo's `.env` (decision 0033). */
|
|
301
307
|
rootCredentialInput(vendor: string): ReturnType<typeof rootCredentialInput>;
|
package/dist/src/world.js
CHANGED
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
// way git finds its repository; every method is a name for a world-runtime verb, in the words a
|
|
4
4
|
// user reads. The `volter` command is one client of this class and adds nothing.
|
|
5
5
|
import { platformPerson } from "./credentials.js";
|
|
6
|
-
import { rebaseChangeset, worldBootMarker, listActions, isTwinBookkeeping, pushablePendingActions, pendingActions, applyTwinWrite, twinResources } from '@volter/world-core';
|
|
6
|
+
import { rebaseChangeset, worldBootMarker, listActions, isTwinBookkeeping, pushablePendingActions, pendingActions, applyTwinWrite, twinResources } from '@volter/world-core/runtime';
|
|
7
7
|
import { spawnSync } from 'node:child_process';
|
|
8
8
|
import clockForm from '@volter/world-core/world-clock';
|
|
9
|
-
import { activateScript, approveWorldChangeset, branchWorld, clockFile, checkoutWorld, createWorldChangeset, diffWorld, downWorld, fetchFromOrigin, findWorldChangeset, initWorld, listWorldChangesets, listWorldMarks, listWorlds, markWorld, pushWorldChangeset, rebaseWorldChangeset, replayWorldChangeset, resetWorld, runWithWorldEnv, seedWorld, shellWorld, statusWorld, statusWorldChangeset, upWorld, verifyWorldChangeset, worldLedgers, worldOrigin, bindLegacyCredentials, deployWorld, loadWorldConfig, writeWorldConfig, readServeRecord, refreshTwin, serveWorld, serveWorldView, findInstalledPackage, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, credentialPayloadFrom, rootCredentialInput, withoutVaultSignIn, materializeRoots, worldChangesetRules } from '@volter/world-runtime';
|
|
9
|
+
import { activateScript, approveWorldChangeset, branchWorld, clockFile, checkoutWorld, createWorldChangeset, diffWorld, downWorld, fetchFromOrigin, findWorldChangeset, initWorld, listWorldChangesets, listWorldMarks, listWorlds, markWorld, pushWorldChangeset, rebaseWorldChangeset, replayWorldChangeset, resetWorld, runWithWorldEnv, seedWorld, shellWorld, statusWorld, statusWorldChangeset, upWorld, verifyWorldChangeset, worldLedgers, worldOrigin, bindLegacyCredentials, deployWorld, loadWorldConfig, writeWorldConfig, readServeRecord, refreshTwin, serveWorld, serveWorldView, findInstalledPackage, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, credentialPayloadFrom, rootCredentialInput, withoutVaultSignIn, materializeRoots, instanceDir, worldChangesetRules } from '@volter/world-runtime';
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { dirname, join, resolve } from 'node:path';
|
|
12
12
|
import { requireToken, storeToken } from "./credentials.js";
|
|
13
13
|
import { currentBranch, findWorldRoot, mainBranch, requireWorldRoot, setCurrentBranch, worldConfigRelative, worldEnvPath, worldSeedPath } from "./locate.js";
|
|
14
|
-
import { destinationOf, parentEntries, rebaseBranch } from '@volter/world-core';
|
|
14
|
+
import { destinationOf, parentEntries, rebaseBranch } from '@volter/world-core/runtime';
|
|
15
15
|
/** One change as `log` shows it: the write, and the receipt the vendor answered with once pushed. */
|
|
16
16
|
/** A twin's branch log as the world reads it: the twin's name, the state service it records under, its control root. */
|
|
17
17
|
/** `30d`, `12h`, `5m`, `90s` in ms. */
|
|
@@ -170,7 +170,9 @@ export class World {
|
|
|
170
170
|
branches: listWorlds(this.root).map((w) => w.name).sort(),
|
|
171
171
|
running: instance?.running ?? false,
|
|
172
172
|
served: (() => { const r = readServeRecord(this.root); return r ? { base: r.base, pid: r.pid, startedAt: r.startedAt } : null; })(),
|
|
173
|
-
|
|
173
|
+
// the origin push and pull use: the remote world.json names, with when it was last fetched (a world that only
|
|
174
|
+
// pushed to it has not fetched, but it has an origin all the same)
|
|
175
|
+
origin: (() => { const o = this.origin(); return o ? { url: o.url, namespace: o.namespace, ...(o.fetchedAt ? { fetchedAt: o.fetchedAt } : {}) } : null; })(),
|
|
174
176
|
unpushed: instance ? this.unpushed().length : 0,
|
|
175
177
|
changesets: { total: changesets.length, pushed: changesets.filter((c) => c.changeset.applied?.outcome === 'applied').length },
|
|
176
178
|
services,
|
|
@@ -367,7 +369,7 @@ export class World {
|
|
|
367
369
|
// ── serving ─────────────────────────────────────────────────────────────────────────────
|
|
368
370
|
/** Serve this world on a port under `/<org>/<world>/`; returns when listening. */
|
|
369
371
|
serve(opts = {}) { return serveWorld(this.name, { root: this.root, ...opts }); }
|
|
370
|
-
/** `volter world view`: this World, its
|
|
372
|
+
/** `volter world view`: this World, its twins' screens, its branches and the console on one origin (docs/contributing/architecture.md, "Viewing a World"). */
|
|
371
373
|
view(opts = {}) { const person = platformPerson(); return serveWorldView(this.name, { root: this.root, ...(person ? { localPerson: person } : {}), ...opts }); }
|
|
372
374
|
// ── remotes ─────────────────────────────────────────────────────────────────────────────
|
|
373
375
|
/** The vendors this world has a twin of, as world.json names them (a twin service's package, else its id). */
|
|
@@ -402,12 +404,24 @@ export class World {
|
|
|
402
404
|
return parseOriginUrl(target);
|
|
403
405
|
if (name !== 'origin')
|
|
404
406
|
return null;
|
|
405
|
-
const recorded =
|
|
407
|
+
const recorded = this.recordedOrigin();
|
|
406
408
|
return recorded ? { url: recorded.url, namespace: recorded.namespace } : null;
|
|
407
409
|
}
|
|
410
|
+
/** The origin the instance recorded, or null for a world that has never run (only world.json names it then);
|
|
411
|
+
* any other failure to read the instance is said as it is. */
|
|
412
|
+
recordedOrigin() {
|
|
413
|
+
try {
|
|
414
|
+
return worldOrigin(this.name, this.root);
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
if (/World instance not found/.test(String(error.message)))
|
|
418
|
+
return null;
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
408
422
|
// ── the origin ──────────────────────────────────────────────────────────────────────────
|
|
409
423
|
/** The origin this world clones from and pushes to, or null: the default data is its only origin. */
|
|
410
|
-
origin() { const r = this.remote('origin'); const recorded =
|
|
424
|
+
origin() { const r = this.remote('origin'); const recorded = this.recordedOrigin(); return r ? { ...(recorded ?? {}), url: r.url, namespace: r.namespace } : recorded; }
|
|
411
425
|
/**
|
|
412
426
|
* Clone a remote's canonical history into this world: record the origin, remember the token,
|
|
413
427
|
* fetch everything. With the world's token (namespace or read), the history arrives through the
|
|
@@ -436,14 +450,26 @@ export class World {
|
|
|
436
450
|
const fetched = await this.fetch(opts);
|
|
437
451
|
return { fetched, rebased: this.repos().map((r) => ({ service: r.service, ...rebaseBranch(r.stateService, r.root, { origin: true }) })) };
|
|
438
452
|
}
|
|
439
|
-
/** Fetch what the origin observed since the last fetch.
|
|
440
|
-
|
|
453
|
+
/** Fetch what the origin observed since the last fetch. A world that has never run (a teammate's checkout, linked
|
|
454
|
+
* with `remote add`) is booted as a clone boots it, with no story, and brings the origin's whole log in. */
|
|
455
|
+
async fetch(opts = {}) {
|
|
441
456
|
const origin = this.origin();
|
|
442
457
|
if (!origin)
|
|
443
458
|
throw new Error(`World "${this.name}" has no origin — its only origin is the default data. \`volter world clone <url>\` connects a remote.`);
|
|
444
459
|
if (opts.token !== undefined && opts.token !== '')
|
|
445
460
|
storeToken(origin.url, opts.token); // the last token given for a remote is the one remembered
|
|
446
|
-
|
|
461
|
+
const key = requireToken(origin.url, opts.token);
|
|
462
|
+
const instance = this.instance();
|
|
463
|
+
// state with no record is not a fresh checkout: a clean boot would take its unpushed work with it
|
|
464
|
+
if (!instance && existsSync(instanceDir(this.root, this.name)))
|
|
465
|
+
throw new Error(`World "${this.name}" has state but no instance record (${instanceDir(this.root, this.name)}): \`volter world up\` resumes it, or \`volter world down --purge\` starts it over`);
|
|
466
|
+
// a record missing a configured twin (a boot that failed part-way) is booted again, resuming what it holds
|
|
467
|
+
const configured = loadWorldConfig(this.configRef(), this.root).config.services.filter((s) => s.type === 'twin' || s.type === undefined).map((s) => s.id);
|
|
468
|
+
if (!instance || configured.some((id) => !(id in instance.services)))
|
|
469
|
+
await this.up({ seed: false });
|
|
470
|
+
// never fetched and nothing of its own: the origin's whole log, as a clone brings it
|
|
471
|
+
const full = !instance?.origin?.fetchedAt && this.log().length === 0;
|
|
472
|
+
return fetchFromOrigin(this.name, { root: this.root, url: origin.url, namespace: origin.namespace, key, ...(full ? { full: true } : {}), ...(opts.services ? { services: opts.services } : {}) });
|
|
447
473
|
}
|
|
448
474
|
// ── changesets and push ─────────────────────────────────────────────────────────────────
|
|
449
475
|
/** Cut a changeset from the unpushed changes: the reviewable unit, with the author's message. */
|
|
@@ -503,7 +529,9 @@ export class World {
|
|
|
503
529
|
if (!service)
|
|
504
530
|
throw new Error(`no twin "${vendor}" in this world`);
|
|
505
531
|
const inst = this.instance();
|
|
506
|
-
|
|
532
|
+
// the door the vendor's webhooks are registered at: a served World's (architecture, "Ingest")
|
|
533
|
+
const served = readServeRecord(this.root);
|
|
534
|
+
return { vendor, ...(inst?.services[vendor]?.url ? { url: inst.services[vendor].url } : {}), ...(served ? { ingest: `${served.base.replace(/\/+$/, '')}/twins/${vendor}/ingest` } : {}), root: service.root ?? null, credential: sealedCredentialInfo(this.root, vendor) };
|
|
507
535
|
}
|
|
508
536
|
/** Set (or clear) the twin's root: the vendor's API and the deploy policy, in world.json. Takes effect at the next `up` or `serve`, or now when the world runs. */
|
|
509
537
|
setTwinRoot(vendor, root) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Run your app against a world of twins. `World` and `Repo` — one method per verb: init, up, run, log, diff, reset, branch, checkout, clone, fetch, changeset, push — and the `volter` command, one client of them.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"volter",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"license": "Apache-2.0",
|
|
16
16
|
"repository": {
|
|
17
17
|
"type": "git",
|
|
18
|
-
"url": "git+https://github.com/volter-ai/twin.git",
|
|
18
|
+
"url": "git+https://github.com/volter-ai/twin-world.git",
|
|
19
19
|
"directory": "packages/cli"
|
|
20
20
|
},
|
|
21
21
|
"publishConfig": {
|
|
@@ -30,12 +30,12 @@
|
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
33
|
-
"@volter/world-core": "
|
|
34
|
-
"@volter/world-runtime": "
|
|
33
|
+
"@volter/world-core": "3.0.0",
|
|
34
|
+
"@volter/world-runtime": "3.0.0",
|
|
35
35
|
"zod": "^4.6.5"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@volter/world-console": "
|
|
38
|
+
"@volter/world-console": "3.0.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependenciesMeta": {
|
|
41
41
|
"@volter/world-console": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
}
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@volter/world-console": "
|
|
46
|
+
"@volter/world-console": "3.0.0"
|
|
47
47
|
},
|
|
48
48
|
"bin": {
|
|
49
49
|
"volter": "dist/src/cli.js"
|
|
@@ -7,13 +7,13 @@ environment injection, a Node preload and a scoped HTTPS proxy route configured
|
|
|
7
7
|
The state model is **Neon for every SaaS**: a log, checkpoints and branches over a parent position.
|
|
8
8
|
Git-inspired verbs describe operations; there is no staging or commit step. Push moves entries
|
|
9
9
|
between Worlds. Deploy performs them against a vendor at a real-system root under its policy.
|
|
10
|
-
The canonical [model](https://github.com/volter-ai/twin/blob/main/docs/concepts/the-model.md),
|
|
11
|
-
[lifecycle](https://github.com/volter-ai/twin/blob/main/docs/concepts/worlds.md) and
|
|
12
|
-
[CLI reference](https://github.com/volter-ai/twin/blob/main/docs/reference/cli.md) own the details.
|
|
10
|
+
The canonical [model](https://github.com/volter-ai/twin-world/blob/main/docs/concepts/the-model.md),
|
|
11
|
+
[lifecycle](https://github.com/volter-ai/twin-world/blob/main/docs/concepts/worlds.md) and
|
|
12
|
+
[CLI reference](https://github.com/volter-ai/twin-world/blob/main/docs/reference/cli.md) own the details.
|
|
13
13
|
|
|
14
14
|
## Set up a World for an app
|
|
15
15
|
|
|
16
|
-
In the app's folder (the [guide](https://github.com/volter-ai/twin/blob/main/docs/guides/use-with-a-coding-agent.md)):
|
|
16
|
+
In the app's folder (the [guide](https://github.com/volter-ai/twin-world/blob/main/docs/guides/use-with-a-coding-agent.md)):
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
19
|
npm install -g @volter/world # once; `volter agents install` then adds this skill and the MCP server to your agents
|
|
@@ -10,5 +10,5 @@ operator instruction source shipped with this skill: setting a World up for an a
|
|
|
10
10
|
to a platform, the `volter mcp` tools, and the lifecycle distinction between the product command
|
|
11
11
|
`volter world` and the explicit operator command `volter-world`.
|
|
12
12
|
|
|
13
|
-
The [documentation map](https://github.com/volter-ai/twin/blob/main/docs/README.md) links the
|
|
13
|
+
The [documentation map](https://github.com/volter-ai/twin-world/blob/main/docs/README.md) links the
|
|
14
14
|
canonical model, task guides and command reference. Do not maintain a second copy here.
|
package/src/agents.ts
CHANGED
|
@@ -69,7 +69,7 @@ const writeJson = (path: string, value: unknown): void => writeAtomic(path, `${J
|
|
|
69
69
|
/** A command run as itself; on Windows, where `claude` is a .cmd that runs only in cmd, one line to cmd with each argument
|
|
70
70
|
* quoted (a path with a space stays one argument), as cross-spawn does. */
|
|
71
71
|
const runTool = (bin: string, args: string[]): ReturnType<typeof spawnSync> => process.platform === 'win32'
|
|
72
|
-
? spawnSync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', `"${[bin, ...args].map((a) => `"${a.
|
|
72
|
+
? spawnSync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', `"${[bin, ...args].map((a) => `"${a.replaceAll('"', '""')}"`).join(' ')}"`], { windowsHide: true, encoding: 'utf8', windowsVerbatimArguments: true })
|
|
73
73
|
: spawnSync(bin, args, { windowsHide: true, encoding: 'utf8' });
|
|
74
74
|
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
|
|
75
75
|
|
package/src/cli.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
9
9
|
import { spawn, spawnSync } from 'node:child_process';
|
|
10
10
|
import { createHash } from 'node:crypto';
|
|
11
11
|
import { hostname } from 'node:os';
|
|
12
|
-
import { formatLedgerDelta } from '@volter/world-core';
|
|
12
|
+
import { formatLedgerDelta } from '@volter/world-core/runtime';
|
|
13
13
|
import { existsSync } from 'node:fs';
|
|
14
14
|
import { join, relative, resolve } from 'node:path';
|
|
15
15
|
import { forgetPlatform, forgetToken, platformOrigin, requireToken, storePlatform, storeToken, tokenFor } from './credentials.ts';
|
|
@@ -56,7 +56,7 @@ world — the twins your app needs, running together (the world of the current
|
|
|
56
56
|
volter world changeset [<name>] -m "<message>" cut the unpushed changes into a reviewable changeset
|
|
57
57
|
volter world pull [--token <token>] fetch, then move this branch onto what came in (git's pull)
|
|
58
58
|
volter world push [<name>] [--token <token>] [--force] push to the remote, which deploys and answers with receipts
|
|
59
|
-
volter world rebase [<changeset>] rebase this branch onto its moved base, or a changeset onto the moved
|
|
59
|
+
volter world rebase [<changeset>] rebase this branch onto its moved base, or a changeset onto the moved remote
|
|
60
60
|
volter world verify <changeset> run the world's checks over a changeset and record the result
|
|
61
61
|
volter world approve <changeset> --as <principal> sign a changeset's current hash
|
|
62
62
|
volter world deploy [<changeset>] perform landed changes against each twin's root, by its policy
|
|
@@ -67,8 +67,6 @@ twin — one twin: in this world, or on its own
|
|
|
67
67
|
volter twin <vendor> credential seal a credential read from stdin beside the world; never readable back
|
|
68
68
|
volter twin <vendor> refresh observe the root now
|
|
69
69
|
volter twin <vendor> serve [--port <p>] [--read-only] serve the twin; your SDK talks to it at http://127.0.0.1:<p>
|
|
70
|
-
volter twin <vendor> mirror [--port <p>] the twin's UI mirror
|
|
71
|
-
volter twin <vendor> conformance check the twin against the vendor's spec
|
|
72
70
|
|
|
73
71
|
agents — your coding agent, driving the same verbs
|
|
74
72
|
volter agents install [--claude] [--cursor] [--vscode] [--codex] [--yes] put the volter-world skill and the MCP server into the agents found here
|
|
@@ -88,6 +86,7 @@ remote — the worlds this one pushes to and fetches from, by name
|
|
|
88
86
|
--world <dir> the world root, when the cwd is not inside it
|
|
89
87
|
--branch <name> act on that branch instead of the checked-out one
|
|
90
88
|
--json machine-readable output where offered
|
|
89
|
+
--version the command's version
|
|
91
90
|
`;
|
|
92
91
|
|
|
93
92
|
function flag(args: string[], name: string): boolean { return args.includes(name); }
|
|
@@ -197,6 +196,10 @@ async function worldCommand(verb: string, args: string[]): Promise<void> {
|
|
|
197
196
|
case 'run': {
|
|
198
197
|
const command = afterDashes(args);
|
|
199
198
|
if (command.length === 0) throw new Error('volter world run: give the command after -- (volter world run -- npm test)');
|
|
199
|
+
// a stopped World is started first, as `volter world up` does (the default data the first time), and left running
|
|
200
|
+
let running = false; try { running = world.instance()?.running ?? false; } catch { /* never started */ }
|
|
201
|
+
if (!running) { process.stderr.write(`${world.name} is not running: starting it (volter world up)
|
|
202
|
+
`); await world.up({ seed: true }); }
|
|
200
203
|
process.exitCode = await world.run(command, { verbose: flag(args.slice(0, args.indexOf('--')), '--verbose') });
|
|
201
204
|
return;
|
|
202
205
|
}
|
|
@@ -337,7 +340,7 @@ async function worldCommand(verb: string, args: string[]): Promise<void> {
|
|
|
337
340
|
process.exit(0); // success follows confirmed shutdown; failures reach main's error path
|
|
338
341
|
return;
|
|
339
342
|
}
|
|
340
|
-
// the console mounts when it is installed beside the CLI; without it the World serves its doors and
|
|
343
|
+
// the console mounts when it is installed beside the CLI; without it the World serves its doors and the twins
|
|
341
344
|
const console = await (async (): Promise<ViewConsole | undefined> => {
|
|
342
345
|
try { const mod = await import('@volter/world-console') as { createConsole: () => ViewConsole }; return mod.createConsole(); } catch (error) {
|
|
343
346
|
// not installed is one thing; installed and broken is another, and says why
|
|
@@ -423,7 +426,7 @@ async function worldCommand(verb: string, args: string[]): Promise<void> {
|
|
|
423
426
|
}
|
|
424
427
|
}
|
|
425
428
|
|
|
426
|
-
/** `volter twin <vendor> <verb…>`: the twin's own CLI (serve
|
|
429
|
+
/** `volter twin <vendor> <verb…>`: the twin's own CLI (serve), found the way a world finds it. */
|
|
427
430
|
async function twinCommand(args: string[]): Promise<void> {
|
|
428
431
|
const [vendor, ...rest] = args;
|
|
429
432
|
if (!vendor || vendor.startsWith('-')) throw new Error('volter twin: which twin? (volter twin stripe serve --port 12111)');
|
|
@@ -450,7 +453,9 @@ async function twinCommand(args: string[]): Promise<void> {
|
|
|
450
453
|
out(`${vendor} credential from ${found.from}`);
|
|
451
454
|
input = found.input;
|
|
452
455
|
}
|
|
453
|
-
await world.sealTwinCredential(vendor, input);
|
|
456
|
+
const sealed = await world.sealTwinCredential(vendor, input);
|
|
457
|
+
// the root runbook: the ingest door's secret, shown once
|
|
458
|
+
if (sealed.mintedSigningSecret) out(`${vendor} signing secret ${sealed.mintedSigningSecret} (enter it at the vendor as the webhook's secret; it is not shown again. A vendor that issues its own seals it instead: {"headers": {...}, "signingSecret": "<the vendor's>"})`);
|
|
454
459
|
} else if (verb === 'refresh') {
|
|
455
460
|
const r = await world.refreshTwin(vendor, { force: flag(rest, '--force') });
|
|
456
461
|
out(r.refreshed ? `${vendor} refreshed ${r.report?.appended ?? 0} changed, ${r.report?.removed ?? 0} gone, ${r.report?.unchanged ?? 0} unchanged` : `${vendor} not refreshed: ${r.reason}`);
|
|
@@ -459,11 +464,13 @@ async function twinCommand(args: string[]): Promise<void> {
|
|
|
459
464
|
const t = world.twin(vendor);
|
|
460
465
|
if (flag(rest, '--json')) { json(t); return; }
|
|
461
466
|
out(`${vendor} ${t.root ? `root ${t.root.url}${t.root.scope ? `/${t.root.scope}` : ''} deploy ${t.root.deploy}` : 'no root'} ${t.credential ? 'credential sealed' : 'no credential'}${t.url ? ` ${t.url}` : ''}`);
|
|
467
|
+
// the root runbook: where the vendor's webhooks go
|
|
468
|
+
if (t.root) out(t.ingest ? `${vendor} ingest ${t.ingest} (register it at the vendor as the webhook URL, with the signing secret sealed with the credential)` : `${vendor} ingest: a served World's door (volter world serve, or a remote) is the webhook URL to register at the vendor`);
|
|
462
469
|
return;
|
|
463
470
|
}
|
|
464
471
|
const dir = resolveCatalog(process.cwd()).packDir(vendor);
|
|
465
472
|
if (dir === undefined) throw new Error(`No twin "${vendor}" here — \`bun add -d @volter/twin-${vendor}\`, or run from a checkout that has it`);
|
|
466
|
-
if (rest.length === 0 || rest[0]!.startsWith('-')) throw new Error(`volter twin ${vendor}: which verb? serve |
|
|
473
|
+
if (rest.length === 0 || rest[0]!.startsWith('-')) throw new Error(`volter twin ${vendor}: which verb? serve | root | credential | refresh`);
|
|
467
474
|
const cli = packCli(dir); if (cli === undefined) throw new Error(`No cli for twin "${vendor}" (its manifest names no bin)`);
|
|
468
475
|
process.exitCode = await passthrough([process.execPath, cli, ...rest]);
|
|
469
476
|
}
|
|
@@ -487,10 +494,15 @@ async function loginCommand(args: string[]): Promise<void> {
|
|
|
487
494
|
let origin: string; try { const u = new URL(url); if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error(); origin = u.origin; } catch { throw new Error(`${url} is not a platform address (https://…)`); }
|
|
488
495
|
const token = value(args, '--token') ?? await deviceSignIn(origin, !flag(args, '--no-open'));
|
|
489
496
|
const res = await fetch(`${origin}/-/orgs`, { headers: { authorization: `Bearer ${token}` } });
|
|
490
|
-
const body = (await res.json().catch(() => ({}))) as { person?: { email?: string; id?: string }; orgs?: Array<{ slug: string | null; name: string }>; error?: string };
|
|
497
|
+
const body = (await res.json().catch(() => ({}))) as { person?: { email?: string; id?: string }; orgs?: Array<{ slug: string | null; name: string; worlds?: Array<{ name: string }> }>; error?: string };
|
|
491
498
|
if (!res.ok) throw new Error(`${origin} answered ${res.status}: ${body.error ?? 'not signed in'}`);
|
|
492
499
|
storeToken(origin, token); storePlatform(origin, undefined, body.person?.email ?? body.person?.id);
|
|
493
|
-
|
|
500
|
+
// a teammate's orgs already hold Worlds: the next step links one; a first person's makes one
|
|
501
|
+
const held = (body.orgs ?? []).flatMap((o) => (o.worlds ?? []).map((w) => w.name));
|
|
502
|
+
const next = held.length
|
|
503
|
+
? `in your app's folder: volter remote add origin ${held[0]}, then volter world pull${held.length > 1 ? ` (your orgs' Worlds: ${held.slice(0, 6).join(', ')}${held.length > 6 ? ', …' : ''})` : ''}`
|
|
504
|
+
: `in your app's folder: volter world init, then volter remote add origin ${(body.orgs ?? [])[0]?.slug ?? '<org>'}/<world> --create`;
|
|
505
|
+
out(`signed in ${origin} as ${body.person?.email ?? body.person?.id ?? 'you'}\norgs ${(body.orgs ?? []).map((o) => o.slug ?? o.name).join(', ') || '(none yet)'}\nnext ${next}`);
|
|
494
506
|
}
|
|
495
507
|
|
|
496
508
|
/** `volter whoami`: which platform the command is signed into, as whom, and the orgs it reaches. */
|
|
@@ -498,7 +510,7 @@ async function whoamiCommand(): Promise<void> {
|
|
|
498
510
|
const origin = platformOrigin(); const token = origin ? tokenFor(origin) : undefined;
|
|
499
511
|
if (!origin || !token) { out('not signed in (volter login <platform url>)'); process.exitCode = 1; return; }
|
|
500
512
|
const res = await fetch(`${origin}/-/orgs`, { headers: { authorization: `Bearer ${token}` } }).catch(() => null);
|
|
501
|
-
if (!res) throw new Error(`${origin} did not answer`);
|
|
513
|
+
if (!res) throw new Error(`${origin} did not answer: the platform this command is signed into is unreachable (volter login <url> signs it into another)`);
|
|
502
514
|
const body = (await res.json().catch(() => ({}))) as { person?: { email?: string; id?: string }; orgs?: Array<{ slug: string | null; name: string; role?: string }>; error?: string };
|
|
503
515
|
// a token the platform no longer takes is signed out; one it takes but that may not read orgs is still signed in
|
|
504
516
|
if (res.status === 403) { out(`signed in ${origin} (this token may not read your orgs: ${body.error ?? 'refused'})`); return; }
|
|
@@ -749,6 +761,7 @@ async function main(): Promise<void> {
|
|
|
749
761
|
async function dispatch(noun: string | undefined, verb: string | undefined, args: string[]): Promise<void> {
|
|
750
762
|
if (!noun || noun === '--help' || noun === '-h' || noun === 'help') { out(HELP); return; }
|
|
751
763
|
if (verb === '--help' || verb === '-h') { out(HELP); return; }
|
|
764
|
+
if (noun === '--version' || noun === '-v' || noun === 'version') { out(`volter ${ownVersion()}`); return; }
|
|
752
765
|
switch (noun) {
|
|
753
766
|
case 'world': {
|
|
754
767
|
if (!verb) throw new Error(`volter world: which verb?\n\n${HELP}`);
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
// #the-config-and-the-running-world): init copies a twin's starter handlers to .volter/handlers/<vendor>.json and
|
|
3
3
|
// names that path relative to the world root, so the committed config reads the same on every
|
|
4
4
|
// machine; `up` resolves it against the root on both the spawn path (--scenario) and the colocated
|
|
5
|
-
// host (scenarioPath). Proven against the real anthropic twin,
|
|
5
|
+
// host (scenarioPath). Proven against the real anthropic twin, installed with starter handlers of the test's own (no
|
|
6
|
+
// pack of the catalog ships any): its files linked from the pack repository, its `defaults/handlers.json` written here.
|
|
6
7
|
import { afterAll, describe, expect, test } from 'bun:test';
|
|
7
|
-
import { mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
8
9
|
import { tmpdir } from 'node:os';
|
|
9
10
|
import { join, resolve } from 'node:path';
|
|
10
11
|
import { World } from './index.ts';
|
|
11
12
|
|
|
12
|
-
const PACKS = resolve(import.meta.dir, '..', '..', 'twin');
|
|
13
|
+
const PACKS = resolve(import.meta.dir, '..', '..', '..', '..', 'twin-packs-p3');
|
|
13
14
|
|
|
14
15
|
function app(): string {
|
|
15
16
|
const parent = mkdtempSync(join(tmpdir(), 'handlers-in-repo-'));
|
|
@@ -17,8 +18,14 @@ function app(): string {
|
|
|
17
18
|
mkdirSync(dir);
|
|
18
19
|
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'agent', dependencies: { '@anthropic-ai/sdk': '^0.30' } }));
|
|
19
20
|
writeFileSync(join(dir, '.env.example'), 'ANTHROPIC_API_KEY=\n');
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
const installed = join(parent, 'node_modules', '@volter', 'twin-anthropic');
|
|
22
|
+
mkdirSync(installed, { recursive: true });
|
|
23
|
+
for (const entry of readdirSync(join(PACKS, 'anthropic'))) {
|
|
24
|
+
if (entry === 'package.json') copyFileSync(join(PACKS, 'anthropic', entry), join(installed, entry));
|
|
25
|
+
else symlinkSync(join(PACKS, 'anthropic', entry), join(installed, entry));
|
|
26
|
+
}
|
|
27
|
+
mkdirSync(join(installed, 'defaults'));
|
|
28
|
+
writeFileSync(join(installed, 'defaults', 'handlers.json'), `${JSON.stringify({ handlers: [{ id: 'demo-answer', on: {}, respond: { text: 'hi' } }] }, null, 2)}\n`);
|
|
22
29
|
return dir;
|
|
23
30
|
}
|
|
24
31
|
|
package/src/journeys/kit.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { resolve } from 'node:path';
|
|
|
6
6
|
export const CLI = resolve(import.meta.dir, '..', 'cli.ts');
|
|
7
7
|
/** The operator's `volter-world` command, which ships with @volter/world-runtime. */
|
|
8
8
|
export const OPERATOR_CLI = resolve(import.meta.dir, '..', '..', '..', 'world-runtime', 'src', 'cli.ts');
|
|
9
|
-
/** The twins
|
|
10
|
-
export const PACKS = resolve(import.meta.dir, '..', '..', '..', 'twin');
|
|
9
|
+
/** The twins: the pack repository beside this checkout. */
|
|
10
|
+
export const PACKS = resolve(import.meta.dir, '..', '..', '..', '..', '..', 'twin-packs-p3');
|
|
11
11
|
/** @volter/world itself. */
|
|
12
12
|
export const SDK = resolve(import.meta.dir, '..', '..');
|
package/src/journeys/tutorial.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
//
|
|
18
18
|
// The same steps make the recording: `tapeFor(page)` renders a vhs tape that types each command,
|
|
19
19
|
// waits, and screenshots after each one, so docs/media/<page>/ is the page's own playback.
|
|
20
|
-
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import { downWorld } from '@volter/world-runtime';
|
|
22
22
|
import { tmpdir } from 'node:os';
|
|
23
23
|
import { basename, delimiter, dirname, join, resolve } from 'node:path';
|
|
@@ -108,7 +108,9 @@ const globalPath = (global: string): string => [...(process.platform === 'win32'
|
|
|
108
108
|
* package.json, its env example, its files — and installs what it names.
|
|
109
109
|
*/
|
|
110
110
|
export function tutorialApp(name: string, registry: { env: Record<string, string> }): { dir: string; parent: string; env: Record<string, string> } {
|
|
111
|
-
|
|
111
|
+
// the real path: macOS's temp directory is a symlink (/var → /private/var), and a World is known by its root's real
|
|
112
|
+
// path, so the harness and the CLI the page runs name it alike
|
|
113
|
+
const parent = realpathSync(mkdtempSync(join(tmpdir(), `tutorial-${name}-`)));
|
|
112
114
|
const dir = join(parent, 'acme-web');
|
|
113
115
|
mkdirSync(dir);
|
|
114
116
|
const global = join(parent, 'bun-global');
|
|
@@ -227,6 +229,7 @@ export async function runTutorial(tutorial: Tutorial, app: { dir: string; parent
|
|
|
227
229
|
};
|
|
228
230
|
const fenceOutput = new Map<number, string>(); // what the reader saw after each bash fence, all its commands
|
|
229
231
|
const backgroundFences = new Set<number>();
|
|
232
|
+
let leak: Error | undefined;
|
|
230
233
|
try {
|
|
231
234
|
for (const step of tutorial.steps) {
|
|
232
235
|
if (step.kind === 'file') {
|
|
@@ -282,9 +285,13 @@ export async function runTutorial(tutorial: Tutorial, app: { dir: string; parent
|
|
|
282
285
|
const alive = new Set(windowsProcesses().map((p) => `${p.pid}@${p.created}`));
|
|
283
286
|
for (const p of leftovers) if (alive.has(`${p.pid}@${p.created}`)) Bun.spawnSync({ cmd: ['taskkill', '/PID', String(p.pid), '/T', '/F'], windowsHide: true, stdout: 'ignore', stderr: 'ignore' });
|
|
284
287
|
await Promise.allSettled([reader.cancel(), errReader.cancel()]);
|
|
285
|
-
|
|
288
|
+
// not thrown here: a throw in `finally` replaces the page's own failure with this report. It is thrown after,
|
|
289
|
+
// when the page itself passed; when the page failed, its failure propagates and this is printed beside it
|
|
290
|
+
leak = new Error(`the page left ${leftovers.length} process(es) running after its own stop and the harness's kill of its jobs (ended now):\n${leftovers.map((p) => ` pid ${p.pid}: ${p.command}`).join('\n')}`);
|
|
291
|
+
console.error(leak.message);
|
|
286
292
|
}
|
|
287
293
|
}
|
|
294
|
+
if (leak) throw leak;
|
|
288
295
|
return results;
|
|
289
296
|
}
|
|
290
297
|
|
package/src/locate.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// `World.open()` start here, the way `git` starts from the nearest `.git`.
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { dirname, join, resolve } from 'node:path';
|
|
7
|
-
import { stateDirName } from '@volter/world-core';
|
|
7
|
+
import { stateDirName } from '@volter/world-core/runtime';
|
|
8
8
|
import { loadWorldConfig } from '@volter/world-runtime';
|
|
9
9
|
|
|
10
10
|
export const WORLD_FILE = 'world.json';
|
package/src/mcp.ts
CHANGED
|
@@ -72,7 +72,7 @@ Work in the app's folder (each tool takes "dir", defaulting to where this server
|
|
|
72
72
|
- Look: world_log and world_diff say what the app changed at each vendor; world_view starts the dashboard and returns its link for the person.
|
|
73
73
|
- Branch and reset like a database: world_branch, world_checkout, world_reset.
|
|
74
74
|
- Share with a team: platform_login returns a link and a code the PERSON must approve in their browser (you cannot approve it); after they do, remote_link links this World to <org>/<world> on the platform (create: true makes it there from this World's vendors); world_changeset cuts the changes with a message, and world_push sends them.
|
|
75
|
-
The guide: https://github.com/volter-ai/twin/blob/main/docs/guides/use-with-a-coding-agent.md`;
|
|
75
|
+
The guide: https://github.com/volter-ai/twin-world/blob/main/docs/guides/use-with-a-coding-agent.md`;
|
|
76
76
|
|
|
77
77
|
export async function serveMcp(version: string): Promise<void> {
|
|
78
78
|
const server = new McpServer({ name: 'volter', title: 'Volter World', version }, { instructions: INSTRUCTIONS });
|
package/src/sdk.test.ts
CHANGED
|
@@ -53,7 +53,7 @@ describe('World — the twins an app needs, running together', () => {
|
|
|
53
53
|
// the default data is the only origin: nothing to push to, nothing to fetch from
|
|
54
54
|
expect(world.origin()).toBeNull();
|
|
55
55
|
await expect(world.push()).rejects.toThrow(/default data cannot be pushed to/);
|
|
56
|
-
expect(
|
|
56
|
+
await expect(world.fetch()).rejects.toThrow(/only origin is the default data/);
|
|
57
57
|
|
|
58
58
|
// a branch over the same mirrors with its own log, checked out; checkout switches back
|
|
59
59
|
const feature = await world.branch('feature');
|
|
@@ -74,7 +74,7 @@ describe('World — the twins an app needs, running together', () => {
|
|
|
74
74
|
expect(back.status().running).toBe(true);
|
|
75
75
|
expect(back.unpushed()).toHaveLength(1);
|
|
76
76
|
await back.down();
|
|
77
|
-
});
|
|
77
|
+
}, 30_000); // two Worlds booted and one resumed: its one-service skeleton alone took 2.4 s (load ~13, 2026-09-30, a scratch probe of up/branch/checkout/down/up/down)
|
|
78
78
|
|
|
79
79
|
test('a remote URL names one world; tokens live in the user\'s config dir, never in the repo', () => {
|
|
80
80
|
expect(parseOriginUrl('https://twins.example.com/acme/web')).toEqual({ url: 'https://twins.example.com', namespace: 'acme/web' });
|
package/src/world.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// way git finds its repository; every method is a name for a world-runtime verb, in the words a
|
|
4
4
|
// user reads. The `volter` command is one client of this class and adds nothing.
|
|
5
5
|
import { platformPerson } from './credentials.ts';
|
|
6
|
-
import { rebaseChangeset, worldBootMarker, type Changeset, type ChangesetApplication, type LedgerDelta, type PerformAction, type RemoteExecute, type TwinAction, type WorldMarker, type ApplyReceipt, listActions, isTwinBookkeeping, pushablePendingActions, pendingActions, applyTwinWrite, twinResources, type TwinResource } from '@volter/world-core';
|
|
6
|
+
import { rebaseChangeset, worldBootMarker, type Changeset, type ChangesetApplication, type LedgerDelta, type PerformAction, type RemoteExecute, type TwinAction, type WorldMarker, type ApplyReceipt, listActions, isTwinBookkeeping, pushablePendingActions, pendingActions, applyTwinWrite, twinResources, type TwinResource } from '@volter/world-core/runtime';
|
|
7
7
|
import { spawnSync } from 'node:child_process';
|
|
8
8
|
import clockForm from '@volter/world-core/world-clock';
|
|
9
9
|
import {
|
|
@@ -11,12 +11,12 @@ import {
|
|
|
11
11
|
statusWorld, statusWorldChangeset, upWorld, verifyWorldChangeset, worldLedgers, worldOrigin,
|
|
12
12
|
bindLegacyCredentials, deployWorld, loadWorldConfig, writeWorldConfig, readServeRecord, refreshTwin, serveWorld, serveWorldView, findInstalledPackage, type ServedWorld, type ViewConsole, type WorldView, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, credentialPayloadFrom, rootCredentialInput, withoutVaultSignIn,
|
|
13
13
|
type DeployTwinOutcome, type MaterializedRoot,
|
|
14
|
-
type ChangesetLocation, type InitOptions, type InitResult, type WorldInstance, materializeRoots, worldChangesetRules } from '@volter/world-runtime';
|
|
14
|
+
type ChangesetLocation, type InitOptions, type InitResult, type WorldInstance, materializeRoots, instanceDir, worldChangesetRules } from '@volter/world-runtime';
|
|
15
15
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
16
|
import { dirname, join, resolve } from 'node:path';
|
|
17
17
|
import { requireToken, storeToken } from './credentials.ts';
|
|
18
18
|
import { currentBranch, findWorldRoot, mainBranch, requireWorldRoot, setCurrentBranch, worldConfigRelative, worldEnvPath, worldSeedPath } from './locate.ts';
|
|
19
|
-
import { destinationOf, parentEntries, rebaseBranch, type Receipt, type RootConfig } from '@volter/world-core';
|
|
19
|
+
import { destinationOf, parentEntries, rebaseBranch, type Receipt, type RootConfig } from '@volter/world-core/runtime';
|
|
20
20
|
|
|
21
21
|
export type WorldRef = { name?: string; root?: string };
|
|
22
22
|
export type WorldMode = 'local' | 'share' | 'sealed';
|
|
@@ -56,7 +56,7 @@ export type WorldStatus = {
|
|
|
56
56
|
origin: { url: string; namespace: string; fetchedAt?: string } | null;
|
|
57
57
|
unpushed: number;
|
|
58
58
|
changesets: { total: number; pushed: number };
|
|
59
|
-
services: Record<string, { url?: string; running: boolean; /** the pack's protocol major and its standing (
|
|
59
|
+
services: Record<string, { url?: string; running: boolean; /** the pack's protocol major and its standing (the kernel serves its own major and refuses any other) */ protocol?: { major: number; standing: string } }>;
|
|
60
60
|
envFile?: string;
|
|
61
61
|
};
|
|
62
62
|
|
|
@@ -175,7 +175,9 @@ export class World {
|
|
|
175
175
|
branches: listWorlds(this.root).map((w) => w.name).sort(),
|
|
176
176
|
running: instance?.running ?? false,
|
|
177
177
|
served: (() => { const r = readServeRecord(this.root); return r ? { base: r.base, pid: r.pid, startedAt: r.startedAt } : null; })(),
|
|
178
|
-
|
|
178
|
+
// the origin push and pull use: the remote world.json names, with when it was last fetched (a world that only
|
|
179
|
+
// pushed to it has not fetched, but it has an origin all the same)
|
|
180
|
+
origin: (() => { const o = this.origin(); return o ? { url: o.url, namespace: o.namespace, ...(o.fetchedAt ? { fetchedAt: o.fetchedAt } : {}) } : null; })(),
|
|
179
181
|
unpushed: instance ? this.unpushed().length : 0,
|
|
180
182
|
changesets: { total: changesets.length, pushed: changesets.filter((c) => c.changeset.applied?.outcome === 'applied').length },
|
|
181
183
|
services,
|
|
@@ -338,7 +340,7 @@ export class World {
|
|
|
338
340
|
// ── serving ─────────────────────────────────────────────────────────────────────────────
|
|
339
341
|
/** Serve this world on a port under `/<org>/<world>/`; returns when listening. */
|
|
340
342
|
serve(opts: { port?: number; host?: string; consolePort?: number; announce?: (info: { name: string; base: string; token: string; readToken: string; console: string | null }) => void } = {}): Promise<ServedWorld> { return serveWorld(this.name, { root: this.root, ...opts }); }
|
|
341
|
-
/** `volter world view`: this World, its
|
|
343
|
+
/** `volter world view`: this World, its twins' screens, its branches and the console on one origin (docs/contributing/architecture.md, "Viewing a World"). */
|
|
342
344
|
view(opts: { port?: number; host?: string; console?: ViewConsole; announce?: (line: string) => void; origins?: boolean; origin?: string } = {}): Promise<WorldView> { const person = platformPerson(); return serveWorldView(this.name, { root: this.root, ...(person ? { localPerson: person } : {}), ...opts }); }
|
|
343
345
|
|
|
344
346
|
// ── remotes ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -369,13 +371,18 @@ export class World {
|
|
|
369
371
|
const target = this.remotes()[name];
|
|
370
372
|
if (target) return parseOriginUrl(target);
|
|
371
373
|
if (name !== 'origin') return null;
|
|
372
|
-
const recorded =
|
|
374
|
+
const recorded = this.recordedOrigin();
|
|
373
375
|
return recorded ? { url: recorded.url, namespace: recorded.namespace } : null;
|
|
374
376
|
}
|
|
375
377
|
|
|
378
|
+
/** The origin the instance recorded, or null for a world that has never run (only world.json names it then);
|
|
379
|
+
* any other failure to read the instance is said as it is. */
|
|
380
|
+
private recordedOrigin(): WorldInstance['origin'] | null {
|
|
381
|
+
try { return worldOrigin(this.name, this.root); } catch (error) { if (/World instance not found/.test(String((error as Error).message))) return null; throw error; }
|
|
382
|
+
}
|
|
376
383
|
// ── the origin ──────────────────────────────────────────────────────────────────────────
|
|
377
384
|
/** The origin this world clones from and pushes to, or null: the default data is its only origin. */
|
|
378
|
-
origin(): WorldInstance['origin'] | null { const r = this.remote('origin'); const recorded =
|
|
385
|
+
origin(): WorldInstance['origin'] | null { const r = this.remote('origin'); const recorded = this.recordedOrigin(); return r ? { ...(recorded ?? {}), url: r.url, namespace: r.namespace } : recorded; }
|
|
379
386
|
/**
|
|
380
387
|
* Clone a remote's canonical history into this world: record the origin, remember the token,
|
|
381
388
|
* fetch everything. With the world's token (namespace or read), the history arrives through the
|
|
@@ -401,12 +408,22 @@ export class World {
|
|
|
401
408
|
const fetched = await this.fetch(opts);
|
|
402
409
|
return { fetched, rebased: this.repos().map((r) => ({ service: r.service, ...rebaseBranch(r.stateService, r.root, { origin: true }) })) };
|
|
403
410
|
}
|
|
404
|
-
/** Fetch what the origin observed since the last fetch.
|
|
405
|
-
|
|
411
|
+
/** Fetch what the origin observed since the last fetch. A world that has never run (a teammate's checkout, linked
|
|
412
|
+
* with `remote add`) is booted as a clone boots it, with no story, and brings the origin's whole log in. */
|
|
413
|
+
async fetch(opts: { token?: string; services?: string[] } = {}): ReturnType<typeof fetchFromOrigin> {
|
|
406
414
|
const origin = this.origin();
|
|
407
415
|
if (!origin) throw new Error(`World "${this.name}" has no origin — its only origin is the default data. \`volter world clone <url>\` connects a remote.`);
|
|
408
416
|
if (opts.token !== undefined && opts.token !== '') storeToken(origin.url, opts.token); // the last token given for a remote is the one remembered
|
|
409
|
-
|
|
417
|
+
const key = requireToken(origin.url, opts.token);
|
|
418
|
+
const instance = this.instance();
|
|
419
|
+
// state with no record is not a fresh checkout: a clean boot would take its unpushed work with it
|
|
420
|
+
if (!instance && existsSync(instanceDir(this.root, this.name))) throw new Error(`World "${this.name}" has state but no instance record (${instanceDir(this.root, this.name)}): \`volter world up\` resumes it, or \`volter world down --purge\` starts it over`);
|
|
421
|
+
// a record missing a configured twin (a boot that failed part-way) is booted again, resuming what it holds
|
|
422
|
+
const configured = loadWorldConfig(this.configRef(), this.root).config.services.filter((s) => s.type === 'twin' || s.type === undefined).map((s) => s.id);
|
|
423
|
+
if (!instance || configured.some((id) => !(id in instance.services))) await this.up({ seed: false });
|
|
424
|
+
// never fetched and nothing of its own: the origin's whole log, as a clone brings it
|
|
425
|
+
const full = !instance?.origin?.fetchedAt && this.log().length === 0;
|
|
426
|
+
return fetchFromOrigin(this.name, { root: this.root, url: origin.url, namespace: origin.namespace, key, ...(full ? { full: true } : {}), ...(opts.services ? { services: opts.services } : {}) });
|
|
410
427
|
}
|
|
411
428
|
|
|
412
429
|
// ── changesets and push ─────────────────────────────────────────────────────────────────
|
|
@@ -456,12 +473,14 @@ export class World {
|
|
|
456
473
|
rebase(name: string): ReturnType<typeof rebaseWorldChangeset> { return rebaseWorldChangeset(name, { root: this.root, world: this.name }); }
|
|
457
474
|
// ── roots (docs/concepts/the-model.md: a twin on a shared world whose root is the vendor) ──
|
|
458
475
|
/** The twin's root and credential, as `volter twin <vendor>` prints them. */
|
|
459
|
-
twin(vendor: string): { vendor: string; url?: string; root: RootConfig | null; credential: { placedAt: string; fingerprint: string } | null } {
|
|
476
|
+
twin(vendor: string): { vendor: string; url?: string; ingest?: string; root: RootConfig | null; credential: { placedAt: string; fingerprint: string } | null } {
|
|
460
477
|
const { config } = loadWorldConfig(this.configRef(), this.root);
|
|
461
478
|
const service = config.services.find((s) => s.id === vendor);
|
|
462
479
|
if (!service) throw new Error(`no twin "${vendor}" in this world`);
|
|
463
480
|
const inst = this.instance();
|
|
464
|
-
|
|
481
|
+
// the door the vendor's webhooks are registered at: a served World's (architecture, "Ingest")
|
|
482
|
+
const served = readServeRecord(this.root);
|
|
483
|
+
return { vendor, ...(inst?.services[vendor]?.url ? { url: inst.services[vendor]!.url } : {}), ...(served ? { ingest: `${served.base.replace(/\/+$/, '')}/twins/${vendor}/ingest` } : {}), root: (service as { root?: RootConfig }).root ?? null, credential: sealedCredentialInfo(this.root, vendor) };
|
|
465
484
|
}
|
|
466
485
|
/** Set (or clear) the twin's root: the vendor's API and the deploy policy, in world.json. Takes effect at the next `up` or `serve`, or now when the world runs. */
|
|
467
486
|
setTwinRoot(vendor: string, root: RootConfig | null): void {
|
|
@@ -470,7 +489,7 @@ export class World {
|
|
|
470
489
|
if (inst?.running) { materializeRoots(loadWorldConfig(this.configRef(), this.root).config, inst.dirs.data, this.root); }
|
|
471
490
|
}
|
|
472
491
|
/** Seal the vendor's credential beside the world (a bare token, or a JSON payload). Never readable back. */
|
|
473
|
-
sealTwinCredential(vendor: string, input: string): Promise<{ placedAt: string; fingerprint: string }> {
|
|
492
|
+
sealTwinCredential(vendor: string, input: string): Promise<{ placedAt: string; fingerprint: string; mintedSigningSecret?: string }> {
|
|
474
493
|
// sealed for the one destination it serves, the twin's root: with none set there is nowhere it may go
|
|
475
494
|
const url = (loadWorldConfig(this.configRef(), this.root).config.services as Array<{ id?: string; root?: { url?: string } }>).find((s) => s.id === vendor)?.root?.url;
|
|
476
495
|
if (!url) return Promise.reject(new Error(`the ${vendor} twin has no root: \`volter twin ${vendor} root <url>\` sets where its credential goes, then seal it`));
|