@volter/world-runtime 2.0.0 → 2.0.1

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.
@@ -373,10 +373,6 @@
373
373
  "disposition": "demanded",
374
374
  "reason": "vendor google-play (Play Developer API), ×1 — ladder classification 2026-09-02"
375
375
  },
376
- "@googleapis/calendar": {
377
- "disposition": "demanded",
378
- "reason": "vendor google-calendar, ×1 (plus the GOOGLE_CALENDAR_API_KEY stem, ×1) — ladder classification 2026-09-02"
379
- },
380
376
  "@googleapis/sheets": {
381
377
  "disposition": "demanded",
382
378
  "reason": "vendor google-sheets, ×1 — ladder classification 2026-09-02"
@@ -863,10 +859,6 @@
863
859
  "disposition": "demanded",
864
860
  "reason": "vendor ghost (VITE_PUBLIC_GHOST_CONTENT_API_KEY — the browser-side Content API key), ×1 — ladder classification 2026-09-02"
865
861
  },
866
- "googlecalendar": {
867
- "disposition": "demanded",
868
- "reason": "vendor google-calendar (GOOGLE_CALENDAR_API_KEY; the @googleapis/calendar client is the same demand), ×1 — ladder classification 2026-09-02"
869
- },
870
862
  "googlecloud": {
871
863
  "disposition": "demanded",
872
864
  "reason": "vendor googlecloud (GOOGLE_CLOUD_API_KEY; the roster's one consumer spends it on the Web Fonts Developer API at www.googleapis.com/webfonts/v1, so the stem is a Google Cloud API key rather than a googleoauth credential), ×1 — ladder classification 2026-09-02"
@@ -2,12 +2,17 @@
2
2
  // log of changes, and a world is a branch with compute attached (docs/concepts/the-model.md).
3
3
  // `branch <world> <name>` boots a new world from the base's config and forks every twin's mirror
4
4
  // into it (core's `forkTwin`: a POINTER to the base at its current position — the base's whole
5
- // history, its own entries included, is the branch's parent; nothing is copied). `checkout <name>`
5
+ // history, its own entries included, is the branch's parent; nothing is copied). A managed database (containerless
6
+ // Postgres) keeps no history, so it is the one thing copied: the stopped base's data, as of now. Two branches of one
7
+ // base running at once share the database port its definition names; run one at a time (the SDK does). `checkout <name>`
6
8
  // brings a stopped world back up from its own instance record, state intact.
7
- import { existsSync, readdirSync } from 'node:fs';
9
+ import { cpSync, existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
8
10
  import { dirname, join, resolve } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
9
12
  import { captureHistory, historyAtInstant, forkTwin, stateGeneration, withAncestryLock, worldPaths, worldStateRoot } from '@volter/world-core';
10
13
  import { downWorld, saveWorldInstance, statusWorld, upWorld } from "./runtime.js";
14
+ import { parseInfraDefinition, pgliteDown, pgliteUp } from "./pglite-backing.js";
15
+ import { redisTwinCli, redisTwinDown, redisTwinUp } from "./redis-backing.js";
11
16
  function stateServices(controlRoot) {
12
17
  const stateRoot = worldStateRoot(controlRoot);
13
18
  if (!existsSync(stateRoot))
@@ -35,6 +40,12 @@ export async function branchWorld(base, name, options = {}) {
35
40
  return { state, view, position, generation: stateGeneration(worldPaths(state, fromRoot).dir) };
36
41
  }) };
37
42
  }));
43
+ // the base's managed databases (checked before the branch boots: its database would share the base's port)
44
+ const databases = managedDatabases(from);
45
+ if (databases.length && (options.at?.instant || options.at?.positions || options.at?.views)) {
46
+ throw new Error(`volter-world branch: ${base} has a managed database, which keeps no history to branch from at an instant or position; branch from its current state`);
47
+ }
48
+ refuseLiveDatabases(from, databases);
38
49
  const instance = await upWorld(existsSync(from.configPath) ? from.configPath : from.config, { name, root, mode: from.mode, envFile });
39
50
  const forked = {};
40
51
  try {
@@ -50,6 +61,7 @@ export async function branchWorld(base, name, options = {}) {
50
61
  if (states.length)
51
62
  forked[service] = states.map(s => s.state);
52
63
  }
64
+ await branchManagedDatabases(from, instance, databases);
53
65
  }
54
66
  catch (error) {
55
67
  await downWorld(name, root, { purge: true, expectedCreatedAt: instance.createdAt });
@@ -57,6 +69,71 @@ export async function branchWorld(base, name, options = {}) {
57
69
  }
58
70
  return { instance, forked };
59
71
  }
