@volter/world 2.0.1 → 2.0.2

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.
@@ -36,12 +36,23 @@ export function storeToken(origin: string, token: string, path: string = credent
36
36
  chmodSync(path, 0o600);
37
37
  }
38
38
 
39
+ /** Forget the token stored for an origin; whether one was stored. */
40
+ export function forgetToken(origin: string, path: string = credentialsPath()): boolean {
41
+ const store = readStore(path); const key = normalizeOrigin(origin);
42
+ if (!(key in store)) return false;
43
+ delete store[key];
44
+ writeFileSync(path, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
45
+ return true;
46
+ }
47
+
39
48
  /** `volter login`: the hosted platform a person signed the CLI into — its origin, beside the credentials. */
40
49
  export function platformPath(): string { return join(dirname(credentialsPath()), 'platform.json'); }
41
50
  export function storePlatform(origin: string, path: string = platformPath()): void {
42
51
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
43
52
  writeFileSync(path, `${JSON.stringify({ url: normalizeOrigin(origin), savedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
44
53
  }
54
+ /** Forget which platform the CLI is signed into. */
55
+ export function forgetPlatform(path: string = platformPath()): void { if (existsSync(path)) writeFileSync(path, '{}\n', { mode: 0o600 }); }
45
56
  export function platformOrigin(path: string = platformPath()): string | undefined {
46
57
  if (!existsSync(path)) return undefined;
47
58
  try { return (JSON.parse(readFileSync(path, 'utf8')) as { url?: string }).url; } catch { return undefined; }
@@ -20,7 +20,7 @@
20
20
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
21
21
  import { downWorld } from '@volter/world-runtime';
22
22
  import { tmpdir } from 'node:os';
23
- import { basename, dirname, join, resolve } from 'node:path';
23
+ import { basename, delimiter, dirname, join, resolve } from 'node:path';
24
24
  import { randomUUID } from 'node:crypto';
25
25
  import { CLI, OPERATOR_CLI, PACKS, SDK } from './kit.ts';
26
26
  void CLI; void OPERATOR_CLI; void PACKS; void SDK;
@@ -98,6 +98,10 @@ export function mask(text: string): string {
98
98
 
99
99
  export type StepResult = { step: Step; output: string; missing: string[]; exit: number | null };
100
100
 
101
+ /** The PATH with the scratch app's global install dir first. bun and npm on POSIX link global bins into `<prefix>/bin`;
102
+ * npm on Windows puts its `.cmd` shims in the prefix itself, and PATH is `;`-separated there. */
103
+ const globalPath = (global: string): string => [...(process.platform === 'win32' ? [global] : []), join(global, 'bin'), process.env.PATH ?? ''].join(delimiter);
104
+
101
105
  /**
102
106
  * A scratch app for a tutorial: an empty directory, a config dir of its own, a global install dir
103
107
  * of its own on the PATH, and the registry to install from. The page makes everything else — its
@@ -109,7 +113,7 @@ export function tutorialApp(name: string, registry: { env: Record<string, string
109
113
  mkdirSync(dir);
110
114
  const global = join(parent, 'bun-global');
111
115
  mkdirSync(join(global, 'bin'), { recursive: true });
112
- const env = { ...process.env as Record<string, string>, ...registry.env, BUN_INSTALL: global, npm_config_prefix: global, npm_config_cache: join(parent, 'npm-cache'), npm_config_update_notifier: 'false', PATH: `${join(global, 'bin')}:${process.env.PATH ?? ''}`, XDG_CONFIG_HOME: join(parent, 'config'), PS1: '$ ' };
116
+ const env = { ...process.env as Record<string, string>, ...registry.env, BUN_INSTALL: global, npm_config_prefix: global, npm_config_cache: join(parent, 'npm-cache'), npm_config_update_notifier: 'false', PATH: globalPath(global), XDG_CONFIG_HOME: join(parent, 'config'), PS1: '$ ' };
113
117
  const localRegistry = registry.env.NPM_CONFIG_REGISTRY;
114
118
  if (localRegistry) {
115
119
  // Both clients read this private scope config, including global installs and nested dirs.
@@ -133,7 +137,22 @@ const DONE = '__VOLTER_STEP_DONE__';
133
137
  * every step's result; `failures()` says which steps the page got wrong.
134
138
  */
135
139
  export async function runTutorial(tutorial: Tutorial, app: { dir: string; parent: string; env: Record<string, string> }, opts: { stepTimeoutMs?: number; onStep?: (r: StepResult) => void } = {}): Promise<StepResult[]> {
136
- const shell = Bun.spawn({ cmd: ['bash', '--noprofile', '--norc'], cwd: app.dir, env: app.env, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' });
140
+ const shell = Bun.spawn({ cmd: ['bash', '--noprofile', '--norc'], cwd: app.dir, env: app.env, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', windowsHide: true });
141
+ // Windows keeps no parent for a process whose parent died, and an msys process that execs (npx's sh script) leaves
142
+ // its Windows children parentless within a second, so what the page started is found through both trees: the msys
143
+ // processes under the shell (Git's `ps`, which keeps their parentage across exec), then the Windows processes under
144
+ // each of those; recorded after every step, by pid and start time, so a reused pid is never mistaken
145
+ const started = new Map<string, WinProcess>();
146
+ const recordDescendants = (): void => {
147
+ if (process.platform !== 'win32' || !shell.pid) return;
148
+ const procs = windowsProcesses();
149
+ const roots = [shell.pid, ...msysDescendantWinpids(shell.pid)];
150
+ const byPid = new Map(procs.map((p) => [p.pid, p]));
151
+ for (const root of roots) {
152
+ const self = byPid.get(root); if (self && root !== shell.pid) started.set(`${self.pid}@${self.created}`, self);
153
+ for (const p of descendantsOf(root, procs)) started.set(`${p.pid}@${p.created}`, p);
154
+ }
155
+ };
137
156
  const results: StepResult[] = [];
138
157
  const reader = shell.stdout.getReader();
139
158
  const errReader = shell.stderr.getReader();
@@ -172,6 +191,8 @@ export async function runTutorial(tutorial: Tutorial, app: { dir: string; parent
172
191
  };
173
192
  const send = (text: string) => { shell.stdin.write(text); shell.stdin.flush(); };
174
193
  send('__volter_tutorial_jobs=()\n');
194
+ // processes the page started that outlived its own stop and the harness's kill of its jobs (Windows)
195
+ let leftovers: WinProcess[] = [];
175
196
  const cleanupShell = async (): Promise<void> => {
176
197
  // Signals begin asynchronous World teardown. Bash must reap its own jobs before we down
177
198
  // their Worlds. A delayed step-DONE is not proof this cleanup protocol has even started.
@@ -181,12 +202,23 @@ export async function runTutorial(tutorial: Tutorial, app: { dir: string; parent
181
202
  send(`for j in $(jobs -pr); do kill "$j" 2>/dev/null || true; done\n__volter_tutorial_cleanup=0\nfor j in "\${__volter_tutorial_jobs[@]}"; do wait "$j"; s=$?; case "$s" in 0|129|130|143) ;; *) echo "background job $j failed during cleanup: $s"; __volter_tutorial_cleanup=1 ;; esac; done\necho ${marker}$__volter_tutorial_cleanup\n`);
182
203
  const joined = await readUntilDone(10_000, marker);
183
204
  if (joined.exit !== 0) throw new Error(joined.output);
205
+ if (process.platform === 'win32') recordDescendants();
184
206
  send('exit\n');
185
207
  const stdoutDrain = (async () => { while (!(await nextChunk()).done) { /* drain to EOF */ } })();
186
- await Promise.race([
187
- Promise.all([shell.exited, stdoutDrain, stderrDrain]),
188
- new Promise<never>((_, reject) => { exitTimer = setTimeout(() => reject(new Error('tutorial shell did not exit and close its output')), 5000); }),
189
- ]);
208
+ stdoutDrain.catch(() => {});
209
+ const bounded = <T>(p: Promise<T>): Promise<T> => Promise.race([p, new Promise<never>((_, reject) => { exitTimer = setTimeout(() => reject(new Error('tutorial shell did not exit and close its output')), 5000); })]);
210
+ if (process.platform !== 'win32') await bounded(Promise.all([shell.exited, stdoutDrain, stderrDrain]));
211
+ else {
212
+ // Windows: a job killed by the page (or above) can leave a server running under it (`kill %1` ends npx, not
213
+ // the node it started); such a server holds the shell's output open. So the shell's own exit is waited for,
214
+ // then what it started and is still running (its console host went with it) is a leftover, ended only once
215
+ // the page's Worlds are down (runTutorial's finally); with none, its output closes as on any platform
216
+ await bounded(shell.exited);
217
+ const alive = new Set(windowsProcesses().map((p) => `${p.pid}@${p.created}`));
218
+ leftovers = [...started.entries()].filter(([k, p]) => alive.has(k) && p.pid !== shell.pid).map(([, p]) => p);
219
+ if (exitTimer) clearTimeout(exitTimer);
220
+ if (!leftovers.length) await bounded(Promise.all([stdoutDrain, stderrDrain]));
221
+ }
190
222
  } catch (error) {
191
223
  if (shell.pid) killTree(shell.pid); // bounded fallback; no claim that its children joined
192
224
  await Promise.allSettled([reader.cancel(), errReader.cancel()]);
@@ -238,11 +270,20 @@ export async function runTutorial(tutorial: Tutorial, app: { dir: string; parent
238
270
  const masked = mask(fenceOutput.get(step.fence) ?? output);
239
271
  const missing = step.expect.filter((line) => !masked.includes(mask(line)));
240
272
  const r: StepResult = { step, output: step.expect.length ? (fenceOutput.get(step.fence) ?? output) : output, missing, exit };
273
+ recordDescendants();
241
274
  results.push(r); opts.onStep?.(r);
242
275
  }
243
276
  } finally {
244
277
  await cleanupShell();
245
278
  await downEverything(app.parent);
279
+ if (leftovers.length) {
280
+ // the page's Worlds are down: what is still running is the page's leak, ended now (only these pids, each checked
281
+ // against its start time) and reported, never hidden
282
+ const alive = new Set(windowsProcesses().map((p) => `${p.pid}@${p.created}`));
283
+ for (const p of leftovers) if (alive.has(`${p.pid}@${p.created}`)) Bun.spawnSync({ cmd: ['taskkill', '/PID', String(p.pid), '/T', '/F'], windowsHide: true, stdout: 'ignore', stderr: 'ignore' });
284
+ await Promise.allSettled([reader.cancel(), errReader.cancel()]);
285
+ throw new Error(`the page left ${leftovers.length} process(es) running after its own stop and the harness's kill of its jobs (ended now):\n${leftovers.map((p) => ` pid ${p.pid}: ${p.command}`).join('\n')}`);
286
+ }
246
287
  }
