@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.
- package/dist/known-external-services.json +0 -8
- package/dist/src/branch.js +79 -2
- package/dist/src/cli.js +47 -19
- package/dist/src/fixture-env.d.ts +3 -0
- package/dist/src/fixture-env.js +24 -0
- package/dist/src/infra-cli.js +59 -10
- package/dist/src/init.js +31 -5
- package/dist/src/pglite-backing.d.ts +8 -0
- package/dist/src/pglite-backing.js +118 -35
- package/dist/src/pglite-host.mjs +520 -14
- package/dist/src/redirect-proxy.d.ts +1 -1
- package/dist/src/redirect-proxy.js +2 -2
- package/dist/src/redis-backing.d.ts +8 -0
- package/dist/src/redis-backing.js +120 -0
- package/dist/src/runtime.d.ts +1 -0
- package/dist/src/runtime.js +24 -7
- package/known-external-services.json +0 -8
- package/package.json +9 -4
- package/src/branch.ts +66 -2
- package/src/cli.ts +40 -18
- package/src/fixture-env.ts +25 -0
- package/src/infra-cli.ts +54 -10
- package/src/init.ts +31 -5
- package/src/pglite-backing.ts +109 -36
- package/src/pglite-host.mjs +520 -14
- package/src/redirect-proxy.ts +2 -2
- package/src/redis-backing.ts +107 -0
- package/src/runtime.ts +25 -7
|
@@ -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"
|
package/dist/src/branch.js
CHANGED
|
@@ -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).
|
|
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
|
|
573
|
-
//
|
|
574
|
-
//
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
622
|
+
console.error(`clock ${action}: ${JSON.stringify(value)} is not <N>(s|m|h|d)`);
|
|
602
623
|
process.exit(2);
|
|
603
624
|
}
|
|
604
|
-
|
|
605
|
-
|
|
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
|
|
609
|
-
|
|
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;
|
package/dist/src/fixture-env.js
CHANGED
|
@@ -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();
|
package/dist/src/infra-cli.js
CHANGED
|
@@ -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
|
|
48
|
-
// (pglite-
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
-
:
|
|
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>;
|