@volter/world 2.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/src/cli.ts ADDED
@@ -0,0 +1,486 @@
1
+ #!/usr/bin/env node
2
+ // `volter` — the user's command (docs/concepts/worlds.md#the-config-and-the-running-world): a noun, then a
3
+ // verb. `volter world …` acts on the world of the cwd the way git acts on the repository of the
4
+ // cwd; `volter twin <vendor> …` runs one twin on its own; `volter remote …` is the hosted remote.
5
+ // One client of `World`; it adds nothing the SDK does not have.
6
+ import { formatInitReport, resolveCatalog, findInstalledPackage, packCli, serveConsoleFor } from '@volter/world-runtime';
7
+ import { readFileSync, realpathSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { spawn } from 'node:child_process';
10
+ import { formatLedgerDelta } from '@volter/world-core';
11
+ import { existsSync } from 'node:fs';
12
+ import { join, relative, resolve } from 'node:path';
13
+ import { platformOrigin, requireToken, storePlatform, storeToken, tokenFor } from './credentials.ts';
14
+ import { parseOriginUrl, World } from './world.ts';
15
+ import { waitForServeShutdown } from './serve-shutdown.ts';
16
+
17
+ export const HELP = `volter — run your app against a world of twins
18
+
19
+ world — the twins your app needs, running together (the world of the current directory)
20
+ volter world init [--name <world>] [--allow-unknown] detect the app's vendors, write .volter/world.json
21
+ volter world up [--sandbox] [--no-seed] start the twins on this branch (loads the default data the first time)
22
+ volter world run [--verbose] -- <command...> run the app or its tests inside the world
23
+ volter world activate eval "$(volter world activate)": vendor CLIs and curl in this shell reach the twins
24
+ volter world shell a subshell with the world active
25
+ 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
26
+ volter world serve [--port <p>] [--console-port <c>] serve this world on a URL under /<org>/<world>/; prints the token and the console (its own origin, port p+1 by default)
27
+ 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
28
+ volter world status world, branch, origin, unpushed changes, what is running
29
+ volter world log [--receipts] [--json] every write the app made; --receipts adds each change's receipt
30
+ volter world diff [--json] what changed since the default data (or the last mark)
31
+ volter world seed load the default data
32
+ volter world reset back to the default data
33
+ volter world down [--purge] stop the twins (--purge forgets this branch's state)
34
+ volter world branch [<name>] list branches, or make one from here and check it out
35
+ volter world checkout <name> switch branches
36
+ volter world replay <changeset> --into <branch> feed a changeset's changes into another branch's twins
37
+ volter world clock show | set <iso> | advance <N s|m|h|d> the world's clock: set, not observed
38
+ 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)
39
+ volter world fetch what the remote observed since the last fetch
40
+ volter world origin where this world clones from and pushes to
41
+ volter world changeset [<name>] -m "<message>" cut the unpushed changes into a reviewable changeset
42
+ volter world pull [--token <token>] fetch, then move this branch onto what came in (git's pull)
43
+ volter world push [<name>] [--token <token>] [--force] push to the remote, which deploys and answers with receipts
44
+ volter world rebase [<changeset>] rebase this branch onto its moved base, or a changeset onto the moved mirror
45
+ volter world verify <changeset> run the world's checks over a changeset and record the result
46
+ volter world approve <changeset> --as <principal> sign a changeset's current hash
47
+ volter world deploy [<changeset>] perform landed changes against each twin's root, by its policy
48
+
49
+ twin — one twin: in this world, or on its own
50
+ volter twin <vendor> the twin's URL, root, deploy policy, whether a credential is sealed
51
+ volter twin <vendor> root <url> [--deploy auto|gated|hold] [--scope <path>] the vendor's real account behind this twin (--none clears it)
52
+ volter twin <vendor> credential seal a credential read from stdin beside the world; never readable back
53
+ volter twin <vendor> refresh observe the root now
54
+ volter twin <vendor> serve [--port <p>] [--read-only] serve the twin; your SDK talks to it at http://127.0.0.1:<p>
55
+ volter twin <vendor> mirror [--port <p>] the twin's UI mirror
56
+ volter twin <vendor> conformance check the twin against the vendor's spec
57
+
58
+ remote — the worlds this one pushes to and fetches from, by name
59
+ volter login <platform url> --token <personal token> sign the CLI into the hosted platform (a personal token from the console)
60
+ volter remote add <name> <url|path|org/world> [--token <token>] name a remote; org/world resolves through the platform you logged into
61
+ volter remote remove <name> forget it
62
+ volter remote list every remote, with its URL and whether a token is stored
63
+ (the hosting product's serve answers here for one release: volter remote serve)
64
+
65
+ --world <dir> the world root, when the cwd is not inside it
66
+ --branch <name> act on that branch instead of the checked-out one
67
+ --json machine-readable output where offered
68
+ `;
69
+
70
+ function flag(args: string[], name: string): boolean { return args.includes(name); }
71
+ function value(args: string[], name: string): string | undefined {
72
+ const i = args.indexOf(name);
73
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
74
+ }
75
+ function positionals(args: string[], valueFlags: string[]): string[] {
76
+ const out: string[] = [];
77
+ for (let i = 0; i < args.length; i += 1) {
78
+ const a = args[i]!;
79
+ if (a === '--') break;
80
+ if (a.startsWith('-')) { if (valueFlags.includes(a)) i += 1; continue; }
81
+ out.push(a);
82
+ }
83
+ return out;
84
+ }
85
+ function afterDashes(args: string[]): string[] { const i = args.indexOf('--'); return i < 0 ? [] : args.slice(i + 1); }
86
+ const VALUE_FLAGS = ['--world', '--branch', '--name', '--token', '--admin-token', '-m', '--message', '--base', '--org', '--vendors', '--into', '--deploy', '--as', '--note', '--port', '--twins', '--host', '--scope', '--refresh', '--at-most', '--at'];
87
+
88
+ /** A subject as a person names it: an id that already carries its context (`acme/web#1`, `C-ops/ts`) stands alone, with an embedded `<type>:` dropped; a bare id gets its type. */
89
+ function subjectLabel(subject: { type: string; id: string }): string {
90
+ const id = subject.id.replace(new RegExp(`(^|[#/:])${subject.type}:`), '$1');
91
+ return /[#/]/.test(id) ? id : `${subject.type}:${id}`;
92
+ }
93
+ function out(text: string): void { process.stdout.write(text.endsWith('\n') ? text : `${text}\n`); }
94
+ function json(value: unknown): void { out(JSON.stringify(value, null, 2)); }
95
+ function when(iso: string): string { return iso.slice(11, 23); }
96
+
97
+ /** Run another CLI in the foreground and adopt its exit code; a signal to us is a signal to it. */
98
+ async function passthrough(cmd: string[], cwd = process.cwd()): Promise<number> {
99
+ const child = spawn(cmd[0]!, cmd.slice(1), { cwd, stdio: 'inherit' });
100
+ const forward = (signal: NodeJS.Signals) => () => { try { child.kill(signal); } catch { /* already gone */ } };
101
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) process.on(signal, forward(signal));
102
+ return await new Promise<number>((resolve, reject) => { child.once('error', reject); child.once('exit', (code, signal) => resolve(code ?? (signal ? 128 : 1))); });
103
+ }
104
+ /** All of stdin, as text: a synchronous read of fd 0 to EOF — the same on Bun and Node, and it
105
+ * holds the process (an async iteration of `process.stdin` here let Bun exit 0 mid-read, the
106
+ * credential unsealed and nothing said — found by the deploy-from-a-shared-world page). */
107
+ function stdinText(): string { try { return readFileSync(0, 'utf8'); } catch { return ''; } }
108
+
109
+ async function worldCommand(verb: string, args: string[]): Promise<void> {
110
+ const worldDir = value(args, '--world');
111
+ const pos = positionals(args, VALUE_FLAGS);
112
+ const asJson = flag(args, '--json');
113
+
114
+ if (verb === 'init' && flag(args, '--bare')) {
115
+ const served = positionals(args, VALUE_FLAGS)[0];
116
+ if (!served) throw new Error('volter world init --bare <org>/<world> --twins a,b');
117
+ const twins = (value(args, '--twins') ?? '').split(',').map((t) => t.trim()).filter(Boolean);
118
+ const { result } = World.initBare(resolve(worldDir ?? process.cwd()), served, twins, { force: flag(args, '--force') });
119
+ if (asJson) json({ ok: result.ok, plan: result.plan });
120
+ else out(`bare ${served}\ntwins ${twins.join(', ')}\nwrote .volter/world.json`);
121
+ if (!result.ok) process.exitCode = 1;
122
+ return;
123
+ }
124
+ if (verb === 'init') {
125
+ const app = resolve(worldDir ?? process.cwd());
126
+ const { result } = World.init(app, { ...(value(args, '--name') ? { name: value(args, '--name')! } : {}), force: flag(args, '--force'), allowUnknown: flag(args, '--allow-unknown') });
127
+ if (asJson) json({ ok: result.ok, plan: result.plan, coverage: result.coverage, next: result.next });
128
+ else {
129
+ const twins = result.plan.vendors.filter((v) => v.service !== null).map((v) => v.vendor).sort();
130
+ out(`twins ${twins.join(', ') || '(none)'}\nwrote ${relative(process.cwd(), result.plan.configPath) || result.plan.configPath}`);
131
+ out(formatInitReport(result));
132
+ }
133
+ if (!result.ok) process.exitCode = 1;
134
+ return;
135
+ }
136
+
137
+ if (verb === 'console') {
138
+ // the console on this machine for a World served elsewhere: the given URL (<origin>/<org>/<world>), else
139
+ // this world's origin; a URL needs no world here
140
+ const given = positionals(args, VALUE_FLAGS)[0];
141
+ const origin = given ? null : World.open({ ...(worldDir ? { root: worldDir } : {}) }).origin();
142
+ const target = given ?? (origin && /^https?:/.test(origin.url) ? `${origin.url.replace(/\/+$/, '')}/${origin.namespace ?? ''}` : null);
143
+ if (!target) throw new Error('volter world console <https://host/org/world>: this world has no http(s) origin to open');
144
+ const port = value(args, '--port');
145
+ if (port !== undefined && !/^\d{1,5}$/.test(port)) throw new Error(`volter world console: --port ${port} is not a port`);
146
+ const served = await serveConsoleFor(target, { ...(port !== undefined ? { port: Number(port) } : {}) });
147
+ out(`console ${served.url}/-/console/\nworlds ${served.worlds} (forwarding to ${new URL(target).origin})\nOpen it and type the World's token.`);
148
+ await waitForServeShutdown(async () => { for (const s of served.servers) await s.stop(); });
149
+ process.exit(0);
150
+ }
151
+
152
+ const world = World.open({ ...(worldDir ? { root: worldDir } : {}), ...(value(args, '--branch') ? { name: value(args, '--branch')! } : {}) });
153
+
154
+ switch (verb) {
155
+ case 'up': {
156
+ const instance = await world.up({ ...(flag(args, '--sandbox') ? { mode: 'sealed' as const } : {}), seed: !flag(args, '--no-seed') });
157
+ if (asJson) { json(world.status()); return; }
158
+ const twins = Object.values(instance.services).filter((s) => s.type === 'twin' || s.type === undefined);
159
+ out(`${world.name} ${twins.length} twin${twins.length === 1 ? '' : 's'} up${flag(args, '--no-seed') ? '' : ', story loaded'}`);
160
+ for (const s of Object.values(instance.services)) out(` ${s.id}: ${s.url ?? '(no listener)'}`);
161
+ out(`\nRun your app inside it: volter world run -- <command>\nEnv: ${relative(process.cwd(), instance.envFile) || instance.envFile}`);
162
+ return;
163
+ }
164
+ case 'down': {
165
+ const result = await world.down({ purge: flag(args, '--purge') });
166
+ out(`Stopped ${result.name}${result.stopped.length ? `: ${result.stopped.join(', ')}` : ''}${result.purged ? ' (state forgotten)' : ''}`);
167
+ if (result.externalErrors.length) { for (const m of result.externalErrors) process.stderr.write(`${m}\n`); process.exitCode = 1; }
168
+ return;
169
+ }
170
+ case 'activate': { process.stdout.write(world.activateScript()); return; }
171
+ case 'shell': { process.exitCode = await world.shell(); return; }
172
+ case 'run': {
173
+ const command = afterDashes(args);
174
+ if (command.length === 0) throw new Error('volter world run: give the command after -- (volter world run -- npm test)');
175
+ process.exitCode = await world.run(command, { verbose: flag(args.slice(0, args.indexOf('--')), '--verbose') });
176
+ return;
177
+ }
178
+ case 'status': {
179
+ const s = world.status();
180
+ if (asJson) { json(s); return; }
181
+ out(`World ${s.world} on branch ${s.branch}: ${s.running ? 'running' : 'stopped'}`);
182
+ if (s.served) out(` served: ${s.served.base} (pid ${s.served.pid}, since ${s.served.startedAt})`);
183
+ out(` origin: ${s.origin ? `${s.origin.url}/${s.origin.namespace}${s.origin.fetchedAt ? ` (fetched ${s.origin.fetchedAt})` : ''}` : 'default data (no remote)'}`);
184
+ out(` unpushed: ${s.unpushed} change${s.unpushed === 1 ? '' : 's'}; changesets: ${s.changesets.pushed}/${s.changesets.total} pushed`);
185
+ if (s.branches.length > 1) out(` branches: ${s.branches.map((b) => (b === s.branch ? `*${b}` : b)).join(' ')}`);
186
+ for (const [id, svc] of Object.entries(s.services)) out(` ${id}: ${svc.url ?? '(no listener)'} (${svc.running ? 'running' : 'stopped'})${svc.protocol ? ` protocol ${svc.protocol.major}${svc.protocol.standing === 'current' ? '' : ` (${svc.protocol.standing})`}` : ''}`);
187
+ return;
188
+ }
189
+ case 'log': {
190
+ const rows = world.log();
191
+ if (asJson) { json(rows); return; }
192
+ if (rows.length === 0) { out('(no changes yet)'); return; }
193
+ const withReceipts = flag(args, '--receipts');
194
+ for (const r of rows) {
195
+ // an observed entry is the vendor's history on the root's log: neither pushed nor pushable
196
+ const v2 = r.operation === 'observed' ? 'observed' : r.landed ? `${r.landed.status}${r.landed.externalId ? ` ${r.landed.externalId}` : ''}${r.landed.reason ? ` ${r.landed.reason}` : ''}` : '';
197
+ const v1 = r.receipt ? `${r.receipt.status}${r.receipt.externalId ? ` ${r.receipt.externalId}` : ''}${r.changeset ? ` (${r.changeset})` : ''}` : '';
198
+ const receipt = withReceipts ? ` ${v2 || v1 || 'unpushed'}` : (v1 ? ` → ${v1}` : '');
199
+ out(`${r.service.padEnd(8)} ${(r.operation ?? r.op).padEnd(22)} ${subjectLabel(r.subject)}${receipt}`);
200
+ }
201
+ return;
202
+ }
203
+ case 'diff': {
204
+ const delta = world.diff(value(args, '--base'));
205
+ if (asJson) { json(delta); return; }
206
+ const changes = delta.actions.filter((a) => !a.action.subject.type.startsWith('_'));
207
+ out(`${changes.length} change${changes.length === 1 ? '' : 's'} since ${value(args, '--base') ? delta.base.id : world.diffBase()}`);
208
+ for (const v of delta.vendors) out(` ${v.service}: ${v.count} change${v.count === 1 ? '' : 's'}`);
209
+ return;
210
+ }
211
+ case 'seed': {
212
+ const r = await world.seed();
213
+ out(`Loaded the default data into ${r.world}: ${Object.entries(r.observed).map(([s, n]) => `${s} ${n}`).join(', ') || 'nothing recorded'}`);
214
+ if (r.exitCode !== 0) process.exitCode = r.exitCode;
215
+ return;
216
+ }
217
+ case 'reset': {
218
+ const r = await world.reset();
219
+ out(`Back to the default data on ${r.world}: ${Object.entries(r.observed).map(([s, n]) => `${s} ${n}`).join(', ') || 'nothing recorded'}`);
220
+ if (r.exitCode !== 0) process.exitCode = r.exitCode;
221
+ return;
222
+ }
223
+ case 'branch': {
224
+ const name = pos[0];
225
+ if (!name) { for (const b of world.branches()) out(`${b === world.name ? '* ' : ' '}${b}`); return; }
226
+ const atText = value(args, '--at');
227
+ const at = atText === undefined ? undefined : /^\d{4}-\d\d-\d\d/.test(atText) ? { instant: atText } : (() => {
228
+ const positions: Record<string, number> = {}; const views: Record<string, string> = {};
229
+ for (const p of atText.split(',')) {
230
+ const match = /^([^@]+)@(?:([a-f0-9]{64}):)?(\d+)$/.exec(p);
231
+ if (!match) throw new Error('--at takes an instant, twin@position, or twin@view:position');
232
+ positions[match[1]!] = Number(match[3]); if (match[2]) views[match[1]!] = match[2];
233
+ }
234
+ return { positions, views };
235
+ })();
236
+ const made = await world.branch(name, at ? { at } : {});
237
+ out(`branch ${made.name} from ${world.isMain() ? 'main' : world.name}${at ? ` at ${atText}` : ''}`);
238
+ return;
239
+ }
240
+ case 'checkout': {
241
+ const name = pos[0];
242
+ if (!name) throw new Error('volter world checkout: which branch?');
243
+ const to = await world.checkout(name);
244
+ out(`Switched to branch ${to.name}`);
245
+ return;
246
+ }
247
+ case 'clock': {
248
+ const [action, arg] = pos;
249
+ if (action === 'set') { if (!arg) throw new Error('volter world clock set <iso-8601>'); out(world.setClock(arg)); return; }
250
+ if (action === 'advance') { if (!arg) throw new Error('volter world clock advance <N s|m|h|d>'); out(world.advanceClock(arg)); return; }
251
+ if (action === undefined || action === 'show') { const c = world.clock(); out(`${c.at}${c.frozen ? '' : ' (wall clock — not set)'}`); return; }
252
+ throw new Error('volter world clock show | set <iso> | advance <N s|m|h|d>');
253
+ }
254
+ case 'replay': {
255
+ const name = pos[0]; const into = value(args, '--into');
256
+ if (!name || !into) throw new Error('volter world replay <changeset> --into <branch>');
257
+ const r = world.replay(name, into);
258
+ if (asJson) { json(r); return; }
259
+ out(`Replayed ${name} into ${into}`);
260
+ return;
261
+ }
262
+ case 'origin': {
263
+ const o = world.origin();
264
+ if (asJson) { json(o); return; }
265
+ out(o ? `origin ${o.url}/${o.namespace}` : 'origin default data');
266
+ return;
267
+ }
268
+ case 'clone': {
269
+ if (!pos[0]) throw new Error('volter world clone <url> --token <token>');
270
+ const outcome = await world.clone(pos[0], { ...(value(args, '--token') ? { token: value(args, '--token')! } : {}), ...(value(args, '--admin-token') ? { adminToken: value(args, '--admin-token')! } : {}) });
271
+ if (asJson) { json(outcome); return; }
272
+ const n = Object.values(outcome.appended).reduce((a, b) => a + b, 0);
273
+ out(`cloned ${outcome.origin.url}/${outcome.origin.namespace} ${n} change${n === 1 ? '' : 's'}`);
274
+ return;
275
+ }
276
+ case 'fetch': {
277
+ const outcome = await world.fetch({ ...(value(args, '--token') ? { token: value(args, '--token')! } : {}) });
278
+ if (asJson) { json(outcome); return; }
279
+ const n = Object.values(outcome.appended).reduce((a, b) => a + b, 0);
280
+ const behind = Object.values(outcome.behind ?? {}).reduce((a, b) => a + b, 0);
281
+ out(`fetched origin ${n} change${n === 1 ? '' : 's'}${behind ? ` (${behind} past this branch's position — \`volter world rebase\` folds them)` : ''}`);
282
+ return;
283
+ }
284
+ case 'pull': {
285
+ const r = await world.pull({ ...(value(args, '--token') ? { token: value(args, '--token')! } : {}) });
286
+ if (asJson) { json(r); return; }
287
+ const n = Object.values(r.fetched.appended).reduce((a, b) => a + b, 0);
288
+ const conflicts = r.rebased.flatMap((x) => x.conflicts.map((c) => `${x.service} ${c.subject.type}:${c.subject.id} ${c.field} ${c.op}`));
289
+ out(`pulled origin ${n} change${n === 1 ? '' : 's'} ${r.rebased.map((x) => `${x.service}@${x.to}`).join(' ')}${conflicts.length ? `\n conflicts:\n ${conflicts.join('\n ')}` : ''}`);
290
+ return;
291
+ }
292
+ case 'changeset': {
293
+ const message = value(args, '-m') ?? value(args, '--message');
294
+ if (message === undefined) throw new Error('volter world changeset: say what and why — volter world changeset -m "<message>"');
295
+ const cs = world.changeset({ ...(pos[0] ? { name: pos[0] } : {}), message, ...(value(args, '--base') ? { base: value(args, '--base')! } : {}), overwrite: flag(args, '--force') });
296
+ if (asJson) { json(cs); return; }
297
+ out(`changeset ${cs.name} ${cs.actions.length} change${cs.actions.length === 1 ? '' : 's'}\n ${message}${cs.narration ? `\n${cs.narration.split('\n').map((l) => ` ${l}`).join('\n')}` : ''}`);
298
+ return;
299
+ }
300
+ case 'serve': {
301
+ const served = await world.serve({ ...(value(args, '--port') ? { port: Number(value(args, '--port')) } : {}), ...(value(args, '--console-port') ? { consolePort: Number(value(args, '--console-port')) } : {}), ...(value(args, '--host') ? { host: value(args, '--host') } : {}), announce: (info) => out(`serving ${info.name} ${info.base}\ntoken ${info.token}\nread ${info.readToken}${info.console ? `\nconsole ${info.console}` : ''}`) });
302
+ await waitForServeShutdown(() => served.stop());
303
+ process.exit(0); // success follows confirmed shutdown; failures reach main's error path
304
+ return;
305
+ }
306
+ case 'rebase': {
307
+ const name = positionals(args, VALUE_FLAGS)[0];
308
+ if (name) {
309
+ const r = world.rebase(name); if (asJson) { json(r); return; }
310
+ const conflicts = r.report.actions.flatMap((a) => (a.conflicts ?? []).map((c) => `${a.service} ${c.subject.type}:${c.subject.id} ${c.field} ${c.op}`));
311
+ out(`rebased ${name} ${r.report.outcome}${conflicts.length ? `\n conflicts:\n ${conflicts.join('\n ')}` : ''}`);
312
+ return;
313
+ }
314
+ const results = world.rebaseBranch();
315
+ if (asJson) { json(results); return; }
316
+ const conflicts = results.flatMap((r) => r.conflicts.map((c) => `${r.service} ${c.subject.type}:${c.subject.id} ${c.field} ${c.op}`));
317
+ out(`rebased ${world.name} onto ${world.isMain() ? 'origin' : 'its base'}${results.some((r) => r.moved) ? ` ${results.map((r) => `${r.service}@${r.to}`).join(' ')}` : ' (already there)'}${conflicts.length ? `\n conflicts:\n ${conflicts.join('\n ')}` : ''}`);
318
+ return;
319
+ }
320
+ case 'verify': {
321
+ const name = positionals(args, VALUE_FLAGS)[0]; if (!name) throw new Error('volter world verify: which changeset?');
322
+ const outcome = await world.verify(name, { ephemeral: true });
323
+ if (asJson) { json(outcome); return; }
324
+ const failed = outcome.verification.results.filter((r) => !r.passed);
325
+ const refusals = outcome.verification.refusals ?? [];
326
+ if (refusals.length) out(`refused ${name} ${refusals.map((r) => `${r.check}: ${r.reason}`).join('; ')}`);
327
+ else out(failed.length === 0 ? `verified ${name} checks passed` : `refused ${name} ${failed.map((r) => `${r.service} ${r.assert.subject.type}:${r.assert.subject.id} ${r.assert.field} ${r.assert.op}`).join('; ')}`);
328
+ return;
329
+ }
330
+ case 'approve': {
331
+ const name = positionals(args, VALUE_FLAGS)[0]; const as = value(args, '--as');
332
+ if (!name || !as) throw new Error('volter world approve <changeset> --as <principal>');
333
+ const outcome = world.approve(name, as, value(args, '--note'));
334
+ if (asJson) { json(outcome); return; }
335
+ out(`approved ${name} by ${as}`);
336
+ return;
337
+ }
338
+ case 'deploy': {
339
+ const name = positionals(args, VALUE_FLAGS)[0];
340
+ const outcomes = await world.deploy(name);
341
+ if (asJson) { json(outcomes); return; }
342
+ if (outcomes.length === 0) { out('nothing to deploy'); return; }
343
+ for (const o of outcomes) out(`deployed ${o.changeset ?? name ?? o.service} ${o.report.pushed} change${o.report.pushed === 1 ? '' : 's'}${o.report.refused ? ` refused by ${o.report.refused.check}: ${o.report.refused.reason}` : o.report.failed ? ` failed: ${o.report.failed.error}` : ''}`);
344
+ return;
345
+ }
346
+ case 'push': {
347
+ const remoteName = 'origin';
348
+ const outcomes = await world.push({ ...(pos[0] ? { name: pos[0] } : {}), ...(value(args, '--token') ? { token: value(args, '--token')! } : {}), force: flag(args, '--force') });
349
+ if (asJson) { json(outcomes); return; }
350
+ if (outcomes.length === 0) { out('nothing to push'); return; }
351
+ for (const o of outcomes) {
352
+ const n = o.changeset.actions.length;
353
+ if (o.application.outcome === 'refused') { out(`refused ${o.changeset.name} ${(o.application.refusal ?? []).join('; ')}`); continue; }
354
+ out(`pushed ${o.changeset.name} ${n} change${n === 1 ? '' : 's'} → ${remoteName}`);
355
+ if (o.unsent) process.stderr.write(`warning: ${o.unsent}\n`);
356
+ for (const r of o.application.receipts) if (r.status === 'failed') out(` ${r.service} refused ${r.error ?? ''}`);
357
+ }
358
+ return;
359
+ }
360
+ default:
361
+ throw new Error(`volter world: unknown verb "${verb}"\n\n${HELP}`);
362
+ }
363
+ }
364
+
365
+ /** `volter twin <vendor> <verb…>`: the twin's own CLI (serve, mirror, conformance), found the way a world finds it. */
366
+ async function twinCommand(args: string[]): Promise<void> {
367
+ const [vendor, ...rest] = args;
368
+ if (!vendor || vendor.startsWith('-')) throw new Error('volter twin: which twin? (volter twin stripe serve --port 12111)');
369
+ const verb = rest[0];
370
+ // the twin IN THIS WORLD: its root, its credential, a refresh, its status
371
+ if (verb === undefined || verb === 'root' || verb === 'credential' || verb === 'refresh' || verb.startsWith('-')) {
372
+ const world = World.open({ ...(value(rest, '--world') ? { root: value(rest, '--world') } : {}) });
373
+ if (verb === 'root') {
374
+ const url = positionals(rest.slice(1), VALUE_FLAGS)[0];
375
+ if (flag(rest, '--none')) { world.setTwinRoot(vendor, null); out(`${vendor} no root`); return; }
376
+ if (!url) throw new Error(`volter twin ${vendor} root <url> [--deploy auto|gated|hold] [--scope <path>] [--refresh <every>] [--at-most <span>]`);
377
+ const deploy = (value(rest, '--deploy') ?? 'gated') as 'auto' | 'gated' | 'hold';
378
+ if (!['auto', 'gated', 'hold'].includes(deploy)) throw new Error('--deploy must be auto, gated or hold');
379
+ const refresh = { ...(value(rest, '--refresh') ? { every: value(rest, '--refresh')! } : {}), ...(value(rest, '--at-most') ? { atMost: value(rest, '--at-most')! } : {}) };
380
+ world.setTwinRoot(vendor, { url, deploy, ...(value(rest, '--scope') ? { scope: value(rest, '--scope')! } : {}), ...(Object.keys(refresh).length ? { refresh } : {}) });
381
+ } else if (verb === 'credential') {
382
+ const input = stdinText();
383
+ if (input.trim() === '') throw new Error(`volter twin ${vendor} credential: read the credential from stdin (printf '<token>' | volter twin ${vendor} credential)`);
384
+ await world.sealTwinCredential(vendor, input);
385
+ } else if (verb === 'refresh') {
386
+ const r = await world.refreshTwin(vendor, { force: flag(rest, '--force') });
387
+ 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}`);
388
+ return;
389
+ }
390
+ const t = world.twin(vendor);
391
+ if (flag(rest, '--json')) { json(t); return; }
392
+ 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}` : ''}`);
393
+ return;
394
+ }
395
+ const dir = resolveCatalog(process.cwd()).packDir(vendor);
396
+ if (dir === undefined) throw new Error(`No twin "${vendor}" here — \`bun add -d @volter/twin-${vendor}\`, or run from a checkout that has it`);
397
+ if (rest.length === 0 || rest[0]!.startsWith('-')) throw new Error(`volter twin ${vendor}: which verb? serve | mirror | conformance`);
398
+ const cli = packCli(dir); if (cli === undefined) throw new Error(`No cli for twin "${vendor}" (its manifest names no bin)`);
399
+ process.exitCode = await passthrough([process.execPath, cli, ...rest]);
400
+ }
401
+
402
+ /** The remote's own CLI entry: the installed package, or this checkout's. */
403
+ function remoteCli(): string {
404
+ const installed = findInstalledPackage(process.cwd(), '@volter/world-host');
405
+ if (installed !== undefined) { const cli = packCli(installed); if (cli !== undefined) return cli; }
406
+ const here = resolve(import.meta.dir, '..', '..', 'world-host', 'src', 'cli.ts');
407
+ if (existsSync(here)) return here;
408
+ throw new Error('volter remote: the hosting product (@volter/world-host) is not installed — `bun add -d @volter/world-host`');
409
+ }
410
+
411
+ /** `volter login <platform url> --token <personal token>`: the CLI signed into the hosted platform as you — the
412
+ * token is a personal access token from the console (shown once there), kept in your config directory. */
413
+ async function loginCommand(args: string[]): Promise<void> {
414
+ const url = positionals(args, VALUE_FLAGS)[0]; const token = value(args, '--token');
415
+ if (!url || !token) throw new Error('volter login <platform url> --token <personal token> (make one in the console under "You")');
416
+ const origin = url.replace(/\/+$/, '');
417
+ const res = await fetch(`${origin}/-/orgs`, { headers: { authorization: `Bearer ${token}` } });
418
+ const body = (await res.json().catch(() => ({}))) as { person?: { email?: string; id?: string }; orgs?: Array<{ slug: string | null; name: string }>; error?: string };
419
+ if (!res.ok) throw new Error(`${origin} answered ${res.status}: ${body.error ?? 'not signed in'}`);
420
+ storeToken(origin, token); storePlatform(origin);
421
+ 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 volter remote add origin <org>/<world>`);
422
+ }
423
+
424
+ async function remoteCommand(verb: string, args: string[]): Promise<void> {
425
+ if (verb === 'add' || verb === 'remove' || verb === 'list') {
426
+ const world = World.open({ ...(value(args, '--world') ? { root: value(args, '--world') } : {}) });
427
+ const pos = positionals(args, VALUE_FLAGS);
428
+ if (verb === 'add') {
429
+ const [name, target] = pos;
430
+ if (!name || !target) throw new Error('volter remote add <name> <url|path|org/world> [--token <token>]');
431
+ // `org/world`: a world the hosted platform serves for your org — its address and token come through the platform, never by hand
432
+ if (/^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/.test(target) && !existsSync(target)) {
433
+ const platform = platformOrigin(); if (!platform) throw new Error(`${target} looks like a hosted world (org/world) — \`volter login <platform url> --token <personal token>\` first`);
434
+ const personal = tokenFor(platform); if (!personal) throw new Error(`no personal token stored for ${platform} — \`volter login\` again`);
435
+ const res = await fetch(`${platform}/-/worlds/${target}/token`, { headers: { authorization: `Bearer ${personal}` } });
436
+ const body = (await res.json().catch(() => ({}))) as { base?: string; token?: string; error?: string };
437
+ if (!res.ok || !body.base || !body.token) throw new Error(`the platform answered ${res.status}: ${body.error ?? 'no such world in your orgs'}`);
438
+ world.addRemote(name, body.base, { token: body.token });
439
+ out(`remote ${name} ${body.base} (through ${platform})`);
440
+ return;
441
+ }
442
+ world.addRemote(name, target, { ...(value(args, '--token') ? { token: value(args, '--token') } : {}) });
443
+ out(`remote ${name} ${target}`);
444
+ return;
445
+ }
446
+ if (verb === 'remove') { if (!pos[0]) throw new Error('volter remote remove <name>'); world.removeRemote(pos[0]); out(`removed ${pos[0]}`); return; }
447
+ const remotes = world.remotes();
448
+ if (flag(args, '--json')) { json(remotes); return; }
449
+ if (Object.keys(remotes).length === 0) { out('(no remotes)'); return; }
450
+ for (const [name, target] of Object.entries(remotes)) out(`${name.padEnd(10)} ${target}${tokenFor(parseOriginUrl(target).url) ? ' (token stored)' : ''}`);
451
+ return;
452
+ }
453
+ if (verb === 'serve') { process.exitCode = await passthrough([process.execPath, remoteCli(), 'serve', ...args]); return; }
454
+ throw new Error(`volter remote: unknown verb "${verb}"\n\n${HELP}`);
455
+ }
456
+
457
+ async function main(): Promise<void> {
458
+ const [noun, verb, ...args] = process.argv.slice(2);
459
+ if (!noun || noun === '--help' || noun === '-h' || noun === 'help') { out(HELP); return; }
460
+ if (verb === '--help' || verb === '-h') { out(HELP); return; }
461
+ switch (noun) {
462
+ case 'world': {
463
+ if (!verb) throw new Error(`volter world: which verb?\n\n${HELP}`);
464
+ await worldCommand(verb, args);
465
+ return;
466
+ }
467
+ case 'login': await loginCommand([verb, ...args].filter((a): a is string => a !== undefined)); return;
468
+ case 'twin': await twinCommand([verb, ...args].filter((a): a is string => a !== undefined)); return;
469
+ case 'remote': {
470
+ if (!verb) throw new Error('volter remote: which verb? add | remove | list | serve');
471
+ await remoteCommand(verb, args);
472
+ return;
473
+ }
474
+ default:
475
+ throw new Error(`volter: unknown noun "${noun}" — world, twin or remote\n\n${HELP}`);
476
+ }
477
+ }
478
+
479
+ const isEntryPoint = import.meta.main ?? (process.argv[1] !== undefined && existsSync(process.argv[1])
480
+ && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url));
481
+ if (isEntryPoint) {
482
+ main().catch((error: unknown) => {
483
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
484
+ process.exit(process.exitCode && process.exitCode !== 0 ? Number(process.exitCode) : 1);
485
+ });
486
+ }
@@ -0,0 +1,56 @@
1
+ // Tokens, not keys (docs/concepts/worlds.md#the-config-and-the-running-world): the remote's read and admin
2
+ // credentials are TOKENS, kept in the user's config directory keyed by origin URL — written by
3
+ // `clone --token`, read by `fetch` and `push`. Nothing in the repo and nothing in the world holds
4
+ // one; "key" means only the vendor's real API key, which lives in the remote's custody.
5
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { dirname, join } from 'node:path';
8
+
9
+ type Store = Record<string, { token: string; savedAt: string }>;
10
+
11
+ /** `$XDG_CONFIG_HOME/volter/credentials.json`, else `~/.config/volter/credentials.json`. */
12
+ export function credentialsPath(): string {
13
+ const base = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME !== '' ? process.env.XDG_CONFIG_HOME : join(homedir(), '.config');
14
+ return join(base, 'volter', 'credentials.json');
15
+ }
16
+
17
+ function normalizeOrigin(url: string): string { return url.replace(/\/+$/, ''); }
18
+
19
+ function readStore(path: string): Store {
20
+ if (!existsSync(path)) return {};
21
+ try { return JSON.parse(readFileSync(path, 'utf8')) as Store; } catch { throw new Error(`${path} is not readable as JSON — fix or delete it`); }
22
+ }
23
+
24
+ /** The token stored for an origin, or undefined. */
25
+ export function tokenFor(origin: string, path: string = credentialsPath()): string | undefined {
26
+ return readStore(path)[normalizeOrigin(origin)]?.token;
27
+ }
28
+
29
+ /** Store a token for an origin (0600). */
30
+ export function storeToken(origin: string, token: string, path: string = credentialsPath()): void {
31
+ if (token === '') throw new Error('an empty token cannot be stored');
32
+ const store = readStore(path);
33
+ store[normalizeOrigin(origin)] = { token, savedAt: new Date().toISOString() };
34
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
35
+ writeFileSync(path, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
36
+ chmodSync(path, 0o600);
37
+ }
38
+
39
+ /** `volter login`: the hosted platform a person signed the CLI into — its origin, beside the credentials. */
40
+ export function platformPath(): string { return join(dirname(credentialsPath()), 'platform.json'); }
41
+ export function storePlatform(origin: string, path: string = platformPath()): void {
42
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
43
+ writeFileSync(path, `${JSON.stringify({ url: normalizeOrigin(origin), savedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
44
+ }
45
+ export function platformOrigin(path: string = platformPath()): string | undefined {
46
+ if (!existsSync(path)) return undefined;
47
+ try { return (JSON.parse(readFileSync(path, 'utf8')) as { url?: string }).url; } catch { return undefined; }
48
+ }
49
+ /** The token a verb uses: an explicit one for this call (CI), else the stored one, else a clear error. */
50
+ export function requireToken(origin: string, explicit?: string, path: string = credentialsPath()): string {
51
+ if (explicit !== undefined && explicit !== '') return explicit;
52
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(origin)) return ''; // a world at a path needs no token
53
+ const stored = tokenFor(origin, path);
54
+ if (stored === undefined) throw new Error(`No token stored for ${normalizeOrigin(origin)} — \`volter world clone <url> --token <token>\` stores one, or pass --token for this call`);
55
+ return stored;
56
+ }
@@ -0,0 +1,45 @@
1
+ // A world in the app repo carries its handlers as ROOT-RELATIVE paths (docs/concepts/worlds.md
2
+ // #the-config-and-the-running-world): init copies a twin's starter handlers to .volter/handlers/<vendor>.json and
3
+ // names that path relative to the world root, so the committed config reads the same on every
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, which ships starter handlers.
6
+ import { afterAll, describe, expect, test } from 'bun:test';
7
+ import { mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join, resolve } from 'node:path';
10
+ import { World } from './index.ts';
11
+
12
+ const PACKS = resolve(import.meta.dir, '..', '..', 'twin');
13
+
14
+ function app(): string {
15
+ const parent = mkdtempSync(join(tmpdir(), 'handlers-in-repo-'));
16
+ const dir = join(parent, 'agent');
17
+ mkdirSync(dir);
18
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'agent', dependencies: { '@anthropic-ai/sdk': '^0.30' } }));
19
+ writeFileSync(join(dir, '.env.example'), 'ANTHROPIC_API_KEY=\n');
20
+ mkdirSync(join(parent, 'node_modules', '@volter'), { recursive: true });
21
+ symlinkSync(join(PACKS, 'anthropic'), join(parent, 'node_modules', '@volter', 'twin-anthropic'));
22
+ return dir;
23
+ }
24
+
25
+ const root = app();
26
+ const world = World.init(root).world;
27
+ afterAll(async () => { await world.down().catch(() => undefined); });
28
+
29
+ describe('handlers in the app repo', () => {
30
+ test('init copies the starter handlers and names them relative to the root', () => {
31
+ expect(readFileSync(join(root, '.volter', 'handlers', 'anthropic.json'), 'utf8')).toContain('"handlers"');
32
+ const config = JSON.parse(readFileSync(join(root, '.volter', 'world.json'), 'utf8')) as { schemaVersion: number; services: Array<{ id: string; execution?: { process?: { args?: string[] }; colocate?: { scenarioPath?: string } } }> };
33
+ expect(config.schemaVersion).toBe(2);
34
+ const anthropic = config.services.find((s) => s.id === 'anthropic')!;
35
+ expect(anthropic.execution?.process?.args).toEqual(['--scenario', '.volter/handlers/anthropic.json']);
36
+ expect(anthropic.execution?.colocate?.scenarioPath).toBe('.volter/handlers/anthropic.json');
37
+ });
38
+
39
+ test('up resolves them against the root, and the twin serves them', async () => {
40
+ const instance = await world.up({ seed: false });
41
+ const url = instance.services.anthropic!.url!;
42
+ const scenario = await fetch(`${url}/twin/scenario`).then((r) => r.json()) as { handlers?: Array<{ id: string }> };
43
+ expect((scenario.handlers ?? []).map((h) => h.id)).toContain('demo-answer');
44
+ });
45
+ });
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ // @volter/world — the product surface (docs/concepts/the-model.md, docs/concepts/worlds.md#the-config-and-the-running-world):
2
+ // World and Repo, one method per verb in the user's words, over core and the kernel, with no
3
+ // mechanism of its own. The `volter` command is one client of this package.
4
+ export { World, parseOriginUrl, worldNameFor, type LogEntry, type WorldMode, type WorldRef, type WorldStatus } from './world.ts';
5
+ export { findWorldRoot, requireWorldRoot, currentBranch, setCurrentBranch, mainBranch, worldConfigPath, worldEnvPath, worldSeedPath, WORLD_FILE } from './locate.ts';
6
+ export { credentialsPath, requireToken, storeToken, tokenFor } from './credentials.ts';
@@ -0,0 +1,12 @@
1
+ // What the tutorial runner and the docs' recorder share: where the commands and the twins are.
2
+ // A tutorial page is its own journey (tutorial.ts); nothing here runs a step of its own.
3
+ import { resolve } from 'node:path';
4
+
5
+ /** The `volter` command. */
6
+ export const CLI = resolve(import.meta.dir, '..', 'cli.ts');
7
+ /** The operator's `volter-world` command, which ships with @volter/world-runtime. */
8
+ export const OPERATOR_CLI = resolve(import.meta.dir, '..', '..', '..', 'world-runtime', 'src', 'cli.ts');
9
+ /** The twins, as this checkout holds them. */
10
+ export const PACKS = resolve(import.meta.dir, '..', '..', '..', 'twin');
11
+ /** @volter/world itself. */
12
+ export const SDK = resolve(import.meta.dir, '..', '..');