72
+ /** The base's containerless managed databases: each PGlite `pglite-<kind>-data` and the MongoDB twin's
73
+ * `twin-mongodb-data` (pglite-backing.ts), and the redis twin's `redis-twin` tree (redis-backing.ts). A stopped World's
74
+ * hosts leave no pid file; a docker-backed World keeps its data under the container's volume, which a branch does not
75
+ * carry. */
76
+ function managedDatabases(from) {
77
+ const data = from.dirs.data;
78
+ if (!existsSync(data))
79
+ return [];
80
+ return readdirSync(data).filter((n) => /^pglite-[a-z0-9_-]+-data$/.test(n) || n === 'twin-mongodb-data' || n === 'redis-twin');
81
+ }
82
+ /** The pid files of the hosts serving one managed database directory. */
83
+ function hostPidFiles(data, db) {
84
+ if (db === 'redis-twin')
85
+ return readdirSync(data).filter((n) => /^redis-twin-\d+\.pid$/.test(n)).map((n) => join(data, n));
86
+ return [join(data, `${db.slice(0, -'-data'.length)}.pid`)];
87
+ }
88
+ /** A copy of a live database directory is not a consistent one, and the branch's database would answer on the base's
89
+ * port: the base must be stopped first (`volter world branch` stops it, as a branch replaces its base). */
90
+ function refuseLiveDatabases(from, dbs) {
91
+ for (const db of dbs) {
92
+ for (const pidFile of hostPidFiles(from.dirs.data, db)) {
93
+ const pid = existsSync(pidFile) ? Number(readFileSync(pidFile, 'utf8').trim()) : NaN;
94
+ let live = false;
95
+ if (Number.isInteger(pid) && pid > 0) {
96
+ try {
97
+ process.kill(pid, 0);
98
+ live = true;
99
+ }
100
+ catch {
101
+ live = false;
102
+ }
103
+ }
104
+ if (live)
105
+ throw new Error(`volter-world branch: ${from.name}'s managed ${db} is running (pid ${pid}); stop ${from.name} first so its database can be copied into the branch consistently`);
106
+ }
107
+ }
108
+ }
109
+ /** The branch's managed databases as the base holds them: the branch's own, just booted empty by `up`, are stopped,
110
+ * replaced by a copy of the base's data, and started again, so a branch of a migrated and seeded World serves that
111
+ * data, as its twins serve the base's history. */
112
+ async function branchManagedDatabases(from, instance, dbs) {
113
+ if (!dbs.length)
114
+ return;
115
+ const definition = join(dirname(instance.configPath), 'world.infrastructure.yml');
116
+ if (!existsSync(definition))
117
+ return;
118
+ const services = parseInfraDefinition(readFileSync(definition, 'utf8'));
119
+ const redis = services.filter((s) => s.kind === 'redis');
120
+ const hosted = services.filter((s) => s.kind !== 'redis');
121
+ const redisCli = dbs.includes('redis-twin') && redis.length ? redisTwinCli([dirname(fileURLToPath(import.meta.url)), dirname(instance.configPath)]) : undefined;
122
+ await pgliteDown(hosted, instance.dirs.data);
123
+ if (redisCli)
124
+ await redisTwinDown(redis, instance.dirs.data);
125
+ for (const db of dbs) {
126
+ if (db === 'redis-twin' && !redisCli)
127
+ continue;
128
+ const to = join(instance.dirs.data, db);
129
+ rmSync(to, { recursive: true, force: true });
130
+ cpSync(join(from.dirs.data, db), to, { recursive: true });
131
+ }
132
+ if (dbs.some((db) => db !== 'redis-twin'))
133
+ await pgliteUp(hosted, instance.dirs.data);
134
+ if (redisCli)
135
+ await redisTwinUp(redis, instance.dirs.data, redisCli);
136
+ }
60
137
  /** A stopped world back up, from its own record. */
