@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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { downEverything, failures, parseTutorial, removeTutorialApp, runTutorial, tutorialApp } from './tutorial.ts';
|
|
6
|
+
|
|
7
|
+
async function run(markdown: string, timeoutMs = 6000) {
|
|
8
|
+
const dir = mkdtempSync(join(tmpdir(), 'tutorial-observation-'));
|
|
9
|
+
try {
|
|
10
|
+
return await runTutorial(parseTutorial(markdown, 'observation.md'),
|
|
11
|
+
{ dir, parent: dir, env: { ...process.env } as Record<string, string> }, { stepTimeoutMs: timeoutMs });
|
|
12
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test('preserves output guaranteed to precede the background launch marker', async () => {
|
|
16
|
+
// The first printf runs synchronously before bash backgrounds sleep; its output must
|
|
17
|
+
// precede the runner's DONE marker regardless of process scheduling.
|
|
18
|
+
const result = await run("```bash\nprintf 'early readiness\\n'; sleep 0.1 &\ncd .\n```\n```text\nearly readiness\n```\n");
|
|
19
|
+
expect(failures(result)).toEqual([]);
|
|
20
|
+
expect(result.at(-1)!.output).toContain('early readiness');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('waits for delayed readiness at the end of a fence, including after a foreground cd', async () => {
|
|
24
|
+
const result = await run("```bash\n(sleep 3; printf 'host ready\\nconsole ready\\n') &\ncd .\n```\n```text\nhost ready\nconsole ready\n```\n");
|
|
25
|
+
expect(failures(result)).toEqual([]);
|
|
26
|
+
}, 15_000);
|
|
27
|
+
|
|
28
|
+
test('missing background output remains a bounded failure with the observed diagnostics', async () => {
|
|
29
|
+
const result = await run("```bash\nprintf 'wrong output\\n' &\ncd .\n```\n```text\nrequired readiness\n```\n", 300);
|
|
30
|
+
expect(failures(result).join('\n')).toContain('required readiness');
|
|
31
|
+
expect(failures(result).join('\n')).toContain('wrong output');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('foreground failures still fail and documented refusals still match their expected text', async () => {
|
|
35
|
+
const result = await run("```bash\nfalse\n```\n```bash\nprintf 'refused\\n'; false\n```\n```text\nrefused\n```\n");
|
|
36
|
+
expect(failures(result)).toHaveLength(1);
|
|
37
|
+
expect(failures(result)[0]).toContain('exited 1');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
for (const delayedStep of [false, true]) test(`joins asynchronous job shutdown before returning (delayed step marker: ${delayedStep})`, async () => {
|
|
41
|
+
const dir = mkdtempSync(join(tmpdir(), 'tutorial-join-'));
|
|
42
|
+
const finished = join(dir, 'finished');
|
|
43
|
+
const pidFile = join(dir, 'job.pid');
|
|
44
|
+
const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response('shutdown permitted') });
|
|
45
|
+
writeFileSync(join(dir, 'job.mjs'), `
|
|
46
|
+
import fs from 'node:fs';
|
|
47
|
+
let stopping=false;
|
|
48
|
+
process.on('SIGTERM',async()=>{
|
|
49
|
+
if(stopping)return; stopping=true;
|
|
50
|
+
await fetch('http://127.0.0.1:${server.port}');
|
|
51
|
+
fs.writeFileSync(${JSON.stringify(finished)},'joined');process.exit(0);
|
|
52
|
+
});
|
|
53
|
+
fs.writeFileSync(${JSON.stringify(pidFile)},String(process.pid));
|
|
54
|
+
console.log('job ready');setInterval(()=>{},1000);
|
|
55
|
+
`);
|
|
56
|
+
const markdown = '```bash\nbun job.mjs &\n```\n```text\njob ready\n```\n'
|
|
57
|
+
+ (delayedStep ? '```bash\nsleep 0.3\n```\n' : '');
|
|
58
|
+
try {
|
|
59
|
+
const run = runTutorial(parseTutorial(markdown, 'join.md'), { dir, parent: dir, env: { ...process.env } as Record<string, string> }, { stepTimeoutMs: delayedStep ? 100 : 6000 });
|
|
60
|
+
if (delayedStep) await expect(run).rejects.toThrow('step timed out');
|
|
61
|
+
else expect(failures(await run)).toEqual([]);
|
|
62
|
+
expect(readFileSync(finished, 'utf8')).toBe('joined');
|
|
63
|
+
} finally {
|
|
64
|
+
// The fixture knows only this exact child; failed assertions must not leave it running.
|
|
65
|
+
if (existsSync(pidFile)) {
|
|
66
|
+
const pid = Number(readFileSync(pidFile, 'utf8'));
|
|
67
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* already reaped */ }
|
|
68
|
+
const alive = () => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
69
|
+
const deadline = Date.now() + 5000;
|
|
70
|
+
while (alive() && Date.now() < deadline) await Bun.sleep(20);
|
|
71
|
+
expect(alive()).toBe(false);
|
|
72
|
+
}
|
|
73
|
+
server.stop(true);
|
|
74
|
+
rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
}, 20_000);
|
|
77
|
+
|
|
78
|
+
test('a background shutdown failure fails tutorial cleanup and retains its directory', async () => {
|
|
79
|
+
const dir = mkdtempSync(join(tmpdir(), 'tutorial-stop-fail-'));
|
|
80
|
+
writeFileSync(join(dir, 'job.mjs'), "process.on('SIGTERM',()=>process.exit(7));console.log('job ready');setInterval(()=>{},1000);");
|
|
81
|
+
try {
|
|
82
|
+
await expect(runTutorial(parseTutorial('```bash\nbun job.mjs &\n```\n```text\njob ready\n```\n', 'stop-fail.md'),
|
|
83
|
+
{ dir, parent: dir, env: { ...process.env } as Record<string, string> })).rejects.toThrow('failed during cleanup: 7');
|
|
84
|
+
expect(existsSync(dir)).toBe(true);
|
|
85
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
86
|
+
}, 20_000);
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
test('npm and Bun keep the local scope in global installs and nested directories, with no public fallback', async () => {
|
|
90
|
+
const hits: string[] = [];
|
|
91
|
+
const registry = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) {
|
|
92
|
+
hits.push(new URL(request.url).pathname);
|
|
93
|
+
return Response.json({ error: 'fixture package missing' }, { status: 404 });
|
|
94
|
+
} });
|
|
95
|
+
let publicRegistry: ReturnType<typeof Bun.serve> | undefined;
|
|
96
|
+
const publicHits: string[] = [];
|
|
97
|
+
let app: ReturnType<typeof tutorialApp> | undefined;
|
|
98
|
+
try {
|
|
99
|
+
app = tutorialApp('registry-scope', { env: { NPM_CONFIG_REGISTRY: `http://127.0.0.1:${registry.port}` } });
|
|
100
|
+
const nested = join(app.dir, 'nested');
|
|
101
|
+
mkdirSync(nested);
|
|
102
|
+
for (const global of [false, true]) {
|
|
103
|
+
const scope = Bun.spawn(['npm', 'view', '@volter/fixture-scope-missing', '--json', '--fetch-retries=0', ...(global ? ['--global'] : [])],
|
|
104
|
+
{ cwd: nested, env: app.env, stdout: 'pipe', stderr: 'pipe' });
|
|
105
|
+
const [code, output, error] = await Promise.all([scope.exited, new Response(scope.stdout).text(), new Response(scope.stderr).text()]);
|
|
106
|
+
expect(code).not.toBe(0);
|
|
107
|
+
expect(output + error).toContain('fixture package missing');
|
|
108
|
+
expect(hits).toHaveLength(global ? 2 : 1);
|
|
109
|
+
const defaults = Bun.spawn(['npm', 'config', 'get', 'registry', ...(global ? ['--global'] : [])],
|
|
110
|
+
{ cwd: nested, env: app.env, stdout: 'pipe', stderr: 'pipe' });
|
|
111
|
+
const [defaultCode, value] = await Promise.all([defaults.exited, new Response(defaults.stdout).text(), new Response(defaults.stderr).text()]);
|
|
112
|
+
expect(defaultCode).toBe(0);
|
|
113
|
+
expect(value.trim()).toBe('https://registry.npmjs.org/');
|
|
114
|
+
}
|
|
115
|
+
expect(app.env.BUN_CONFIG_REGISTRY).toBe('https://registry.npmjs.org/');
|
|
116
|
+
publicRegistry = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) {
|
|
117
|
+
publicHits.push(new URL(request.url).pathname);
|
|
118
|
+
return Response.json({ error: 'default fixture missing' }, { status: 404 });
|
|
119
|
+
} });
|
|
120
|
+
const bunEnv = { ...app.env, BUN_CONFIG_REGISTRY: `http://127.0.0.1:${publicRegistry.port}` };
|
|
121
|
+
for (const global of [false, true]) {
|
|
122
|
+
const bun = Bun.spawn(['bun', 'add', '@volter/fixture-scope-missing', '--ignore-scripts', ...(global ? ['--global'] : [])],
|
|
123
|
+
{ cwd: nested, env: bunEnv, stdout: 'pipe', stderr: 'pipe' });
|
|
124
|
+
const [bunCode, bunOutput, bunError] = await Promise.all([bun.exited, new Response(bun.stdout).text(), new Response(bun.stderr).text()]);
|
|
125
|
+
expect(bunCode).not.toBe(0);
|
|
126
|
+
expect(bunOutput + bunError).toContain('404');
|
|
127
|
+
expect(hits).toHaveLength(global ? 4 : 3);
|
|
128
|
+
expect(publicHits).toEqual([]);
|
|
129
|
+
}
|
|
130
|
+
const unscoped = Bun.spawn(['bun', 'add', 'fixture-public-missing', '--ignore-scripts'],
|
|
131
|
+
{ cwd: nested, env: bunEnv, stdout: 'pipe', stderr: 'pipe' });
|
|
132
|
+
const [unscopedCode] = await Promise.all([unscoped.exited, new Response(unscoped.stdout).text(), new Response(unscoped.stderr).text()]);
|
|
133
|
+
expect(unscopedCode).not.toBe(0);
|
|
134
|
+
expect(publicHits).toEqual(['/fixture-public-missing']);
|
|
135
|
+
expect(hits).toHaveLength(4);
|
|
136
|
+
expect(hits.every(path => decodeURIComponent(path) === '/@volter/fixture-scope-missing')).toBe(true);
|
|
137
|
+
} finally { registry.stop(true); publicRegistry?.stop(true); if (app) rmSync(app.parent, { recursive: true, force: true }); }
|
|
138
|
+
}, 30_000);
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
test('cleanup ignores arbitrary pid files and symlinked World roots', async () => {
|
|
142
|
+
const parent = mkdtempSync(join(tmpdir(), 'tutorial-cleanup-'));
|
|
143
|
+
const outside = mkdtempSync(join(tmpdir(), 'tutorial-outside-'));
|
|
144
|
+
const child = Bun.spawn([process.execPath, '-e', 'setInterval(()=>{},1000)'], { stdout: 'ignore', stderr: 'ignore' });
|
|
145
|
+
try {
|
|
146
|
+
writeFileSync(join(parent, 'pids'), `${child.pid}\n`);
|
|
147
|
+
const instance = join(outside, '.volter', 'worlds', 'outside');
|
|
148
|
+
mkdirSync(instance, { recursive: true });
|
|
149
|
+
writeFileSync(join(instance, 'instance.json'), 'unreadable fixture');
|
|
150
|
+
symlinkSync(outside, join(parent, 'linked-app'));
|
|
151
|
+
const app = join(parent, 'app');
|
|
152
|
+
mkdirSync(join(app, '.volter'), { recursive: true });
|
|
153
|
+
symlinkSync(join(outside, '.volter', 'worlds'), join(app, '.volter', 'worlds'));
|
|
154
|
+
await downEverything(parent);
|
|
155
|
+
expect(process.kill(child.pid, 0)).toBe(true);
|
|
156
|
+
expect(readFileSync(join(instance, 'instance.json'), 'utf8')).toBe('unreadable fixture');
|
|
157
|
+
} finally {
|
|
158
|
+
child.kill('SIGTERM'); await child.exited;
|
|
159
|
+
rmSync(parent, { recursive: true, force: true });
|
|
160
|
+
rmSync(outside, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('failed World teardown prevents fixture removal and reports the retained path', async () => {
|
|
165
|
+
const parent = mkdtempSync(join(tmpdir(), 'tutorial-retain-'));
|
|
166
|
+
const instance = join(parent, '.volter', 'worlds', 'broken');
|
|
167
|
+
mkdirSync(instance, { recursive: true });
|
|
168
|
+
mkdirSync(join(parent, '.volter', 'worlds', '.locks'));
|
|
169
|
+
mkdirSync(join(parent, '.volter', 'worlds', '.resources'));
|
|
170
|
+
const metadata = join(instance, 'instance.json');
|
|
171
|
+
writeFileSync(metadata, 'unreadable fixture');
|
|
172
|
+
try {
|
|
173
|
+
await expect(removeTutorialApp(parent)).rejects.toThrow(`retained ${parent}`);
|
|
174
|
+
expect(readFileSync(metadata, 'utf8')).toBe('unreadable fixture');
|
|
175
|
+
// This fixture never launched processes: removing the corrupt test record makes teardown safe.
|
|
176
|
+
rmSync(metadata);
|
|
177
|
+
await removeTutorialApp(parent);
|
|
178
|
+
expect(existsSync(parent)).toBe(false);
|
|
179
|
+
} finally { rmSync(parent, { recursive: true, force: true }); }
|
|
180
|
+
});
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// The tutorial runner — a tutorial page IS its journey (owner, 2026-09-05: "the journeys should
|
|
2
|
+
// ONLY be what happens in the tutorials — that way to keep the tutorials up to date and proven").
|
|
3
|
+
//
|
|
4
|
+
// A page is read for its fences, in order:
|
|
5
|
+
// ```bash every non-comment line is one command the reader types, run in ONE bash
|
|
6
|
+
// session that persists across the page (env, cwd, background jobs), the
|
|
7
|
+
// way a terminal does; a trailing `&` runs a server in the background
|
|
8
|
+
// ```text right after a bash fence: what the reader sees — every line must appear
|
|
9
|
+
// in that command's output, after ports, ids, hashes and times are masked
|
|
10
|
+
// ```<lang> file=p a file the reader writes at path p (relative to the app) before going on
|
|
11
|
+
// Everything else (a ```json config excerpt, a ```ts snippet with no file=) is illustration.
|
|
12
|
+
//
|
|
13
|
+
// Every command runs literally, `bun add` included: the app installs from the registry the
|
|
14
|
+
// caller names (scripts/registry.ts — this checkout's packages, published to a registry on this
|
|
15
|
+
// machine), with a global install dir and an install cache of its own, so `bun add -g
|
|
16
|
+
// @volter/world` puts `volter` on the PATH the way it does for a reader.
|
|
17
|
+
//
|
|
18
|
+
// The same steps make the recording: `tapeFor(page)` renders a vhs tape that types each command,
|
|
19
|
+
// waits, and screenshots after each one, so docs/media/<page>/ is the page's own playback.
|
|
20
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { downWorld } from '@volter/world-runtime';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
24
|
+
import { randomUUID } from 'node:crypto';
|
|
25
|
+
import { CLI, OPERATOR_CLI, PACKS, SDK } from './kit.ts';
|
|
26
|
+
void CLI; void OPERATOR_CLI; void PACKS; void SDK;
|
|
27
|
+
|
|
28
|
+
export type Step =
|
|
29
|
+
| { kind: 'file'; path: string; content: string; line: number }
|
|
30
|
+
| { kind: 'run'; command: string; background: boolean; expect: string[]; line: number; /** the bash fence this command came from: a text fence is what the reader sees after the WHOLE fence */ fence: number };
|
|
31
|
+
|
|
32
|
+
export type Tutorial = { name: string; file: string; steps: Step[] };
|
|
33
|
+
|
|
34
|
+
const FENCE = /^```([^\n]*)\n([\s\S]*?)^```[ \t]*$/gm;
|
|
35
|
+
|
|
36
|
+
/** The steps of a tutorial page, in the order the reader meets them. */
|
|
37
|
+
export function parseTutorial(markdown: string, file: string): Tutorial {
|
|
38
|
+
const steps: Step[] = [];
|
|
39
|
+
let lastRun: Extract<Step, { kind: 'run' }> | null = null;
|
|
40
|
+
let fence = 0;
|
|
41
|
+
for (const m of markdown.matchAll(FENCE)) {
|
|
42
|
+
const info = m[1]!.trim();
|
|
43
|
+
const body = m[2]!;
|
|
44
|
+
const line = markdown.slice(0, m.index).split('\n').length;
|
|
45
|
+
const [lang, ...attrs] = info.split(/\s+/);
|
|
46
|
+
const fileAttr = attrs.find((a) => a.startsWith('file='))?.slice(5);
|
|
47
|
+
if (fileAttr) { steps.push({ kind: 'file', path: fileAttr, content: body, line }); lastRun = null; continue; }
|
|
48
|
+
if (lang === 'bash' || lang === 'sh' || lang === 'shell') {
|
|
49
|
+
lastRun = null; fence += 1;
|
|
50
|
+
for (const command of commandsOf(body)) {
|
|
51
|
+
const background = /\s&\s*$/.test(command);
|
|
52
|
+
const step: Extract<Step, { kind: 'run' }> = { kind: 'run', command: command.replace(/\s&\s*$/, '').trim(), background, expect: [], line, fence };
|
|
53
|
+
steps.push(step); lastRun = step;
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (lang === 'text' && lastRun) {
|
|
58
|
+
lastRun.expect = body.split('\n').map((l) => l.trimEnd()).filter((l) => l.trim() !== '');
|
|
59
|
+
lastRun = null;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
lastRun = null;
|
|
63
|
+
}
|
|
64
|
+
// a cookbook recipe's page is its README: the recipe's directory is its name
|
|
65
|
+
const base = basename(file).replace(/\.md$/, '');
|
|
66
|
+
return { name: base === 'README' ? basename(dirname(file)) : base, file, steps };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Lines of a bash fence as commands: comments and blanks dropped, `\` continuations joined. */
|
|
70
|
+
function commandsOf(body: string): string[] {
|
|
71
|
+
const out: string[] = [];
|
|
72
|
+
let pending = '';
|
|
73
|
+
for (const raw of body.split('\n')) {
|
|
74
|
+
const line = raw.replace(/\s+#.*$/, '').trimEnd();
|
|
75
|
+
if (line.trim() === '' || line.trim().startsWith('#')) continue;
|
|
76
|
+
if (line.endsWith('\\')) { pending += `${line.slice(0, -1)} `; continue; }
|
|
77
|
+
out.push((pending + line).trim()); pending = '';
|
|
78
|
+
}
|
|
79
|
+
if (pending.trim()) out.push(pending.trim());
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** What varies between runs and machines, masked before an expected line is looked for. */
|
|
84
|
+
export function mask(text: string): string {
|
|
85
|
+
text = text.replace(/[ \t]{2,}/g, ' '); // columns are alignment, not content
|
|
86
|
+
return text
|
|
87
|
+
.replace(/127\.0\.0\.1:\d+/g, '127.0.0.1:PORT')
|
|
88
|
+
.replace(/\b\d\d:\d\d:\d\d\.\d{3}\b/g, 'TIME')
|
|
89
|
+
.replace(/\b\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?Z\b/g, 'ISO')
|
|
90
|
+
.replace(/\b(cus|evt|sub|price|prod|ch|pi|in|cs|act|chg|ghp|xoxb|xoxp)_[A-Za-z0-9_]+/g, '$1_ID')
|
|
91
|
+
.replace(/\(([0-9a-f]{12})\)/g, '(HASH)')
|
|
92
|
+
.replace(/\b[0-9a-f]{32,64}\b/g, 'HEX')
|
|
93
|
+
.replace(/Stopped (\S+): [\d, ]+/g, 'Stopped $1: PIDS')
|
|
94
|
+
.replace(/\/(?:private\/)?(?:var|tmp)\/[^\s'"]+/g, '/TMP')
|
|
95
|
+
.replace(/[ \t]+/g, ' ')
|
|
96
|
+
.trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type StepResult = { step: Step; output: string; missing: string[]; exit: number | null };
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A scratch app for a tutorial: an empty directory, a config dir of its own, a global install dir
|
|
103
|
+
* of its own on the PATH, and the registry to install from. The page makes everything else — its
|
|
104
|
+
* package.json, its env example, its files — and installs what it names.
|
|
105
|
+
*/
|
|
106
|
+
export function tutorialApp(name: string, registry: { env: Record<string, string> }): { dir: string; parent: string; env: Record<string, string> } {
|
|
107
|
+
const parent = mkdtempSync(join(tmpdir(), `tutorial-${name}-`));
|
|
108
|
+
const dir = join(parent, 'acme-web');
|
|
109
|
+
mkdirSync(dir);
|
|
110
|
+
const global = join(parent, 'bun-global');
|
|
111
|
+
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: '$ ' };
|
|
113
|
+
const localRegistry = registry.env.NPM_CONFIG_REGISTRY;
|
|
114
|
+
if (localRegistry) {
|
|
115
|
+
// Both clients read this private scope config, including global installs and nested dirs.
|
|
116
|
+
// Public SDK tarballs use the default registry without an extra Verdaccio relay.
|
|
117
|
+
mkdirSync(env.XDG_CONFIG_HOME, { recursive: true });
|
|
118
|
+
const npmrc = join(env.XDG_CONFIG_HOME, '.npmrc');
|
|
119
|
+
writeFileSync(npmrc, `@volter:registry=${localRegistry}\n`);
|
|
120
|
+
Object.assign(env, {
|
|
121
|
+
NPM_CONFIG_USERCONFIG: npmrc, npm_config_userconfig: npmrc,
|
|
122
|
+
NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org/', npm_config_registry: 'https://registry.npmjs.org/',
|
|
123
|
+
BUN_CONFIG_REGISTRY: 'https://registry.npmjs.org/',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return { dir, parent, env };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const DONE = '__VOLTER_STEP_DONE__';
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Run a tutorial's steps in one bash session in the app, checking each expected line. Returns
|
|
133
|
+
* every step's result; `failures()` says which steps the page got wrong.
|
|
134
|
+
*/
|
|
135
|
+
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' });
|
|
137
|
+
const results: StepResult[] = [];
|
|
138
|
+
const reader = shell.stdout.getReader();
|
|
139
|
+
const errReader = shell.stderr.getReader();
|
|
140
|
+
const decoder = new TextDecoder();
|
|
141
|
+
let buffer = '';
|
|
142
|
+
let errBuffer = '';
|
|
143
|
+
// one read in flight at a time: a read abandoned by a timeout race would swallow its chunk
|
|
144
|
+
let pending: ReturnType<typeof reader.read> | null = null;
|
|
145
|
+
const nextChunk = (): ReturnType<typeof reader.read> => {
|
|
146
|
+
if (pending === null) pending = reader.read().then((r) => { pending = null; return r; });
|
|
147
|
+
return pending;
|
|
148
|
+
};
|
|
149
|
+
// stderr is drained continuously (a background server's chatter must never block the shell)
|
|
150
|
+
const stderrDrain = (async () => { for (;;) { const { value, done } = await errReader.read(); if (done) break; errBuffer += decoder.decode(value); } })();
|
|
151
|
+
stderrDrain.catch(() => {}); // awaited at shutdown, including a failed reader
|
|
152
|
+
let current: Extract<Step, { kind: 'run' }> | null = null; // the step under way, for the timeout's message
|
|
153
|
+
const readUntilDone = async (timeoutMs: number, marker = DONE): Promise<{ output: string; exit: number | null }> => {
|
|
154
|
+
const started = Date.now();
|
|
155
|
+
for (;;) {
|
|
156
|
+
const i = buffer.indexOf(marker);
|
|
157
|
+
if (i >= 0) {
|
|
158
|
+
const tail = buffer.slice(i + marker.length);
|
|
159
|
+
const nl = tail.indexOf('\n');
|
|
160
|
+
if (nl >= 0) {
|
|
161
|
+
const exit = Number(tail.slice(0, nl));
|
|
162
|
+
const output = buffer.slice(0, i);
|
|
163
|
+
buffer = tail.slice(nl + 1);
|
|
164
|
+
return { output, exit: Number.isFinite(exit) ? exit : null };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (Date.now() - started > timeoutMs) throw new Error(`step timed out after ${timeoutMs}ms${current ? ` at line ${current.line}: \`${current.command}\`` : ''}; output so far:\n${buffer}\n${errBuffer}`);
|
|
168
|
+
const chunk = await Promise.race([nextChunk(), new Promise<{ value: undefined; done: false }>((r) => setTimeout(() => r({ value: undefined, done: false }), 250))]);
|
|
169
|
+
if (chunk.done) throw new Error(`the shell exited; output so far:\n${buffer}\n${errBuffer}`);
|
|
170
|
+
if (chunk.value) buffer += decoder.decode(chunk.value);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
const send = (text: string) => { shell.stdin.write(text); shell.stdin.flush(); };
|
|
174
|
+
send('__volter_tutorial_jobs=()\n');
|
|
175
|
+
const cleanupShell = async (): Promise<void> => {
|
|
176
|
+
// Signals begin asynchronous World teardown. Bash must reap its own jobs before we down
|
|
177
|
+
// their Worlds. A delayed step-DONE is not proof this cleanup protocol has even started.
|
|
178
|
+
const marker = `__VOLTER_CLEANUP_${randomUUID()}__`;
|
|
179
|
+
let exitTimer: ReturnType<typeof setTimeout> | undefined;
|
|
180
|
+
try {
|
|
181
|
+
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
|
+
const joined = await readUntilDone(10_000, marker);
|
|
183
|
+
if (joined.exit !== 0) throw new Error(joined.output);
|
|
184
|
+
send('exit\n');
|
|
185
|
+
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
|
+
]);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (shell.pid) killTree(shell.pid); // bounded fallback; no claim that its children joined
|
|
192
|
+
await Promise.allSettled([reader.cancel(), errReader.cancel()]);
|
|
193
|
+
throw new Error(`Tutorial shell cleanup unconfirmed; retained ${app.parent}: ${String(error)}`);
|
|
194
|
+
} finally { if (exitTimer) clearTimeout(exitTimer); }
|
|
195
|
+
};
|
|
196
|
+
const fenceOutput = new Map<number, string>(); // what the reader saw after each bash fence, all its commands
|
|
197
|
+
const backgroundFences = new Set<number>();
|
|
198
|
+
try {
|
|
199
|
+
for (const step of tutorial.steps) {
|
|
200
|
+
if (step.kind === 'file') {
|
|
201
|
+
const path = join(app.dir, step.path);
|
|
202
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
203
|
+
writeFileSync(path, step.content);
|
|
204
|
+
const r: StepResult = { step, output: '', missing: [], exit: 0 };
|
|
205
|
+
results.push(r); opts.onStep?.(r);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
let output = '';
|
|
209
|
+
let exit: number | null = 0;
|
|
210
|
+
current = step;
|
|
211
|
+
const deadline = Date.now() + (opts.stepTimeoutMs ?? 120_000);
|
|
212
|
+
if (step.background) {
|
|
213
|
+
backgroundFences.add(step.fence);
|
|
214
|
+
send(`${step.command} 2>&1 &\n__volter_tutorial_jobs+=("$!")\necho ${DONE}0\n`);
|
|
215
|
+
({ output, exit } = await readUntilDone(opts.stepTimeoutMs ?? 120_000));
|
|
216
|
+
// Keep the existing settling period for subsequent commands in this fence;
|
|
217
|
+
// required output is additionally awaited at the fence boundary below.
|
|
218
|
+
await new Promise((r) => setTimeout(r, 2500));
|
|
219
|
+
send(`echo ${DONE}0\n`);
|
|
220
|
+
output += (await readUntilDone(opts.stepTimeoutMs ?? 120_000)).output;
|
|
221
|
+
} else {
|
|
222
|
+
send(`${step.command} 2>&1\necho ${DONE}$?\n`);
|
|
223
|
+
({ output, exit } = await readUntilDone(opts.stepTimeoutMs ?? 120_000));
|
|
224
|
+
}
|
|
225
|
+
fenceOutput.set(step.fence, `${fenceOutput.get(step.fence) ?? ''}${output}`);
|
|
226
|
+
if (step.expect.length && backgroundFences.has(step.fence)) {
|
|
227
|
+
// Expectations describe the WHOLE fence, even when its final command is cd.
|
|
228
|
+
// No extra app command is injected, and noisy output never extends the deadline.
|
|
229
|
+
for (;;) {
|
|
230
|
+
if (buffer) { fenceOutput.set(step.fence, `${fenceOutput.get(step.fence) ?? ''}${buffer}`); buffer = ''; }
|
|
231
|
+
const observed = mask(fenceOutput.get(step.fence) ?? '');
|
|
232
|
+
if (step.expect.every(line => observed.includes(mask(line))) || Date.now() >= deadline) break;
|
|
233
|
+
const chunk = await Promise.race([nextChunk(), new Promise<{ value: undefined; done: false }>((r) => setTimeout(() => r({ value: undefined, done: false }), Math.min(250, Math.max(1, deadline - Date.now()))))]);
|
|
234
|
+
if (chunk.done) break;
|
|
235
|
+
if (chunk.value) buffer += decoder.decode(chunk.value);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const masked = mask(fenceOutput.get(step.fence) ?? output);
|
|
239
|
+
const missing = step.expect.filter((line) => !masked.includes(mask(line)));
|
|
240
|
+
const r: StepResult = { step, output: step.expect.length ? (fenceOutput.get(step.fence) ?? output) : output, missing, exit };
|
|
241
|
+
results.push(r); opts.onStep?.(r);
|
|
242
|
+
}
|
|
243
|
+
} finally {
|
|
244
|
+
await cleanupShell();
|
|
245
|
+
await downEverything(app.parent);
|
|
246
|
+
}
|
|
247
|
+
return results;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Stop only Worlds discovered within this fixture, through the runtime's ownership checks. */
|
|
251
|
+
export async function downEverything(parent: string): Promise<void> {
|
|
252
|
+
const worlds: Array<{ name: string; root: string }> = [];
|
|
253
|
+
const directory = (path: string): boolean => lstatSync(path).isDirectory();
|
|
254
|
+
const walk = (dir: string): void => {
|
|
255
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
256
|
+
if (!entry.isDirectory() || entry.name === 'node_modules') continue; // never follow symlinks
|
|
257
|
+
const full = join(dir, entry.name);
|
|
258
|
+
if (entry.name === '.volter') {
|
|
259
|
+
const instances = join(full, 'worlds');
|
|
260
|
+
if (existsSync(instances) && directory(instances)) {
|
|
261
|
+
for (const world of readdirSync(instances, { withFileTypes: true })) {
|
|
262
|
+
// Runtime metadata (.locks/.resources) is not a World; use the World-name grammar.
|
|
263
|
+
if (world.isDirectory() && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(world.name)) worlds.push({ name: world.name, root: dir });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
continue; // instance data is not another app's World root
|
|
267
|
+
}
|
|
268
|
+
walk(full);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
if (!directory(parent)) throw new Error(`Tutorial cleanup requires a real scratch directory: ${parent}`);
|
|
272
|
+
walk(parent);
|
|
273
|
+
const errors: Error[] = [];
|
|
274
|
+
for (const w of worlds) {
|
|
275
|
+
try {
|
|
276
|
+
const result = await downWorld(w.name, w.root);
|
|
277
|
+
if (result.externalErrors.length) throw new Error(result.externalErrors.join('; '));
|
|
278
|
+
} catch (error) {
|
|
279
|
+
errors.push(new Error(`${w.name} at ${w.root}: ${String(error)}`));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (errors.length) throw new AggregateError(errors, `Tutorial cleanup incomplete; retained ${parent}: ${errors.map(error => error.message).join('; ')}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Removal is conditional on successful World teardown, including after a failed page. */
|
|
286
|
+
export async function removeTutorialApp(parent: string): Promise<void> {
|
|
287
|
+
await downEverything(parent);
|
|
288
|
+
rmSync(parent, { recursive: true, force: true });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Kill a process and its descendants, children first, each by its exact pid. */
|
|
292
|
+
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);
|
|
294
|
+
for (const child of children) killTree(child);
|
|
295
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function failures(results: StepResult[]): string[] {
|
|
299
|
+
const out: string[] = [];
|
|
300
|
+
for (const r of results) {
|
|
301
|
+
if (r.step.kind !== 'run') continue;
|
|
302
|
+
if (r.missing.length) out.push(`line ${r.step.line}: \`${r.step.command}\` — expected line(s) not in output:\n ${r.missing.join('\n ')}\n output:\n ${r.output.trim().split('\n').join('\n ')}`);
|
|
303
|
+
// a command with no expected output must succeed; one with expected output is judged by it (a page may show a refusal)
|
|
304
|
+
if (r.exit !== 0 && r.exit !== null && !r.step.background && r.step.expect.length === 0) out.push(`line ${r.step.line}: \`${r.step.command}\` exited ${r.exit}:\n ${r.output.trim().split('\n').join('\n ')}`);
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The vhs tape for a page: type each command, wait, screenshot; files are named, not typed. */
|
|
310
|
+
export function tapeFor(tutorial: Tutorial, opts: { gif: string; shotsDir: string }): string {
|
|
311
|
+
const lines = [
|
|
312
|
+
`# ${tutorial.file}, recorded — generated from the page by scripts/docs-media.ts; do not edit.`,
|
|
313
|
+
`Output ${opts.gif}`,
|
|
314
|
+
'Set Shell "bash"', 'Set FontSize 15', 'Set Width 1100', 'Set Height 640', 'Set Padding 24', 'Set Theme "Catppuccin Mocha"', 'Set TypingSpeed 35ms', '',
|
|
315
|
+
];
|
|
316
|
+
let n = 0;
|
|
317
|
+
for (const step of tutorial.steps) {
|
|
318
|
+
if (step.kind === 'file') { lines.push(`Type "# wrote ${step.path}"`, 'Enter', 'Sleep 800ms', ''); continue; }
|
|
319
|
+
n += 1;
|
|
320
|
+
const wait = /^volter world (up|reset|init|clone|push)\b/.test(step.command) ? '6s' : /^volter world run\b|^bun add\b/.test(step.command) ? '4s' : '2500ms';
|
|
321
|
+
lines.push(`Type "${step.command.replace(/"/g, '\\"')}${step.background ? ' &' : ''}"`, 'Enter', `Sleep ${wait}`, `Screenshot ${opts.shotsDir}/step-${String(n).padStart(2, '0')}.png`, '');
|
|
322
|
+
}
|
|
323
|
+
return `${lines.join('\n')}\n`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Every @volter package the pages install, for the registry to publish. */
|
|
327
|
+
export function packagesNamedIn(files: string[]): string[] {
|
|
328
|
+
const { readFileSync } = require('node:fs') as typeof import('node:fs');
|
|
329
|
+
const names = new Set<string>(['@volter/world']);
|
|
330
|
+
// every install line a page types — `bun add …`, `npm install …`, `npm i …` — names the packages the registry must hold
|
|
331
|
+
for (const f of files) for (const m of readFileSync(f, 'utf8').matchAll(/\b(?:bun add|npm (?:install|i|add))\b[^\n]*/g)) for (const t of m[0].split(/\s+/)) if (t.startsWith('@volter/')) names.add(t.replace(/@[^@/]*$/, ''));
|
|
332
|
+
return [...names];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export const TUTORIALS_DIR = resolve(import.meta.dir, '..', '..', '..', '..', 'docs');
|
|
336
|
+
/** Every tutorial page: getting-started, then the guides. */
|
|
337
|
+
export function tutorialFiles(): string[] {
|
|
338
|
+
const { readdirSync } = require('node:fs') as typeof import('node:fs');
|
|
339
|
+
const guides = readdirSync(join(TUTORIALS_DIR, 'guides')).filter((f) => f.endsWith('.md')).sort().map((f) => join(TUTORIALS_DIR, 'guides', f));
|
|
340
|
+
// a cookbook page is a journey too when it says so (`<!-- journey -->`): the same runner, the same proof
|
|
341
|
+
const { readFileSync } = require('node:fs') as typeof import('node:fs');
|
|
342
|
+
const cookbook = join(TUTORIALS_DIR, '..', 'cookbook');
|
|
343
|
+
const recipes = readdirSync(cookbook, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => join(cookbook, e.name, 'README.md')).filter((f) => { try { return readFileSync(f, 'utf8').includes('<!-- journey -->'); } catch { return false; } }).sort();
|
|
344
|
+
return [join(TUTORIALS_DIR, 'getting-started.md'), ...guides, ...recipes];
|
|
345
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Every tutorial page, executed as written (tutorial.ts): the page's bash fences are typed into
|
|
2
|
+
// one shell in a scratch app, each ```text fence must appear in its command's output, each
|
|
3
|
+
// `file=` fence is written where the page says. Nothing runs that the page does not show — a
|
|
4
|
+
// page that drifts from the product fails here, by line number.
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { relative } from 'node:path';
|
|
8
|
+
import { failures, packagesNamedIn, parseTutorial, removeTutorialApp, runTutorial, tutorialApp, tutorialFiles, TUTORIALS_DIR } from './tutorial.ts';
|
|
9
|
+
import { startRegistry, type Registry } from '../../../../scripts/registry.ts';
|
|
10
|
+
|
|
11
|
+
const only = process.env.TUTORIAL;
|
|
12
|
+
const pages = tutorialFiles().filter((f) => !only || f.endsWith(`${only}.md`));
|
|
13
|
+
|
|
14
|
+
// one registry for the whole file: this checkout's packages, published to a registry on this
|
|
15
|
+
// machine, so every page's `bun add` is a real install
|
|
16
|
+
let registry: Registry | null = null;
|
|
17
|
+
beforeAll(async () => { registry = await startRegistry({ only: packagesNamedIn(pages) }); }, 300_000);
|
|
18
|
+
afterAll(() => { registry?.stop(); });
|
|
19
|
+
|
|
20
|
+
for (const file of pages) {
|
|
21
|
+
const tutorial = parseTutorial(readFileSync(file, 'utf8'), file);
|
|
22
|
+
const label = relative(TUTORIALS_DIR, file);
|
|
23
|
+
describe(label, () => {
|
|
24
|
+
test('has steps a reader runs', () => { expect(tutorial.steps.filter((s) => s.kind === 'run').length).toBeGreaterThan(2); });
|
|
25
|
+
test('runs as written', async () => {
|
|
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
|
+
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);
|
|
31
|
+
let results;
|
|
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
|
|
35
|
+
const wrong = failures(results);
|
|
36
|
+
expect(wrong, wrong.join('\n\n')).toEqual([]);
|
|
37
|
+
}, 900_000);
|
|
38
|
+
});
|
|
39
|
+
}
|
package/src/locate.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Where the world is (docs/concepts/worlds.md#the-config-and-the-running-world): the nearest `.volter/world.json`
|
|
2
|
+
// above the cwd names the world root — the app repo — and `.volter/current` names the branch that
|
|
3
|
+
// is checked out (absent: the main branch, the config's `id`). Every `volter` verb and
|
|
4
|
+
// `World.open()` start here, the way `git` starts from the nearest `.git`.
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { stateDirName } from '@volter/world-core';
|
|
8
|
+
import { loadWorldConfig } from '@volter/world-runtime';
|
|
9
|
+
|
|
10
|
+
export const WORLD_FILE = 'world.json';
|
|
11
|
+
|
|
12
|
+
/** `<root>/.volter` */
|
|
13
|
+
export function stateDir(root: string): string { return join(root, stateDirName()); }
|
|
14
|
+
/** `<root>/.volter/world.json` — the config, relative to the root as `up` takes it. */
|
|
15
|
+
export function worldConfigRelative(): string { return join(stateDirName(), WORLD_FILE); }
|
|
16
|
+
export function worldConfigPath(root: string): string { return join(stateDir(root), WORLD_FILE); }
|
|
17
|
+
/** `<root>/.volter/world.env` — where `up` writes the live env (gitignored by init). */
|
|
18
|
+
export function worldEnvPath(root: string): string { return join(stateDir(root), 'world.env'); }
|
|
19
|
+
/** `<root>/.volter/seed.ts` — the default data, when the world has any. */
|
|
20
|
+
export function worldSeedPath(root: string): string { return join(stateDir(root), 'seed.ts'); }
|
|
21
|
+
|
|
22
|
+
/** The world root above `from`, or null: the nearest directory holding `.volter/world.json`. */
|
|
23
|
+
export function findWorldRoot(from: string = process.cwd()): string | null {
|
|
24
|
+
let dir = resolve(from);
|
|
25
|
+
for (;;) {
|
|
26
|
+
if (existsSync(worldConfigPath(dir))) return dir;
|
|
27
|
+
const up = dirname(dir);
|
|
28
|
+
if (up === dir) return null;
|
|
29
|
+
dir = up;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function requireWorldRoot(from: string = process.cwd()): string {
|
|
34
|
+
const root = findWorldRoot(from);
|
|
35
|
+
if (root === null) {
|
|
36
|
+
throw new Error(`Not in a world: no ${worldConfigRelative()} here or above ${resolve(from)}. Run \`volter world init\` in your app, or pass --world <dir>.`);
|
|
37
|
+
}
|
|
38
|
+
return root;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The main branch: the config's `id`. */
|
|
42
|
+
export function mainBranch(root: string): string {
|
|
43
|
+
return loadWorldConfig(worldConfigPath(root), root).config.id;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const CURRENT = 'current';
|
|
47
|
+
|
|
48
|
+
/** The checked-out branch: `.volter/current`, else main. */
|
|
49
|
+
export function currentBranch(root: string): string {
|
|
50
|
+
const file = join(stateDir(root), CURRENT);
|
|
51
|
+
if (!existsSync(file)) return mainBranch(root);
|
|
52
|
+
const name = readFileSync(file, 'utf8').trim();
|
|
53
|
+
return name === '' ? mainBranch(root) : name;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function setCurrentBranch(root: string, name: string): void {
|
|
57
|
+
mkdirSync(stateDir(root), { recursive: true });
|
|
58
|
+
const file = join(stateDir(root), CURRENT);
|
|
59
|
+
if (name === mainBranch(root)) { if (existsSync(file)) rmSync(file); return; }
|
|
60
|
+
writeFileSync(file, `${name}\n`);
|
|
61
|
+
}
|