@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.
@@ -0,0 +1,491 @@
1
+ // WORLD — the twins an app needs, running together (docs/concepts/the-model.md,
2
+ // docs/concepts/worlds.md#the-config-and-the-running-world): a branch with compute attached. `World.open()` finds the world of the cwd the
3
+ // way git finds its repository; every method is a name for a world-runtime verb, in the words a
4
+ // user reads. The `volter` command is one client of this class and adds nothing.
5
+ import { rebaseChangeset, worldBootMarker, listActions, isTwinBookkeeping, pushablePendingActions, pendingActions, applyTwinWrite, twinResources } from '@volter/world-core';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { activateScript, approveWorldChangeset, branchWorld, clockFile, checkoutWorld, createWorldChangeset, diffWorld, downWorld, fetchFromOrigin, findWorldChangeset, initWorld, listWorldChangesets, listWorldMarks, listWorlds, markWorld, pushWorldChangeset, rebaseWorldChangeset, replayWorldChangeset, resetWorld, runWithWorldEnv, seedWorld, shellWorld, statusWorld, statusWorldChangeset, upWorld, verifyWorldChangeset, worldLedgers, worldOrigin, deployWorld, loadWorldConfig, writeWorldConfig, readServeRecord, refreshTwin, serveWorld, findInstalledPackage, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, credentialPayloadFrom, materializeRoots } from '@volter/world-runtime';
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { dirname, join, resolve } from 'node:path';
10
+ import { requireToken, storeToken } from "./credentials.js";
11
+ import { currentBranch, findWorldRoot, mainBranch, requireWorldRoot, setCurrentBranch, worldConfigRelative, worldEnvPath, worldSeedPath } from "./locate.js";
12
+ import { parentEntries, rebaseBranch } from '@volter/world-core';
13
+ /** One change as `log` shows it: the write, and the receipt the vendor answered with once pushed. */
14
+ /** A twin's branch log as the world reads it: the twin's name, the state service it records under, its control root. */
15
+ export class TwinLog {
16
+ service;
17
+ stateService;
18
+ root;
19
+ constructor(service, stateService, root) {
20
+ this.service = service;
21
+ this.stateService = stateService;
22
+ this.root = root;
23
+ }
24
+ /** this branch's own entries, bookkeeping aside */
25
+ log() { return listActions(this.stateService, this.root).filter((a) => !isTwinBookkeeping(a)); }
26
+ /** entries the parent does not hold */
27
+ unpushed(opts = {}) { return opts.pushable ? pushablePendingActions(this.stateService, this.root) : pendingActions(this.stateService, this.root); }
28
+ /** the tree: this twin's resources as a read sees them */
29
+ state() { return twinResources(this.stateService, this.root); }
30
+ /** one write through the kernel's write path (the head performs it when this twin's root says so) */
31
+ change(write) { return applyTwinWrite(this.stateService, write, this.root); }
32
+ }
33
+ /** `https://host/org/world` → the remote's base URL and the namespace it addresses. */
34
+ export function parseOriginUrl(url) {
35
+ // a PATH is a world on this machine (git's file transport): its directory, and its served name
36
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
37
+ const root = resolve(url);
38
+ const configPath = join(root, '.volter', 'world.json');
39
+ if (!existsSync(configPath))
40
+ throw new Error(`Not a remote URL or world directory: ${url} (want https://<host>/<org>/<world>, or a directory holding .volter/world.json)`);
41
+ const config = loadWorldConfig(configPath, root).config;
42
+ return { url: root, namespace: config.bare?.name ?? `${config.id}/${config.id}` };
43
+ }
44
+ let parsed;
45
+ try {
46
+ parsed = new URL(url);
47
+ }
48
+ catch {
49
+ throw new Error(`Not a remote URL: ${url} (want https://<host>/<org>/<world>)`);
50
+ }
51
+ const parts = parsed.pathname.split('/').filter(Boolean);
52
+ if (parts.length !== 2)
53
+ throw new Error(`A remote URL names one world: https://<host>/<org>/<world>, got ${url}`);
54
+ return { url: `${parsed.origin}`, namespace: `${parts[0]}/${parts[1]}` };
55
+ }
56
+ export class World {
57
+ /** The branch this handle is on — the instance name in the kernel. */
58
+ name;
59
+ /** The world root: the app repo. */
60
+ root;
61
+ constructor(name, root) { this.name = name; this.root = root; }
62
+ // ── finding and making worlds ───────────────────────────────────────────────────────────
63
+ /** The world of the cwd (or of `ref.root`), on its checked-out branch (or `ref.name`). */
64
+ /** The config `loadWorldConfig` resolves for this world: the in-repo world.json, else the world's name. */
65
+ configRef() { const inRepo = join(this.root, '.volter', 'world.json'); return existsSync(inRepo) ? inRepo : this.name; }
66
+ static open(ref = {}) {
67
+ const root = ref.root === undefined ? requireWorldRoot() : requireWorldRoot(ref.root);
68
+ return new World(ref.name ?? currentBranch(root), root);
69
+ }
70
+ /** Whether `from` (default: the cwd) is inside a world. */
71
+ static find(from) {
72
+ const root = findWorldRoot(from);
73
+ return root === null ? null : new World(currentBranch(root), root);
74
+ }
75
+ /** `volter world init --bare <org>/<world> --twins a,b`: a world with no app — a package.json naming the
76
+ * twins (installed with bun), then the world over them, served as `/<org>/<world>/`. */
77
+ static initBare(dir, served, twins, opts = {}) {
78
+ if (!/^[a-z0-9_-]+\/[a-z0-9_-]+$/i.test(served))
79
+ throw new Error(`a bare world is named <org>/<world>, got ${served}`);
80
+ if (twins.length === 0)
81
+ throw new Error('a bare world needs its twins: --twins github,slack');
82
+ mkdirSync(dir, { recursive: true });
83
+ const pkgPath = join(dir, 'package.json');
84
+ if (!existsSync(pkgPath))
85
+ writeFileSync(pkgPath, `${JSON.stringify({ name: served.split('/')[1], private: true, devDependencies: Object.fromEntries(twins.map((t) => [`@volter/twin-${t}`, '*'])) }, null, 2)}\n`);
86
+ if (opts.install !== false && !twins.every((t) => findInstalledPackage(dir, `@volter/twin-${t}`))) {
87
+ // the runtime's own installer: bun under Bun, npm under Node — never assumed
88
+ const cmd = typeof Bun !== 'undefined' ? ['bun', 'add', '-d'] : ['npm', 'install', '--save-dev'];
89
+ const r = spawnSync(cmd[0], [...cmd.slice(1), ...twins.map((t) => `@volter/twin-${t}`)], { cwd: dir, stdio: 'inherit' });
90
+ if (r.status !== 0)
91
+ throw new Error(`installing the twins failed (${cmd.join(' ')} exited ${r.status})`);
92
+ }
93
+ const name = served.split('/')[1];
94
+ const result = initWorld(name, dir, { root: dir, vendors: twins, bare: served, force: opts.force ?? false });
95
+ return { world: new World(name, dir), result };
96
+ }
97
+ /** `volter world init`: detect the app's vendors and write `.volter/world.json` beside the code. */
98
+ static init(app = process.cwd(), opts = {}) {
99
+ const name = opts.name ?? worldNameFor(app);
100
+ const { name: _n, root: _r, ...rest } = opts;
101
+ const result = initWorld(name, app, { root: opts.root ?? app, ...rest });
102
+ return { world: new World(name, opts.root ?? app), result };
103
+ }
104
+ // ── lifecycle ───────────────────────────────────────────────────────────────────────────
105
+ /**
106
+ * Start the twins on this branch. A branch that has run before comes back with its state (down
107
+ * stops compute, up resumes it — `reset` is the way back to the default data); a fresh branch
108
+ * boots clean and loads the default data unless `seed: false`.
109
+ */
110
+ async up(opts = {}) {
111
+ const existing = this.instance();
112
+ if (existing?.running)
113
+ return existing; // already up: nothing to do
114
+ const instance = await upWorld(worldConfigRelative(), {
115
+ name: this.name, root: this.root, envFile: worldEnvPath(this.root),
116
+ ...(opts.mode ? { mode: opts.mode } : existing ? { mode: existing.mode } : {}),
117
+ ...(existing ? { keepState: true } : {}),
118
+ });
119
+ try {
120
+ if (!existing && opts.seed !== false && existsSync(worldSeedPath(this.root))) {
121
+ // a story that fails is a loud failure: the world is up, but not the world the app expects
122
+ const seeded = await seedWorld(this.name, { root: this.root, cwd: opts.cwd ?? this.root });
123
+ if (seeded.exitCode !== 0)
124
+ throw new Error(`the story failed (${seeded.entry} exited ${seeded.exitCode}); the world is up without it — fix the seed and \`volter world seed\``);
125
+ }
126
+ }
127
+ finally {
128
+ // a root set while the world was stopped reaches the twins' state after the default data (which a
129
+ // fresh boot runs simulated), where the deploy reads it — also when the seed failed
130
+ materializeRoots(loadWorldConfig(this.configRef(), this.root).config, instance.dirs.data, this.root);
131
+ }
132
+ return instance;
133
+ }
134
+ /** Stop the twins; the branch's state stays (`up` resumes). `purge` forgets it. */
135
+ down(opts = {}) { return downWorld(this.name, this.root, opts); }
136
+ /** Run a command inside the world: the app or its tests, with the twins' URLs and fake credentials in its env. */
137
+ run(command, opts = {}) { return runWithWorldEnv(this.name, command, this.root, { ...opts, cwd: opts.cwd ?? this.root }); }
138
+ /** The shell script that activates the world in the current shell: `eval "$(volter world activate)"` — vendor CLIs and curl reach the twins. */
139
+ activateScript() { return activateScript(this.name, this.root); }
140
+ /** A subshell with the world active; resolves to its exit code. */
141
+ shell() { return shellWorld(this.name, this.root); }
142
+ /** The kernel's instance record, or null before the first `up`. */
143
+ instance() {
144
+ try {
145
+ return statusWorld(this.name, this.root);
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ }
151
+ /** The world, the branch, the origin, what is unpushed, what is running. */
152
+ status() {
153
+ const instance = this.instance();
154
+ const changesets = instance ? this.changesets() : [];
155
+ const services = {};
156
+ if (instance)
157
+ for (const [id, s] of Object.entries(instance.services))
158
+ services[id] = { ...(s.url ? { url: s.url } : {}), running: s.type === 'external' || instance.livePids.includes(s.pid), ...(s.protocol ? { protocol: { major: s.protocol.major, standing: s.protocol.standing } } : {}) };
159
+ return {
160
+ world: this.name, root: this.root, branch: this.name,
161
+ branches: listWorlds(this.root).map((w) => w.name).sort(),
162
+ running: instance?.running ?? false,
163
+ served: (() => { const r = readServeRecord(this.root); return r ? { base: r.base, pid: r.pid, startedAt: r.startedAt } : null; })(),
164
+ origin: instance?.origin ? { url: instance.origin.url, namespace: instance.origin.namespace, ...(instance.origin.fetchedAt ? { fetchedAt: instance.origin.fetchedAt } : {}) } : null,
165
+ unpushed: instance ? this.unpushed().length : 0,
166
+ changesets: { total: changesets.length, pushed: changesets.filter((c) => c.changeset.applied?.outcome === 'applied').length },
167
+ services,
168
+ ...(instance ? { envFile: instance.envFile } : {}),
169
+ };
170
+ }
171
+ // ── the repos ───────────────────────────────────────────────────────────────────────────
172
+ /** One twin log per twin the world runs: its branch entries and what is unpushed. */
173
+ repos() { return worldLedgers(this.name, this.root).map((l) => new TwinLog(l.service, l.stateService, l.controlRoot)); }
174
+ repo(service) {
175
+ const found = this.repos().find((r) => r.service === service);
176
+ if (!found) {
177
+ const instance = statusWorld(this.name, this.root);
178
+ if (!instance.services[service])
179
+ throw new Error(`World "${this.name}" has no twin "${service}" (has: ${Object.keys(instance.services).sort().join(', ') || 'none'})`);
180
+ return new TwinLog(service, service, join(instance.dirs.data, service));
181
+ }
182
+ return found;
183
+ }
184
+ // ── the log ─────────────────────────────────────────────────────────────────────────────
185
+ /** Every write the app made, across the twins, oldest first, with the receipt against each pushed one. */
186
+ log() {
187
+ const receipts = new Map();
188
+ for (const located of this.changesets())
189
+ for (const receipt of located.changeset.applied?.receipts ?? [])
190
+ receipts.set(receipt.actionId, { receipt, changeset: located.changeset.name });
191
+ const rows = [];
192
+ for (const repo of this.repos()) {
193
+ // v2 (log.ts): the parent log first — what origin holds, minus the default data (a placeholder
194
+ // row) and minus this branch's own landed copies (shown once, as the branch entry with its receipt)
195
+ const landed = new Map();
196
+ const own = new Set(repo.log().map((a) => a.id));
197
+ // the position of each entry in this twin's whole log: the parent view, then the branch
198
+ const positionOf = new Map();
199
+ {
200
+ let n = 0;
201
+ for (const e of parentEntries(repo.stateService, repo.root))
202
+ positionOf.set(e.id, ++n);
203
+ for (const e of repo.log())
204
+ if (!positionOf.has(e.id))
205
+ positionOf.set(e.id, ++n);
206
+ }
207
+ for (const e of parentEntries(repo.stateService, repo.root)) {
208
+ // the settled receipt wins over a provisional `landed` one: origin's copy says deployed/refused/failed after this branch's own copy said landed
209
+ if (e.landsId && e.receipt) {
210
+ const prior = landed.get(e.landsId);
211
+ if (!prior || prior.status === 'landed' || e.receipt.status !== 'landed')
212
+ landed.set(e.landsId, e.receipt);
213
+ }
214
+ if (e.landsId && own.has(e.landsId))
215
+ continue;
216
+ if (own.has(e.id))
217
+ continue;
218
+ if (e.provenance === 'placeholder')
219
+ continue;
220
+ if (e.op !== 'set' || !e.fields || e.subject.type.startsWith('_'))
221
+ continue;
222
+ rows.push({ ...e, service: repo.service, ...(e.receipt ? { landed: e.receipt } : {}), ...(positionOf.has(e.id) ? { position: positionOf.get(e.id) } : {}) });
223
+ }
224
+ for (const action of repo.log()) {
225
+ const r = receipts.get(action.id);
226
+ const l = landed.get(action.id);
227
+ // the row names the TWIN (slack), not the state service it records under (chat)
228
+ const row = { ...action, service: repo.service, ...(positionOf.has(action.id) ? { position: positionOf.get(action.id) } : {}) };
229
+ rows.push(l ? { ...row, landed: l } : r ? { ...row, receipt: r.receipt, changeset: r.changeset } : row);
230
+ }
231
+ }
232
+ return rows.sort((a, b) => a.occurredAt.localeCompare(b.occurredAt) || a.id.localeCompare(b.id));
233
+ }
234
+ /** Changes not yet pushed (`log origin..HEAD`), across the twins. */
235
+ unpushed() { return this.repos().flatMap((r) => r.unpushed({ pushable: true })).sort((a, b) => a.occurredAt.localeCompare(b.occurredAt)); }
236
+ diff(base) { return diffWorld(this.name, { root: this.root, ...(base ? { base } : {}) }); }
237
+ // ── default data ────────────────────────────────────────────────────────────────────────
238
+ /** Load the default data: the seed runs with every twin recording what it creates as data that was already there. */
239
+ seed(opts = {}) { return seedWorld(this.name, { root: this.root, cwd: this.root, ...opts }); }
240
+ /** Back to the default data: forget this branch's state, boot, seed. */
241
+ reset(opts = {}) { return resetWorld(this.name, { root: this.root, cwd: this.root, ...opts }); }
242
+ // ── branches ────────────────────────────────────────────────────────────────────────────
243
+ /**
244
+ * A new branch from here — its own log over this branch's mirrors — checked out. One branch runs
245
+ * at a time in a world (the app's env names one set of twins), so this branch stops as the new
246
+ * one starts; `checkout` brings it back with its state.
247
+ */
248
+ async branch(name, opts = {}) {
249
+ if (listWorlds(this.root).some((w) => w.name === name))
250
+ throw new Error(`A branch "${name}" already exists — \`volter world checkout ${name}\` switches to it`);
251
+ await branchWorld(this.name, name, { root: this.root, envFile: worldEnvPath(this.root), ...(opts.at ? { at: opts.at } : {}) });
252
+ if (this.instance()?.running)
253
+ await downWorld(this.name, this.root);
254
+ setCurrentBranch(this.root, name);
255
+ return new World(name, this.root);
256
+ }
257
+ /** Switch to a branch: its twins come back with their state; this branch's stop. */
258
+ async checkout(name) {
259
+ if (name === 'main')
260
+ name = mainBranch(this.root); // the main branch answers to its name and to `main`
261
+ if (name === this.name)
262
+ return this;
263
+ if (!listWorlds(this.root).some((w) => w.name === name))
264
+ throw new Error(`No branch "${name}" in this world (branches: ${listWorlds(this.root).map((w) => w.name).sort().join(', ') || 'none'})`);
265
+ const wasRunning = this.instance()?.running ?? false;
266
+ if (wasRunning)
267
+ await downWorld(this.name, this.root);
268
+ const target = new World(name, this.root);
269
+ if (wasRunning && !target.instance()?.running)
270
+ await checkoutWorld(name, { root: this.root });
271
+ setCurrentBranch(this.root, name);
272
+ return target;
273
+ }
274
+ branches() { return listWorlds(this.root).map((w) => w.name).sort(); }
275
+ /** Is this the main branch? */
276
+ isMain() { return this.name === mainBranch(this.root); }
277
+ /** What this branch's changes are measured from, as `diff` says it: the branch point, origin, or the story. */
278
+ diffBase() {
279
+ if (!this.isMain())
280
+ return `branch ${this.name}`;
281
+ if (this.remote('origin'))
282
+ return 'origin';
283
+ return existsSync(worldSeedPath(this.root)) ? 'the story' : 'the default data';
284
+ }
285
+ // ── the clock ───────────────────────────────────────────────────────────────────────────
286
+ /** The world's clock: the frozen instant every twin stamps from, or the wall clock when unset. */
287
+ clock() {
288
+ const file = clockFile(this.root, this.name);
289
+ return existsSync(file) ? { at: readFileSync(file, 'utf8').trim(), frozen: true } : { at: new Date().toISOString(), frozen: false };
290
+ }
291
+ /** Set the world's clock to an instant; every twin stamps from it until it moves. */
292
+ setClock(iso) {
293
+ const parsed = Date.parse(iso);
294
+ if (Number.isNaN(parsed))
295
+ throw new Error(`${JSON.stringify(iso)} is not an ISO-8601 instant`);
296
+ const file = clockFile(this.root, this.name);
297
+ mkdirSync(dirname(file), { recursive: true });
298
+ writeFileSync(file, `${new Date(parsed).toISOString()}\n`);
299
+ return new Date(parsed).toISOString();
300
+ }
301
+ /** Move a set clock forward: `30d`, `12h`, `5m`, `90s`. */
302
+ advanceClock(by) {
303
+ const m = /^(\d+(?:\.\d+)?)(s|m|h|d)$/.exec(by.trim());
304
+ if (!m)
305
+ throw new Error(`${JSON.stringify(by)} is not <N>(s|m|h|d)`);
306
+ const current = this.clock();
307
+ if (!current.frozen)
308
+ throw new Error('the clock is not set — `volter world clock set <iso>` first; advancing the wall clock would freeze time as a side effect');
309
+ const ms = Number(m[1]) * { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
310
+ return this.setClock(new Date(Date.parse(current.at) + ms).toISOString());
311
+ }
312
+ /** Rebase this branch onto its base's current position, every twin; conflicts named per subject and field. */
313
+ rebaseBranch() { return this.repos().map((r) => ({ service: r.service, ...rebaseBranch(r.stateService, r.root) })); }
314
+ /** Replay a changeset's changes into another branch's twins, in order, with the same ids. */
315
+ replay(name, into) { return replayWorldChangeset(name, { root: this.root, world: this.name, into }); }
316
+ // ── serving ─────────────────────────────────────────────────────────────────────────────
317
+ /** Serve this world on a port under `/<org>/<world>/`; returns when listening. */
318
+ serve(opts = {}) { return serveWorld(this.name, { root: this.root, ...opts }); }
319
+ // ── remotes ─────────────────────────────────────────────────────────────────────────────
320
+ /** The remotes named in world.json (`volter remote add`), name → url or path. */
321
+ remotes() { return { ...(loadWorldConfig(this.configRef(), this.root).config.remotes ?? {}) }; }
322
+ /** Name a remote; `origin` is the one fetch and push use by default. A token given is remembered for it. */
323
+ addRemote(name, target, opts = {}) {
324
+ const { path, config } = loadWorldConfig(this.configRef(), this.root);
325
+ const remotes = { ...(config.remotes ?? {}), [name]: target };
326
+ writeWorldConfig(path, { ...config, remotes });
327
+ if (opts.token)
328
+ storeToken(parseOriginUrl(target).url, opts.token);
329
+ }
330
+ removeRemote(name) {
331
+ const { path, config } = loadWorldConfig(this.configRef(), this.root);
332
+ const remotes = { ...(config.remotes ?? {}) };
333
+ delete remotes[name];
334
+ writeWorldConfig(path, { ...config, remotes });
335
+ }
336
+ /** The remote a verb uses: the named one, else origin from world.json, else the instance's recorded origin. */
337
+ remote(name = 'origin') {
338
+ const target = this.remotes()[name];
339
+ if (target)
340
+ return parseOriginUrl(target);
341
+ if (name !== 'origin')
342
+ return null;
343
+ const recorded = worldOrigin(this.name, this.root);
344
+ return recorded ? { url: recorded.url, namespace: recorded.namespace } : null;
345
+ }
346
+ // ── the origin ──────────────────────────────────────────────────────────────────────────
347
+ /** The origin this world clones from and pushes to, or null: the default data is its only origin. */
348
+ origin() { const r = this.remote('origin'); const recorded = worldOrigin(this.name, this.root); return r ? { ...(recorded ?? {}), url: r.url, namespace: r.namespace } : recorded; }
349
+ /**
350
+ * Clone a remote's canonical history into this world: record the origin, remember the token,
351
+ * fetch everything. With the world's token (namespace or read), the history arrives through the
352
+ * mirror, from the beginning; with `adminToken` as well, the remote's whole tree is copied in one
353
+ * move, which is what an admin can do and a reader cannot. Either way one token is remembered:
354
+ * the world's, which is what `fetch` and `push` use afterwards.
355
+ */
356
+ async clone(url, opts = {}) {
357
+ const origin = parseOriginUrl(url);
358
+ if (opts.token !== undefined && opts.token !== '')
359
+ storeToken(origin.url, opts.token);
360
+ if (opts.adminToken !== undefined && opts.adminToken !== '') {
361
+ if (opts.token === undefined || opts.token === '')
362
+ storeToken(origin.url, opts.adminToken);
363
+ return fetchFromOrigin(this.name, { root: this.root, url: origin.url, namespace: origin.namespace, key: opts.adminToken, full: true });
364
+ }
365
+ const key = requireToken(origin.url, opts.token);
366
+ // v2: a clone brings the served world's whole log in from position zero, and names it origin;
367
+ // a world that has never run is booted first (no story: its history is the origin's)
368
+ this.addRemote('origin', url);
369
+ if (!this.instance())
370
+ await this.up({ seed: false });
371
+ return fetchFromOrigin(this.name, { root: this.root, url: origin.url, namespace: origin.namespace, key, full: true });
372
+ }
373
+ /** Fetch, then move this branch onto what came in: git's pull. */
374
+ async pull(opts = {}) {
375
+ const fetched = await this.fetch(opts);
376
+ return { fetched, rebased: this.repos().map((r) => ({ service: r.service, ...rebaseBranch(r.stateService, r.root, { origin: true }) })) };
377
+ }
378
+ /** Fetch what the origin observed since the last fetch. */
379
+ fetch(opts = {}) {
380
+ const origin = this.origin();
381
+ if (!origin)
382
+ throw new Error(`World "${this.name}" has no origin — its only origin is the default data. \`volter world clone <url>\` connects a remote.`);
383
+ if (opts.token !== undefined && opts.token !== '')
384
+ storeToken(origin.url, opts.token); // the last token given for a remote is the one remembered
385
+ return fetchFromOrigin(this.name, { root: this.root, url: origin.url, namespace: origin.namespace, key: requireToken(origin.url, opts.token), ...(opts.services ? { services: opts.services } : {}) });
386
+ }
387
+ // ── changesets and push ─────────────────────────────────────────────────────────────────
388
+ /** Cut a changeset from the unpushed changes: the reviewable unit, with the author's message. */
389
+ changeset(opts = {}) {
390
+ const name = opts.name ?? (opts.message ? slugName(opts.message, this.changesets()) : nextChangesetName(this.changesets()));
391
+ return createWorldChangeset(this.name, name, { root: this.root, ...(opts.message !== undefined ? { message: opts.message } : {}), ...(opts.base ? { base: opts.base } : {}), ...(opts.verifiers ? { verifiers: opts.verifiers } : {}), ...(opts.overwrite ? { overwrite: true } : {}) });
392
+ }
393
+ changesets() { return listWorldChangesets({ root: this.root, world: this.name }); }
394
+ /**
395
+ * Push to the origin, which deploys to the vendor with its own keys and answers with receipts:
396
+ * the named changeset, or every changeset not yet pushed, oldest first. A local world never
397
+ * holds a vendor credential.
398
+ */
399
+ async push(opts = {}) {
400
+ const origin = this.origin();
401
+ if (!origin)
402
+ throw new Error(`World "${this.name}" has no origin to push to — the default data cannot be pushed to. \`volter world clone <url>\` connects a remote.`);
403
+ if (opts.token !== undefined && opts.token !== '')
404
+ storeToken(origin.url, opts.token);
405
+ const key = requireToken(origin.url, opts.token);
406
+ const to = { to: origin.url, namespace: origin.namespace };
407
+ const queue = opts.name !== undefined
408
+ ? [findWorldChangeset(opts.name, { root: this.root, world: this.name })]
409
+ : this.changesets().filter((c) => c.changeset.applied === null || c.changeset.applied.outcome === 'refused').sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
410
+ if (queue.length === 0)
411
+ throw new Error(this.unpushed().length ? 'Nothing to push: cut a changeset first — `volter world changeset -m "<what and why>"`' : 'Nothing to push: no unpushed changes');
412
+ const outcomes = [];
413
+ for (const located of queue) {
414
+ const outcome = await pushWorldChangeset(located.changeset.name, { root: this.root, world: this.name, ...to, key, ...(opts.force ? { force: true } : {}) });
415
+ outcomes.push(outcome);
416
+ if (outcome.application.outcome === 'refused')
417
+ break;
418
+ }
419
+ return outcomes;
420
+ }
421
+ // ── review (the operator's verbs, kept on the SDK for the remote and for CI) ────────────
422
+ mark(id, now) { return markWorld(this.name, { root: this.root, ...(id ? { id } : {}), ...(now ? { now } : {}) }); }
423
+ marks() { return listWorldMarks(this.name, this.root); }
424
+ verify(name, opts = { ephemeral: true }) { return verifyWorldChangeset(name, { root: this.root, world: this.name, ...opts }); }
425
+ approve(name, principal, note) { return approveWorldChangeset(name, { root: this.root, world: this.name, principal, ...(note ? { note } : {}) }); }
426
+ readiness(name) { return statusWorldChangeset(name, { root: this.root, world: this.name }); }
427
+ rebase(name) { return rebaseWorldChangeset(name, { root: this.root, world: this.name }); }
428
+ // ── roots (docs/concepts/the-model.md: a twin on a shared world whose root is the vendor) ──
429
+ /** The twin's root and credential, as `volter twin <vendor>` prints them. */
430
+ twin(vendor) {
431
+ const { config } = loadWorldConfig(this.configRef(), this.root);
432
+ const service = config.services.find((s) => s.id === vendor);
433
+ if (!service)
434
+ throw new Error(`no twin "${vendor}" in this world`);
435
+ const inst = this.instance();
436
+ return { vendor, ...(inst?.services[vendor]?.url ? { url: inst.services[vendor].url } : {}), root: service.root ?? null, credential: sealedCredentialInfo(this.root, vendor) };
437
+ }
438
+ /** Set (or clear) the twin's root: the vendor's API and the deploy policy, in world.json. Takes effect at the next `up` or `serve`, or now when the world runs. */
439
+ setTwinRoot(vendor, root) {
440
+ setTwinRoot(this.root, vendor, root, { config: this.configRef() });
441
+ const inst = this.instance();
442
+ if (inst?.running) {
443
+ materializeRoots(loadWorldConfig(this.configRef(), this.root).config, inst.dirs.data, this.root);
444
+ }
445
+ }
446
+ /** Seal the vendor's credential beside the world (a bare token, or a JSON payload). Never readable back. */
447
+ sealTwinCredential(vendor, input) { return sealTwinCredential(this.root, vendor, credentialPayloadFrom(input, vendor)); }
448
+ /** Refresh one twin from its root now. */
449
+ async refreshTwin(vendor, opts = {}) {
450
+ const { controlRoot, root } = this.rootOf(vendor);
451
+ return refreshTwin({ worldRoot: this.root, vendor, controlRoot, root, ...(opts.force ? { force: true } : {}) });
452
+ }
453
+ rootOf(vendor) {
454
+ const inst = statusWorld(this.name, this.root);
455
+ const controlRoot = join(inst.dirs.data, vendor);
456
+ const root = rootForControlRoot(controlRoot, vendor);
457
+ if (!root)
458
+ throw new Error(`the ${vendor} twin has no root — \`volter twin ${vendor} root <url>\` sets one`);
459
+ return { controlRoot, root };
460
+ }
461
+ /** DEPLOY: perform landed entries against each root twin's vendor, by policy (runtime `deployWorld`). */
462
+ deploy(name) { return deployWorld(this.name, { root: this.root, ...(name ? { changeset: name } : {}) }); }
463
+ }
464
+ /** The world's name when none is given: the app directory's basename, made safe. */
465
+ export function worldNameFor(app) {
466
+ const base = app.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? 'world';
467
+ const safe = base.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[^a-z0-9]+/, '');
468
+ return safe === '' ? 'world' : safe;
469
+ }
470
+ /** A name from the message (`Triage acme/web#1 as OPS-1` → `triage-acme-web-1-as-ops-1`), suffixed when taken. */
471
+ function slugName(message, existing) {
472
+ const base = message.toLowerCase().replace(/['’]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'changeset';
473
+ const taken = new Set(existing.map((c) => c.changeset.name));
474
+ if (!taken.has(base))
475
+ return base;
476
+ for (let n = 2;; n += 1) {
477
+ const name = `${base}-${n}`;
478
+ if (!taken.has(name))
479
+ return name;
480
+ }
481
+ }
482
+ /** `changeset-1`, `changeset-2`, … — the next free name when the author gives none. */
483
+ function nextChangesetName(existing) {
484
+ const taken = new Set(existing.map((c) => c.changeset.name));
485
+ for (let n = existing.length + 1;; n += 1) {
486
+ const name = `changeset-${n}`;
487
+ if (!taken.has(name))
488
+ return name;
489
+ }
490
+ }
491
+ export { rebaseChangeset, worldBootMarker };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@volter/world",
3
+ "version": "2.0.0",
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
+ "keywords": [
6
+ "volter",
7
+ "world",
8
+ "twin",
9
+ "sdk",
10
+ "cli",
11
+ "version-control",
12
+ "changeset"
13
+ ],
14
+ "author": "Volter (https://github.com/volter-ai)",
15
+ "license": "Apache-2.0",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "type": "module",
20
+ "main": "dist/src/index.js",
21
+ "files": [
22
+ "src",
23
+ "dist"
24
+ ],
25
+ "dependencies": {
26
+ "@volter/world-core": "2.0.0",
27
+ "@volter/world-runtime": "2.0.0"
28
+ },
29
+ "bin": {
30
+ "volter": "dist/src/cli.js"
31
+ },
32
+ "scripts": {
33
+ "build": "node ../../scripts/publish/build.mjs",
34
+ "prepack": "node ../../scripts/publish/prepare-publish.mjs prepack",
35
+ "postpack": "node ../../scripts/publish/prepare-publish.mjs postpack"
36
+ }
37
+ }