61
138
  export async function checkoutWorld(name, options = {}) {
62
139
  const root = resolve(options.root ?? process.cwd());
package/dist/src/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { sessionTrustEnv } from "./ca-trust.js";
3
3
  import { spawn, spawnSync } from 'node:child_process';
4
4
  import { createRequire } from 'node:module';
5
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
5
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { dirname, join, resolve } from 'node:path';
8
8
  import { activateScript, branchWorld, checkoutWorld, fetchFromOrigin, pushWorldChangeset, rebaseWorldChangeset, resetWorld, seedWorld, worldOrigin, approveWorldChangeset, checkPrerequisites, coverWorld, createWorldChangeset, diffWorld, doctorWorld, downWorld, findWorldChangeset, formatCoverageReport, formatInitReport, formatPrerequisiteChecks, formatProjectInspection, initWorld, inspectProject, isRemoteWorldRef, listWorldChangesets, listWorlds, markWorld, migrateWorldConfig, readReflectRoutes, reflectRoutesPath, replayWorldChangeset, resolveWorldRef, attachWorld, retireWorldConsumers, runWorld, shareWorldServices, shellWorld, startReflectFront, startReflectResolver, statusWorld, statusWorldChangeset, tailWorldActions, unshareWorld, urlsWorld, urlWorld, verifyWorldChangeset, worldManifest, writeReflectRoutes, readReflectManifest, writeReflectManifest, clearReflectManifest, composeOverrideForReflect, splitDockerComposeArgs, dockerComposeWithOverride, ATTACHED_CA_PATH } from "./index.js";
@@ -568,21 +568,44 @@ async function main() {
568
568
  return;
569
569
  }
570
570
  if (cmd === 'clock') {
571
- // THE operator door for world time (physics): show / set <iso> / advance <duration>.
572
- // The clock is a frozen instant every twin reads per request (kernel worldNow()); setting
573
- // or advancing takes effect live, no restarts. `advance` requires a set clock (advancing
574
- // wall-clock would silently freeze time as a side effect).
571
+ // THE operator door for world time (physics): show / set <iso> / advance <duration> / shift <duration> / clear.
572
+ // The clock every twin reads per request (kernel worldNow()), and every application process through the injector
573
+ // (inject.cjs, WORLD TIME); a change takes effect live, no restarts. Its two forms (world-clock.cjs): `set` freezes
574
+ // time at an instant (scripted time: a life, a seed); `shift` moves a World forward while its time keeps running (a
575
+ // World serving an application, whose time a frozen instant would stop); `advance` moves either form; `clear`
576
+ // returns the World to the machine's time. `advance` refuses a World on the machine's time, which it would freeze.
575
577
  // argv shape here: cmd='clock', subject=<world>, rest=[action, value?, flags...]
578
+ const clockForm = createRequire(import.meta.url)('@volter/world-core/world-clock');
576
579
  const name = subject;
577
580
  const [action, value] = rest;
578
- if (!name || !action || (action !== 'show' && action !== 'set' && action !== 'advance')) {
579
- console.error('usage: volter-world clock <world> show | set <iso-8601> | advance <N s|m|h|d> [--root <repo>]');
581
+ if (!name || !action || !['show', 'set', 'advance', 'shift', 'clear'].includes(action)) {
582
+ console.error('usage: volter-world clock <world> show | set <iso-8601> | advance <N s|m|h|d> | shift <N s|m|h|d> | clear [--root <repo>]');
580
583
  process.exit(2);
581
584
  }
582
585
  const rootDir = resolve(optionValue(rest, '--root') ?? process.cwd());
583
586
  const file = clockFile(rootDir, name);
587
+ const current = existsSync(file) ? clockForm.parseClock(readFileSync(file, 'utf8')) : null;
588
+ const write = (clock) => {
589
+ mkdirSync(dirname(file), { recursive: true });
590
+ writeFileSync(file, `${clockForm.formatClock(clock)}\n`);
591
+ // the World's time now, bare, as scripts read it (`show` says which form)
592
+ console.log(new Date(clockForm.clockNowMs(clock, Date.now())).toISOString());
593
+ };
584
594
  if (action === 'show') {
585
- console.log(existsSync(file) ? `${readFileSync(file, 'utf8').trim()} (frozen)` : `${new Date().toISOString()} (wall clock — no world clock set)`);
595
+ if (!current)
596
+ console.log(`${new Date().toISOString()} (wall clock — no world clock set)`);
597
+ else if (current.kind === 'frozen')
598
+ console.log(`${new Date(current.at).toISOString()} (frozen)`);
599
+ else {
600
+ const ahead = current.at - current.since;
601
+ console.log(`${new Date(clockForm.clockNowMs(current, Date.now())).toISOString()} (running, ${ahead >= 0 ? '+' : '-'}${Math.abs(ahead) / 1000}s from the machine's time)`);
602
+ }
603
+ return;
604
+ }
605
+ if (action === 'clear') {
606
+ if (existsSync(file))
607
+ rmSync(file);
608
+ console.log(`${new Date().toISOString()} (wall clock — no world clock set)`);
586
609
  return;
587
610
  }
588
611
  if (action === 'set') {
@@ -591,25 +614,30 @@ async function main() {
591
614
  console.error(`clock set: ${JSON.stringify(value)} is not an ISO-8601 instant`);
592
615
  process.exit(2);
593
616
  }
594
- mkdirSync(dirname(file), { recursive: true });
595
- writeFileSync(file, `${new Date(parsed).toISOString()}\n`);
596
- console.log(new Date(parsed).toISOString());
617
+ write({ kind: 'frozen', at: parsed });
597
618
  return;
598
619
  }
599
620
  const m = /^(\d+(?:\.\d+)?)(s|m|h|d)$/.exec((value ?? '').trim());
600
621
  if (!m) {
601
- console.error(`clock advance: ${JSON.stringify(value)} is not <N>(s|m|h|d)`);
622
+ console.error(`clock ${action}: ${JSON.stringify(value)} is not <N>(s|m|h|d)`);
602
623
  process.exit(2);
603
624
  }
604
- if (!existsSync(file)) {
605
- console.error('clock advance: no world clock is set (advance from wall-clock would freeze time as a side effect) — `clock set <iso>` first');
625
+ const by = Number(m[1]) * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
626
+ if (action === 'advance') {
627
+ if (!current) {
628
+ console.error('clock advance: no world clock is set (advance from wall-clock would freeze time as a side effect) — `clock set <iso>` first, or `clock shift` to move a running World');
629
+ process.exit(2);
630
+ }
631
+ write({ ...current, at: current.at + by });
632
+ return;
633
+ }
634
+ // shift: a World on the machine's time, or already running, moves forward and keeps running
635
+ if (current?.kind === 'frozen') {
636
+ console.error('clock shift: this World\'s clock is frozen — `clock advance` moves it, or `clock clear` first');
606
637
  process.exit(2);
607
638
  }
608
- const base = Date.parse(readFileSync(file, 'utf8').trim());
609
- const unit = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
610
- const next = new Date(base + Number(m[1]) * unit).toISOString();
611
- writeFileSync(file, `${next}\n`);
612
- console.log(next);
639
+ const wall = Date.now();
640
+ write(current ? { ...current, at: current.at + by } : { kind: 'running', at: wall + by, since: wall });
613
641
  return;
614
642
  }
615
643
  if (cmd === 'urls') {
@@ -39,4 +39,7 @@ export declare function isGoogleOAuthClientEnvName(name: string): boolean;
39
39
  * come from fakeGoogleServiceAccountJson instead), and NOT the OAuth-client names above —
40
40
  * those need the `{"web":{...}}` shape, not SA JSON. */
41
41
  export declare function isGoogleServiceAccountEnvName(name: string): boolean;
42
+ /** Whether `name` is a key an application parses (APP_KEY_SHAPES): a secret init fakes in its shape, whatever the
43
+ * example leaves it (LibreChat's CREDS_IV is empty in its example and not credential-shaped by name). */
44
+ export declare function isAppKeyName(name: string): boolean;
42
45
  export declare function fakeEnvValue(name: string): string;
@@ -137,6 +137,11 @@ export function isGoogleServiceAccountEnvName(name) {
137
137
  * world-runtime must not import a pack, and the tests pin every value against the injector's actual
138
138
  * VENDOR_HOSTS predicate so either side changing makes the contract fail loudly. */
139
139
  const ENDPOINT_SHAPES = [
140
+ // the vendor's own API URL, which the World routes to its twin: an app that reads a base-URL override (empty in its
141
+ // example, the vendor's URL as its default) gets the vendor's URL, never an unreachable fake
142
+ { match: /^DYNADOT_BASE_URL$/, value: 'https://api.dynadot.com/api3.json', source: 'packages/twin/dynadot/src/index.ts hosts (api.dynadot.com); dubinc/dub apps/web/lib/dynadot/client.ts default' },
143
+ // Vercel KV's REST URL (the Upstash Redis REST API under Vercel's names): an upstash host the twin serves
144
+ { match: /(^|_)KV_REST_API_URL$/, value: 'https://twin-fake.upstash.io', source: 'packages/world-core inject VENDOR_HOSTS.upstash; lukevella/rallly KV_REST_API_URL' },
140
145
  {
141
146
  match: /(^|_)UPSTASH_REDIS_REST_URL$/,
142
147
  value: 'https://twin-fake.upstash.io',
@@ -184,6 +189,14 @@ const CREDENTIAL_SHAPES = [
184
189
  // (`.clerk.accounts.dev`, packages/twin/clerk/src/index.ts hosts), so the boundary — the injector
185
190
  // server-side, the browser proxy browser-side — resolves it to the twin, and an app CSP that
186
191
  // allowlists that host stays legal (peak drive 2026-08-26).
192
+ // Stripe's keys carry their kind in their prefix, and applications check it before calling (Cal.com's Stripe app
193
+ // refuses a key that is not `sk_…`/`pk_…`); a webhook signing secret is `whsec_…`. Test mode, as a World is.
194
+ { match: /(^|_)STRIPE_[A-Z0-9_]*WEBHOOK_SECRET$/, value: () => 'whsec_twinfake0000000000000000000000000000', source: 'https://docs.stripe.com/webhooks#verify-events' },
195
+ { match: /(^|_)STRIPE_[A-Z0-9_]*(SECRET|PRIVATE|API)(_API)?_KEY$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (sk_test_)' },
196
+ { match: /(^|_)STRIPE_[A-Z0-9_]*(PUBLISHABLE|PUBLIC)(_API)?_KEY$/, value: () => 'pk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (pk_test_)' },
197
+ // tavily-auth.ts and firecrawl-auth.ts refuse a key without the vendor's prefix with the vendor's 401
198
+ { match: /(^|_)TAVILY_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'tvly-twinfake00000000000000000000', source: 'packages/twin/tavily/src/tavily-auth.ts' },
199
+ { match: /(^|_)FIRECRAWL_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'fc-twinfake00000000000000000000000', source: 'packages/twin/firecrawl/src/firecrawl-auth.ts FIRECRAWL_KEY_PREFIX' },
187
200
  { match: /(^|_)CLERK_[A-Z0-9_]*SECRET[A-Z0-9_]*$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'packages/twin/clerk/src/clerk-twin.ts' },
188
201
  { match: /(^|_)CLERK_[A-Z0-9_]*PUBLISHABLE[A-Z0-9_]*$/, value: () => `pk_test_${Buffer.from('twin.clerk.accounts.dev$').toString('base64')}`, source: 'packages/twin/clerk/src/index.ts' },
189
202
  ];
@@ -194,7 +207,18 @@ const APP_KEY_SHAPES = [
194
207
  // AES-256-GCM: the key base64-decodes to 32 bytes ("ENCRYPTION_KEY must be 32 bytes (base64-encoded)"); the 32 bytes
195
208
  // spell out that they are fake
196
209
  { match: /^ENCRYPTION_KEY$/, value: () => Buffer.from('twin-fake-encryption-key-32bytes').toString('base64'), source: "dubinc/dub apps/web/lib/encryption.ts" },
210
+ // AES-256-CBC as hex: the key is 32 bytes (64 hex characters) and the IV 16 (32); LibreChat refuses to boot on any
211
+ // other shape; the bytes spell out that they are fake
212
+ // AES-256 over the key's latin1 bytes: exactly 32 characters (Cal.com's symmetricEncrypt)
213
+ { match: /^CALENDSO_ENCRYPTION_KEY$/, value: () => 'twin-fake-calendso-key-32-chars!', source: 'calcom/cal.diy packages/lib/crypto.ts' },
214
+ { match: /^CREDS_KEY$/, value: () => Buffer.from('twin-fake-creds-key-of-32-bytes!').toString('hex'), source: 'LibreChat-AI/LibreChat api/server/utils/crypto.js' },
215
+ { match: /^CREDS_IV$/, value: () => Buffer.from('twin-fake-iv-16b').toString('hex'), source: 'LibreChat-AI/LibreChat api/server/utils/crypto.js' },
197
216
  ];
217
+ /** Whether `name` is a key an application parses (APP_KEY_SHAPES): a secret init fakes in its shape, whatever the
218
+ * example leaves it (LibreChat's CREDS_IV is empty in its example and not credential-shaped by name). */
219
+ export function isAppKeyName(name) {
220
+ return APP_KEY_SHAPES.some((shape) => shape.match.test(name));
221
+ }
198
222
  export function fakeEnvValue(name) {
199
223
  if (isGoogleOAuthClientEnvName(name))
200
224
  return fakeGoogleOAuthClientJson();
@@ -4,6 +4,7 @@
4
4
  import { existsSync, readFileSync } from 'node:fs';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { spawnSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
7
8
  import { infraConnections, parseInfraDefinition } from "./pglite-backing.js";
8
9
  const phase = process.argv[2];
9
10
  if (phase !== 'up' && phase !== 'status' && phase !== 'down') {
@@ -44,8 +45,9 @@ const fail = (result) => {
44
45
  // runtime answers it is private and chosen here, per machine, at each phase:
45
46
  // 1. VOLTER_WORLD_INFRA_BACKING=docker|pglite — explicit, for tests/operators;
46
47
  // 2. a working container runtime — the compose path, byte-identical to before;
47
- // 3. no container runtime + a postgres-only definition — the PGlite backing
48
- // (pglite-host.ts), announced loudly;
48
+ // 3. no container runtime + a definition of postgres and/or mongodb services —
49
+ // the containerless backing (pglite-backing.ts: PGlite for postgres, the
50
+ // MongoDB twin for mongodb), announced loudly;
49
51
  // 4. otherwise the honest refusal naming what this machine cannot serve.
50
52
  function selectBacking() {
51
53
  const forced = process.env.VOLTER_WORLD_INFRA_BACKING;
@@ -69,7 +71,7 @@ function selectBacking() {
69
71
  }
70
72
  async function runPglite() {
71
73
  const { unsupportedKinds, pgliteUp, pgliteStatus, pgliteDown } = await import("./pglite-backing.js");
72
- const services = parseInfraDefinition(readFileSync(definition, 'utf8'));
74
+ const services = parseInfraDefinition(readFileSync(definition, 'utf8')).filter((s) => composed === null || composed.includes(s.kind));
73
75
  if (services.length === 0) {
74
76
  process.stderr.write('managed infrastructure: the declared definition names no services\n');
75
77
  process.exit(1);
@@ -80,7 +82,7 @@ async function runPglite() {
80
82
  process.exit(1);
81
83
  }
82
84
  if (phase === 'up') {
83
- process.stdout.write('managed infrastructure backing: pglite (no container runtime)\n');
85
+ process.stdout.write('managed infrastructure backing: containerless (no container runtime)\n');
84
86
  try {
85
87
  await pgliteUp(services, worldData);
86
88
  }
@@ -97,18 +99,65 @@ async function runPglite() {
97
99
  process.stderr.write(`managed infrastructure status failed: ${status.ready}/${services.length} declared services are ready\n`);
98
100
  process.exit(1);
99
101
  }
100
- process.stdout.write(`${JSON.stringify({ ok: true, services: services.length, connections: infraConnections(services, process.env) })}\n`);
102
+ process.stdout.write(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, process.env) })}\n`);
101
103
  process.exit(0);
102
104
  }
103
105
  await pgliteDown(services, worldData);
104
106
  process.stdout.write('managed infrastructure stopped\n');
105
107
  process.exit(0);
106
108
  }
109
+ // ---- redis: the twin, containerless ----------------------------------------
110
+ // A redis service is served by the redis twin (redis-backing.ts) on every machine, container runtime or not,
111
+ // unless the operator forces the container backing; the other services take the backing chosen above. Where the
112
+ // twin is not installed (@volter/twin-redis is not this runtime's dependency), redis goes to that backing too:
113
+ // the container where there is one, else the backing's refusal naming redis.
114
+ const declared = parseInfraDefinition(readFileSync(definition, 'utf8'));
115
+ const { redisTwinCli } = await import("./redis-backing.js");
116
+ const twinCli = redisTwinCli([dirname(fileURLToPath(import.meta.url)), dirname(worldConfig)]);
117
+ const twinned = process.env.VOLTER_WORLD_INFRA_BACKING === 'docker' || twinCli === undefined ? [] : declared.filter((s) => s.kind === 'redis');
118
+ /** The services the backing below answers for: every one, or those the twin does not serve (by compose service name). */
119
+ const composed = twinned.length === 0 ? null : declared.filter((s) => s.kind !== 'redis').map((s) => s.kind);
120
+ if (twinned.length > 0)
121
+ await runRedisTwin();
122
+ async function runRedisTwin() {
123
+ const { redisTwinDown, redisTwinStatus, redisTwinUp } = await import("./redis-backing.js");
124
+ const cli = twinCli;
125
+ if (phase === 'up') {
126
+ process.stdout.write('managed infrastructure backing: redis twin (containerless)\n');
127
+ try {
128
+ await redisTwinUp(twinned, worldData, cli);
129
+ }
130
+ catch (error) {
131
+ process.stderr.write(`managed infrastructure up failed: ${String(error.message ?? error)}\n`);
132
+ process.exit(1);
133
+ }
134
+ }
135
+ else if (phase === 'status') {
136
+ const ready = await redisTwinStatus(twinned, worldData);
137
+ if (ready !== twinned.length) {
138
+ process.stderr.write(`managed infrastructure status failed: ${ready}/${declared.length} declared services are ready\n`);
139
+ process.exit(1);
140
+ }
141
+ }
142
+ else {
143
+ await redisTwinDown(twinned, worldData);
144
+ }
145
+ if (composed.length === 0) {
146
+ if (phase === 'up')
147
+ process.stdout.write('managed infrastructure ready\n');
148
+ else if (phase === 'status')
149
+ process.stdout.write(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, process.env) })}\n`);
150
+ else
151
+ process.stdout.write('managed infrastructure stopped\n');
152
+ process.exit(0);
153
+ }
154
+ }
107
155
  if (selectBacking() === 'pglite') {
108
156
  await runPglite();
109
157
  }
158
+ const only = composed ?? [];
110
159
  if (phase === 'up') {
111
- const result = run(['up', '-d', '--wait']);
160
+ const result = run(['up', '-d', '--wait', ...only]);
112
161
  if (result.status !== 0)
113
162
  fail(result);
114
163
  process.stdout.write('managed infrastructure ready\n');
@@ -120,13 +169,13 @@ else if (phase === 'status') {
120
169
  const running = run(['ps', '--status', 'running', '--services']);
121
170
  if (running.status !== 0)
122
171
  fail(running);
123
- const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).sort();
124
- const runningNames = running.stdout.split(/\s+/u).filter(Boolean).sort();
172
+ const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
173
+ const runningNames = running.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
125
174
  if (expectedNames.length === 0 || expectedNames.join('\0') !== runningNames.join('\0')) {
126
- process.stderr.write(`managed infrastructure status failed: ${runningNames.length}/${expectedNames.length} declared services are ready\n`);
175
+ process.stderr.write(`managed infrastructure status failed: ${runningNames.length + twinned.length}/${expectedNames.length + twinned.length} declared services are ready\n`);
127
176
  process.exit(1);
128
177
  }
129
- process.stdout.write(`${JSON.stringify({ ok: true, services: expectedNames.length, connections: infraConnections(parseInfraDefinition(readFileSync(definition, 'utf8')), process.env) })}\n`);
178
+ process.stdout.write(`${JSON.stringify({ ok: true, services: expectedNames.length + twinned.length, connections: infraConnections(declared, process.env) })}\n`);
130
179
  }
131
180
  else {
132
181
  const result = run(['down', '--remove-orphans']);
package/dist/src/init.js CHANGED
@@ -37,7 +37,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
37
37
  import { PROTOCOL_MAJOR, stateDirName } from '@volter/world-core';
38
38
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
39
39
  import { coverWorld, detectRepoVendors, envNameVendor, formatCoverageReport, injectorEnvNameForKey, injectorVendorKeysFor, isCredentialShapedEnvName, registryAcknowledgedReason, } from "./covers.js";
40
- import { fakeEnvValue, isGoogleOAuthClientEnvName, isGoogleServiceAccountEnvName } from "./fixture-env.js";
40
+ import { fakeEnvValue, isAppKeyName, isGoogleOAuthClientEnvName, isGoogleServiceAccountEnvName } from "./fixture-env.js";
41
41
  import { resolveCatalog, twinPackageName } from "./catalog.js";
42
42
  import { projectEnvReads, projectManifestDirs } from "./project-inspect.js";
43
43
  import { overlayEndpointEnv, packFacts } from "./pack-facts.js";
@@ -119,6 +119,20 @@ export const APP_READ_ENDPOINT_ENV = {
119
119
  + 'sends there. Read the mail back over the twin-only inspect sidecar: `world-smtp serve --inspect-port N`, then '
120
120
  + 'GET http://127.0.0.1:N/twin/messages/latest.',
121
121
  },
122
+ // RAW PROTOCOL, the gRPC kind. The Temporal SDKs dial the frontend address they are given
123
+ // (`Connection.connect({ address })`, `NativeConnection.connect({ address })`), conventionally read
124
+ // from TEMPORAL_ADDRESS — Postiz's temporal.module.ts, the SDK samples and the Temporal CLI all read
125
+ // that name — and speak gRPC over HTTP/2, which the http/fetch injector never sees. The value is an
126
+ // address (host:port), not a URL, which is why this lives here as a template and not as the
127
+ // descriptor's `endpointEnv` (whose `name` receives the twin's URL).
128
+ temporal: {
129
+ injectEnvTemplates: {
130
+ TEMPORAL_ADDRESS: '${host}:${port}',
131
+ },
132
+ note: 'no injector entry and none possible: the Temporal SDKs speak gRPC to the address they are configured with. '
133
+ + 'TEMPORAL_ADDRESS=<host>:<port> points them at the twin (namespace `default` exists); an app that sets '
134
+ + 'TEMPORAL_API_KEY turns TLS on in the SDK, which this loopback frontend does not speak — leave it unset.',
135
+ },
122
136
  };
123
137
  // descriptor-first migration (adding-a-twin.md §3): packs now declare their endpoint-env wiring (with its grounding
124
138
  // note) on the descriptor (`endpointEnv` on TwinPack); the table above shrinks toward empty as
@@ -257,7 +271,7 @@ const INFRA_PLACEHOLDER = {
257
271
  //
258
272
  // The subject pilots (dub, cal.com) put the number on it: world-side setup is seconds, and the
259
273
  // minutes go to the app side — most avoidably, to hand-writing infrastructure for the Postgres/
260
- // MySQL/Redis the repo signalled. For the kinds below, `init` upgrades the placeholder to a
274
+ // MySQL/Redis/MongoDB the repo signalled. For the kinds below, `init` upgrades the placeholder to a
261
275
  // World-managed service and private definition with env URLs already pointing at it. The helper
262
276
  // owns its implementation behind the declared service boundary. Kinds without a recipe keep the
263
277
  // placeholder + `//infra` stub.
@@ -285,6 +299,15 @@ const COMPOSE_INFRA = {
285
299
  volumePath: '/data',
286
300
  healthcheck: () => ['CMD', 'redis-cli', 'ping'],
287
301
  },
302
+ // Without a container runtime the containerless backing serves this kind with the MongoDB twin
303
+ // (@volter/twin-mongodb), at the same loopback port and URL.
304
+ mongodb: {
305
+ image: 'mongo:7',
306
+ containerPort: 27017,
307
+ memoryMiB: 1024,
308
+ volumePath: '/data/db',
309
+ healthcheck: () => ['CMD', 'mongosh', '--quiet', '--eval', "db.adminCommand('ping').ok"],
310
+ },
288
311
  };
289
312
  /** FNV-1a 32-bit — a tiny, dependency-free stable string hash for port derivation. */
290
313
  function fnv1a(text) {
@@ -326,9 +349,12 @@ function composeService(name, kind, signals, taken) {
326
349
  const recipe = COMPOSE_INFRA[kind];
327
350
  const hostPort = composePort(name, kind, taken);
328
351
  const ident = composeIdent(name);
352
+ // redis and mongodb run without authentication (no credentials in their images' env), so their URLs carry none
329
353
  const url = kind === 'redis'
330
354
  ? `redis://127.0.0.1:${hostPort}`
331
- : `${kind}://${composeUser(name, kind)}:${ident}@127.0.0.1:${hostPort}/${ident}`;
355
+ : kind === 'mongodb'
356
+ ? `mongodb://127.0.0.1:${hostPort}/${ident}`
357
+ : `${kind}://${composeUser(name, kind)}:${ident}@127.0.0.1:${hostPort}/${ident}`;
332
358
  return {
333
359
  kind,
334
360
  image: recipe.image,
@@ -340,7 +366,7 @@ function composeService(name, kind, signals, taken) {
340
366
  signals,
341
367
  };
342
368
  }
343
- /** The declared env of one compose service (empty for redis). */
369
+ /** The declared env of one compose service (empty for redis and mongodb). */
344
370
  function composeEnvironment(name, kind) {
345
371
  const ident = composeIdent(name);
346
372
  if (kind === 'postgres') {
@@ -661,7 +687,7 @@ export function planWorldInit(name, repoPath, options = {}) {
661
687
  // RULE 3: credential-shaped means faked, even when no vendor claims the stem. That covers
662
688
  // app-local secrets (JWT_SECRET, ENCRYPTION_KEY) and untwinned vendors alike — and it is the
663
689
  // reason a live key committed to `.env.example` can never reach the emitted world.
664
- if (isCredentialShapedEnvName(name)) {
690
+ if (isCredentialShapedEnvName(name) || isAppKeyName(name)) {
665
691
  env[name] = fakeEnvValue(name);
666
692
  envRows.push({ name, disposition: 'faked', source, reason: 'credential-shaped name — the example value is never copied' });
667
693
  continue;
@@ -11,6 +11,14 @@ export declare function parseInfraDefinition(text: string): InfraService[];
11
11
  /** Endpoints of the declared infrastructure, published through external.discover. Only URLs
12
12
  * whose protocol and loopback port match a declared service belong to this lifecycle. */
13
13
  export declare function infraConnections(services: InfraService[], env: Record<string, string | undefined>): Record<string, string>;
14
+ /** The MongoDB twin's cli: the package installed above the World's config (an app repo), else the one
15
+ * this runtime was installed with (its dependency, or the checkout's workspace link). */
16
+ export declare function mongodbTwinCli(worldConfig?: string, froms?: string[]): string;
17
+ /** The runtime a twin's cli runs under. A published package's cli is JavaScript and runs under this
18
+ * process's own runtime; a checkout's is TypeScript whose kernel needs more than Node's type
19
+ * stripping, so it runs under Bun (this process's, or the one on PATH) — the repository's toolchain —
20
+ * and without Bun it is refused by name rather than started to fail. */
21
+ export declare function twinRunner(cli: string, bunOnPath?: () => boolean, underBun?: boolean): string;
14
22
  /** The kinds this backing cannot serve, or [] when it can serve the world. */
15
23
  export declare function unsupportedKinds(services: InfraService[]): string[];
16
24
  export declare function pgliteUp(services: InfraService[], dataDir: string): Promise<void>;