@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/README.md +27 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +669 -0
- package/dist/src/credentials.d.ts +12 -0
- package/dist/src/credentials.js +64 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +6 -0
- package/dist/src/locate.d.ts +18 -0
- package/dist/src/locate.js +60 -0
- package/dist/src/serve-shutdown.d.ts +6 -0
- package/dist/src/serve-shutdown.js +27 -0
- package/dist/src/world.d.ts +299 -0
- package/dist/src/world.js +491 -0
- package/package.json +37 -0
- package/src/cli.ts +486 -0
- package/src/credentials.ts +56 -0
- package/src/handlers-in-repo.test.ts +45 -0
- package/src/index.ts +6 -0
- package/src/journeys/kit.ts +12 -0
- package/src/journeys/tutorial-runner.test.ts +180 -0
- package/src/journeys/tutorial.ts +345 -0
- package/src/journeys/tutorials.test.ts +39 -0
- package/src/locate.ts +61 -0
- package/src/sdk.test.ts +92 -0
- package/src/serve-shutdown.test.ts +40 -0
- package/src/serve-shutdown.ts +26 -0
- package/src/world.ts +448 -0
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
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 { existsSync } from 'node:fs';
|
|
11
|
+
import { relative, resolve } from 'node:path';
|
|
12
|
+
import { platformOrigin, storePlatform, storeToken, tokenFor } from "./credentials.js";
|
|
13
|
+
import { parseOriginUrl, World } from "./world.js";
|
|
14
|
+
import { waitForServeShutdown } from "./serve-shutdown.js";
|
|
15
|
+
export const HELP = `volter — run your app against a world of twins
|
|
16
|
+
|
|
17
|
+
world — the twins your app needs, running together (the world of the current directory)
|
|
18
|
+
volter world init [--name <world>] [--allow-unknown] detect the app's vendors, write .volter/world.json
|
|
19
|
+
volter world up [--sandbox] [--no-seed] start the twins on this branch (loads the default data the first time)
|
|
20
|
+
volter world run [--verbose] -- <command...> run the app or its tests inside the world
|
|
21
|
+
volter world activate eval "$(volter world activate)": vendor CLIs and curl in this shell reach the twins
|
|
22
|
+
volter world shell a subshell with the world active
|
|
23
|
+
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
|
|
24
|
+
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)
|
|
25
|
+
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
|
|
26
|
+
volter world status world, branch, origin, unpushed changes, what is running
|
|
27
|
+
volter world log [--receipts] [--json] every write the app made; --receipts adds each change's receipt
|
|
28
|
+
volter world diff [--json] what changed since the default data (or the last mark)
|
|
29
|
+
volter world seed load the default data
|
|
30
|
+
volter world reset back to the default data
|
|
31
|
+
volter world down [--purge] stop the twins (--purge forgets this branch's state)
|
|
32
|
+
volter world branch [<name>] list branches, or make one from here and check it out
|
|
33
|
+
volter world checkout <name> switch branches
|
|
34
|
+
volter world replay <changeset> --into <branch> feed a changeset's changes into another branch's twins
|
|
35
|
+
volter world clock show | set <iso> | advance <N s|m|h|d> the world's clock: set, not observed
|
|
36
|
+
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)
|
|
37
|
+
volter world fetch what the remote observed since the last fetch
|
|
38
|
+
volter world origin where this world clones from and pushes to
|
|
39
|
+
volter world changeset [<name>] -m "<message>" cut the unpushed changes into a reviewable changeset
|
|
40
|
+
volter world pull [--token <token>] fetch, then move this branch onto what came in (git's pull)
|
|
41
|
+
volter world push [<name>] [--token <token>] [--force] push to the remote, which deploys and answers with receipts
|
|
42
|
+
volter world rebase [<changeset>] rebase this branch onto its moved base, or a changeset onto the moved mirror
|
|
43
|
+
volter world verify <changeset> run the world's checks over a changeset and record the result
|
|
44
|
+
volter world approve <changeset> --as <principal> sign a changeset's current hash
|
|
45
|
+
volter world deploy [<changeset>] perform landed changes against each twin's root, by its policy
|
|
46
|
+
|
|
47
|
+
twin — one twin: in this world, or on its own
|
|
48
|
+
volter twin <vendor> the twin's URL, root, deploy policy, whether a credential is sealed
|
|
49
|
+
volter twin <vendor> root <url> [--deploy auto|gated|hold] [--scope <path>] the vendor's real account behind this twin (--none clears it)
|
|
50
|
+
volter twin <vendor> credential seal a credential read from stdin beside the world; never readable back
|
|
51
|
+
volter twin <vendor> refresh observe the root now
|
|
52
|
+
volter twin <vendor> serve [--port <p>] [--read-only] serve the twin; your SDK talks to it at http://127.0.0.1:<p>
|
|
53
|
+
volter twin <vendor> mirror [--port <p>] the twin's UI mirror
|
|
54
|
+
volter twin <vendor> conformance check the twin against the vendor's spec
|
|
55
|
+
|
|
56
|
+
remote — the worlds this one pushes to and fetches from, by name
|
|
57
|
+
volter login <platform url> --token <personal token> sign the CLI into the hosted platform (a personal token from the console)
|
|
58
|
+
volter remote add <name> <url|path|org/world> [--token <token>] name a remote; org/world resolves through the platform you logged into
|
|
59
|
+
volter remote remove <name> forget it
|
|
60
|
+
volter remote list every remote, with its URL and whether a token is stored
|
|
61
|
+
(the hosting product's serve answers here for one release: volter remote serve)
|
|
62
|
+
|
|
63
|
+
--world <dir> the world root, when the cwd is not inside it
|
|
64
|
+
--branch <name> act on that branch instead of the checked-out one
|
|
65
|
+
--json machine-readable output where offered
|
|
66
|
+
`;
|
|
67
|
+
function flag(args, name) { return args.includes(name); }
|
|
68
|
+
function value(args, name) {
|
|
69
|
+
const i = args.indexOf(name);
|
|
70
|
+
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
|
|
71
|
+
}
|
|
72
|
+
function positionals(args, valueFlags) {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
75
|
+
const a = args[i];
|
|
76
|
+
if (a === '--')
|
|
77
|
+
break;
|
|
78
|
+
if (a.startsWith('-')) {
|
|
79
|
+
if (valueFlags.includes(a))
|
|
80
|
+
i += 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
out.push(a);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
function afterDashes(args) { const i = args.indexOf('--'); return i < 0 ? [] : args.slice(i + 1); }
|
|
88
|
+
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'];
|
|
89
|
+
/** 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. */
|
|
90
|
+
function subjectLabel(subject) {
|
|
91
|
+
const id = subject.id.replace(new RegExp(`(^|[#/:])${subject.type}:`), '$1');
|
|
92
|
+
return /[#/]/.test(id) ? id : `${subject.type}:${id}`;
|
|
93
|
+
}
|
|
94
|
+
function out(text) { process.stdout.write(text.endsWith('\n') ? text : `${text}\n`); }
|
|
95
|
+
function json(value) { out(JSON.stringify(value, null, 2)); }
|
|
96
|
+
function when(iso) { return iso.slice(11, 23); }
|
|
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, cwd = process.cwd()) {
|
|
99
|
+
const child = spawn(cmd[0], cmd.slice(1), { cwd, stdio: 'inherit' });
|
|
100
|
+
const forward = (signal) => () => { try {
|
|
101
|
+
child.kill(signal);
|
|
102
|
+
}
|
|
103
|
+
catch { /* already gone */ } };
|
|
104
|
+
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'])
|
|
105
|
+
process.on(signal, forward(signal));
|
|
106
|
+
return await new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', (code, signal) => resolve(code ?? (signal ? 128 : 1))); });
|
|
107
|
+
}
|
|
108
|
+
/** All of stdin, as text: a synchronous read of fd 0 to EOF — the same on Bun and Node, and it
|
|
109
|
+
* holds the process (an async iteration of `process.stdin` here let Bun exit 0 mid-read, the
|
|
110
|
+
* credential unsealed and nothing said — found by the deploy-from-a-shared-world page). */
|
|
111
|
+
function stdinText() { try {
|
|
112
|
+
return readFileSync(0, 'utf8');
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return '';
|
|
116
|
+
} }
|
|
117
|
+
async function worldCommand(verb, args) {
|
|
118
|
+
const worldDir = value(args, '--world');
|
|
119
|
+
const pos = positionals(args, VALUE_FLAGS);
|
|
120
|
+
const asJson = flag(args, '--json');
|
|
121
|
+
if (verb === 'init' && flag(args, '--bare')) {
|
|
122
|
+
const served = positionals(args, VALUE_FLAGS)[0];
|
|
123
|
+
if (!served)
|
|
124
|
+
throw new Error('volter world init --bare <org>/<world> --twins a,b');
|
|
125
|
+
const twins = (value(args, '--twins') ?? '').split(',').map((t) => t.trim()).filter(Boolean);
|
|
126
|
+
const { result } = World.initBare(resolve(worldDir ?? process.cwd()), served, twins, { force: flag(args, '--force') });
|
|
127
|
+
if (asJson)
|
|
128
|
+
json({ ok: result.ok, plan: result.plan });
|
|
129
|
+
else
|
|
130
|
+
out(`bare ${served}\ntwins ${twins.join(', ')}\nwrote .volter/world.json`);
|
|
131
|
+
if (!result.ok)
|
|
132
|
+
process.exitCode = 1;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (verb === 'init') {
|
|
136
|
+
const app = resolve(worldDir ?? process.cwd());
|
|
137
|
+
const { result } = World.init(app, { ...(value(args, '--name') ? { name: value(args, '--name') } : {}), force: flag(args, '--force'), allowUnknown: flag(args, '--allow-unknown') });
|
|
138
|
+
if (asJson)
|
|
139
|
+
json({ ok: result.ok, plan: result.plan, coverage: result.coverage, next: result.next });
|
|
140
|
+
else {
|
|
141
|
+
const twins = result.plan.vendors.filter((v) => v.service !== null).map((v) => v.vendor).sort();
|
|
142
|
+
out(`twins ${twins.join(', ') || '(none)'}\nwrote ${relative(process.cwd(), result.plan.configPath) || result.plan.configPath}`);
|
|
143
|
+
out(formatInitReport(result));
|
|
144
|
+
}
|
|
145
|
+
if (!result.ok)
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (verb === 'console') {
|
|
150
|
+
// the console on this machine for a World served elsewhere: the given URL (<origin>/<org>/<world>), else
|
|
151
|
+
// this world's origin; a URL needs no world here
|
|
152
|
+
const given = positionals(args, VALUE_FLAGS)[0];
|
|
153
|
+
const origin = given ? null : World.open({ ...(worldDir ? { root: worldDir } : {}) }).origin();
|
|
154
|
+
const target = given ?? (origin && /^https?:/.test(origin.url) ? `${origin.url.replace(/\/+$/, '')}/${origin.namespace ?? ''}` : null);
|
|
155
|
+
if (!target)
|
|
156
|
+
throw new Error('volter world console <https://host/org/world>: this world has no http(s) origin to open');
|
|
157
|
+
const port = value(args, '--port');
|
|
158
|
+
if (port !== undefined && !/^\d{1,5}$/.test(port))
|
|
159
|
+
throw new Error(`volter world console: --port ${port} is not a port`);
|
|
160
|
+
const served = await serveConsoleFor(target, { ...(port !== undefined ? { port: Number(port) } : {}) });
|
|
161
|
+
out(`console ${served.url}/-/console/\nworlds ${served.worlds} (forwarding to ${new URL(target).origin})\nOpen it and type the World's token.`);
|
|
162
|
+
await waitForServeShutdown(async () => { for (const s of served.servers)
|
|
163
|
+
await s.stop(); });
|
|
164
|
+
process.exit(0);
|
|
165
|
+
}
|
|
166
|
+
const world = World.open({ ...(worldDir ? { root: worldDir } : {}), ...(value(args, '--branch') ? { name: value(args, '--branch') } : {}) });
|
|
167
|
+
switch (verb) {
|
|
168
|
+
case 'up': {
|
|
169
|
+
const instance = await world.up({ ...(flag(args, '--sandbox') ? { mode: 'sealed' } : {}), seed: !flag(args, '--no-seed') });
|
|
170
|
+
if (asJson) {
|
|
171
|
+
json(world.status());
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const twins = Object.values(instance.services).filter((s) => s.type === 'twin' || s.type === undefined);
|
|
175
|
+
out(`${world.name} ${twins.length} twin${twins.length === 1 ? '' : 's'} up${flag(args, '--no-seed') ? '' : ', story loaded'}`);
|
|
176
|
+
for (const s of Object.values(instance.services))
|
|
177
|
+
out(` ${s.id}: ${s.url ?? '(no listener)'}`);
|
|
178
|
+
out(`\nRun your app inside it: volter world run -- <command>\nEnv: ${relative(process.cwd(), instance.envFile) || instance.envFile}`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
case 'down': {
|
|
182
|
+
const result = await world.down({ purge: flag(args, '--purge') });
|
|
183
|
+
out(`Stopped ${result.name}${result.stopped.length ? `: ${result.stopped.join(', ')}` : ''}${result.purged ? ' (state forgotten)' : ''}`);
|
|
184
|
+
if (result.externalErrors.length) {
|
|
185
|
+
for (const m of result.externalErrors)
|
|
186
|
+
process.stderr.write(`${m}\n`);
|
|
187
|
+
process.exitCode = 1;
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
case 'activate': {
|
|
192
|
+
process.stdout.write(world.activateScript());
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
case 'shell': {
|
|
196
|
+
process.exitCode = await world.shell();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
case 'run': {
|
|
200
|
+
const command = afterDashes(args);
|
|
201
|
+
if (command.length === 0)
|
|
202
|
+
throw new Error('volter world run: give the command after -- (volter world run -- npm test)');
|
|
203
|
+
process.exitCode = await world.run(command, { verbose: flag(args.slice(0, args.indexOf('--')), '--verbose') });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
case 'status': {
|
|
207
|
+
const s = world.status();
|
|
208
|
+
if (asJson) {
|
|
209
|
+
json(s);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
out(`World ${s.world} on branch ${s.branch}: ${s.running ? 'running' : 'stopped'}`);
|
|
213
|
+
if (s.served)
|
|
214
|
+
out(` served: ${s.served.base} (pid ${s.served.pid}, since ${s.served.startedAt})`);
|
|
215
|
+
out(` origin: ${s.origin ? `${s.origin.url}/${s.origin.namespace}${s.origin.fetchedAt ? ` (fetched ${s.origin.fetchedAt})` : ''}` : 'default data (no remote)'}`);
|
|
216
|
+
out(` unpushed: ${s.unpushed} change${s.unpushed === 1 ? '' : 's'}; changesets: ${s.changesets.pushed}/${s.changesets.total} pushed`);
|
|
217
|
+
if (s.branches.length > 1)
|
|
218
|
+
out(` branches: ${s.branches.map((b) => (b === s.branch ? `*${b}` : b)).join(' ')}`);
|
|
219
|
+
for (const [id, svc] of Object.entries(s.services))
|
|
220
|
+
out(` ${id}: ${svc.url ?? '(no listener)'} (${svc.running ? 'running' : 'stopped'})${svc.protocol ? ` protocol ${svc.protocol.major}${svc.protocol.standing === 'current' ? '' : ` (${svc.protocol.standing})`}` : ''}`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
case 'log': {
|
|
224
|
+
const rows = world.log();
|
|
225
|
+
if (asJson) {
|
|
226
|
+
json(rows);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (rows.length === 0) {
|
|
230
|
+
out('(no changes yet)');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const withReceipts = flag(args, '--receipts');
|
|
234
|
+
for (const r of rows) {
|
|
235
|
+
// an observed entry is the vendor's history on the root's log: neither pushed nor pushable
|
|
236
|
+
const v2 = r.operation === 'observed' ? 'observed' : r.landed ? `${r.landed.status}${r.landed.externalId ? ` ${r.landed.externalId}` : ''}${r.landed.reason ? ` ${r.landed.reason}` : ''}` : '';
|
|
237
|
+
const v1 = r.receipt ? `${r.receipt.status}${r.receipt.externalId ? ` ${r.receipt.externalId}` : ''}${r.changeset ? ` (${r.changeset})` : ''}` : '';
|
|
238
|
+
const receipt = withReceipts ? ` ${v2 || v1 || 'unpushed'}` : (v1 ? ` → ${v1}` : '');
|
|
239
|
+
out(`${r.service.padEnd(8)} ${(r.operation ?? r.op).padEnd(22)} ${subjectLabel(r.subject)}${receipt}`);
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
case 'diff': {
|
|
244
|
+
const delta = world.diff(value(args, '--base'));
|
|
245
|
+
if (asJson) {
|
|
246
|
+
json(delta);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const changes = delta.actions.filter((a) => !a.action.subject.type.startsWith('_'));
|
|
250
|
+
out(`${changes.length} change${changes.length === 1 ? '' : 's'} since ${value(args, '--base') ? delta.base.id : world.diffBase()}`);
|
|
251
|
+
for (const v of delta.vendors)
|
|
252
|
+
out(` ${v.service}: ${v.count} change${v.count === 1 ? '' : 's'}`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
case 'seed': {
|
|
256
|
+
const r = await world.seed();
|
|
257
|
+
out(`Loaded the default data into ${r.world}: ${Object.entries(r.observed).map(([s, n]) => `${s} ${n}`).join(', ') || 'nothing recorded'}`);
|
|
258
|
+
if (r.exitCode !== 0)
|
|
259
|
+
process.exitCode = r.exitCode;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
case 'reset': {
|
|
263
|
+
const r = await world.reset();
|
|
264
|
+
out(`Back to the default data on ${r.world}: ${Object.entries(r.observed).map(([s, n]) => `${s} ${n}`).join(', ') || 'nothing recorded'}`);
|
|
265
|
+
if (r.exitCode !== 0)
|
|
266
|
+
process.exitCode = r.exitCode;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
case 'branch': {
|
|
270
|
+
const name = pos[0];
|
|
271
|
+
if (!name) {
|
|
272
|
+
for (const b of world.branches())
|
|
273
|
+
out(`${b === world.name ? '* ' : ' '}${b}`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const atText = value(args, '--at');
|
|
277
|
+
const at = atText === undefined ? undefined : /^\d{4}-\d\d-\d\d/.test(atText) ? { instant: atText } : (() => {
|
|
278
|
+
const positions = {};
|
|
279
|
+
const views = {};
|
|
280
|
+
for (const p of atText.split(',')) {
|
|
281
|
+
const match = /^([^@]+)@(?:([a-f0-9]{64}):)?(\d+)$/.exec(p);
|
|
282
|
+
if (!match)
|
|
283
|
+
throw new Error('--at takes an instant, twin@position, or twin@view:position');
|
|
284
|
+
positions[match[1]] = Number(match[3]);
|
|
285
|
+
if (match[2])
|
|
286
|
+
views[match[1]] = match[2];
|
|
287
|
+
}
|
|
288
|
+
return { positions, views };
|
|
289
|
+
})();
|
|
290
|
+
const made = await world.branch(name, at ? { at } : {});
|
|
291
|
+
out(`branch ${made.name} from ${world.isMain() ? 'main' : world.name}${at ? ` at ${atText}` : ''}`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
case 'checkout': {
|
|
295
|
+
const name = pos[0];
|
|
296
|
+
if (!name)
|
|
297
|
+
throw new Error('volter world checkout: which branch?');
|
|
298
|
+
const to = await world.checkout(name);
|
|
299
|
+
out(`Switched to branch ${to.name}`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
case 'clock': {
|
|
303
|
+
const [action, arg] = pos;
|
|
304
|
+
if (action === 'set') {
|
|
305
|
+
if (!arg)
|
|
306
|
+
throw new Error('volter world clock set <iso-8601>');
|
|
307
|
+
out(world.setClock(arg));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (action === 'advance') {
|
|
311
|
+
if (!arg)
|
|
312
|
+
throw new Error('volter world clock advance <N s|m|h|d>');
|
|
313
|
+
out(world.advanceClock(arg));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (action === undefined || action === 'show') {
|
|
317
|
+
const c = world.clock();
|
|
318
|
+
out(`${c.at}${c.frozen ? '' : ' (wall clock — not set)'}`);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
throw new Error('volter world clock show | set <iso> | advance <N s|m|h|d>');
|
|
322
|
+
}
|
|
323
|
+
case 'replay': {
|
|
324
|
+
const name = pos[0];
|
|
325
|
+
const into = value(args, '--into');
|
|
326
|
+
if (!name || !into)
|
|
327
|
+
throw new Error('volter world replay <changeset> --into <branch>');
|
|
328
|
+
const r = world.replay(name, into);
|
|
329
|
+
if (asJson) {
|
|
330
|
+
json(r);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
out(`Replayed ${name} into ${into}`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
case 'origin': {
|
|
337
|
+
const o = world.origin();
|
|
338
|
+
if (asJson) {
|
|
339
|
+
json(o);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
out(o ? `origin ${o.url}/${o.namespace}` : 'origin default data');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
case 'clone': {
|
|
346
|
+
if (!pos[0])
|
|
347
|
+
throw new Error('volter world clone <url> --token <token>');
|
|
348
|
+
const outcome = await world.clone(pos[0], { ...(value(args, '--token') ? { token: value(args, '--token') } : {}), ...(value(args, '--admin-token') ? { adminToken: value(args, '--admin-token') } : {}) });
|
|
349
|
+
if (asJson) {
|
|
350
|
+
json(outcome);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const n = Object.values(outcome.appended).reduce((a, b) => a + b, 0);
|
|
354
|
+
out(`cloned ${outcome.origin.url}/${outcome.origin.namespace} ${n} change${n === 1 ? '' : 's'}`);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
case 'fetch': {
|
|
358
|
+
const outcome = await world.fetch({ ...(value(args, '--token') ? { token: value(args, '--token') } : {}) });
|
|
359
|
+
if (asJson) {
|
|
360
|
+
json(outcome);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const n = Object.values(outcome.appended).reduce((a, b) => a + b, 0);
|
|
364
|
+
const behind = Object.values(outcome.behind ?? {}).reduce((a, b) => a + b, 0);
|
|
365
|
+
out(`fetched origin ${n} change${n === 1 ? '' : 's'}${behind ? ` (${behind} past this branch's position — \`volter world rebase\` folds them)` : ''}`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
case 'pull': {
|
|
369
|
+
const r = await world.pull({ ...(value(args, '--token') ? { token: value(args, '--token') } : {}) });
|
|
370
|
+
if (asJson) {
|
|
371
|
+
json(r);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const n = Object.values(r.fetched.appended).reduce((a, b) => a + b, 0);
|
|
375
|
+
const conflicts = r.rebased.flatMap((x) => x.conflicts.map((c) => `${x.service} ${c.subject.type}:${c.subject.id} ${c.field} ${c.op}`));
|
|
376
|
+
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 ')}` : ''}`);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
case 'changeset': {
|
|
380
|
+
const message = value(args, '-m') ?? value(args, '--message');
|
|
381
|
+
if (message === undefined)
|
|
382
|
+
throw new Error('volter world changeset: say what and why — volter world changeset -m "<message>"');
|
|
383
|
+
const cs = world.changeset({ ...(pos[0] ? { name: pos[0] } : {}), message, ...(value(args, '--base') ? { base: value(args, '--base') } : {}), overwrite: flag(args, '--force') });
|
|
384
|
+
if (asJson) {
|
|
385
|
+
json(cs);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
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')}` : ''}`);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
case 'serve': {
|
|
392
|
+
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}` : ''}`) });
|
|
393
|
+
await waitForServeShutdown(() => served.stop());
|
|
394
|
+
process.exit(0); // success follows confirmed shutdown; failures reach main's error path
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
case 'rebase': {
|
|
398
|
+
const name = positionals(args, VALUE_FLAGS)[0];
|
|
399
|
+
if (name) {
|
|
400
|
+
const r = world.rebase(name);
|
|
401
|
+
if (asJson) {
|
|
402
|
+
json(r);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const conflicts = r.report.actions.flatMap((a) => (a.conflicts ?? []).map((c) => `${a.service} ${c.subject.type}:${c.subject.id} ${c.field} ${c.op}`));
|
|
406
|
+
out(`rebased ${name} ${r.report.outcome}${conflicts.length ? `\n conflicts:\n ${conflicts.join('\n ')}` : ''}`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const results = world.rebaseBranch();
|
|
410
|
+
if (asJson) {
|
|
411
|
+
json(results);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const conflicts = results.flatMap((r) => r.conflicts.map((c) => `${r.service} ${c.subject.type}:${c.subject.id} ${c.field} ${c.op}`));
|
|
415
|
+
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 ')}` : ''}`);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
case 'verify': {
|
|
419
|
+
const name = positionals(args, VALUE_FLAGS)[0];
|
|
420
|
+
if (!name)
|
|
421
|
+
throw new Error('volter world verify: which changeset?');
|
|
422
|
+
const outcome = await world.verify(name, { ephemeral: true });
|
|
423
|
+
if (asJson) {
|
|
424
|
+
json(outcome);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const failed = outcome.verification.results.filter((r) => !r.passed);
|
|
428
|
+
const refusals = outcome.verification.refusals ?? [];
|
|
429
|
+
if (refusals.length)
|
|
430
|
+
out(`refused ${name} ${refusals.map((r) => `${r.check}: ${r.reason}`).join('; ')}`);
|
|
431
|
+
else
|
|
432
|
+
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('; ')}`);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
case 'approve': {
|
|
436
|
+
const name = positionals(args, VALUE_FLAGS)[0];
|
|
437
|
+
const as = value(args, '--as');
|
|
438
|
+
if (!name || !as)
|
|
439
|
+
throw new Error('volter world approve <changeset> --as <principal>');
|
|
440
|
+
const outcome = world.approve(name, as, value(args, '--note'));
|
|
441
|
+
if (asJson) {
|
|
442
|
+
json(outcome);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
out(`approved ${name} by ${as}`);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
case 'deploy': {
|
|
449
|
+
const name = positionals(args, VALUE_FLAGS)[0];
|
|
450
|
+
const outcomes = await world.deploy(name);
|
|
451
|
+
if (asJson) {
|
|
452
|
+
json(outcomes);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (outcomes.length === 0) {
|
|
456
|
+
out('nothing to deploy');
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
for (const o of outcomes)
|
|
460
|
+
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}` : ''}`);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
case 'push': {
|
|
464
|
+
const remoteName = 'origin';
|
|
465
|
+
const outcomes = await world.push({ ...(pos[0] ? { name: pos[0] } : {}), ...(value(args, '--token') ? { token: value(args, '--token') } : {}), force: flag(args, '--force') });
|
|
466
|
+
if (asJson) {
|
|
467
|
+
json(outcomes);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (outcomes.length === 0) {
|
|
471
|
+
out('nothing to push');
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
for (const o of outcomes) {
|
|
475
|
+
const n = o.changeset.actions.length;
|
|
476
|
+
if (o.application.outcome === 'refused') {
|
|
477
|
+
out(`refused ${o.changeset.name} ${(o.application.refusal ?? []).join('; ')}`);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
out(`pushed ${o.changeset.name} ${n} change${n === 1 ? '' : 's'} → ${remoteName}`);
|
|
481
|
+
if (o.unsent)
|
|
482
|
+
process.stderr.write(`warning: ${o.unsent}\n`);
|
|
483
|
+
for (const r of o.application.receipts)
|
|
484
|
+
if (r.status === 'failed')
|
|
485
|
+
out(` ${r.service} refused ${r.error ?? ''}`);
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
default:
|
|
490
|
+
throw new Error(`volter world: unknown verb "${verb}"\n\n${HELP}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/** `volter twin <vendor> <verb…>`: the twin's own CLI (serve, mirror, conformance), found the way a world finds it. */
|
|
494
|
+
async function twinCommand(args) {
|
|
495
|
+
const [vendor, ...rest] = args;
|
|
496
|
+
if (!vendor || vendor.startsWith('-'))
|
|
497
|
+
throw new Error('volter twin: which twin? (volter twin stripe serve --port 12111)');
|
|
498
|
+
const verb = rest[0];
|
|
499
|
+
// the twin IN THIS WORLD: its root, its credential, a refresh, its status
|
|
500
|
+
if (verb === undefined || verb === 'root' || verb === 'credential' || verb === 'refresh' || verb.startsWith('-')) {
|
|
501
|
+
const world = World.open({ ...(value(rest, '--world') ? { root: value(rest, '--world') } : {}) });
|
|
502
|
+
if (verb === 'root') {
|
|
503
|
+
const url = positionals(rest.slice(1), VALUE_FLAGS)[0];
|
|
504
|
+
if (flag(rest, '--none')) {
|
|
505
|
+
world.setTwinRoot(vendor, null);
|
|
506
|
+
out(`${vendor} no root`);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (!url)
|
|
510
|
+
throw new Error(`volter twin ${vendor} root <url> [--deploy auto|gated|hold] [--scope <path>] [--refresh <every>] [--at-most <span>]`);
|
|
511
|
+
const deploy = (value(rest, '--deploy') ?? 'gated');
|
|
512
|
+
if (!['auto', 'gated', 'hold'].includes(deploy))
|
|
513
|
+
throw new Error('--deploy must be auto, gated or hold');
|
|
514
|
+
const refresh = { ...(value(rest, '--refresh') ? { every: value(rest, '--refresh') } : {}), ...(value(rest, '--at-most') ? { atMost: value(rest, '--at-most') } : {}) };
|
|
515
|
+
world.setTwinRoot(vendor, { url, deploy, ...(value(rest, '--scope') ? { scope: value(rest, '--scope') } : {}), ...(Object.keys(refresh).length ? { refresh } : {}) });
|
|
516
|
+
}
|
|
517
|
+
else if (verb === 'credential') {
|
|
518
|
+
const input = stdinText();
|
|
519
|
+
if (input.trim() === '')
|
|
520
|
+
throw new Error(`volter twin ${vendor} credential: read the credential from stdin (printf '<token>' | volter twin ${vendor} credential)`);
|
|
521
|
+
await world.sealTwinCredential(vendor, input);
|
|
522
|
+
}
|
|
523
|
+
else if (verb === 'refresh') {
|
|
524
|
+
const r = await world.refreshTwin(vendor, { force: flag(rest, '--force') });
|
|
525
|
+
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}`);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const t = world.twin(vendor);
|
|
529
|
+
if (flag(rest, '--json')) {
|
|
530
|
+
json(t);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
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}` : ''}`);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const dir = resolveCatalog(process.cwd()).packDir(vendor);
|
|
537
|
+
if (dir === undefined)
|
|
538
|
+
throw new Error(`No twin "${vendor}" here — \`bun add -d @volter/twin-${vendor}\`, or run from a checkout that has it`);
|
|
539
|
+
if (rest.length === 0 || rest[0].startsWith('-'))
|
|
540
|
+
throw new Error(`volter twin ${vendor}: which verb? serve | mirror | conformance`);
|
|
541
|
+
const cli = packCli(dir);
|
|
542
|
+
if (cli === undefined)
|
|
543
|
+
throw new Error(`No cli for twin "${vendor}" (its manifest names no bin)`);
|
|
544
|
+
process.exitCode = await passthrough([process.execPath, cli, ...rest]);
|
|
545
|
+
}
|
|
546
|
+
/** The remote's own CLI entry: the installed package, or this checkout's. */
|
|
547
|
+
function remoteCli() {
|
|
548
|
+
const installed = findInstalledPackage(process.cwd(), '@volter/world-host');
|
|
549
|
+
if (installed !== undefined) {
|
|
550
|
+
const cli = packCli(installed);
|
|
551
|
+
if (cli !== undefined)
|
|
552
|
+
return cli;
|
|
553
|
+
}
|
|
554
|
+
const here = resolve(import.meta.dir, '..', '..', 'world-host', 'src', 'cli.ts');
|
|
555
|
+
if (existsSync(here))
|
|
556
|
+
return here;
|
|
557
|
+
throw new Error('volter remote: the hosting product (@volter/world-host) is not installed — `bun add -d @volter/world-host`');
|
|
558
|
+
}
|
|
559
|
+
/** `volter login <platform url> --token <personal token>`: the CLI signed into the hosted platform as you — the
|
|
560
|
+
* token is a personal access token from the console (shown once there), kept in your config directory. */
|
|
561
|
+
async function loginCommand(args) {
|
|
562
|
+
const url = positionals(args, VALUE_FLAGS)[0];
|
|
563
|
+
const token = value(args, '--token');
|
|
564
|
+
if (!url || !token)
|
|
565
|
+
throw new Error('volter login <platform url> --token <personal token> (make one in the console under "You")');
|
|
566
|
+
const origin = url.replace(/\/+$/, '');
|
|
567
|
+
const res = await fetch(`${origin}/-/orgs`, { headers: { authorization: `Bearer ${token}` } });
|
|
568
|
+
const body = (await res.json().catch(() => ({})));
|
|
569
|
+
if (!res.ok)
|
|
570
|
+
throw new Error(`${origin} answered ${res.status}: ${body.error ?? 'not signed in'}`);
|
|
571
|
+
storeToken(origin, token);
|
|
572
|
+
storePlatform(origin);
|
|
573
|
+
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>`);
|
|
574
|
+
}
|
|
575
|
+
async function remoteCommand(verb, args) {
|
|
576
|
+
if (verb === 'add' || verb === 'remove' || verb === 'list') {
|
|
577
|
+
const world = World.open({ ...(value(args, '--world') ? { root: value(args, '--world') } : {}) });
|
|
578
|
+
const pos = positionals(args, VALUE_FLAGS);
|
|
579
|
+
if (verb === 'add') {
|
|
580
|
+
const [name, target] = pos;
|
|
581
|
+
if (!name || !target)
|
|
582
|
+
throw new Error('volter remote add <name> <url|path|org/world> [--token <token>]');
|
|
583
|
+
// `org/world`: a world the hosted platform serves for your org — its address and token come through the platform, never by hand
|
|
584
|
+
if (/^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/.test(target) && !existsSync(target)) {
|
|
585
|
+
const platform = platformOrigin();
|
|
586
|
+
if (!platform)
|
|
587
|
+
throw new Error(`${target} looks like a hosted world (org/world) — \`volter login <platform url> --token <personal token>\` first`);
|
|
588
|
+
const personal = tokenFor(platform);
|
|
589
|
+
if (!personal)
|
|
590
|
+
throw new Error(`no personal token stored for ${platform} — \`volter login\` again`);
|
|
591
|
+
const res = await fetch(`${platform}/-/worlds/${target}/token`, { headers: { authorization: `Bearer ${personal}` } });
|
|
592
|
+
const body = (await res.json().catch(() => ({})));
|
|
593
|
+
if (!res.ok || !body.base || !body.token)
|
|
594
|
+
throw new Error(`the platform answered ${res.status}: ${body.error ?? 'no such world in your orgs'}`);
|
|
595
|
+
world.addRemote(name, body.base, { token: body.token });
|
|
596
|
+
out(`remote ${name} ${body.base} (through ${platform})`);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
world.addRemote(name, target, { ...(value(args, '--token') ? { token: value(args, '--token') } : {}) });
|
|
600
|
+
out(`remote ${name} ${target}`);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (verb === 'remove') {
|
|
604
|
+
if (!pos[0])
|
|
605
|
+
throw new Error('volter remote remove <name>');
|
|
606
|
+
world.removeRemote(pos[0]);
|
|
607
|
+
out(`removed ${pos[0]}`);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
const remotes = world.remotes();
|
|
611
|
+
if (flag(args, '--json')) {
|
|
612
|
+
json(remotes);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (Object.keys(remotes).length === 0) {
|
|
616
|
+
out('(no remotes)');
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
for (const [name, target] of Object.entries(remotes))
|
|
620
|
+
out(`${name.padEnd(10)} ${target}${tokenFor(parseOriginUrl(target).url) ? ' (token stored)' : ''}`);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (verb === 'serve') {
|
|
624
|
+
process.exitCode = await passthrough([process.execPath, remoteCli(), 'serve', ...args]);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
throw new Error(`volter remote: unknown verb "${verb}"\n\n${HELP}`);
|
|
628
|
+
}
|
|
629
|
+
async function main() {
|
|
630
|
+
const [noun, verb, ...args] = process.argv.slice(2);
|
|
631
|
+
if (!noun || noun === '--help' || noun === '-h' || noun === 'help') {
|
|
632
|
+
out(HELP);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (verb === '--help' || verb === '-h') {
|
|
636
|
+
out(HELP);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
switch (noun) {
|
|
640
|
+
case 'world': {
|
|
641
|
+
if (!verb)
|
|
642
|
+
throw new Error(`volter world: which verb?\n\n${HELP}`);
|
|
643
|
+
await worldCommand(verb, args);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
case 'login':
|
|
647
|
+
await loginCommand([verb, ...args].filter((a) => a !== undefined));
|
|
648
|
+
return;
|
|
649
|
+
case 'twin':
|
|
650
|
+
await twinCommand([verb, ...args].filter((a) => a !== undefined));
|
|
651
|
+
return;
|
|
652
|
+
case 'remote': {
|
|
653
|
+
if (!verb)
|
|
654
|
+
throw new Error('volter remote: which verb? add | remove | list | serve');
|
|
655
|
+
await remoteCommand(verb, args);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
default:
|
|
659
|
+
throw new Error(`volter: unknown noun "${noun}" — world, twin or remote\n\n${HELP}`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const isEntryPoint = import.meta.main ?? (process.argv[1] !== undefined && existsSync(process.argv[1])
|
|
663
|
+
&& realpathSync(process.argv[1]) === fileURLToPath(import.meta.url));
|
|
664
|
+
if (isEntryPoint) {
|
|
665
|
+
main().catch((error) => {
|
|
666
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
667
|
+
process.exit(process.exitCode && process.exitCode !== 0 ? Number(process.exitCode) : 1);
|
|
668
|
+
});
|
|
669
|
+
}
|