247
288
  return results;
248
289
  }
@@ -288,9 +329,46 @@ export async function removeTutorialApp(parent: string): Promise<void> {
288
329
  rmSync(parent, { recursive: true, force: true });
289
330
  }
290
331
 
332
+ /** A Windows process as the harness records it: identity is the pid with its start time (pids are reused). */
333
+ export type WinProcess = { pid: number; parent: number; created: string; command: string };
334
+
335
+ /** Every process on this Windows machine, with its parent, start time and command line. */
336
+ export function windowsProcesses(): WinProcess[] {
337
+ const query = 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId)`t$($_.ParentProcessId)`t$($_.CreationDate.ToFileTimeUtc())`t$($_.CommandLine)" }';
338
+ const out = Bun.spawnSync({ cmd: ['powershell', '-NoProfile', '-Command', query], windowsHide: true, stdout: 'pipe', stderr: 'ignore' }).stdout.toString();
339
+ return out.split(/\r?\n/).filter(Boolean).map((line) => { const [pid, parent, created, ...command] = line.split('\t'); return { pid: Number(pid), parent: Number(parent), created: created ?? '', command: command.join('\t') }; }).filter((p) => p.pid > 0);
340
+ }
341
+
342
+ /** The Windows pids of the msys processes descended from the msys process whose Windows pid is `winpid`, from Git's
343
+ * `ps -l` (columns PID PPID PGID WINPID …); none where there is no `ps`. */
344
+ export function msysDescendantWinpids(winpid: number): number[] {
345
+ const ps = Bun.which('ps'); if (!ps) return [];
346
+ const rows = Bun.spawnSync({ cmd: [ps, '-l'], windowsHide: true, stdout: 'pipe', stderr: 'ignore' }).stdout.toString().split(/\r?\n/)
347
+ .map((line) => /^[^\d]*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s/.exec(line)).filter((m): m is RegExpExecArray => m !== null)
348
+ .map((m) => ({ pid: Number(m[1]), ppid: Number(m[2]), winpid: Number(m[4]) }));
349
+ const top = rows.find((r) => r.winpid === winpid); if (!top) return [];
350
+ const out: number[] = []; const frontier = [top.pid]; const seen = new Set([top.pid]);
351
+ while (frontier.length) { const parent = frontier.pop()!; for (const r of rows) if (r.ppid === parent && !seen.has(r.pid)) { seen.add(r.pid); out.push(r.winpid); frontier.push(r.pid); } }
352
+ return out;
353
+ }
354
+
355
+ /** The processes descended from `root` in a snapshot: a child must have started after its parent (Windows reuses the
356
+ * pid of a dead parent, and a newer process under that pid is no ancestor). */
357
+ export function descendantsOf(root: number, procs: WinProcess[]): WinProcess[] {
358
+ const byPid = new Map(procs.map((p) => [p.pid, p]));
359
+ const out: WinProcess[] = []; const frontier = [root]; const seen = new Set<number>([root]);
360
+ while (frontier.length) {
361
+ const parent = frontier.pop()!; const at = byPid.get(parent)?.created ?? '0';
362
+ for (const p of procs) if (p.parent === parent && !seen.has(p.pid) && BigInt(p.created || '0') >= BigInt(at || '0')) { seen.add(p.pid); out.push(p); frontier.push(p.pid); }
363
+ }
364
+ return out;
365
+ }
366
+
291
367
  /** Kill a process and its descendants, children first, each by its exact pid. */
292
368
  export function killTree(pid: number): void {
293
- const children = Bun.spawnSync({ cmd: ['pgrep', '-P', String(pid)] }).stdout.toString().split('\n').map((s) => Number(s.trim())).filter((n) => Number.isFinite(n) && n > 0);
369
+ // Windows has no pgrep and no signal a process can catch: taskkill ends the pid and its whole tree
370
+ if (process.platform === 'win32') { Bun.spawnSync({ cmd: ['taskkill', '/PID', String(pid), '/T', '/F'], windowsHide: true, stdout: 'ignore', stderr: 'ignore' }); return; }
371
+ const children = Bun.spawnSync({ cmd: ['pgrep', '-P', String(pid)], windowsHide: true }).stdout.toString().split('\n').map((s) => Number(s.trim())).filter((n) => Number.isFinite(n) && n > 0);
294
372
  for (const child of children) killTree(child);
295
373
  try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
296
374
  }
@@ -24,14 +24,27 @@ for (const file of pages) {
24
24
  test('has steps a reader runs', () => { expect(tutorial.steps.filter((s) => s.kind === 'run').length).toBeGreaterThan(2); });
25
25
  test('runs as written', async () => {
26
26
  const app = tutorialApp(tutorial.name, registry!);
27
- // a page about the hosted product runs against the hosted stack — the rehearsal's, stood up for the page (scripts/hosted-stack.ts)
28
27
  const { needsHosted, startHostedStack } = await import('../../../../scripts/hosted-stack.ts');
29
- const hosted = needsHosted(readFileSync(file, 'utf8')) ? await startHostedStack() : null;
30
- if (hosted) Object.assign(app.env, hosted.env);
28
+ const { needsIssuer, startIssuer } = await import('../../../../scripts/issuer-stack.ts');
29
+ let hosted: Awaited<ReturnType<typeof startHostedStack>> | null = null;
30
+ let issuer: Awaited<ReturnType<typeof startIssuer>> | null = null;
31
31
  let results;
32
32
  let completed = false;
33
- try { results = await runTutorial(tutorial, app, { stepTimeoutMs: 180_000 }); completed = true; }
34
- finally { await hosted?.stop(); if (process.env.TUTORIAL_KEEP || !completed) console.error(`kept ${app.parent}`); else await removeTutorialApp(app.parent); } // failed shell/World cleanup retains its evidence; TUTORIAL_KEEP=1 also keeps a successful page
33
+ // TUTORIAL_TRACE=1 prints each step as it finishes: what a failed shell cleanup would otherwise take with it
34
+ const trace = process.env.TUTORIAL_TRACE ? (r: { step: { kind: string; line: number; command?: string; path?: string }; output: string; missing: string[]; exit: number | null }) => console.error(`── line ${r.step.line} ${r.step.command ?? r.step.path} → exit ${r.exit}${r.missing.length ? `, missing: ${r.missing.join(' | ')}` : ''}\n${r.output.trim().slice(-1500)}`) : undefined;
35
+ try {
36
+ // a page about the hosted product runs against the hosted stack — the rehearsal's, stood up for the page (scripts/hosted-stack.ts)
37
+ if (needsHosted(readFileSync(file, 'utf8'))) { hosted = await startHostedStack(); Object.assign(app.env, hosted.env); }
38
+ // a page about self-hosting the platform brings its own identity provider: the values its provider gives back (scripts/issuer-stack.ts)
39
+ const platformOrigin = needsIssuer(readFileSync(file, 'utf8'));
40
+ if (platformOrigin) { issuer = await startIssuer(platformOrigin); Object.assign(app.env, issuer.env); }
41
+ results = await runTutorial(tutorial, app, { stepTimeoutMs: 180_000, ...(trace ? { onStep: trace } : {}) }); completed = true;
42
+ } finally {
43
+ // each stack is stopped whatever the other or the page did; a stop's failure is said, never in place of the page's own
44
+ const stopped = await Promise.allSettled([hosted?.stop(), issuer?.stop()]);
45
+ for (const s of stopped) if (s.status === 'rejected') { completed = false; console.error(`a stack did not stop: ${String(s.reason)}`); }
46
+ if (process.env.TUTORIAL_KEEP || !completed) console.error(`kept ${app.parent}`); else await removeTutorialApp(app.parent); // failed shell/World cleanup retains its evidence; TUTORIAL_KEEP=1 also keeps a successful page
47
+ }
35
48
  const wrong = failures(results);
36
49
  expect(wrong, wrong.join('\n\n')).toEqual([]);
37
50
  }, 900_000);
package/src/mcp.ts ADDED
@@ -0,0 +1,181 @@
1
+ // `volter mcp` (docs/contributing/architecture.md, "The way in is the app's folder"): an MCP server over stdio whose tools
2
+ // are the `volter` command's own verbs. A tool that reads (status, log, diff, branches) asks the SDK; a tool that acts runs
3
+ // the command itself, as a child, so there is one implementation of each verb and an agent gets what a person would.
4
+ // Locally a World is reached with its own token (the command finds it); the platform with the person's token from
5
+ // `volter login`, which only the person can approve, in their browser.
6
+ import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { resolve } from 'node:path';
9
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
+ import { z } from 'zod';
12
+ import { World } from './world.ts';
13
+ import { agentPrompt } from './agent-prompt.ts';
14
+
15
+ /** This command's own entry, beside this module (src/cli.ts in a checkout, dist/src/cli.js when published). */
16
+ const CLI = fileURLToPath(new URL(`./cli${import.meta.url.endsWith('.ts') ? '.ts' : '.js'}`, import.meta.url));
17
+ const MAX_OUTPUT = 20_000;
18
+
19
+ type Ran = { code: number; output: string };
20
+ /** Run `volter <args>` in `dir` to its end (or its timeout), its stdout and stderr together, the tail kept. */
21
+ function volter(args: string[], dir: string, timeoutSeconds = 600): Promise<Ran> {
22
+ return new Promise((done) => {
23
+ const child = spawn(process.execPath, [CLI, ...args], { cwd: dir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
24
+ let output = '';
25
+ const take = (d: Buffer): void => { output += d.toString(); if (output.length > MAX_OUTPUT * 2) output = output.slice(-MAX_OUTPUT); };
26
+ child.stdout.on('data', take); child.stderr.on('data', take);
27
+ const timer = setTimeout(() => { output += `\n(stopped after ${timeoutSeconds}s)`; child.kill(); }, timeoutSeconds * 1000);
28
+ child.on('error', (e) => { clearTimeout(timer); done({ code: 1, output: `${output}${e.message}` }); });
29
+ child.on('close', (code) => { clearTimeout(timer); done({ code: code ?? 1, output: output.length > MAX_OUTPUT ? `…${output.slice(-MAX_OUTPUT)}` : output }); });
30
+ });
31
+ }
32
+
33
+ /** A command that keeps running (the World's view, a sign-in waiting for approval): started once, answered as soon as
34
+ * its first lines say what the agent needs, left running for as long as this server runs. */
35
+ const background = new Map<string, ChildProcess>();
36
+ function startBackground(key: string, args: string[], dir: string, ready: RegExp, seconds = 60): Promise<Ran> {
37
+ background.get(key)?.kill(); background.delete(key);
38
+ return new Promise((done) => {
39
+ const child = spawn(process.execPath, [CLI, ...args], { cwd: dir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
40
+ background.set(key, child);
41
+ let output = ''; let answered = false;
42
+ const answer = (code: number): void => { if (!answered) { answered = true; clearTimeout(timer); done({ code, output }); } };
43
+ const take = (d: Buffer): void => { output += d.toString(); if (ready.test(output)) answer(0); };
44
+ child.stdout!.on('data', take); child.stderr!.on('data', take);
45
+ child.on('error', (e) => { output += e.message; answer(1); });
46
+ child.on('close', (code) => { background.delete(key); answer(code ?? 1); });
47
+ const timer = setTimeout(() => answer(0), seconds * 1000);
48
+ });
49
+ }
50
+ /** Everything this server left running, ended: a World under a dashboard is brought down through its own command first
51
+ * (its state kept, its teardown recorded), then the command itself. */
52
+ function stopAll(): void {
53
+ for (const [key, child] of background) {
54
+ if (key.startsWith('view:')) spawnSync(process.execPath, [CLI, 'world', 'down'], { cwd: key.slice('view:'.length), windowsHide: true, stdio: 'ignore', timeout: 60_000 });
55
+ child.kill();
56
+ }
57
+ background.clear();
58
+ }
59
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => { stopAll(); process.exit(0); });
60
+ process.on('exit', stopAll);
61
+
62
+ const text = (s: string, isError = false) => ({ content: [{ type: 'text' as const, text: s.trim() || '(no output)' }], ...(isError ? { isError: true } : {}) });
63
+ const ran = (r: Ran) => text(r.output, r.code !== 0);
64
+ const asJson = (v: unknown) => text(JSON.stringify(v, null, 2));
65
+ const fail = (e: unknown) => text(e instanceof Error ? e.message : String(e), true);
66
+
67
+ const INSTRUCTIONS = `Volter World runs an app against twins of the SaaS APIs it calls (Stripe, Slack, GitHub, OpenAI and about a hundred more): the app keeps its real SDKs and code, and every write lands in a World that can be branched, reset and looked at on each vendor's own screens.
68
+
69
+ Work in the app's folder (each tool takes "dir", defaulting to where this server was started).
70
+ - Set up: world_init detects the vendors from the app's manifests and env names; show the person the list before keeping it. Then world_up.
71
+ - Run the app or its tests inside the World with world_run (never bare against the real vendors).
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
+ - Branch and reset like a database: world_branch, world_checkout, world_reset.
74
+ - Share with a team: platform_login returns a link and a code the PERSON must approve in their browser (you cannot approve it); after they do, remote_link links this World to <org>/<world> on the platform (create: true makes it there from this World's vendors); world_changeset cuts the changes with a message, and world_push sends them.
75
+ The guide: https://github.com/volter-ai/twin/blob/main/docs/guides/use-with-a-coding-agent.md`;
76
+
77
+ export async function serveMcp(version: string): Promise<void> {
78
+ const server = new McpServer({ name: 'volter', title: 'Volter World', version }, { instructions: INSTRUCTIONS });
79
+ const here = (dir?: string): string => resolve(dir ?? process.cwd());
80
+ const dirArg = { dir: z.string().optional().describe("The app's folder (the World's root). Defaults to where this server was started.") };
81
+
82
+ server.registerTool('world_status', {
83
+ title: 'World status', description: 'The World of the app folder: its branch, whether it is running, each twin\'s URL, its origin (the remote it pushes to) and unpushed changes.',
84
+ inputSchema: dirArg, annotations: { readOnlyHint: true },
85
+ }, async ({ dir }) => { try { return asJson(World.open({ root: here(dir) }).status()); } catch (e) { return fail(e); } });
86
+
87
+ server.registerTool('world_init', {
88
+ title: 'Set up a World for the app', description: "Detect the vendors the app calls (from its package manifests and env var names), install their twins when none are installed yet, and write .volter/world.json beside the code. Show the person the detected vendors; pass allowUnknown to keep going when some have no twin yet.",
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('Overwrite an existing .volter/world.json.') },
90
+ }, async ({ dir, name, allowUnknown, force }) => ran(await volter(['world', 'init', '--install', ...(name ? ['--name', name] : []), ...(allowUnknown ? ['--allow-unknown'] : []), ...(force ? ['--force'] : [])], here(dir))));
91
+
92
+ server.registerTool('world_up', {
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.',
94
+ inputSchema: { ...dirArg, seed: z.boolean().optional().describe('Load the default data on a fresh branch (default true).') },
95
+ }, async ({ dir, seed }) => ran(await volter(['world', 'up', ...(seed === false ? ['--no-seed'] : [])], here(dir))));
96
+
97
+ server.registerTool('world_run', {
98
+ title: 'Run a command in the World', description: "Run the app or its tests inside the World: the vendors' hosts go to their twins, so the real SDKs work unchanged. Returns the command's exit code and the tail of its output.",
99
+ inputSchema: { ...dirArg, command: z.array(z.string()).min(1).describe('The command and its arguments, e.g. ["npm", "test"].'), timeoutSeconds: z.number().int().positive().max(3600).optional() },
100
+ }, async ({ dir, command, timeoutSeconds }) => { const r = await volter(['world', 'run', '--', ...command], here(dir), timeoutSeconds ?? 600); return text(`exit ${r.code}\n${r.output}`, r.code !== 0); });
101
+
102
+ server.registerTool('world_down', {
103
+ title: 'Stop the World', description: 'Stop the twins. Their state is kept for the next world_up unless purge is true.',
104
+ inputSchema: { ...dirArg, purge: z.boolean().optional() }, annotations: { destructiveHint: true },
105
+ }, async ({ dir, purge }) => ran(await volter(['world', 'down', ...(purge ? ['--purge'] : [])], here(dir))));
106
+
107
+ server.registerTool('world_view', {
108
+ title: 'Open the dashboard', description: "Run the World with its dashboard on this machine (each vendor's own screens, the calls, the changes, the branches) and return its link for the person. The dashboard runs the World itself: a World already started with world_up is stopped first (its state is kept) and comes back under the dashboard. It keeps running while this server does; world_run works against it meanwhile.",
109
+ inputSchema: dirArg,
110
+ }, async ({ dir }) => {
111
+ const root = here(dir);
112
+ let before = '';
113
+ try { const s = World.open({ root }).status(); if (s.running && !s.served) { const down = await volter(['world', 'down'], root); if (down.code !== 0) return ran(down); before = `${down.output.trim()} (state kept; the dashboard runs it now)\n`; } } catch (e) { return fail(e); }
114
+ const r = await startBackground(`view:${root}`, ['world', 'view', '--no-open'], root, /console\s+\S+/);
115
+ return text(`${before}${r.output}`, r.code !== 0);
116
+ });
117
+
118
+ server.registerTool('world_log', {
119
+ title: 'What the app changed', description: 'Every write the app made in this World, newest last: vendor, operation and subject.',
120
+ inputSchema: { ...dirArg, limit: z.number().int().positive().max(1000).optional().describe('The newest this many (default 50).') }, annotations: { readOnlyHint: true },
121
+ }, async ({ dir, limit }) => { try { const rows = World.open({ root: here(dir) }).log(); return asJson(rows.slice(-(limit ?? 50)).map((r) => ({ vendor: r.service, operation: r.operation ?? r.op, subject: r.subject, ...(r.receipt ? { receipt: r.receipt } : {}) }))); } catch (e) { return fail(e); } });
122
+
123
+ server.registerTool('world_diff', {
124
+ title: 'Changes since the default data', description: 'How many changes each vendor has since the default data (or the last mark).',
125
+ inputSchema: dirArg, annotations: { readOnlyHint: true },
126
+ }, async ({ dir }) => { try { const d = World.open({ root: here(dir) }).diff(); return asJson({ base: d.base, vendors: d.vendors }); } catch (e) { return fail(e); } });
127
+
128
+ server.registerTool('world_branch', {
129
+ title: 'List or make branches', description: 'Without a name, the branches (the checked-out one first). With a name, make a branch from here and check it out, like a database branch: try something, then check out the old one or reset.',
130
+ inputSchema: { ...dirArg, name: z.string().optional() },
131
+ }, async ({ dir, name }) => {
132
+ if (name) return ran(await volter(['world', 'branch', name], here(dir)));
133
+ try { const w = World.open({ root: here(dir) }); return asJson({ current: w.name, branches: w.branches() }); } catch (e) { return fail(e); }
134
+ });
135
+
136
+ server.registerTool('world_checkout', {
137
+ title: 'Switch branch', description: 'Check out another branch of the World.',
138
+ inputSchema: { ...dirArg, name: z.string() },
139
+ }, async ({ dir, name }) => ran(await volter(['world', 'checkout', name], here(dir))));
140
+
141
+ server.registerTool('world_reset', {
142
+ title: 'Back to the default data', description: "Discard the current branch's changes and load its default data again.",
143
+ inputSchema: dirArg, annotations: { destructiveHint: true },
144
+ }, async ({ dir }) => ran(await volter(['world', 'reset'], here(dir))));
145
+
146
+ server.registerTool('platform_login', {
147
+ title: 'Sign in to a platform', description: 'Start signing this machine\'s `volter` command in to a Volter World platform. Returns a link and a code: give both to the PERSON, who approves it in their browser (you cannot). The sign-in completes by itself once they do; check with platform_whoami.',
148
+ inputSchema: { url: z.string().url().describe("The platform's address (https://…), as the person gave it") },
149
+ }, async ({ url }) => ran(await startBackground('login', ['login', url, '--no-open'], process.cwd(), /code\s+[A-Z]{4}-[A-Z]{4}/, 30)));
150
+
151
+ server.registerTool('platform_whoami', {
152
+ title: 'Who the command is signed in as', description: 'The platform this machine\'s `volter` command is signed in to, as whom, and their orgs.',
153
+ inputSchema: {}, annotations: { readOnlyHint: true },
154
+ }, async () => ran(await volter(['whoami'], process.cwd())));
155
+
156
+ server.registerTool('remote_link', {
157
+ title: 'Link the World to the platform', description: "Link this World to <org>/<world> on the platform the command is signed in to, as the remote it pushes to. create: true makes that World on the platform from this World's vendors when it does not exist.",
158
+ inputSchema: { ...dirArg, target: z.string().regex(/^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/).describe('<org>/<world>'), name: z.string().optional().describe('The remote\'s name (default origin).'), create: z.boolean().optional() },
159
+ }, async ({ dir, target, name, create }) => ran(await volter(['remote', 'add', name ?? 'origin', target, ...(create ? ['--create'] : [])], here(dir))));
160
+
161
+ server.registerTool('world_changeset', {
162
+ title: 'Cut a changeset', description: "Cut the World's unpushed changes into a reviewable changeset, with a message saying what and why; world_push sends it.",
163
+ inputSchema: { ...dirArg, message: z.string().min(1).describe('What the changes are and why.'), name: z.string().optional() },
164
+ }, async ({ dir, message, name }) => ran(await volter(['world', 'changeset', ...(name ? [name] : []), '-m', message], here(dir))));
165
+
166
+ server.registerTool('world_push', {
167
+ title: 'Push to the remote', description: "Send this World's unpushed changes to its remote (the platform's World after remote_link); the remote answers with a receipt for each change.",
168
+ inputSchema: { ...dirArg, changeset: z.string().optional().describe('Push one changeset by name; default all unpushed.') },
169
+ }, async ({ dir, changeset }) => ran(await volter(['world', 'push', ...(changeset ? [changeset] : [])], here(dir))));
170
+
171
+ server.registerPrompt('get-started', {
172
+ title: 'Set up Volter World for this app', description: 'Walks through setting up a World for the app in this folder, running its tests in it, and optionally sharing it on a platform.',
173
+ argsSchema: { platform: z.string().optional().describe("A platform to share the World on: its address"), world: z.string().optional().describe('<org>/<world> on that platform') },
174
+ }, ({ platform, world }) => ({ messages: [{ role: 'user', content: { type: 'text', text: agentPrompt({ ...(platform ? { platform } : {}), ...(world ? { world } : {}), tools: true }) } }] }));
175
+
176
+ // the client closing the connection (its stdin ends) ends this server, and the commands it left running with it
177
+ const stop = (): void => { stopAll(); process.exit(0); };
178
+ server.server.onclose = stop;
179
+ process.stdin.on('end', stop);
180
+ await server.connect(new StdioServerTransport());
181
+ }
@@ -0,0 +1,44 @@
1
+ // The update notice (docs/contributing/architecture.md, "The way in is the app's folder"): at most once a day the command
2
+ // asks the npm registry for @volter/world's latest version, while it does its own work, and says in one line on stderr
3
+ // when a newer one exists. Never in CI, never for --json, never when the person opted out (VOLTER_NO_UPDATE_NOTIFIER=1):
4
+ // then nothing is asked at all. The answer is kept beside the credentials, so a day's commands ask once.
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { credentialsPath } from './credentials.ts';
8
+
9
+ const DAY = 86_400_000;
10
+ const REGISTRY = 'https://registry.npmjs.org/@volter/world/latest';
11
+
12
+ type Checked = { checkedAt: string; latest: string | null };
13
+
14
+ /** This package's version, read from its own package.json. */
15
+ export function currentVersion(): string {
16
+ try { return (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version?: string }).version ?? '0.0.0'; } catch { return '0.0.0'; }
17
+ }
18
+
19
+ /** Whether `a` is a newer release than `b` (major.minor.patch; a prerelease is not offered). */
20
+ export function newer(a: string, b: string): boolean {
21
+ const parse = (v: string): number[] | null => (/^\d+\.\d+\.\d+$/.test(v) ? v.split('.').map(Number) : null);
22
+ const x = parse(a); const y = parse(b); if (!x || !y) return false;
23
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i]! > y[i]!;
24
+ return false;
25
+ }
26
+
27
+ /** Start the day's check if one is due, while the command runs; the returned function, called when the command is
28
+ * done, waits a moment at most for it and prints the notice when there is one. Off, it does nothing and asks nothing. */
29
+ export function startUpdateCheck(args: string[], env: NodeJS.ProcessEnv = process.env): () => Promise<void> {
30
+ if (env.VOLTER_NO_UPDATE_NOTIFIER || env.CI || args.includes('--json') || !process.stderr.isTTY) return async () => undefined;
31
+ const path = join(dirname(credentialsPath()), 'update-check.json');
32
+ let kept: Checked | null = null;
33
+ try { if (existsSync(path)) kept = JSON.parse(readFileSync(path, 'utf8')) as Checked; } catch { kept = null; }
34
+ const due = !kept || Date.now() - Date.parse(kept.checkedAt) > DAY;
35
+ const check: Promise<string | null> = due
36
+ ? fetch(REGISTRY, { signal: AbortSignal.timeout(1500) }).then(async (r) => (r.ok ? ((await r.json()) as { version?: string }).version ?? null : null)).catch(() => null)
37
+ .then((latest) => { try { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify({ checkedAt: new Date().toISOString(), latest } satisfies Checked)}\n`); } catch { /* a read-only home only loses the cache */ } return latest; })
38
+ : Promise.resolve(kept?.latest ?? null);
39
+ return async () => {
40
+ const latest = await Promise.race([check, new Promise<null>((r) => setTimeout(() => r(null), 800))]);
41
+ const here = currentVersion();
42
+ if (latest && newer(latest, here)) process.stderr.write(`\nvolter ${latest} is out (you have ${here}): npm install -g @volter/world\n`);
43
+ };
44
+ }
package/src/world.ts CHANGED
@@ -8,7 +8,7 @@ import clockForm from '@volter/world-core/world-clock';
8
8
  import {
9
9
  activateScript, approveWorldChangeset, branchWorld, clockFile, checkoutWorld, createWorldChangeset, diffWorld, downWorld, fetchFromOrigin, findWorldChangeset, initWorld, listWorldChangesets, listWorldMarks, listWorlds, markWorld, pushWorldChangeset, rebaseWorldChangeset, replayWorldChangeset, resetWorld, runWithWorldEnv, seedWorld, shellWorld,
10
10
  statusWorld, statusWorldChangeset, upWorld, verifyWorldChangeset, worldLedgers, worldOrigin,
11
- deployWorld, loadWorldConfig, writeWorldConfig, readServeRecord, refreshTwin, serveWorld, findInstalledPackage, type ServedWorld, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, credentialPayloadFrom,
11
+ deployWorld, loadWorldConfig, writeWorldConfig, readServeRecord, refreshTwin, serveWorld, serveWorldView, findInstalledPackage, type ServedWorld, type ViewConsole, type WorldView, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, credentialPayloadFrom,
12
12
  type DeployTwinOutcome, type MaterializedRoot,
13
13
  type ChangesetLocation, type InitOptions, type InitResult, type WorldInstance, materializeRoots } from '@volter/world-runtime';
14
14
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
@@ -108,7 +108,7 @@ export class World {
108
108
  if (opts.install !== false && !twins.every((t) => findInstalledPackage(dir, `@volter/twin-${t}`))) {
109
109
  // the runtime's own installer: bun under Bun, npm under Node — never assumed
110
110
  const cmd = typeof Bun !== 'undefined' ? ['bun', 'add', '-d'] : ['npm', 'install', '--save-dev'];
111
- const r = spawnSync(cmd[0]!, [...cmd.slice(1), ...twins.map((t) => `@volter/twin-${t}`)], { cwd: dir, stdio: 'inherit' });
111
+ const r = spawnSync(cmd[0]!, [...cmd.slice(1), ...twins.map((t) => `@volter/twin-${t}`)], { windowsHide: true, cwd: dir, stdio: 'inherit' });
112
112
  if (r.status !== 0) throw new Error(`installing the twins failed (${cmd.join(' ')} exited ${r.status})`);
113
113
  }
114
114
  const name = served.split('/')[1]!;
@@ -329,8 +329,15 @@ export class World {
329
329
  // ── serving ─────────────────────────────────────────────────────────────────────────────
330
330
  /** Serve this world on a port under `/<org>/<world>/`; returns when listening. */
331
331
  serve(opts: { port?: number; host?: string; consolePort?: number; announce?: (info: { name: string; base: string; token: string; readToken: string; console: string | null }) => void } = {}): Promise<ServedWorld> { return serveWorld(this.name, { root: this.root, ...opts }); }
332
+ /** `volter world view`: this World, its mirrors, its branches and the console on one origin (docs/contributing/architecture.md, "Viewing a World"). */
333
+ view(opts: { port?: number; host?: string; console?: ViewConsole; announce?: (line: string) => void; origins?: boolean } = {}): Promise<WorldView> { return serveWorldView(this.name, { root: this.root, ...opts }); }
332
334
 
333
335
  // ── remotes ─────────────────────────────────────────────────────────────────────────────
336
+ /** The vendors this world has a twin of, as world.json names them (a twin service's package, else its id). */
337
+ vendors(): string[] {
338
+ const { config } = loadWorldConfig(this.configRef(), this.root);
339
+ return [...new Set(config.services.filter((s) => (s.type ?? 'twin') === 'twin').map((s) => /^@volter\/twin-(.+)$/.exec(s.package ?? '')?.[1] ?? s.id))].sort();
340
+ }
334
341
  /** The remotes named in world.json (`volter remote add`), name → url or path. */
335
342
  remotes(): Record<string, string> { return { ...(loadWorldConfig(this.configRef(), this.root).config.remotes ?? {}) }; }
336
343
  /** Name a remote; `origin` is the one fetch and push use by default. A token given is remembered for it. */