@volter/world 3.0.63 → 3.0.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +33 -8
- package/dist/src/mcp.js +5 -5
- package/package.json +5 -5
- package/src/cli.ts +28 -8
- package/src/init-selection.test.ts +153 -0
- package/src/mcp.ts +5 -5
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
|
|
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>] [--twins a,b] [--source vendor=package] [--allow-unknown] detect or select vendors, choose installed publishers, 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] send entries to the remote; vendor deployment follows its root policy\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 submit --source <id> --vendor <vendor> --package <name> --version <exact> prepare a catalog submission\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
|
@@ -39,7 +39,7 @@ function openInBrowser(url) {
|
|
|
39
39
|
export const HELP = `volter — run your app against a world of twins
|
|
40
40
|
|
|
41
41
|
world — the twins your app needs, running together (the world of the current directory)
|
|
42
|
-
volter world init [--install] [--name <world>] [--allow-unknown] detect
|
|
42
|
+
volter world init [--install] [--name <world>] [--twins a,b] [--source vendor=package] [--allow-unknown] detect or select vendors, choose installed publishers, write .volter/world.json
|
|
43
43
|
volter world up [--sandbox] [--no-seed] start the twins on this branch (loads the default data the first time)
|
|
44
44
|
volter world run [--verbose] -- <command...> run the app or its tests inside the world
|
|
45
45
|
volter world activate eval "$(volter world activate)": vendor CLIs and curl in this shell reach the twins
|
|
@@ -63,7 +63,7 @@ world — the twins your app needs, running together (the world of the current
|
|
|
63
63
|
volter world origin where this world clones from and pushes to
|
|
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
|
-
volter world push [<name>] [--token <token>] [--force]
|
|
66
|
+
volter world push [<name>] [--token <token>] [--force] send entries to the remote; vendor deployment follows its root policy
|
|
67
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
|
|
@@ -118,7 +118,7 @@ function positionals(args, valueFlags) {
|
|
|
118
118
|
return out;
|
|
119
119
|
}
|
|
120
120
|
function afterDashes(args) { const i = args.indexOf('--'); return i < 0 ? [] : args.slice(i + 1); }
|
|
121
|
-
const VALUE_FLAGS = ['--world', '--branch', '--name', '--token', '--admin-token', '-m', '--message', '--base', '--org', '--vendors', '--into', '--deploy', '--as', '--note', '--port', '--twins', '--host', '--origin', '--scope', '--refresh', '--at-most', '--at'];
|
|
121
|
+
const VALUE_FLAGS = ['--world', '--branch', '--name', '--token', '--admin-token', '-m', '--message', '--base', '--org', '--vendors', '--into', '--deploy', '--as', '--note', '--port', '--twins', '--source', '--host', '--origin', '--scope', '--refresh', '--at-most', '--at'];
|
|
122
122
|
/** 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. */
|
|
123
123
|
function subjectLabel(subject) {
|
|
124
124
|
const id = subject.id.replace(new RegExp(`(^|[#/:])${subject.type}:`), '$1');
|
|
@@ -152,6 +152,8 @@ async function worldCommand(verb, args) {
|
|
|
152
152
|
const pos = positionals(args, VALUE_FLAGS);
|
|
153
153
|
const asJson = flag(args, '--json');
|
|
154
154
|
if (verb === 'init' && flag(args, '--bare')) {
|
|
155
|
+
if (args.some(arg => arg === '--source' || arg.startsWith('--source=')))
|
|
156
|
+
throw new Error('--source selects installed publishers for app initialization; omit --bare and use world init --twins <vendors> --source vendor=package');
|
|
155
157
|
const served = positionals(args, VALUE_FLAGS)[0];
|
|
156
158
|
if (!served)
|
|
157
159
|
throw new Error('volter world init --bare <org>/<world> --twins a,b');
|
|
@@ -167,8 +169,9 @@ async function worldCommand(verb, args) {
|
|
|
167
169
|
}
|
|
168
170
|
if (verb === 'init') {
|
|
169
171
|
const app = resolve(worldDir ?? process.cwd());
|
|
170
|
-
|
|
171
|
-
|
|
172
|
+
const selection = initSelection(args);
|
|
173
|
+
await installDetectedTwins(app, flag(args, '--install'), selection);
|
|
174
|
+
const { result } = World.init(app, { ...selection, ...(value(args, '--name') ? { name: value(args, '--name') } : {}), force: flag(args, '--force'), allowUnknown: flag(args, '--allow-unknown') });
|
|
172
175
|
if (asJson)
|
|
173
176
|
json({ ok: result.ok, plan: result.plan, coverage: result.coverage, next: result.next });
|
|
174
177
|
else {
|
|
@@ -973,17 +976,39 @@ ${Object.entries(tree).map(([n, v]) => ` '${n}' = @(${v.map((x) => `'${x}'`).
|
|
|
973
976
|
/** A fresh app has no twins installed, so `init` has nothing to plan with: its vendors are found from what the app
|
|
974
977
|
* depends on and names in its env (the signatures ship with the command), and the twins for them are installed with
|
|
975
978
|
* the app's own package manager: asked at a terminal, or at once with `--install` (a script's or an agent's way). */
|
|
976
|
-
|
|
979
|
+
function initSelection(args) {
|
|
980
|
+
const list = value(args, '--twins');
|
|
981
|
+
const vendors = list === undefined ? undefined : [...new Set(list.split(',').map(v => v.trim()))];
|
|
982
|
+
if (args.includes('--twins') && (vendors === undefined || vendors.some(v => !/^[a-z][a-z0-9-]*$/.test(v))))
|
|
983
|
+
throw new Error('--twins needs vendor names, such as --twins stripe,slack');
|
|
984
|
+
const sources = {};
|
|
985
|
+
for (let i = 0; i < args.length; i++) {
|
|
986
|
+
const raw = args[i] === '--source' ? args[++i] : args[i].startsWith('--source=') ? args[i].slice('--source='.length) : undefined;
|
|
987
|
+
if (raw === undefined && args[i - 1] !== '--source')
|
|
988
|
+
continue;
|
|
989
|
+
const match = /^([a-z][a-z0-9-]*)=((?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*)$/.exec(raw ?? '');
|
|
990
|
+
if (!match)
|
|
991
|
+
throw new Error('--source needs vendor=package, such as --source stripe=@volter/twin-stripe');
|
|
992
|
+
const [, vendor, name] = match;
|
|
993
|
+
if (vendors && !vendors.includes(vendor))
|
|
994
|
+
throw new Error(`--source ${vendor} must also be selected by --twins`);
|
|
995
|
+
if (sources[vendor] && sources[vendor] !== name)
|
|
996
|
+
throw new Error(`${vendor}: select one package with --source`);
|
|
997
|
+
sources[vendor] = name;
|
|
998
|
+
}
|
|
999
|
+
return { ...(vendors ? { vendors } : {}), sources };
|
|
1000
|
+
}
|
|
1001
|
+
async function installDetectedTwins(app, install, selection = {}) {
|
|
977
1002
|
let installed = new Set();
|
|
978
1003
|
try {
|
|
979
|
-
installed = new Set(resolveCatalog(app).vendors());
|
|
1004
|
+
installed = new Set(resolveCatalog(app, selection).vendors());
|
|
980
1005
|
}
|
|
981
1006
|
catch (error) {
|
|
982
1007
|
if (!(error instanceof Error) || !error.message.startsWith('No twins found for '))
|
|
983
1008
|
throw error;
|
|
984
1009
|
}
|
|
985
1010
|
const found = detectRepoVendors(app).detected;
|
|
986
|
-
const vendors = [...found.keys()].filter((v) => !installed.has(v) && /^[a-z0-9][a-z0-9-]*$/.test(v)).sort();
|
|
1011
|
+
const vendors = (selection.vendors ?? [...found.keys()]).filter((v) => !installed.has(v) && /^[a-z0-9][a-z0-9-]*$/.test(v)).sort();
|
|
987
1012
|
if (vendors.length === 0)
|
|
988
1013
|
return; // init says what it found and how to add a twin by hand
|
|
989
1014
|
const index = findInstalledPackage(app, CATALOG_INDEX_PACKAGE) ?? findInstalledPackage(fileURLToPath(new URL('.', import.meta.url)), CATALOG_INDEX_PACKAGE);
|
package/dist/src/mcp.js
CHANGED
|
@@ -70,10 +70,10 @@ const text = (s, isError = false) => ({ content: [{ type: 'text', text: s.trim()
|
|
|
70
70
|
const ran = (r) => text(r.output, r.code !== 0);
|
|
71
71
|
const asJson = (v) => text(JSON.stringify(v, null, 2));
|
|
72
72
|
const fail = (e) => text(e instanceof Error ? e.message : String(e), true);
|
|
73
|
-
const INSTRUCTIONS = `Volter World runs an app against
|
|
73
|
+
const INSTRUCTIONS = `Volter World runs an app against the twins deliberately selected for its workflow: the app keeps its real SDKs, and stored writes land in a World that can be branched, reset and inspected. Screen availability and supported operations depend on the selected implementation.
|
|
74
74
|
|
|
75
75
|
Work in the app's folder (each tool takes "dir", defaulting to where this server was started).
|
|
76
|
-
- Set up: world_init detects
|
|
76
|
+
- Set up: world_init detects vendors from manifests and env names; show the person the list before keeping it. Use vendors to select the workflow's twins and sources to choose installed publishers. Then world_up.
|
|
77
77
|
- Run the app or its tests inside the World with world_run (never bare against the real vendors).
|
|
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.
|
|
@@ -93,9 +93,9 @@ export async function serveMcp(version) {
|
|
|
93
93
|
return fail(e);
|
|
94
94
|
} });
|
|
95
95
|
server.registerTool('world_init', {
|
|
96
|
-
title: 'Set up a World for the app', description: "Detect
|
|
97
|
-
inputSchema: { ...dirArg, name: z.string().optional().describe('The World\'s name (defaults to the folder\'s).'), allowUnknown: z.boolean().optional(), force: z.boolean().optional().describe('
|
|
98
|
-
}, async ({ dir, name, allowUnknown, force }) => ran(await volter(['world', 'init', '--install', ...(name ? ['--name', name] : []), ...(allowUnknown ? ['--allow-unknown'] : []), ...(force ? ['--force'] : [])], here(dir))));
|
|
96
|
+
title: 'Set up a World for the app', description: "Detect or deliberately select vendors, install missing selected twins from catalog defaults, and write .volter/world.json. Show the detected vendors before keeping them. Sources select exact installed package identities; the config records their versions.",
|
|
97
|
+
inputSchema: { ...dirArg, name: z.string().optional().describe('The World\'s name (defaults to the folder\'s).'), vendors: z.array(z.string()).optional().describe('The vendors deliberately selected for this workflow.'), sources: z.record(z.string(), z.string()).optional().describe('Installed package names by vendor, for example stripe: @publisher/twin-stripe.'), allowUnknown: z.boolean().optional(), force: z.boolean().optional().describe('Regenerate while preserving authored inputs and saved pins.') },
|
|
98
|
+
}, async ({ dir, name, vendors, sources, allowUnknown, force }) => ran(await volter(['world', 'init', '--install', ...(name ? ['--name', name] : []), ...(vendors ? ['--twins', vendors.join(',')] : []), ...Object.entries(sources ?? {}).flatMap(([vendor, pkg]) => ['--source', `${vendor}=${pkg}`]), ...(allowUnknown ? ['--allow-unknown'] : []), ...(force ? ['--force'] : [])], here(dir))));
|
|
99
99
|
server.registerTool('world_up', {
|
|
100
100
|
title: 'Start the World', description: 'Start the twins on the current branch. A fresh branch loads its default data (the story); a stopped one resumes where it was.',
|
|
101
101
|
inputSchema: { ...dirArg, seed: z.boolean().optional().describe('Load the default data on a fresh branch (default true).') },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.64",
|
|
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",
|
|
@@ -30,12 +30,12 @@
|
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
33
|
-
"@volter/world-core": "3.0.
|
|
34
|
-
"@volter/world-runtime": "3.0.
|
|
33
|
+
"@volter/world-core": "3.0.64",
|
|
34
|
+
"@volter/world-runtime": "3.0.64",
|
|
35
35
|
"zod": "^4.6.5"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@volter/world-console": "3.0.
|
|
38
|
+
"@volter/world-console": "3.0.59"
|
|
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": "3.0.
|
|
46
|
+
"@volter/world-console": "3.0.59"
|
|
47
47
|
},
|
|
48
48
|
"bin": {
|
|
49
49
|
"volter": "dist/src/cli.js"
|
package/src/cli.ts
CHANGED
|
@@ -31,7 +31,7 @@ function openInBrowser(url: string): void {
|
|
|
31
31
|
export const HELP = `volter — run your app against a world of twins
|
|
32
32
|
|
|
33
33
|
world — the twins your app needs, running together (the world of the current directory)
|
|
34
|
-
volter world init [--install] [--name <world>] [--allow-unknown] detect
|
|
34
|
+
volter world init [--install] [--name <world>] [--twins a,b] [--source vendor=package] [--allow-unknown] detect or select vendors, choose installed publishers, write .volter/world.json
|
|
35
35
|
volter world up [--sandbox] [--no-seed] start the twins on this branch (loads the default data the first time)
|
|
36
36
|
volter world run [--verbose] -- <command...> run the app or its tests inside the world
|
|
37
37
|
volter world activate eval "$(volter world activate)": vendor CLIs and curl in this shell reach the twins
|
|
@@ -55,7 +55,7 @@ world — the twins your app needs, running together (the world of the current
|
|
|
55
55
|
volter world origin where this world clones from and pushes to
|
|
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
|
-
volter world push [<name>] [--token <token>] [--force]
|
|
58
|
+
volter world push [<name>] [--token <token>] [--force] send entries to the remote; vendor deployment follows its root policy
|
|
59
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
|
|
@@ -106,7 +106,7 @@ function positionals(args: string[], valueFlags: string[]): string[] {
|
|
|
106
106
|
return out;
|
|
107
107
|
}
|
|
108
108
|
function afterDashes(args: string[]): string[] { const i = args.indexOf('--'); return i < 0 ? [] : args.slice(i + 1); }
|
|
109
|
-
const VALUE_FLAGS = ['--world', '--branch', '--name', '--token', '--admin-token', '-m', '--message', '--base', '--org', '--vendors', '--into', '--deploy', '--as', '--note', '--port', '--twins', '--host', '--origin', '--scope', '--refresh', '--at-most', '--at'];
|
|
109
|
+
const VALUE_FLAGS = ['--world', '--branch', '--name', '--token', '--admin-token', '-m', '--message', '--base', '--org', '--vendors', '--into', '--deploy', '--as', '--note', '--port', '--twins', '--source', '--host', '--origin', '--scope', '--refresh', '--at-most', '--at'];
|
|
110
110
|
|
|
111
111
|
/** 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. */
|
|
112
112
|
function subjectLabel(subject: { type: string; id: string }): string {
|
|
@@ -135,6 +135,7 @@ async function worldCommand(verb: string, args: string[]): Promise<void> {
|
|
|
135
135
|
const asJson = flag(args, '--json');
|
|
136
136
|
|
|
137
137
|
if (verb === 'init' && flag(args, '--bare')) {
|
|
138
|
+
if (args.some(arg => arg === '--source' || arg.startsWith('--source='))) throw new Error('--source selects installed publishers for app initialization; omit --bare and use world init --twins <vendors> --source vendor=package');
|
|
138
139
|
const served = positionals(args, VALUE_FLAGS)[0];
|
|
139
140
|
if (!served) throw new Error('volter world init --bare <org>/<world> --twins a,b');
|
|
140
141
|
const twins = (value(args, '--twins') ?? '').split(',').map((t) => t.trim()).filter(Boolean);
|
|
@@ -146,8 +147,9 @@ async function worldCommand(verb: string, args: string[]): Promise<void> {
|
|
|
146
147
|
}
|
|
147
148
|
if (verb === 'init') {
|
|
148
149
|
const app = resolve(worldDir ?? process.cwd());
|
|
149
|
-
|
|
150
|
-
|
|
150
|
+
const selection = initSelection(args);
|
|
151
|
+
await installDetectedTwins(app, flag(args, '--install'), selection);
|
|
152
|
+
const { result } = World.init(app, { ...selection, ...(value(args, '--name') ? { name: value(args, '--name')! } : {}), force: flag(args, '--force'), allowUnknown: flag(args, '--allow-unknown') });
|
|
151
153
|
if (asJson) json({ ok: result.ok, plan: result.plan, coverage: result.coverage, next: result.next });
|
|
152
154
|
else {
|
|
153
155
|
const twins = result.plan.vendors.filter((v) => v.service !== null).map((v) => v.vendor).sort();
|
|
@@ -710,13 +712,31 @@ ${Object.entries(tree).map(([n, v]) => ` '${n}' = @(${v.map((x) => `'${x}'`).
|
|
|
710
712
|
/** A fresh app has no twins installed, so `init` has nothing to plan with: its vendors are found from what the app
|
|
711
713
|
* depends on and names in its env (the signatures ship with the command), and the twins for them are installed with
|
|
712
714
|
* the app's own package manager: asked at a terminal, or at once with `--install` (a script's or an agent's way). */
|
|
713
|
-
|
|
715
|
+
function initSelection(args: string[]): { vendors?: string[]; sources: Record<string, string> } {
|
|
716
|
+
const list = value(args, '--twins');
|
|
717
|
+
const vendors = list === undefined ? undefined : [...new Set(list.split(',').map(v => v.trim()))];
|
|
718
|
+
if (args.includes('--twins') && (vendors === undefined || vendors.some(v => !/^[a-z][a-z0-9-]*$/.test(v)))) throw new Error('--twins needs vendor names, such as --twins stripe,slack');
|
|
719
|
+
const sources: Record<string, string> = {};
|
|
720
|
+
for (let i = 0; i < args.length; i++) {
|
|
721
|
+
const raw = args[i] === '--source' ? args[++i] : args[i]!.startsWith('--source=') ? args[i]!.slice('--source='.length) : undefined;
|
|
722
|
+
if (raw === undefined && args[i - 1] !== '--source') continue;
|
|
723
|
+
const match = /^([a-z][a-z0-9-]*)=((?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*)$/.exec(raw ?? '');
|
|
724
|
+
if (!match) throw new Error('--source needs vendor=package, such as --source stripe=@volter/twin-stripe');
|
|
725
|
+
const [, vendor, name] = match;
|
|
726
|
+
if (vendors && !vendors.includes(vendor!)) throw new Error(`--source ${vendor} must also be selected by --twins`);
|
|
727
|
+
if (sources[vendor!] && sources[vendor!] !== name) throw new Error(`${vendor}: select one package with --source`);
|
|
728
|
+
sources[vendor!] = name!;
|
|
729
|
+
}
|
|
730
|
+
return { ...(vendors ? { vendors } : {}), sources };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async function installDetectedTwins(app: string, install: boolean, selection: { vendors?: string[]; sources?: Record<string, string> } = {}): Promise<void> {
|
|
714
734
|
let installed = new Set<string>();
|
|
715
|
-
try { installed = new Set(resolveCatalog(app).vendors()); } catch (error) {
|
|
735
|
+
try { installed = new Set(resolveCatalog(app, selection).vendors()); } catch (error) {
|
|
716
736
|
if (!(error instanceof Error) || !error.message.startsWith('No twins found for ')) throw error;
|
|
717
737
|
}
|
|
718
738
|
const found = detectRepoVendors(app).detected;
|
|
719
|
-
const vendors = [...found.keys()].filter((v) => !installed.has(v) && /^[a-z0-9][a-z0-9-]*$/.test(v)).sort();
|
|
739
|
+
const vendors = (selection.vendors ?? [...found.keys()]).filter((v) => !installed.has(v) && /^[a-z0-9][a-z0-9-]*$/.test(v)).sort();
|
|
720
740
|
if (vendors.length === 0) return; // init says what it found and how to add a twin by hand
|
|
721
741
|
const index = findInstalledPackage(app, CATALOG_INDEX_PACKAGE) ?? findInstalledPackage(fileURLToPath(new URL('.', import.meta.url)), CATALOG_INDEX_PACKAGE);
|
|
722
742
|
const recommended = index ? catalogDefaults(index, vendors) : undefined;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// The user's selection must survive both installation planning and the actual CLI entry point.
|
|
2
|
+
// These are discovery fixtures, not vendor implementations; no fixture service is started.
|
|
3
|
+
import { afterEach, expect, test } from 'bun:test';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { World } from './world.ts';
|
|
9
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
10
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
11
|
+
|
|
12
|
+
const roots: string[] = [];
|
|
13
|
+
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
|
|
14
|
+
function app(packages = ['@one/twin-stripe']): string {
|
|
15
|
+
const root = mkdtempSync(join(tmpdir(), 'world-init-selection-')); roots.push(root);
|
|
16
|
+
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'selection-app', private: true, dependencies: { stripe: '17.7.0', '@slack/web-api': '7.9.2' } }));
|
|
17
|
+
writeFileSync(join(root, '.env.example'), 'STRIPE_SECRET_KEY=\nSLACK_BOT_TOKEN=\n');
|
|
18
|
+
for (const name of packages) {
|
|
19
|
+
const dir = join(root, 'node_modules', name);
|
|
20
|
+
mkdirSync(join(dir, 'src', 'generated'), { recursive: true });
|
|
21
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version: name.startsWith('@one/') ? '1.0.0' : '9.0.0' }));
|
|
22
|
+
writeFileSync(join(dir, 'src', 'cli.ts'), '// Discovery fixture: never executed.\n');
|
|
23
|
+
writeFileSync(join(dir, 'src', 'generated', 'surface.gen.json'), '{}\n');
|
|
24
|
+
}
|
|
25
|
+
return root;
|
|
26
|
+
}
|
|
27
|
+
function init(root: string, ...args: string[]) {
|
|
28
|
+
return spawnSync(process.execPath, [join(import.meta.dir, 'cli.ts'), 'world', 'init', '--name', 'selection-app', '--json', ...args], { cwd: root, encoding: 'utf8', env: { ...process.env, VOLTER_NO_UPDATE_NOTIFIER: '1' } });
|
|
29
|
+
}
|
|
30
|
+
const document = (root: string) => JSON.parse(readFileSync(join(root, '.volter', 'world.json'), 'utf8'));
|
|
31
|
+
|
|
32
|
+
test('selecting Stripe neither requests the missing Slack twin nor marks Slack covered', () => {
|
|
33
|
+
const root = app(); const result = init(root, '--twins', 'stripe');
|
|
34
|
+
expect(result.status).toBe(0);
|
|
35
|
+
const report = JSON.parse(result.stdout);
|
|
36
|
+
expect(report.coverage.excluded).toContain('slack');
|
|
37
|
+
expect(report.coverage.covered).not.toContain('slack');
|
|
38
|
+
expect(document(root).services.map((s: { id: string }) => s.id)).toEqual(['stripe']);
|
|
39
|
+
expect(document(root).discovery.selection.include).toEqual([{ vendor: 'stripe' }]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a missing selected twin offers only the selected install, not every detected SDK', () => {
|
|
43
|
+
const root = app(['@one/twin-slack']);
|
|
44
|
+
const index = join(root, 'node_modules', '@volter', 'twin-catalog');
|
|
45
|
+
mkdirSync(join(index, 'vendors'), { recursive: true });
|
|
46
|
+
writeFileSync(join(index, 'package.json'), JSON.stringify({ name: '@volter/twin-catalog', version: '0.0.0' }));
|
|
47
|
+
writeFileSync(join(index, 'sources.json'), JSON.stringify([{ name: 'one', scope: '@one', repository: 'one/twins', protocol: '3' }]));
|
|
48
|
+
writeFileSync(join(index, 'vendors', 'stripe.json'), JSON.stringify({ vendor: 'stripe', packages: [{ name: '@one/twin-stripe', source: 'one', versions: [{ version: '1.0.0', status: 'live' }] }] }));
|
|
49
|
+
const result = init(root, '--twins', 'stripe');
|
|
50
|
+
expect(result.status).toBe(1);
|
|
51
|
+
expect(result.stderr).toContain('install the twins first: npm install -D @one/twin-stripe@1.0.0');
|
|
52
|
+
expect(result.stderr).not.toContain('twin-slack');
|
|
53
|
+
expect(existsSync(join(root, '.volter', 'world.json'))).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a fresh World chooses one installed publisher and pins its actual version', () => {
|
|
57
|
+
const root = app(['@one/twin-stripe', '@two/twin-stripe']);
|
|
58
|
+
const result = init(root, '--twins', 'stripe', '--source', 'stripe=@one/twin-stripe');
|
|
59
|
+
expect(result.status).toBe(0);
|
|
60
|
+
expect(document(root).services[0].source).toEqual({ package: '@one/twin-stripe', version: '1.0.0' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('SDK initialization exposes the same vendor and publisher choice', () => {
|
|
64
|
+
const root = app(['@one/twin-stripe', '@two/twin-stripe']);
|
|
65
|
+
const { result } = World.init(root, { name: 'selection-app', vendors: ['stripe'], sources: { stripe: '@two/twin-stripe' } });
|
|
66
|
+
expect(result.ok).toBe(true);
|
|
67
|
+
expect(document(root).services[0].source).toEqual({ package: '@two/twin-stripe', version: '9.0.0' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('an independent package name gets vendor identity from its published facts', () => {
|
|
71
|
+
const root = app(['@one/twin-stripe', '@two/stripe-simulator']);
|
|
72
|
+
const dir = join(root, 'node_modules', '@two', 'stripe-simulator', 'generated');
|
|
73
|
+
mkdirSync(dir);
|
|
74
|
+
const facts = JSON.parse(readFileSync(join(import.meta.dir, '../../world-core/generated/pack-facts.json'), 'utf8'));
|
|
75
|
+
writeFileSync(join(dir, 'pack-facts.json'), JSON.stringify({ packs: { stripe: facts.packs.stripe } }));
|
|
76
|
+
const result = init(root, '--twins', 'stripe', '--source', 'stripe=@two/stripe-simulator');
|
|
77
|
+
expect(result.status).toBe(0);
|
|
78
|
+
expect(document(root).services[0].source.package).toBe('@two/stripe-simulator');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('competing implementations of an unselected vendor do not block the selected workflow', () => {
|
|
82
|
+
const root = app(['@one/twin-stripe', '@one/twin-slack', '@two/twin-slack']);
|
|
83
|
+
expect(init(root, '--twins', 'stripe').status).toBe(0);
|
|
84
|
+
expect(document(root).services.map((s: { id: string }) => s.id)).toEqual(['stripe']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a newly selected revoked release refuses, while an existing World pin survives revocation', () => {
|
|
88
|
+
const root = app();
|
|
89
|
+
const index = join(root, 'node_modules', '@volter', 'twin-catalog');
|
|
90
|
+
mkdirSync(index, { recursive: true });
|
|
91
|
+
writeFileSync(join(index, 'package.json'), JSON.stringify({ name: '@volter/twin-catalog', version: '0.0.0' }));
|
|
92
|
+
const revoke = () => writeFileSync(join(index, 'revocations.json'), JSON.stringify([{ package: '@one/twin-stripe', version: '1.0.0' }]));
|
|
93
|
+
revoke();
|
|
94
|
+
expect(init(root, '--twins', 'stripe', '--source', 'stripe=@one/twin-stripe').stderr).toContain('is revoked');
|
|
95
|
+
expect(existsSync(join(root, '.volter', 'world.json'))).toBe(false);
|
|
96
|
+
writeFileSync(join(index, 'revocations.json'), '[]');
|
|
97
|
+
expect(init(root, '--twins', 'stripe', '--source', 'stripe=@one/twin-stripe').status).toBe(0);
|
|
98
|
+
revoke();
|
|
99
|
+
expect(init(root, '--force', '--twins', 'stripe').status).toBe(0);
|
|
100
|
+
expect(document(root).services[0].source).toEqual({ package: '@one/twin-stripe', version: '1.0.0' });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('ambiguous fresh discovery gives an actionable source selection without writing a World', () => {
|
|
104
|
+
const root = app(['@one/twin-stripe', '@two/twin-stripe']); const result = init(root, '--twins', 'stripe');
|
|
105
|
+
expect(result.status).toBe(1);
|
|
106
|
+
expect(result.stderr).toContain('--source stripe=<package>');
|
|
107
|
+
expect(existsSync(join(root, '.volter', 'world.json'))).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a source for another vendor refuses before writing the config', () => {
|
|
111
|
+
const root = app(['@one/twin-stripe', '@two/twin-slack']); const result = init(root, '--twins', 'stripe', '--source=stripe=@two/twin-slack');
|
|
112
|
+
expect(result.status).toBe(1);
|
|
113
|
+
expect(result.stderr).toContain('declares vendor slack');
|
|
114
|
+
expect(existsSync(join(root, '.volter', 'world.json'))).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('regeneration cannot replace a saved publisher pin', () => {
|
|
118
|
+
const root = app(['@one/twin-stripe', '@two/twin-stripe']);
|
|
119
|
+
expect(init(root, '--twins', 'stripe', '--source', 'stripe=@one/twin-stripe').status).toBe(0);
|
|
120
|
+
const before = readFileSync(join(root, '.volter', 'world.json'), 'utf8');
|
|
121
|
+
const result = init(root, '--force', '--twins', 'stripe', '--source', 'stripe=@two/twin-stripe');
|
|
122
|
+
expect(result.status).toBe(1);
|
|
123
|
+
expect(result.stderr).toContain('saved World selects @one/twin-stripe');
|
|
124
|
+
expect(readFileSync(join(root, '.volter', 'world.json'), 'utf8')).toBe(before);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('malformed, conflicting and unselected choices refuse before creating a World', () => {
|
|
128
|
+
for (const args of [['--twins', ''], ['--source'], ['--source', 'stripe=../other'], ['--twins', 'stripe', '--source', 'slack=@one/twin-slack'], ['--source', 'stripe=@one/twin-stripe', '--source', 'stripe=@two/twin-stripe']]) {
|
|
129
|
+
const root = app(); const result = init(root, ...args);
|
|
130
|
+
expect(result.status).toBe(1);
|
|
131
|
+
expect(existsSync(join(root, '.volter', 'world.json'))).toBe(false);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('bare initialization refuses an app-only publisher option instead of ignoring it', () => {
|
|
136
|
+
const root = app(); const result = init(root, '--bare', 'team/demo', '--twins', 'stripe', '--source', 'stripe=@one/twin-stripe');
|
|
137
|
+
expect(result.status).toBe(1);
|
|
138
|
+
expect(result.stderr).toContain('--source selects installed publishers for app initialization');
|
|
139
|
+
expect(existsSync(join(root, '.volter', 'world.json'))).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('a coding agent can select vendors and installed publishers through world_init', async () => {
|
|
143
|
+
const root = app(['@one/twin-stripe', '@two/twin-stripe']);
|
|
144
|
+
const transport = new StdioClientTransport({ command: process.execPath, args: [join(import.meta.dir, 'cli.ts'), 'mcp'], cwd: root, env: { ...process.env, VOLTER_NO_UPDATE_NOTIFIER: '1' } as Record<string, string> });
|
|
145
|
+
const client = new Client({ name: 'world-init-user-journey', version: '1.0.0' });
|
|
146
|
+
try {
|
|
147
|
+
await client.connect(transport);
|
|
148
|
+
const result = await client.callTool({ name: 'world_init', arguments: { dir: root, name: 'selection-app', vendors: ['stripe'], sources: { stripe: '@two/twin-stripe' } } });
|
|
149
|
+
expect(result.isError).not.toBe(true);
|
|
150
|
+
expect(document(root).services.map((service: { id: string }) => service.id)).toEqual(['stripe']);
|
|
151
|
+
expect(document(root).services[0].source).toEqual({ package: '@two/twin-stripe', version: '9.0.0' });
|
|
152
|
+
} finally { await client.close(); }
|
|
153
|
+
}, 15_000);
|
package/src/mcp.ts
CHANGED
|
@@ -64,10 +64,10 @@ const ran = (r: Ran) => text(r.output, r.code !== 0);
|
|
|
64
64
|
const asJson = (v: unknown) => text(JSON.stringify(v, null, 2));
|
|
65
65
|
const fail = (e: unknown) => text(e instanceof Error ? e.message : String(e), true);
|
|
66
66
|
|
|
67
|
-
const INSTRUCTIONS = `Volter World runs an app against
|
|
67
|
+
const INSTRUCTIONS = `Volter World runs an app against the twins deliberately selected for its workflow: the app keeps its real SDKs, and stored writes land in a World that can be branched, reset and inspected. Screen availability and supported operations depend on the selected implementation.
|
|
68
68
|
|
|
69
69
|
Work in the app's folder (each tool takes "dir", defaulting to where this server was started).
|
|
70
|
-
- Set up: world_init detects
|
|
70
|
+
- Set up: world_init detects vendors from manifests and env names; show the person the list before keeping it. Use vendors to select the workflow's twins and sources to choose installed publishers. Then world_up.
|
|
71
71
|
- Run the app or its tests inside the World with world_run (never bare against the real vendors).
|
|
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.
|
|
@@ -85,9 +85,9 @@ export async function serveMcp(version: string): Promise<void> {
|
|
|
85
85
|
}, async ({ dir }) => { try { return asJson(World.open({ root: here(dir) }).status()); } catch (e) { return fail(e); } });
|
|
86
86
|
|
|
87
87
|
server.registerTool('world_init', {
|
|
88
|
-
title: 'Set up a World for the app', description: "Detect
|
|
89
|
-
inputSchema: { ...dirArg, name: z.string().optional().describe('The World\'s name (defaults to the folder\'s).'), allowUnknown: z.boolean().optional(), force: z.boolean().optional().describe('
|
|
90
|
-
}, async ({ dir, name, allowUnknown, force }) => ran(await volter(['world', 'init', '--install', ...(name ? ['--name', name] : []), ...(allowUnknown ? ['--allow-unknown'] : []), ...(force ? ['--force'] : [])], here(dir))));
|
|
88
|
+
title: 'Set up a World for the app', description: "Detect or deliberately select vendors, install missing selected twins from catalog defaults, and write .volter/world.json. Show the detected vendors before keeping them. Sources select exact installed package identities; the config records their versions.",
|
|
89
|
+
inputSchema: { ...dirArg, name: z.string().optional().describe('The World\'s name (defaults to the folder\'s).'), vendors: z.array(z.string()).optional().describe('The vendors deliberately selected for this workflow.'), sources: z.record(z.string(), z.string()).optional().describe('Installed package names by vendor, for example stripe: @publisher/twin-stripe.'), allowUnknown: z.boolean().optional(), force: z.boolean().optional().describe('Regenerate while preserving authored inputs and saved pins.') },
|
|
90
|
+
}, async ({ dir, name, vendors, sources, allowUnknown, force }) => ran(await volter(['world', 'init', '--install', ...(name ? ['--name', name] : []), ...(vendors ? ['--twins', vendors.join(',')] : []), ...Object.entries(sources ?? {}).flatMap(([vendor, pkg]) => ['--source', `${vendor}=${pkg}`]), ...(allowUnknown ? ['--allow-unknown'] : []), ...(force ? ['--force'] : [])], here(dir))));
|
|
91
91
|
|
|
92
92
|
server.registerTool('world_up', {
|
|
93
93
|
title: 'Start the World', description: 'Start the twins on the current branch. A fresh branch loads its default data (the story); a stopped one resumes where it was.',
|