@volter/world-runtime 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/dist/known-external-services.json +1108 -0
- package/dist/src/ancestry.d.ts +2 -0
- package/dist/src/ancestry.js +42 -0
- package/dist/src/app-url.d.ts +47 -0
- package/dist/src/app-url.js +239 -0
- package/dist/src/attach.d.ts +48 -0
- package/dist/src/attach.js +87 -0
- package/dist/src/branch.d.ts +20 -0
- package/dist/src/branch.js +65 -0
- package/dist/src/browser-proxy-cli.d.ts +2 -0
- package/dist/src/browser-proxy-cli.js +41 -0
- package/dist/src/ca-trust.d.ts +5 -0
- package/dist/src/ca-trust.js +64 -0
- package/dist/src/catalog.d.ts +31 -0
- package/dist/src/catalog.js +148 -0
- package/dist/src/changeset.d.ts +142 -0
- package/dist/src/changeset.js +570 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +1262 -0
- package/dist/src/command-lifetime.d.ts +15 -0
- package/dist/src/command-lifetime.js +98 -0
- package/dist/src/configs.d.ts +18 -0
- package/dist/src/configs.js +119 -0
- package/dist/src/console-apart.d.ts +38 -0
- package/dist/src/console-apart.js +107 -0
- package/dist/src/consumers.d.ts +46 -0
- package/dist/src/consumers.js +200 -0
- package/dist/src/covers.d.ts +183 -0
- package/dist/src/covers.js +800 -0
- package/dist/src/fixture-env.d.ts +42 -0
- package/dist/src/fixture-env.js +221 -0
- package/dist/src/host-cli.d.ts +2 -0
- package/dist/src/host-cli.js +92 -0
- package/dist/src/host-fault-fixture.d.ts +32 -0
- package/dist/src/host-fault-fixture.js +100 -0
- package/dist/src/host-worker.d.ts +1 -0
- package/dist/src/host-worker.js +23 -0
- package/dist/src/host.d.ts +38 -0
- package/dist/src/host.js +135 -0
- package/dist/src/index.d.ts +48 -0
- package/dist/src/index.js +35 -0
- package/dist/src/infra-cli.d.ts +2 -0
- package/dist/src/infra-cli.js +136 -0
- package/dist/src/init.d.ts +227 -0
- package/dist/src/init.js +1117 -0
- package/dist/src/inject-map.d.ts +34 -0
- package/dist/src/inject-map.js +56 -0
- package/dist/src/lifecycle-record.d.ts +47 -0
- package/dist/src/lifecycle-record.js +196 -0
- package/dist/src/origin.d.ts +31 -0
- package/dist/src/origin.js +139 -0
- package/dist/src/pack-facts.d.ts +75 -0
- package/dist/src/pack-facts.js +98 -0
- package/dist/src/pglite-backing.d.ts +21 -0
- package/dist/src/pglite-backing.js +158 -0
- package/dist/src/pglite-host.mjs +147 -0
- package/dist/src/placeholder.d.ts +20 -0
- package/dist/src/placeholder.js +100 -0
- package/dist/src/prerequisites.d.ts +21 -0
- package/dist/src/prerequisites.js +49 -0
- package/dist/src/process-groups.d.ts +4 -0
- package/dist/src/process-groups.js +49 -0
- package/dist/src/project-inspect.d.ts +109 -0
- package/dist/src/project-inspect.js +827 -0
- package/dist/src/proxy-daemon.d.ts +2 -0
- package/dist/src/proxy-daemon.js +18 -0
- package/dist/src/redirect-proxy.d.ts +105 -0
- package/dist/src/redirect-proxy.js +665 -0
- package/dist/src/reflect.d.ts +74 -0
- package/dist/src/reflect.js +392 -0
- package/dist/src/resources.d.ts +26 -0
- package/dist/src/resources.js +22 -0
- package/dist/src/root.d.ts +114 -0
- package/dist/src/root.js +312 -0
- package/dist/src/run-task-worker.d.ts +1 -0
- package/dist/src/run-task-worker.js +38 -0
- package/dist/src/run-task.d.ts +18 -0
- package/dist/src/run-task.js +48 -0
- package/dist/src/runtime-test-support.d.ts +59 -0
- package/dist/src/runtime-test-support.js +205 -0
- package/dist/src/runtime.d.ts +256 -0
- package/dist/src/runtime.js +3502 -0
- package/dist/src/schema.d.ts +449 -0
- package/dist/src/schema.js +605 -0
- package/dist/src/serve.d.ts +30 -0
- package/dist/src/serve.js +82 -0
- package/dist/src/served-world.d.ts +194 -0
- package/dist/src/served-world.js +986 -0
- package/dist/src/service-exit.d.ts +46 -0
- package/dist/src/service-exit.js +195 -0
- package/dist/src/service-recorder.d.ts +1 -0
- package/dist/src/service-recorder.js +121 -0
- package/dist/src/sibling.d.ts +1 -0
- package/dist/src/sibling.js +9 -0
- package/dist/src/signals.d.ts +1 -0
- package/dist/src/signals.js +11 -0
- package/dist/src/storage-capacity.d.ts +8 -0
- package/dist/src/storage-capacity.js +61 -0
- package/dist/src/tail.d.ts +30 -0
- package/dist/src/tail.js +160 -0
- package/dist/src/tcp-port.d.ts +2 -0
- package/dist/src/tcp-port.js +36 -0
- package/dist/src/up-task-worker.d.ts +1 -0
- package/dist/src/up-task-worker.js +61 -0
- package/dist/src/up-task.d.ts +17 -0
- package/dist/src/up-task.js +49 -0
- package/dist/src/websocket-relay.d.ts +3 -0
- package/dist/src/websocket-relay.js +40 -0
- package/known-external-services.json +1108 -0
- package/package.json +83 -0
- package/src/ancestry.ts +36 -0
- package/src/app-url.ts +253 -0
- package/src/attach.ts +117 -0
- package/src/branch.ts +63 -0
- package/src/browser-proxy-cli.ts +44 -0
- package/src/ca-trust.ts +57 -0
- package/src/catalog.ts +156 -0
- package/src/changeset.ts +627 -0
- package/src/cli.ts +1111 -0
- package/src/command-lifetime.ts +79 -0
- package/src/configs.ts +110 -0
- package/src/console-apart.ts +90 -0
- package/src/consumers.ts +185 -0
- package/src/covers.ts +934 -0
- package/src/fixture-env.ts +230 -0
- package/src/host-cli.ts +90 -0
- package/src/host-worker.ts +23 -0
- package/src/host.ts +169 -0
- package/src/index.ts +171 -0
- package/src/infra-cli.ts +133 -0
- package/src/init.ts +1316 -0
- package/src/inject-map.ts +72 -0
- package/src/lifecycle-record.ts +168 -0
- package/src/origin.ts +134 -0
- package/src/pack-facts.ts +128 -0
- package/src/pglite-backing.ts +141 -0
- package/src/pglite-host.mjs +147 -0
- package/src/placeholder.ts +89 -0
- package/src/prerequisites.ts +66 -0
- package/src/process-groups.ts +33 -0
- package/src/project-inspect.ts +770 -0
- package/src/proxy-daemon.ts +21 -0
- package/src/redirect-proxy.ts +684 -0
- package/src/reflect.ts +440 -0
- package/src/resources.ts +22 -0
- package/src/root.ts +290 -0
- package/src/run-task-worker.ts +27 -0
- package/src/run-task.ts +44 -0
- package/src/runtime-test-support.ts +208 -0
- package/src/runtime.ts +3357 -0
- package/src/schema.ts +922 -0
- package/src/serve.ts +102 -0
- package/src/served-world.ts +812 -0
- package/src/service-exit.ts +175 -0
- package/src/service-recorder.ts +89 -0
- package/src/sibling.ts +10 -0
- package/src/signals.ts +10 -0
- package/src/storage-capacity.ts +60 -0
- package/src/tail.ts +205 -0
- package/src/tcp-port.ts +35 -0
- package/src/up-task-worker.ts +40 -0
- package/src/up-task.ts +45 -0
- package/src/websocket-relay.ts +32 -0
package/dist/src/init.js
ADDED
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
// `volter-world init <name> --repo <path>` — the DETERMINISTIC FRONT DOOR of world adoption.
|
|
2
|
+
//
|
|
3
|
+
// Everything it needs already existed, in three pieces that no one command composed:
|
|
4
|
+
// • `inspect-project` / `covers` know WHICH vendors a repo talks to (dependencies across every
|
|
5
|
+
// workspace member, vendor-shaped env names in committed .env files, SMTP/raw-protocol signals,
|
|
6
|
+
// and literal fetch destinations in production source);
|
|
7
|
+
// • `@volter/world-core/inject`'s VENDOR_HOSTS knows WHICH of those can be intercepted zero-edit, and
|
|
8
|
+
// under exactly which `*_TWIN_URL` name (the LibreChat `AWS_TWIN_URL` trap is what happens when
|
|
9
|
+
// a config guesses instead of asking);
|
|
10
|
+
// • `fixture-env` knows how to mint a fake credential that a client SDK will actually accept
|
|
11
|
+
// (structurally valid where the SDK parses it, opaque where it does not).
|
|
12
|
+
// `init` composes them into the two files an operator otherwise hand-writes — a world config and an
|
|
13
|
+
// env file — and then RUNS THE PROOF over its own output, so the command's exit code is the answer
|
|
14
|
+
// to "can I `up` this?" rather than a claim that it wrote something.
|
|
15
|
+
//
|
|
16
|
+
// THE FOUR RULES THIS COMMAND IS BUILT ON
|
|
17
|
+
//
|
|
18
|
+
// 1. THE WORLD LIVES IN THE APP REPO. Output goes to `<repo>/.volter/` by default (docs/concepts/worlds.md
|
|
19
|
+
// #the-config-and-the-running-world): the config, the default data and the handlers are the story and are
|
|
20
|
+
// committed; the running state is gitignored; nothing written holds a key (`$mint` is minted at boot).
|
|
21
|
+
// `--out` still emits a disposable pilot elsewhere.
|
|
22
|
+
// 2. INJECT-ENV IS DERIVED, NEVER GUESSED. Every `*_TWIN_URL` this command emits comes from
|
|
23
|
+
// `injectorVendorKeysFor()` reading the injector's own table. A vendor the injector cannot
|
|
24
|
+
// intercept gets its REAL app-read endpoint env (INNGEST_BASE_URL, LIVEKIT_URL, …)
|
|
25
|
+
// from APP_READ_ENDPOINT_ENV below, or — when the vendor's SDK is wired only by explicit client
|
|
26
|
+
// config — NO env var at all and a note saying so. Emitting a plausible-looking var that
|
|
27
|
+
// nothing reads would turn the proof green over a world whose traffic still leaves the machine.
|
|
28
|
+
// 3. A COMMITTED EXAMPLE VALUE IS NEVER A SAFE VALUE. Every credential-shaped name is replaced by a
|
|
29
|
+
// fake, whether or not its stem maps to a vendor: `.env.example` files really do carry live keys
|
|
30
|
+
// (the ponder blind-adoption run found some). Only non-credential app config is copied verbatim.
|
|
31
|
+
// 4. THE EXIT CODE IS THE VENDOR-COVERAGE PROOF'S. 0 means every detected vendor is covered. 1
|
|
32
|
+
// means the honest vendor worklist — missing packs, unknown SDKs, twins nothing can reach —
|
|
33
|
+
// with a remediation line each. App-boot risks are a separate, prominent signal: an empty
|
|
34
|
+
// example value read by production source can block before listen even when vendor coverage is
|
|
35
|
+
// green. `init` never boots the world: it diagnoses, the operator (or the room) boots.
|
|
36
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
37
|
+
import { PROTOCOL_MAJOR, stateDirName } from '@volter/world-core';
|
|
38
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
39
|
+
import { coverWorld, detectRepoVendors, envNameVendor, formatCoverageReport, injectorEnvNameForKey, injectorVendorKeysFor, isCredentialShapedEnvName, registryAcknowledgedReason, } from "./covers.js";
|
|
40
|
+
import { fakeEnvValue, isGoogleOAuthClientEnvName, isGoogleServiceAccountEnvName } from "./fixture-env.js";
|
|
41
|
+
import { resolveCatalog, twinPackageName } from "./catalog.js";
|
|
42
|
+
import { projectEnvReads, projectManifestDirs } from "./project-inspect.js";
|
|
43
|
+
import { overlayEndpointEnv, packFacts } from "./pack-facts.js";
|
|
44
|
+
import { DEFAULT_WORLD_SELECTION, MINT_ENV, worldConfigDocument, worldSelectionIncludes } from "./schema.js";
|
|
45
|
+
import { loadWorldConfig } from "./configs.js";
|
|
46
|
+
// ---------------------------------------------------------------------------------------------
|
|
47
|
+
// The pack catalog
|
|
48
|
+
// ---------------------------------------------------------------------------------------------
|
|
49
|
+
/** The twins available at `root`, sorted — a deterministic input (catalog.ts: this checkout's
|
|
50
|
+
* `packages/twin/<vendor>/` dirs, or the `@volter/twin-<vendor>` packages installed above root). */
|
|
51
|
+
export function packCatalog(root) {
|
|
52
|
+
return resolveCatalog(root).vendors();
|
|
53
|
+
}
|
|
54
|
+
// ---------------------------------------------------------------------------------------------
|
|
55
|
+
// Wiring knowledge
|
|
56
|
+
// ---------------------------------------------------------------------------------------------
|
|
57
|
+
/**
|
|
58
|
+
* The REAL endpoint env names for packs the injector cannot intercept — the other half of
|
|
59
|
+
* `scripts/vendor-hosts.test.ts`'s descriptor hostsNone ruling. That allowlist states WHY each pack
|
|
60
|
+
* has no VENDOR_HOSTS entry; this table states WHAT to wire instead, using the env var the vendor's
|
|
61
|
+
* own SDK documents. Every entry is grounded in the SDK, not invented:
|
|
62
|
+
*
|
|
63
|
+
* inngest `INNGEST_BASE_URL` (+ `INNGEST_EVENT_API_BASE_URL` for the event API)
|
|
64
|
+
* — inngest@4's own apiBaseUrl/eventBaseUrl getters.
|
|
65
|
+
* ai-gateway `AI_GATEWAY_BASE_URL` — `@ai-sdk/gateway`'s baseURL env.
|
|
66
|
+
* supabase `SUPABASE_MGMT_TWIN_URL` — the MANAGEMENT (control-plane) twin only,
|
|
67
|
+
* which is what this pack is; the DATA plane is the real local Supabase
|
|
68
|
+
* stack and is emitted as an infra stub, not faked.
|
|
69
|
+
* livekit `LIVEKIT_URL` as a ws:// template — the media plane is a WebSocket
|
|
70
|
+
* URL the SDK takes as its first constructor argument.
|
|
71
|
+
*
|
|
72
|
+
* A pack that is base-URL-configurable but has NO conventional env var (algolia, pinecone,
|
|
73
|
+
* replicate, fal, twilio, sendblue, figma, notion) is deliberately ABSENT: it gets a service
|
|
74
|
+
* with no endpoint env at all, and the proof reports it UNINTERCEPTABLE. Inventing a
|
|
75
|
+
* `<VENDOR>_BASE_URL` for it would make `covers` call the world covered (any app-read env counts)
|
|
76
|
+
* while the app, which reads no such var, still talks to the real vendor — the exact class of lie
|
|
77
|
+
* the proof exists to catch.
|
|
78
|
+
*/
|
|
79
|
+
export const APP_READ_ENDPOINT_ENV = {
|
|
80
|
+
tunnel: {
|
|
81
|
+
injectEnv: 'TUNNEL_SERVER_URL',
|
|
82
|
+
note: 'no injector entry: the native volter-tunnel CLI and @volter/tunnel WebSocket client are endpoint-configured through TUNNEL_SERVER_URL / the SDK host option; native cloudflared is outside Node HTTP injection.',
|
|
83
|
+
},
|
|
84
|
+
// (inngest moved to its pack descriptor's `endpointEnv` — the descriptor-first exemplar (adding-a-twin.md §3).)
|
|
85
|
+
'ai-gateway': {
|
|
86
|
+
injectEnv: 'AI_GATEWAY_BASE_URL',
|
|
87
|
+
note: 'no injector entry: @ai-sdk/gateway is base-URL-configured through AI_GATEWAY_BASE_URL.',
|
|
88
|
+
},
|
|
89
|
+
supabase: {
|
|
90
|
+
injectEnv: 'SUPABASE_MGMT_TWIN_URL',
|
|
91
|
+
note: 'CONTROL PLANE ONLY — this twin is the Supabase MANAGEMENT API. The DATA plane (Postgres/PostgREST/Storage) '
|
|
92
|
+
+ 'is the REAL local Supabase stack: see the `//infra` supabase stub, fill in its `supabase start` lifecycle, and '
|
|
93
|
+
+ 'move it into "services". Until then the proof reports supabase UNINTERCEPTABLE, which is the truth.',
|
|
94
|
+
},
|
|
95
|
+
livekit: {
|
|
96
|
+
injectEnvTemplates: { LIVEKIT_URL: 'ws://${host}:${port}' },
|
|
97
|
+
note: 'no injector entry: LIVEKIT_URL is a ws:// endpoint the SDK takes directly — WebSocket media is outside the HTTP injector\'s reach.',
|
|
98
|
+
},
|
|
99
|
+
// RAW PROTOCOL. Not "no injector entry yet" — there is no host to map at all: SMTP is a line
|
|
100
|
+
// protocol over a raw TCP socket, so the injector's http/fetch patches never see it, and the
|
|
101
|
+
// relay hostname is whatever the operator configured rather than a fixed vendor host. The env
|
|
102
|
+
// vars ARE the interception, and every one below is a name a real client documents, not an
|
|
103
|
+
// invention: SMTP_HOST/SMTP_PORT is nodemailer's own transport config and the classic pair every
|
|
104
|
+
// framework reads; EMAIL_SERVER_HOST/EMAIL_SERVER_PORT is Cal.com's split form; EMAIL_SERVER is
|
|
105
|
+
// NextAuth's connection-URL form (its Email provider takes `server` as a `smtp://` URL).
|
|
106
|
+
// Emitting all three shapes is deliberate — an app reads one of them, and which one is not
|
|
107
|
+
// knowable from the outside.
|
|
108
|
+
smtp: {
|
|
109
|
+
injectEnvTemplates: {
|
|
110
|
+
SMTP_HOST: '${host}',
|
|
111
|
+
SMTP_PORT: '${port}',
|
|
112
|
+
EMAIL_SERVER_HOST: '${host}',
|
|
113
|
+
EMAIL_SERVER_PORT: '${port}',
|
|
114
|
+
EMAIL_SERVER: 'smtp://${host}:${port}',
|
|
115
|
+
},
|
|
116
|
+
note: 'no injector entry and none possible: SMTP is a RAW TCP protocol, invisible to the http/fetch injector, '
|
|
117
|
+
+ 'with no fixed vendor host. Interception is the app-read env itself — point SMTP_HOST/SMTP_PORT (or Cal.com\'s '
|
|
118
|
+
+ 'EMAIL_SERVER_HOST/PORT, or NextAuth\'s EMAIL_SERVER url) at the twin\'s listener and an unmodified nodemailer '
|
|
119
|
+
+ 'sends there. Read the mail back over the twin-only inspect sidecar: `world-smtp serve --inspect-port N`, then '
|
|
120
|
+
+ 'GET http://127.0.0.1:N/twin/messages/latest.',
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
// descriptor-first migration (adding-a-twin.md §3): packs now declare their endpoint-env wiring (with its grounding
|
|
124
|
+
// note) on the descriptor (`endpointEnv` on TwinPack); the table above shrinks toward empty as
|
|
125
|
+
// entries move. The overlay throws on a vendor declared in both homes.
|
|
126
|
+
overlayEndpointEnv(APP_READ_ENDPOINT_ENV);
|
|
127
|
+
/** The canonical wiring for one vendor's twin service, derived from the injector where possible. */
|
|
128
|
+
function wiringFor(vendor) {
|
|
129
|
+
// RULE 2: ask the injector, never guess. `aws` yields s3/dynamodb/timestream here — the very keys
|
|
130
|
+
// the inert `AWS_TWIN_URL` does not.
|
|
131
|
+
const keys = injectorVendorKeysFor(vendor);
|
|
132
|
+
const twinUrlNames = keys.map(injectorEnvNameForKey);
|
|
133
|
+
if (twinUrlNames.length > 0) {
|
|
134
|
+
const [primary, ...rest] = twinUrlNames;
|
|
135
|
+
// An injector pack may ALSO declare app-read endpoint templates on its descriptor (the
|
|
136
|
+
// client's own documented option, e.g. PLANETSCALE_DATABASE_URL): those ride alongside.
|
|
137
|
+
const declared = packFacts()[vendor]?.endpointEnv?.templates ?? {};
|
|
138
|
+
const templates = { ...Object.fromEntries(rest.map((name) => [name, '${url}'])), ...declared };
|
|
139
|
+
return {
|
|
140
|
+
wiring: 'injector',
|
|
141
|
+
injectEnv: primary,
|
|
142
|
+
...(Object.keys(templates).length ? { injectEnvTemplates: templates } : {}),
|
|
143
|
+
...(vendor === 'xai' ? {
|
|
144
|
+
cliRedirect: {
|
|
145
|
+
GROK_CLI_CHAT_PROXY_BASE_URL: '${url}/v1',
|
|
146
|
+
XAI_TWIN_AUTH_BASE_URL: '${url}',
|
|
147
|
+
},
|
|
148
|
+
serviceEnv: { TWIN_XAI_AUTH_SEAM: 'sealed' },
|
|
149
|
+
} : {}),
|
|
150
|
+
note: rest.length
|
|
151
|
+
? `the injector redirects this vendor under ${twinUrlNames.join(' / ')} — one consolidated twin answers for all of them.`
|
|
152
|
+
: vendor === 'xai'
|
|
153
|
+
? 'zero-edit for SDK traffic through the injector; the real Grok CLI documents GROK_CLI_CHAT_PROXY_BASE_URL for its chat transport.'
|
|
154
|
+
: 'zero-edit: the injector redirects this vendor\'s hosts to the twin.',
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const appRead = APP_READ_ENDPOINT_ENV[vendor];
|
|
158
|
+
if (appRead) {
|
|
159
|
+
return {
|
|
160
|
+
wiring: 'app-read',
|
|
161
|
+
...(appRead.injectEnv ? { injectEnv: appRead.injectEnv } : {}),
|
|
162
|
+
...(appRead.injectEnvTemplates ? { injectEnvTemplates: appRead.injectEnvTemplates } : {}),
|
|
163
|
+
note: appRead.note,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
wiring: 'explicit-config',
|
|
168
|
+
note: 'no injector entry and no conventional endpoint env — point the SDK at this twin through its own client option '
|
|
169
|
+
+ `(baseUrl / serverUrl / host). Get the URL with \`volter-world url <world> ${vendor}\`. The proof reports this vendor `
|
|
170
|
+
+ 'UNINTERCEPTABLE until you do; --acknowledge it once the client is wired.',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
// ---------------------------------------------------------------------------------------------
|
|
174
|
+
// Env-file emission
|
|
175
|
+
// ---------------------------------------------------------------------------------------------
|
|
176
|
+
/** Example/dev env files, in the order a repo's own conventions put them. The first file that
|
|
177
|
+
* defines a NAME wins, so an explicit `.env.example` beats a stray `.env.development`. */
|
|
178
|
+
const ENV_SOURCE_FILES = [
|
|
179
|
+
'.env.example',
|
|
180
|
+
// wasp splits its example env by tier; `covers` already detects the names inside them, so
|
|
181
|
+
// omitting them left app.env empty for a repo the scanner could see perfectly well (F5).
|
|
182
|
+
'.env.server.example',
|
|
183
|
+
'.env.client.example',
|
|
184
|
+
'.env.sample',
|
|
185
|
+
'.env.template',
|
|
186
|
+
'.env.dev.example',
|
|
187
|
+
'.env.development.example',
|
|
188
|
+
'.env.local.example',
|
|
189
|
+
'.env.dev',
|
|
190
|
+
'.env.development',
|
|
191
|
+
'.env',
|
|
192
|
+
];
|
|
193
|
+
/** Infra connection strings — the local dependencies a twin never models. Matched on an infra
|
|
194
|
+
* KEYWORD plus a connection-string suffix, so `REDIS_URL` and `SHADOW_DATABASE_URL` land here while
|
|
195
|
+
* `UPSTASH_REDIS_REST_URL` (a hosted vendor, `_REST_URL`) is classified as a credential first. */
|
|
196
|
+
const INFRA_ENV = /(^|_)(DATABASE|POSTGRES|POSTGRESQL|PG|MYSQL|MARIADB|MONGO|MONGODB|REDIS|VALKEY|RABBITMQ|AMQP|KAFKA|NATS|CLICKHOUSE|ELASTICSEARCH|OPENSEARCH|MEMCACHED)[A-Z0-9_]*_(URL|URI|DSN|CONNECTION_STRING)$/;
|
|
197
|
+
/** A connection string's own scheme → the infra kind it names, or null. The VALUE is better
|
|
198
|
+
* evidence than the name: `DATABASE_URL` is generic (dub's is `mysql://`), so a name-only reading
|
|
199
|
+
* emits a postgres service for a MySQL app and nothing works. */
|
|
200
|
+
const SCHEME_KIND = {
|
|
201
|
+
postgres: 'postgres', postgresql: 'postgres', pg: 'postgres',
|
|
202
|
+
mysql: 'mysql', mariadb: 'mysql',
|
|
203
|
+
mongodb: 'mongodb', 'mongodb+srv': 'mongodb',
|
|
204
|
+
redis: 'redis', rediss: 'redis', valkey: 'redis',
|
|
205
|
+
amqp: 'rabbitmq', amqps: 'rabbitmq',
|
|
206
|
+
kafka: 'kafka', nats: 'nats', clickhouse: 'clickhouse',
|
|
207
|
+
};
|
|
208
|
+
function schemeKindFor(value) {
|
|
209
|
+
const scheme = value.trim().match(/^([a-z][a-z0-9+.-]*):\/\//i);
|
|
210
|
+
return scheme === null ? null : SCHEME_KIND[scheme[1].toLowerCase()] ?? null;
|
|
211
|
+
}
|
|
212
|
+
/** name (+ its value, when the example file carries a real connection string) → the infra kind it
|
|
213
|
+
* signals, or null. */
|
|
214
|
+
function infraKindFor(name, value = '') {
|
|
215
|
+
if (!INFRA_ENV.test(name))
|
|
216
|
+
return null;
|
|
217
|
+
const fromScheme = schemeKindFor(value);
|
|
218
|
+
if (fromScheme !== null)
|
|
219
|
+
return fromScheme;
|
|
220
|
+
if (/(^|_)(POSTGRES|POSTGRESQL|PG|DATABASE)/.test(name))
|
|
221
|
+
return 'postgres';
|
|
222
|
+
if (/(^|_)(MYSQL|MARIADB)/.test(name))
|
|
223
|
+
return 'mysql';
|
|
224
|
+
if (/(^|_)(MONGO|MONGODB)/.test(name))
|
|
225
|
+
return 'mongodb';
|
|
226
|
+
if (/(^|_)(REDIS|VALKEY)/.test(name))
|
|
227
|
+
return 'redis';
|
|
228
|
+
if (/(^|_)(RABBITMQ|AMQP)/.test(name))
|
|
229
|
+
return 'rabbitmq';
|
|
230
|
+
if (/(^|_)KAFKA/.test(name))
|
|
231
|
+
return 'kafka';
|
|
232
|
+
if (/(^|_)NATS/.test(name))
|
|
233
|
+
return 'nats';
|
|
234
|
+
if (/(^|_)CLICKHOUSE/.test(name))
|
|
235
|
+
return 'clickhouse';
|
|
236
|
+
if (/(^|_)(ELASTICSEARCH|OPENSEARCH)/.test(name))
|
|
237
|
+
return 'search';
|
|
238
|
+
return 'memcached';
|
|
239
|
+
}
|
|
240
|
+
/** A documented, unmistakably-not-real placeholder for an infra URL: the right SHAPE (so an app that
|
|
241
|
+
* parses the URL at import time still starts) with `REPLACE_ME` where the operator must decide. */
|
|
242
|
+
const INFRA_PLACEHOLDER = {
|
|
243
|
+
postgres: 'postgres://REPLACE_ME:REPLACE_ME@127.0.0.1:5432/REPLACE_ME',
|
|
244
|
+
mysql: 'mysql://REPLACE_ME:REPLACE_ME@127.0.0.1:3306/REPLACE_ME',
|
|
245
|
+
mongodb: 'mongodb://127.0.0.1:27017/REPLACE_ME',
|
|
246
|
+
redis: 'redis://127.0.0.1:6379',
|
|
247
|
+
rabbitmq: 'amqp://REPLACE_ME:REPLACE_ME@127.0.0.1:5672',
|
|
248
|
+
kafka: '127.0.0.1:9092',
|
|
249
|
+
nats: 'nats://127.0.0.1:4222',
|
|
250
|
+
clickhouse: 'http://127.0.0.1:8123',
|
|
251
|
+
search: 'http://127.0.0.1:9200',
|
|
252
|
+
memcached: '127.0.0.1:11211',
|
|
253
|
+
};
|
|
254
|
+
// ---------------------------------------------------------------------------------------------
|
|
255
|
+
// Infra compose emission
|
|
256
|
+
// ---------------------------------------------------------------------------------------------
|
|
257
|
+
//
|
|
258
|
+
// The subject pilots (dub, cal.com) put the number on it: world-side setup is seconds, and the
|
|
259
|
+
// 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
|
|
261
|
+
// World-managed service and private definition with env URLs already pointing at it. The helper
|
|
262
|
+
// owns its implementation behind the declared service boundary. Kinds without a recipe keep the
|
|
263
|
+
// placeholder + `//infra` stub.
|
|
264
|
+
/** The compose recipes. Versions are CONSERVATIVE major pins (current LTS-grade, not latest):
|
|
265
|
+
* a pilot wants "boots everywhere", not "newest features" — bump per pilot if the app demands. */
|
|
266
|
+
const COMPOSE_INFRA = {
|
|
267
|
+
postgres: {
|
|
268
|
+
image: 'postgres:16',
|
|
269
|
+
containerPort: 5432,
|
|
270
|
+
memoryMiB: 1024,
|
|
271
|
+
volumePath: '/var/lib/postgresql/data',
|
|
272
|
+
healthcheck: (user) => ['CMD-SHELL', `pg_isready -U ${user}`],
|
|
273
|
+
},
|
|
274
|
+
mysql: {
|
|
275
|
+
image: 'mysql:8.0',
|
|
276
|
+
containerPort: 3306,
|
|
277
|
+
memoryMiB: 1024,
|
|
278
|
+
volumePath: '/var/lib/mysql',
|
|
279
|
+
healthcheck: () => ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 --silent'],
|
|
280
|
+
},
|
|
281
|
+
redis: {
|
|
282
|
+
image: 'redis:7',
|
|
283
|
+
containerPort: 6379,
|
|
284
|
+
memoryMiB: 256,
|
|
285
|
+
volumePath: '/data',
|
|
286
|
+
healthcheck: () => ['CMD', 'redis-cli', 'ping'],
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
/** FNV-1a 32-bit — a tiny, dependency-free stable string hash for port derivation. */
|
|
290
|
+
function fnv1a(text) {
|
|
291
|
+
let hash = 0x811c9dc5;
|
|
292
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
293
|
+
hash ^= text.charCodeAt(index);
|
|
294
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
295
|
+
}
|
|
296
|
+
return hash >>> 0;
|
|
297
|
+
}
|
|
298
|
+
/** 'auto' ports, deterministically: hashed from `<world>:<kind>` into 20000–39999 (above the
|
|
299
|
+
* well-known infra defaults, below the common ephemeral range), probing upward on an in-file
|
|
300
|
+
* collision. Same world name ⇒ same ports (the byte-identity claim holds); two pilots with
|
|
301
|
+
* different names get different ports and run side by side. NOT a liveness check — init never
|
|
302
|
+
* binds sockets; a real clash surfaces during World-managed infrastructure startup. */
|
|
303
|
+
function composePort(name, kind, taken) {
|
|
304
|
+
let port = 20000 + (fnv1a(`${name}:${kind}`) % 20000);
|
|
305
|
+
while (taken.has(port))
|
|
306
|
+
port = port === 39999 ? 20000 : port + 1;
|
|
307
|
+
taken.add(port);
|
|
308
|
+
return port;
|
|
309
|
+
}
|
|
310
|
+
/** The world name as a conservative identifier for db/user/password names inside the compose
|
|
311
|
+
* services (world names allow `.`/`-`, SQL identifiers and URL userinfo are happier without). */
|
|
312
|
+
function composeIdent(name) {
|
|
313
|
+
return name.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
314
|
+
}
|
|
315
|
+
/** The db user the compose service creates. mysql refuses MYSQL_USER=root, so that one world
|
|
316
|
+
* name gets a suffix — the URL and the compose env both come from here, so they cannot drift. */
|
|
317
|
+
function composeUser(name, kind) {
|
|
318
|
+
const ident = composeIdent(name);
|
|
319
|
+
return kind === 'mysql' && ident === 'root' ? 'root_app' : ident;
|
|
320
|
+
}
|
|
321
|
+
/** Compose project names must be `[a-z0-9][a-z0-9_-]*`; world names also allow `.` and uppercase. */
|
|
322
|
+
function composeProject(name) {
|
|
323
|
+
return `${name.toLowerCase().replace(/[^a-z0-9_-]/g, '-')}-infra`;
|
|
324
|
+
}
|
|
325
|
+
function composeService(name, kind, signals, taken) {
|
|
326
|
+
const recipe = COMPOSE_INFRA[kind];
|
|
327
|
+
const hostPort = composePort(name, kind, taken);
|
|
328
|
+
const ident = composeIdent(name);
|
|
329
|
+
const url = kind === 'redis'
|
|
330
|
+
? `redis://127.0.0.1:${hostPort}`
|
|
331
|
+
: `${kind}://${composeUser(name, kind)}:${ident}@127.0.0.1:${hostPort}/${ident}`;
|
|
332
|
+
return {
|
|
333
|
+
kind,
|
|
334
|
+
image: recipe.image,
|
|
335
|
+
hostPort,
|
|
336
|
+
containerPort: recipe.containerPort,
|
|
337
|
+
memoryMiB: recipe.memoryMiB,
|
|
338
|
+
volume: recipe.volumePath === null ? null : `${kind}-data`,
|
|
339
|
+
url,
|
|
340
|
+
signals,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/** The declared env of one compose service (empty for redis). */
|
|
344
|
+
function composeEnvironment(name, kind) {
|
|
345
|
+
const ident = composeIdent(name);
|
|
346
|
+
if (kind === 'postgres') {
|
|
347
|
+
return [['POSTGRES_USER', ident], ['POSTGRES_PASSWORD', ident], ['POSTGRES_DB', ident]];
|
|
348
|
+
}
|
|
349
|
+
if (kind === 'mysql') {
|
|
350
|
+
return [['MYSQL_ROOT_PASSWORD', ident], ['MYSQL_DATABASE', ident], ['MYSQL_USER', composeUser(name, kind)], ['MYSQL_PASSWORD', ident]];
|
|
351
|
+
}
|
|
352
|
+
return [];
|
|
353
|
+
}
|
|
354
|
+
/** Render the private managed-infrastructure definition — deterministic, with no absolute paths.
|
|
355
|
+
* Only meaningful when `plan.compose` is non-empty (writeWorldInit skips it otherwise). */
|
|
356
|
+
export function renderComposeFile(plan) {
|
|
357
|
+
const lines = [
|
|
358
|
+
`# ${plan.name} — private managed infrastructure for this World.`,
|
|
359
|
+
'# Do not operate this file directly; volter-world owns its complete lifecycle.',
|
|
360
|
+
'# Persistent bytes live under VOLTER_WORLD_DATA; runtime resources are reclaimed on down.',
|
|
361
|
+
`name: ${composeProject(plan.name)}`,
|
|
362
|
+
'services:',
|
|
363
|
+
];
|
|
364
|
+
for (const service of plan.compose) {
|
|
365
|
+
const recipe = COMPOSE_INFRA[service.kind];
|
|
366
|
+
lines.push(` ${service.kind}:`);
|
|
367
|
+
lines.push(` image: ${service.image}`);
|
|
368
|
+
lines.push(` mem_limit: ${service.memoryMiB}m`);
|
|
369
|
+
lines.push(` memswap_limit: ${service.memoryMiB}m`);
|
|
370
|
+
lines.push(' cpus: 1');
|
|
371
|
+
lines.push(' ports:');
|
|
372
|
+
lines.push(` - "127.0.0.1:${service.hostPort}:${service.containerPort}"`);
|
|
373
|
+
const environment = composeEnvironment(plan.name, service.kind);
|
|
374
|
+
if (environment.length > 0) {
|
|
375
|
+
lines.push(' environment:');
|
|
376
|
+
for (const [key, value] of environment)
|
|
377
|
+
lines.push(` ${key}: ${value}`);
|
|
378
|
+
}
|
|
379
|
+
// a JSON array is a valid YAML flow sequence — exec-form healthchecks, no quoting surprises
|
|
380
|
+
lines.push(' healthcheck:');
|
|
381
|
+
lines.push(` test: ${JSON.stringify(recipe.healthcheck(composeUser(plan.name, service.kind)))}`);
|
|
382
|
+
lines.push(' interval: 2s');
|
|
383
|
+
lines.push(' timeout: 5s');
|
|
384
|
+
lines.push(' retries: 30');
|
|
385
|
+
if (service.volume !== null) {
|
|
386
|
+
lines.push(' volumes:');
|
|
387
|
+
lines.push(` - "\${VOLTER_WORLD_DATA:?}/${service.volume}:${recipe.volumePath}"`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return `${lines.join('\n')}\n`;
|
|
391
|
+
}
|
|
392
|
+
/** Parse one dotenv-ish file into ordered NAME/value pairs. Deliberately conservative: the same
|
|
393
|
+
* `NAME=` line shape `projectEnvNames` detects, plus quote stripping, so the two views of a repo's
|
|
394
|
+
* env agree on which names exist. */
|
|
395
|
+
function parseEnvFile(text, source) {
|
|
396
|
+
const entries = [];
|
|
397
|
+
for (const line of text.split(/\r?\n/)) {
|
|
398
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=(.*)$/);
|
|
399
|
+
if (!match)
|
|
400
|
+
continue;
|
|
401
|
+
let value = match[2].trim();
|
|
402
|
+
if ((value.startsWith('"') && value.endsWith('"') && value.length >= 2)
|
|
403
|
+
|| (value.startsWith("'") && value.endsWith("'") && value.length >= 2)) {
|
|
404
|
+
value = value.slice(1, -1);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
// Unquoted: a `#` after whitespace opens a trailing comment (dotenv semantics). Without this
|
|
408
|
+
// a line like `NEXTAUTH_URL=http://localhost:3000 # dev` yields a value with the comment
|
|
409
|
+
// still attached, and every consumer that URL-parses it fails at runtime.
|
|
410
|
+
const comment = value.search(/\s#/);
|
|
411
|
+
if (comment !== -1)
|
|
412
|
+
value = value.slice(0, comment).trimEnd();
|
|
413
|
+
}
|
|
414
|
+
entries.push({ name: match[1], value, source });
|
|
415
|
+
}
|
|
416
|
+
return entries;
|
|
417
|
+
}
|
|
418
|
+
/** Every example-env entry in the repo, first definition winning, in a deterministic order:
|
|
419
|
+
* root before workspace members (sorted), and within a dir the ENV_SOURCE_FILES priority. */
|
|
420
|
+
function repoEnvEntries(repo) {
|
|
421
|
+
const seen = new Set();
|
|
422
|
+
const entries = [];
|
|
423
|
+
for (const dir of projectManifestDirs(repo)) {
|
|
424
|
+
for (const file of ENV_SOURCE_FILES) {
|
|
425
|
+
const path = resolve(dir, file);
|
|
426
|
+
if (!existsSync(path))
|
|
427
|
+
continue;
|
|
428
|
+
const source = relative(repo, path) || file;
|
|
429
|
+
for (const entry of parseEnvFile(readFileSync(path, 'utf8'), source)) {
|
|
430
|
+
if (seen.has(entry.name))
|
|
431
|
+
continue;
|
|
432
|
+
seen.add(entry.name);
|
|
433
|
+
entries.push(entry);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return entries;
|
|
438
|
+
}
|
|
439
|
+
// ---------------------------------------------------------------------------------------------
|
|
440
|
+
// Planning
|
|
441
|
+
// ---------------------------------------------------------------------------------------------
|
|
442
|
+
function assertSafeName(name) {
|
|
443
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
|
|
444
|
+
throw new Error(`volter-world init: invalid world name "${name}" (letters, digits, then . _ -)`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/** Is `candidate` inside `parent` (or the same dir)? The pristine-repo guard. */
|
|
448
|
+
function isInside(parent, candidate) {
|
|
449
|
+
const rel = relative(resolve(parent), resolve(candidate));
|
|
450
|
+
return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
|
|
451
|
+
}
|
|
452
|
+
export function planWorldInit(name, repoPath, options = {}) {
|
|
453
|
+
assertSafeName(name);
|
|
454
|
+
const repo = resolve(repoPath);
|
|
455
|
+
if (!existsSync(repo))
|
|
456
|
+
throw new Error(`volter init: the application repo does not exist: ${repo}`);
|
|
457
|
+
const root = resolve(options.root ?? repo);
|
|
458
|
+
// The world lives in the app repo: `.volter/world.json` beside the code, committed like a
|
|
459
|
+
// compose file (docs/concepts/worlds.md#the-config-and-the-running-world). It holds no secret and no
|
|
460
|
+
// minted material (credential-shaped names carry `twin-fake-…` scalars; a name whose value
|
|
461
|
+
// must be a real key carries `$mint`, minted at boot into the ignored live env), so the tree is
|
|
462
|
+
// safe to `git add -A`. `--out` still emits a pilot elsewhere.
|
|
463
|
+
const out = resolve(options.out ?? resolve(repo, stateDirName()));
|
|
464
|
+
const configPath = resolve(out, 'world.json');
|
|
465
|
+
const existing = options.force && existsSync(configPath) ? loadWorldConfig(configPath, root).config : undefined;
|
|
466
|
+
if (existing?.manifestVersion === 1) {
|
|
467
|
+
throw new Error(`volter init: ${configPath} uses legacy format 1; run \`volter-world migrate-config ${configPath}\` before regenerating it`);
|
|
468
|
+
}
|
|
469
|
+
const selection = options.vendors
|
|
470
|
+
? { include: options.vendors.map((vendor) => ({ vendor })), exclude: [] }
|
|
471
|
+
: existing?.selection ?? DEFAULT_WORLD_SELECTION;
|
|
472
|
+
const inRepo = isInside(repo, out);
|
|
473
|
+
/** a path the config carries: relative to the world root when the config lives under it
|
|
474
|
+
* (portable between teammates), absolute otherwise (a pilot emitted away from its root) */
|
|
475
|
+
const carried = (path) => (isInside(root, path) ? relative(root, path) : path);
|
|
476
|
+
const catalogSource = resolveCatalog(root);
|
|
477
|
+
const catalog = new Set(catalogSource.vendors());
|
|
478
|
+
// Pack defaults (defaults/handlers.json, defaults/seed.ts): copied VISIBLY into the world
|
|
479
|
+
// dir — the author's to edit or delete (copy = ownership; never baked into the pack).
|
|
480
|
+
const defaultsCopies = [];
|
|
481
|
+
const signals = options.vendors ? null : detectRepoVendors(repo);
|
|
482
|
+
const detected = options.vendors
|
|
483
|
+
? new Map(options.vendors.map((v) => [v, ['--twins']]))
|
|
484
|
+
: new Map([...signals.detected].filter(([vendor]) => (signals.uses.get(vendor) ?? []).some((use) => worldSelectionIncludes(selection, vendor, use.usage))));
|
|
485
|
+
const unknown = options.vendors
|
|
486
|
+
? new Map()
|
|
487
|
+
: new Map([...signals.unknown].filter(([vendor]) => (signals.unknownUses.get(vendor) ?? []).some((use) => worldSelectionIncludes(selection, vendor, use.usage))));
|
|
488
|
+
const vendors = [];
|
|
489
|
+
const services = [];
|
|
490
|
+
const existingIds = new Set(existing?.services.map((service) => service.id) ?? []);
|
|
491
|
+
/** endpoint env name → the service that already claims it */
|
|
492
|
+
const claimedEnv = new Map();
|
|
493
|
+
const endpointNames = (service) => [...new Set([
|
|
494
|
+
...(service.injectEnv ? [service.injectEnv] : []),
|
|
495
|
+
...Object.keys(service.injectEnvTemplates ?? {}),
|
|
496
|
+
...Object.keys(service.cliRedirect ?? {}),
|
|
497
|
+
...(service.external?.discover?.map((mapping) => mapping.as) ?? []),
|
|
498
|
+
])];
|
|
499
|
+
for (const service of existing?.services ?? []) {
|
|
500
|
+
for (const name of endpointNames(service))
|
|
501
|
+
claimedEnv.set(name, service.id);
|
|
502
|
+
}
|
|
503
|
+
for (const vendor of [...detected.keys()].sort()) {
|
|
504
|
+
const detectedVia = [...(detected.get(vendor) ?? [])].sort();
|
|
505
|
+
if (!catalog.has(vendor)) {
|
|
506
|
+
vendors.push({
|
|
507
|
+
vendor,
|
|
508
|
+
pack: null,
|
|
509
|
+
detectedVia,
|
|
510
|
+
wiring: 'no-pack',
|
|
511
|
+
service: null,
|
|
512
|
+
note: `no packages/twin/${vendor} pack in this catalog — the proof reports it MISSING until one exists.`,
|
|
513
|
+
});
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
const wiring = wiringFor(vendor);
|
|
517
|
+
// Two packs can want the SAME endpoint env. One world cannot point
|
|
518
|
+
// that var at two twins, so the first claimant keeps it and the second
|
|
519
|
+
// is emitted WITHOUT it — which the proof then reports UNINTERCEPTABLE. Silently letting the
|
|
520
|
+
// later service overwrite the var would send one vendor's traffic to the other's twin.
|
|
521
|
+
const claimant = wiring.injectEnv !== undefined ? claimedEnv.get(wiring.injectEnv) : undefined;
|
|
522
|
+
const conflict = claimant === vendor ? undefined : claimant;
|
|
523
|
+
const injectEnv = conflict === undefined ? wiring.injectEnv : undefined;
|
|
524
|
+
if (injectEnv !== undefined && !existingIds.has(vendor))
|
|
525
|
+
claimedEnv.set(injectEnv, vendor);
|
|
526
|
+
const note = conflict === undefined
|
|
527
|
+
? wiring.note
|
|
528
|
+
: `${wiring.note} CONFLICT: ${wiring.injectEnv} is already claimed by the "${conflict}" service, so this twin is emitted `
|
|
529
|
+
+ `without it — one world cannot point ${wiring.injectEnv} at two twins. Decide which vendor that var serves, or run them in separate worlds.`;
|
|
530
|
+
const packDir = catalogSource.packDir(vendor);
|
|
531
|
+
const defaultsHandlersPath = join(packDir, 'defaults', 'handlers.json');
|
|
532
|
+
const handlersTo = existsSync(defaultsHandlersPath) ? resolve(out, 'handlers', `${vendor}.json`) : null;
|
|
533
|
+
if (handlersTo !== null)
|
|
534
|
+
defaultsCopies.push({ vendor, kind: 'handlers', from: defaultsHandlersPath, to: handlersTo });
|
|
535
|
+
const handlersArg = handlersTo === null ? null : carried(handlersTo);
|
|
536
|
+
const defaultsSeedPath = join(packDir, 'defaults', 'seed.ts');
|
|
537
|
+
if (existsSync(defaultsSeedPath))
|
|
538
|
+
defaultsCopies.push({ vendor, kind: 'seed', from: defaultsSeedPath, to: resolve(out, 'seeds', 'defaults', `${vendor}.ts`) });
|
|
539
|
+
// COLOCATION (runtime contract R2a): a pack whose serve factory is known (derived into the
|
|
540
|
+
// pack-facts artifact) is emitted with `colocate`, so `up` boots it inside the ONE
|
|
541
|
+
// volter-world-host process instead of spawning a process per twin. The spawn `command`
|
|
542
|
+
// stays alongside as the process-isolation path. A service carrying a --scenario file
|
|
543
|
+
// keeps the spawn path for now: the colocate factory contract has no scenarioPath yet,
|
|
544
|
+
// and silently dropping the world's handlers would be a fake success.
|
|
545
|
+
// A service carrying a handlers file colocates too (R2a): the colocate contract carries
|
|
546
|
+
// `scenarioPath` and the host hands it to the factory — the same file the spawn path passes
|
|
547
|
+
// as --scenario.
|
|
548
|
+
const serveExport = packFacts()[vendor]?.serveExport;
|
|
549
|
+
// In an app repo the twin is a PACKAGE, resolved from the world root at boot (hoisting-proof);
|
|
550
|
+
// inside this checkout it is the checkout-relative path, as the cookbook and the gates expect.
|
|
551
|
+
const boot = catalogSource.kind === 'installed'
|
|
552
|
+
? {
|
|
553
|
+
package: twinPackageName(vendor),
|
|
554
|
+
...(handlersArg !== null ? { args: ['--scenario', handlersArg] } : {}),
|
|
555
|
+
...(serveExport !== undefined ? { colocate: { export: serveExport, ...(handlersArg !== null ? { scenarioPath: handlersArg } : {}) } } : {}),
|
|
556
|
+
}
|
|
557
|
+
: {
|
|
558
|
+
command: 'bun',
|
|
559
|
+
args: [`packages/twin/${vendor}/src/cli.ts`, 'serve', ...(handlersArg !== null ? ['--scenario', handlersArg] : [])],
|
|
560
|
+
...(serveExport !== undefined ? { colocate: { module: `./packages/twin/${vendor}/src/index.ts`, export: serveExport, ...(handlersArg !== null ? { scenarioPath: handlersArg } : {}) } } : {}),
|
|
561
|
+
};
|
|
562
|
+
const service = {
|
|
563
|
+
'//': note,
|
|
564
|
+
id: vendor,
|
|
565
|
+
type: 'twin',
|
|
566
|
+
...boot,
|
|
567
|
+
// R17 — the pin: the package version this world was born against (its package.json).
|
|
568
|
+
...(catalogSource.version(vendor) !== undefined ? { version: catalogSource.version(vendor) } : {}),
|
|
569
|
+
port: 'auto',
|
|
570
|
+
...(injectEnv === undefined ? {} : { injectEnv }),
|
|
571
|
+
...(conflict === undefined && wiring.injectEnvTemplates ? { injectEnvTemplates: wiring.injectEnvTemplates } : {}),
|
|
572
|
+
...(conflict === undefined && wiring.cliRedirect ? { cliRedirect: wiring.cliRedirect } : {}),
|
|
573
|
+
...(wiring.serviceEnv ? { env: wiring.serviceEnv } : {}),
|
|
574
|
+
};
|
|
575
|
+
if (existing !== undefined && !existingIds.has(vendor)) {
|
|
576
|
+
// Check the complete proposed bindings, including any that the fresh-init conflict path
|
|
577
|
+
// would otherwise omit. Regeneration must refuse before touching the saved World.
|
|
578
|
+
const proposal = { ...service, injectEnv: wiring.injectEnv, injectEnvTemplates: wiring.injectEnvTemplates, cliRedirect: wiring.cliRedirect };
|
|
579
|
+
for (const name of endpointNames(proposal)) {
|
|
580
|
+
const owner = claimedEnv.get(name);
|
|
581
|
+
if (owner !== undefined && owner !== vendor) {
|
|
582
|
+
throw new Error(`volter init: service "${vendor}" conflicts with "${owner}" on endpoint variable ${name}; edit the World config to resolve ownership before regenerating`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (!existingIds.has(vendor))
|
|
587
|
+
for (const name of endpointNames(service))
|
|
588
|
+
claimedEnv.set(name, vendor);
|
|
589
|
+
services.push(service);
|
|
590
|
+
const savedService = existing?.services.find((entry) => entry.id === vendor);
|
|
591
|
+
vendors.push({
|
|
592
|
+
vendor,
|
|
593
|
+
pack: vendor,
|
|
594
|
+
detectedVia,
|
|
595
|
+
wiring: savedService !== undefined ? 'explicit-config' : conflict === undefined ? wiring.wiring : 'explicit-config',
|
|
596
|
+
service: savedService ?? service,
|
|
597
|
+
note: savedService !== undefined ? `Retained saved service "${vendor}" and its configured bindings.` : note,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
// --- env ------------------------------------------------------------------------------------
|
|
601
|
+
const env = {};
|
|
602
|
+
const envRows = [];
|
|
603
|
+
const envSources = [];
|
|
604
|
+
const bootRisks = [];
|
|
605
|
+
const envReads = projectEnvReads(repo);
|
|
606
|
+
const mintedEnv = [];
|
|
607
|
+
const infraSignals = new Map();
|
|
608
|
+
const infraEntries = [];
|
|
609
|
+
// Every name a twin service above injects at boot (its endpoint env and templates).
|
|
610
|
+
const worldInjected = new Set([
|
|
611
|
+
...(existing?.services ?? []),
|
|
612
|
+
...services.filter((service) => !existingIds.has(service.id)),
|
|
613
|
+
].flatMap(endpointNames));
|
|
614
|
+
for (const entry of repoEnvEntries(repo)) {
|
|
615
|
+
if (!envSources.includes(entry.source))
|
|
616
|
+
envSources.push(entry.source);
|
|
617
|
+
const { name, value, source } = entry;
|
|
618
|
+
// World-owned names: the world INJECTS these at boot from the services above. Carrying a repo's
|
|
619
|
+
// stale copy would shadow the live one.
|
|
620
|
+
if (/_TWIN_URL$/.test(name) || /^VOLTER_/.test(name) || worldInjected.has(name)) {
|
|
621
|
+
envRows.push({ name, disposition: 'unknown', source, reason: 'world-owned (injected at boot) — dropped from the emitted env' });
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
// STRUCTURED credential shapes first: `GOOGLE_VERTEX_JSON` / `GOOGLE_API_CREDENTIALS` carry a
|
|
625
|
+
// whole JSON document the client SDK PARSES (and signs with) before any request leaves the
|
|
626
|
+
// process, and neither name ends in a credential SUFFIX — so the suffix heuristics below are
|
|
627
|
+
// structurally blind to exactly the credentials that must be faked most carefully. `fakeEnvValue`
|
|
628
|
+
// already knows both shapes; this asks it the same question it answers.
|
|
629
|
+
if (isGoogleServiceAccountEnvName(name) || isGoogleOAuthClientEnvName(name)) {
|
|
630
|
+
const minted = isGoogleServiceAccountEnvName(name);
|
|
631
|
+
// A value that must carry a REAL (throwaway) private key is never written into the config:
|
|
632
|
+
// `$mint` is minted per boot into the live env only (docs/concepts/worlds.md
|
|
633
|
+
// #the-config-and-the-running-world), which is what keeps init byte-deterministic and the committed tree key-free.
|
|
634
|
+
env[name] = minted ? MINT_ENV : fakeEnvValue(name);
|
|
635
|
+
if (minted)
|
|
636
|
+
mintedEnv.push(name);
|
|
637
|
+
envRows.push({
|
|
638
|
+
name,
|
|
639
|
+
disposition: 'faked',
|
|
640
|
+
source,
|
|
641
|
+
reason: minted
|
|
642
|
+
? 'Google service-account JSON — `$mint`: up mints a structurally valid fake with a throwaway RSA key the SDK can genuinely sign with, into the live env only'
|
|
643
|
+
: 'Google OAuth client JSON — structurally valid fake (the {"web":{…}} shape the consumer parses)',
|
|
644
|
+
});
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
const vendor = envNameVendor(name);
|
|
648
|
+
if (vendor !== null) {
|
|
649
|
+
env[name] = fakeEnvValue(name);
|
|
650
|
+
envRows.push({ name, disposition: 'faked', source, reason: `${vendor} credential` });
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
const infraKind = infraKindFor(name, value);
|
|
654
|
+
if (infraKind !== null) {
|
|
655
|
+
// DEFERRED: the value depends on whether this kind gets a compose service (a URL pointing
|
|
656
|
+
// at it) or stays a documented placeholder — decided below, once every signal is in.
|
|
657
|
+
infraSignals.set(infraKind, [...(infraSignals.get(infraKind) ?? []), name]);
|
|
658
|
+
infraEntries.push({ name, source, kind: infraKind });
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
// RULE 3: credential-shaped means faked, even when no vendor claims the stem. That covers
|
|
662
|
+
// app-local secrets (JWT_SECRET, ENCRYPTION_KEY) and untwinned vendors alike — and it is the
|
|
663
|
+
// reason a live key committed to `.env.example` can never reach the emitted world.
|
|
664
|
+
if (isCredentialShapedEnvName(name)) {
|
|
665
|
+
env[name] = fakeEnvValue(name);
|
|
666
|
+
envRows.push({ name, disposition: 'faked', source, reason: 'credential-shaped name — the example value is never copied' });
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
if (value !== '') {
|
|
670
|
+
env[name] = value;
|
|
671
|
+
envRows.push({ name, disposition: 'kept', source, reason: 'non-secret app config — copied verbatim' });
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
// declared, not set: an empty value, set, would win over the app's own env files (Next.js and dotenv never let a
|
|
675
|
+
// file override a set variable), so the key is recorded for the report and the env file, and left unset
|
|
676
|
+
const readAt = envReads.get(name) ?? [];
|
|
677
|
+
if (readAt.length > 0) {
|
|
678
|
+
bootRisks.push({ name, source, readAt });
|
|
679
|
+
envRows.push({
|
|
680
|
+
name,
|
|
681
|
+
disposition: 'unknown',
|
|
682
|
+
source,
|
|
683
|
+
reason: `BOOT RISK — empty in the example but read by production source at ${readAt.join(', ')}; the app may reject it before listening`,
|
|
684
|
+
unset: true,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
envRows.push({ name, disposition: 'unknown', source, reason: 'empty in the example and not credential-shaped — you decide', unset: true });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
// --- native frontends ------------------------------------------------------------------------
|
|
692
|
+
// A detected native connection (Prisma over mysql://) that a SELECTED twin serves as a native
|
|
693
|
+
// frontend of its own state (the descriptor's `nativeTransport`) is that twin, not managed
|
|
694
|
+
// infrastructure: a separate database would split the state the app reads through the vendor's
|
|
695
|
+
// HTTP client from the state it writes natively — the two-client bug this exists to prevent.
|
|
696
|
+
for (const [kind, signals] of [...infraSignals.entries()]) {
|
|
697
|
+
const owner = vendors.find((entry) => entry.service !== null && packFacts()[entry.vendor]?.nativeTransport?.protocol === kind);
|
|
698
|
+
if (owner === undefined || owner.service === null)
|
|
699
|
+
continue;
|
|
700
|
+
const native = packFacts()[owner.vendor].nativeTransport;
|
|
701
|
+
const id = `${owner.vendor}-${kind}`;
|
|
702
|
+
const frontend = {
|
|
703
|
+
'//': `${owner.vendor}'s native ${kind} frontend: the same state as the "${owner.vendor}" twin over the ${kind} wire protocol (declare the HTTP twin first; it reads ${native.upstreamEnv} from it).`,
|
|
704
|
+
id,
|
|
705
|
+
type: 'twin',
|
|
706
|
+
...(catalogSource.kind === 'installed'
|
|
707
|
+
? { package: twinPackageName(owner.vendor), args: [native.flag] }
|
|
708
|
+
: { command: 'bun', args: [`packages/twin/${owner.vendor}/src/cli.ts`, 'serve', native.flag] }),
|
|
709
|
+
...(catalogSource.version(owner.vendor) !== undefined ? { version: catalogSource.version(owner.vendor) } : {}),
|
|
710
|
+
port: 'auto',
|
|
711
|
+
injectEnvTemplates: Object.fromEntries([...signals].sort().map((name) => [name, `${kind}://twin:twin@${'${host}'}:${'${port}'}/twin`])),
|
|
712
|
+
};
|
|
713
|
+
services.splice(services.indexOf(owner.service) + 1, 0, frontend);
|
|
714
|
+
for (const entry of infraEntries.filter((candidate) => candidate.kind === kind)) {
|
|
715
|
+
envRows.push({ name: entry.name, disposition: 'unknown', source: entry.source, reason: `${kind} connection string — injected at boot by the "${id}" twin service (${owner.vendor}'s native frontend over the same state)` });
|
|
716
|
+
}
|
|
717
|
+
infraSignals.delete(kind);
|
|
718
|
+
infraEntries.splice(0, infraEntries.length, ...infraEntries.filter((candidate) => candidate.kind !== kind));
|
|
719
|
+
}
|
|
720
|
+
// --- infra ----------------------------------------------------------------------------------
|
|
721
|
+
// Kinds with a managed recipe get a private definition next to the config, a declared external
|
|
722
|
+
// service whose lifecycle belongs to World, and env URLs pointing at it. Recipe-less kinds keep
|
|
723
|
+
// the documented placeholder + `//infra` stub.
|
|
724
|
+
const takenPorts = new Set();
|
|
725
|
+
const compose = [...infraSignals.entries()]
|
|
726
|
+
.filter(([kind]) => COMPOSE_INFRA[kind] !== undefined)
|
|
727
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
728
|
+
.map(([kind, signals]) => composeService(name, kind, [...signals].sort(), takenPorts));
|
|
729
|
+
const composeByKind = new Map(compose.map((service) => [service.kind, service]));
|
|
730
|
+
for (const entry of infraEntries) {
|
|
731
|
+
const composed = composeByKind.get(entry.kind);
|
|
732
|
+
if (composed !== undefined) {
|
|
733
|
+
env[entry.name] = composed.url;
|
|
734
|
+
envRows.push({
|
|
735
|
+
name: entry.name,
|
|
736
|
+
disposition: 'compose',
|
|
737
|
+
source: entry.source,
|
|
738
|
+
reason: `${entry.kind} connection string — points at the World-managed infrastructure service`,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
else {
|
|
742
|
+
env[entry.name] = INFRA_PLACEHOLDER[entry.kind] ?? 'REPLACE_ME';
|
|
743
|
+
envRows.push({ name: entry.name, disposition: 'placeholder', source: entry.source, reason: `${entry.kind} connection string — no twin models it; point it at your own local instance` });
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const infra = [...infraSignals.entries()]
|
|
747
|
+
.filter(([kind]) => !composeByKind.has(kind))
|
|
748
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
749
|
+
.map(([kind, signals]) => ({
|
|
750
|
+
kind,
|
|
751
|
+
signals: [...signals].sort(),
|
|
752
|
+
stub: {
|
|
753
|
+
description: `${signals.sort().join(', ')} signal${signals.length > 1 ? '' : 's'} ${kind}, which no twin models. `
|
|
754
|
+
+ `Fill in the up/status/down commands for YOUR local ${kind} service, `
|
|
755
|
+
+ 'then MOVE this object into "services" so `volter-world up`/`down` own its lifecycle. '
|
|
756
|
+
+ 'Add `bindings.discover` entries if the tool prints the connection URL; otherwise keep the placeholder in the env file. '
|
|
757
|
+
+ 'See docs/concepts/worlds.md § the external service type.',
|
|
758
|
+
id: kind,
|
|
759
|
+
type: 'external',
|
|
760
|
+
execution: {
|
|
761
|
+
lifecycle: {
|
|
762
|
+
up: ['REPLACE_ME'],
|
|
763
|
+
status: ['REPLACE_ME'],
|
|
764
|
+
down: ['REPLACE_ME'],
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
},
|
|
768
|
+
}));
|
|
769
|
+
// `//infra` is a documented, runtime-ignored sidecar rather than a `services` entry ON PURPOSE:
|
|
770
|
+
// an `external` service with placeholder commands passes no schema check worth having and would
|
|
771
|
+
// make `up` fail on a config init just told the operator to boot. Stubs sit outside `services`
|
|
772
|
+
// until they are real.
|
|
773
|
+
if (compose.length > 0) {
|
|
774
|
+
if (existingIds.has('infrastructure')) {
|
|
775
|
+
throw new Error('volter init: new infrastructure requires changes to the saved infrastructure service and world.infrastructure.yml; edit them together before regenerating');
|
|
776
|
+
}
|
|
777
|
+
services.push({
|
|
778
|
+
'//': `World-managed infrastructure (${compose.map((service) => service.kind).join(', ')}); lifecycle and diagnostics stay behind this declared service boundary.`,
|
|
779
|
+
id: 'infrastructure',
|
|
780
|
+
type: 'external',
|
|
781
|
+
controlPlane: true,
|
|
782
|
+
external: {
|
|
783
|
+
up: ['volter-world-infra', 'up'],
|
|
784
|
+
status: ['volter-world-infra', 'status'],
|
|
785
|
+
discover: compose.flatMap((service) => service.signals.map((name) => ({ as: name, jsonPath: `connections.${name}` }))),
|
|
786
|
+
down: ['volter-world-infra', 'down'],
|
|
787
|
+
readyWhen: { command: 'volter-world-infra', args: ['status'], timeoutMs: 120000, intervalMs: 500 },
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
const mergedServices = existing === undefined ? services : [...existing.services, ...services.filter((service) => !existingIds.has(service.id))];
|
|
792
|
+
if (existing !== undefined) {
|
|
793
|
+
const owners = new Map();
|
|
794
|
+
for (const service of mergedServices) {
|
|
795
|
+
for (const name of endpointNames(service)) {
|
|
796
|
+
const owner = owners.get(name);
|
|
797
|
+
if (!existingIds.has(service.id) && owner !== undefined && owner !== service.id) {
|
|
798
|
+
throw new Error(`volter init: service "${service.id}" conflicts with "${owner}" on endpoint variable ${name}; edit the World config to resolve ownership before regenerating`);
|
|
799
|
+
}
|
|
800
|
+
owners.set(name, service.id);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
// an empty value an earlier init wrote is dropped: an empty example key is declared and left unset, not set to nothing
|
|
805
|
+
const mergedEnv = Object.fromEntries(Object.entries({ ...env, ...(existing?.env ?? {}) }).filter(([, value]) => value !== '').sort(([a], [b]) => a.localeCompare(b)));
|
|
806
|
+
const comments = Object.fromEntries(Object.entries(existing ?? {}).filter(([key]) => key.startsWith('//')));
|
|
807
|
+
const savedInfra = comments['//infra'];
|
|
808
|
+
if (savedInfra !== undefined && !Array.isArray(savedInfra) && infra.length > 0) {
|
|
809
|
+
throw new Error('volter init: cannot merge new infrastructure stubs into a non-array //infra; edit the World config before regenerating');
|
|
810
|
+
}
|
|
811
|
+
const savedInfraIds = new Set(Array.isArray(savedInfra)
|
|
812
|
+
? savedInfra.flatMap((stub) => stub !== null && typeof stub === 'object' && 'id' in stub ? [stub.id] : [])
|
|
813
|
+
: []);
|
|
814
|
+
const mergedInfra = [
|
|
815
|
+
...(Array.isArray(savedInfra) ? savedInfra : []),
|
|
816
|
+
...infra.filter((entry) => !savedInfraIds.has(entry.stub.id)).map((entry) => entry.stub),
|
|
817
|
+
];
|
|
818
|
+
const config = {
|
|
819
|
+
...comments,
|
|
820
|
+
id: name,
|
|
821
|
+
manifestVersion: 2,
|
|
822
|
+
selection,
|
|
823
|
+
...(existing?.bare ? { bare: existing.bare } : options.bare ? { bare: { name: options.bare } } : {}),
|
|
824
|
+
// THE BIRTH STAMP (architecture gap #2, ratified): the catalog version this world was
|
|
825
|
+
// created under. Replay/upgrade decisions are impossible to make honestly without it,
|
|
826
|
+
// and retrofitting provenance later is the expensive version. 'unknown' only when the
|
|
827
|
+
// checkout is not a git tree (an installed package) — still a fact, recorded.
|
|
828
|
+
// (sha only — init's output is BYTE-DETERMINISTIC by contract, so no wall-clock here;
|
|
829
|
+
// the serverless provision door, an operator act, stamps createdAt on its side.)
|
|
830
|
+
// …and the platform protocol major the world is born under (R18): `up` reads it back and
|
|
831
|
+
// refuses a world this platform can no longer read by construction.
|
|
832
|
+
catalog: existing?.catalog ?? { sha: catalogSource.sha(), protocol: String(PROTOCOL_MAJOR) },
|
|
833
|
+
description: existing?.description ?? `World generated by \`volter init\`: selected external vendors the application talks to, `
|
|
834
|
+
+ `fake credentials, and documented stubs for the infra it signals. Regenerate with \`volter init\` — do not hand-edit `
|
|
835
|
+
+ `what init can re-derive; DO fill in the //infra stubs and move them into "services".`,
|
|
836
|
+
// What a world of twins reserves: ledgers are megabytes, and a branch is another world on the
|
|
837
|
+
// same box, so the reservation is sized for many worlds side by side, not for one.
|
|
838
|
+
// One twin process per WORLD, not per vendor (runtime contract R2a): every colocatable
|
|
839
|
+
// service boots inside the volter-world-host. Local-mode preference only — share/sealed
|
|
840
|
+
// boots downgrade to per-process isolation (their TWIN-67 contract) in runtime.ts.
|
|
841
|
+
...(existing?.isolation ? { isolation: existing.isolation } : mergedServices.some((s) => s.colocate !== undefined) ? { isolation: 'colocated' } : {}),
|
|
842
|
+
env: mergedEnv,
|
|
843
|
+
...(existing?.stripEnv ? { stripEnv: existing.stripEnv } : {}),
|
|
844
|
+
...(existing?.network ? { network: existing.network } : {}),
|
|
845
|
+
services: mergedServices,
|
|
846
|
+
...(existing?.share ? { share: existing.share } : {}),
|
|
847
|
+
...(existing?.remotes ? { remotes: existing.remotes } : {}),
|
|
848
|
+
...(existing?.actors ? { actors: existing.actors } : {}),
|
|
849
|
+
...(existing?.fixtures ? { fixtures: existing.fixtures } : {}),
|
|
850
|
+
...(mergedInfra.length ? { '//infra': mergedInfra } : {}),
|
|
851
|
+
};
|
|
852
|
+
return {
|
|
853
|
+
name,
|
|
854
|
+
repo,
|
|
855
|
+
out,
|
|
856
|
+
inRepo,
|
|
857
|
+
catalog: catalogSource.kind,
|
|
858
|
+
configPath,
|
|
859
|
+
envPath: resolve(out, 'app.env'),
|
|
860
|
+
defaultsCopies,
|
|
861
|
+
seedAdditions: existsSync(resolve(out, 'seed.ts'))
|
|
862
|
+
? defaultsCopies.filter((copy) => copy.kind === 'seed' && !existingIds.has(copy.vendor)).map((copy) => ({
|
|
863
|
+
vendor: copy.vendor,
|
|
864
|
+
import: `import { seed as ${copy.vendor.replace(/[^a-zA-Z0-9]/g, '_')}Defaults } from './seeds/defaults/${copy.vendor}.ts';`,
|
|
865
|
+
call: `await ${copy.vendor.replace(/[^a-zA-Z0-9]/g, '_')}Defaults();`,
|
|
866
|
+
}))
|
|
867
|
+
: [],
|
|
868
|
+
seedEntry: defaultsCopies.some((c) => c.kind === 'seed')
|
|
869
|
+
? { entryPath: resolve(out, 'seed.ts'), storyPath: resolve(out, 'seeds', 'story.ts') }
|
|
870
|
+
: null,
|
|
871
|
+
config,
|
|
872
|
+
env: config.env,
|
|
873
|
+
vendors,
|
|
874
|
+
envRows,
|
|
875
|
+
envSources,
|
|
876
|
+
bootRisks: [...bootRisks].sort((a, b) => a.name.localeCompare(b.name)),
|
|
877
|
+
infra,
|
|
878
|
+
compose,
|
|
879
|
+
composePath: compose.length > 0 ? resolve(out, 'world.infrastructure.yml') : null,
|
|
880
|
+
// Standing registry acknowledgments leave the worklist but never vanish (each keeps its
|
|
881
|
+
// recorded reason) — the same partition coverWorld's proof applies to the same signals.
|
|
882
|
+
unknown: [...unknown.keys()].filter((name) => registryAcknowledgedReason(name) === null).sort(),
|
|
883
|
+
acknowledgedExternal: [...unknown.keys()]
|
|
884
|
+
.map((name) => ({ name, reason: registryAcknowledgedReason(name) }))
|
|
885
|
+
.filter((row) => row.reason !== null)
|
|
886
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
887
|
+
mintedEnv: [...mintedEnv].sort(),
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
// ---------------------------------------------------------------------------------------------
|
|
891
|
+
// Emission
|
|
892
|
+
// ---------------------------------------------------------------------------------------------
|
|
893
|
+
/** The `app.env` rendering of the SAME map the config's `env` block holds — a dotenv for anything
|
|
894
|
+
* attached to the World (a shell, app process, or CI step), annotated with the
|
|
895
|
+
* disposition of every name so the file explains itself. Both artifacts are rendered from one map
|
|
896
|
+
* in one call, so they cannot drift. */
|
|
897
|
+
export function renderEnvFile(plan) {
|
|
898
|
+
const byName = new Map(plan.envRows.map((row) => [row.name, row]));
|
|
899
|
+
const lines = [
|
|
900
|
+
`# ${plan.name} — env for the world, generated by \`volter init\`.`,
|
|
901
|
+
'# Credential-shaped names hold FAKE values (a twin accepts any bearer token; names an SDK parses',
|
|
902
|
+
'# get a structurally valid fake). Values copied from the repo\'s example env are non-secret app',
|
|
903
|
+
'# config only. Infra URLs are placeholders — point them at your own local services.',
|
|
904
|
+
'# This file mirrors runtime.environment.values in world.json, which is what `volter up` injects.',
|
|
905
|
+
...(plan.mintedEnv.length
|
|
906
|
+
? [
|
|
907
|
+
'#',
|
|
908
|
+
`# ${plan.mintedEnv.join(', ')} ${plan.mintedEnv.length === 1 ? 'is' : 'are'} \`$mint\`: up mints a throwaway private key for`,
|
|
909
|
+
'# each boot into the live env (world.env) only — never into this file or the config.',
|
|
910
|
+
]
|
|
911
|
+
: []),
|
|
912
|
+
'',
|
|
913
|
+
];
|
|
914
|
+
for (const name of Object.keys(plan.env).sort()) {
|
|
915
|
+
const row = byName.get(name);
|
|
916
|
+
if (row)
|
|
917
|
+
lines.push(`# ${row.disposition}: ${row.reason}`);
|
|
918
|
+
const value = plan.env[name];
|
|
919
|
+
// A value with a newline (a service-account JSON) or a leading/trailing space must be quoted to
|
|
920
|
+
// survive any dotenv reader; JSON.stringify gives exactly the escaping every one of them accepts.
|
|
921
|
+
lines.push(`${name}=${/[\n"']/.test(value) || value !== value.trim() ? JSON.stringify(value) : value}`);
|
|
922
|
+
}
|
|
923
|
+
const declared = plan.envRows.filter((row) => row.unset === true && !(row.name in plan.env)).sort((a, b) => a.name.localeCompare(b.name));
|
|
924
|
+
if (declared.length) {
|
|
925
|
+
lines.push('', "# Declared by the repo's example env with no value, and left unset: the app's own env files provide them.");
|
|
926
|
+
for (const row of declared)
|
|
927
|
+
lines.push(`# ${row.disposition}: ${row.reason}`, `# ${row.name}=`);
|
|
928
|
+
}
|
|
929
|
+
return `${lines.join('\n')}\n`;
|
|
930
|
+
}
|
|
931
|
+
/** Write the plan's two artifacts. Byte-identical for the same repo + catalog: sorted keys, no
|
|
932
|
+
* timestamps, no absolute paths inside the files. */
|
|
933
|
+
/** What `.volter/.gitignore` says: the story is committed, the running state never is. */
|
|
934
|
+
export const STATE_GITIGNORE = `# Volter: the world's running state and live env — never committed. world.json, handlers/ and seeds/ are the story.
|
|
935
|
+
worlds/
|
|
936
|
+
current
|
|
937
|
+
*.env
|
|
938
|
+
credentials/
|
|
939
|
+
token
|
|
940
|
+
`;
|
|
941
|
+
export function writeWorldInit(plan, options = {}) {
|
|
942
|
+
// A world already here is refused unless forced; a `.volter/` holding only running state
|
|
943
|
+
// (worlds/, an env) is not a world and does not block init.
|
|
944
|
+
if (existsSync(plan.configPath) && options.force !== true) {
|
|
945
|
+
throw new Error(`volter init: a world already exists at ${plan.configPath}. `
|
|
946
|
+
+ 'Pass --force to regenerate it, or --out <dir> to emit elsewhere.');
|
|
947
|
+
}
|
|
948
|
+
mkdirSync(plan.out, { recursive: true });
|
|
949
|
+
writeFileSync(plan.configPath, `${JSON.stringify(worldConfigDocument(plan.config), null, 2)}\n`);
|
|
950
|
+
if (plan.inRepo)
|
|
951
|
+
writeFileSync(join(plan.out, '.gitignore'), STATE_GITIGNORE);
|
|
952
|
+
writeFileSync(plan.envPath, renderEnvFile(plan));
|
|
953
|
+
if (plan.composePath !== null)
|
|
954
|
+
writeFileSync(plan.composePath, renderComposeFile(plan));
|
|
955
|
+
// Pack defaults, copied byte-for-byte into the world dir. Ownership transfers on copy: these
|
|
956
|
+
// are the author's files now (edit or delete; regenerate a fresh world for fresh defaults).
|
|
957
|
+
for (const copy of plan.defaultsCopies) {
|
|
958
|
+
mkdirSync(dirname(copy.to), { recursive: true });
|
|
959
|
+
if (!(options.force === true && existsSync(copy.to)))
|
|
960
|
+
writeFileSync(copy.to, readFileSync(copy.from));
|
|
961
|
+
}
|
|
962
|
+
if (plan.seedEntry !== null) {
|
|
963
|
+
mkdirSync(dirname(plan.seedEntry.storyPath), { recursive: true });
|
|
964
|
+
if (!(options.force === true && existsSync(plan.seedEntry.storyPath))) {
|
|
965
|
+
writeFileSync(plan.seedEntry.storyPath, `// Your world's STORY — the cross-vendor narrative (the only seed you write).\n`
|
|
966
|
+
+ `// Seed state through each vendor's ORDINARY SDK/API pointed at its *_TWIN_URL.\n`
|
|
967
|
+
+ `export async function story(): Promise<void> {\n // e.g. post the opening support thread, create the incident, ...\n}\n`);
|
|
968
|
+
}
|
|
969
|
+
const seedImports = plan.defaultsCopies.filter((c) => c.kind === 'seed');
|
|
970
|
+
if (!existsSync(plan.seedEntry.entryPath))
|
|
971
|
+
writeFileSync(plan.seedEntry.entryPath, `// THE seed entry point — composes order VISIBLY (no magic globbing): defaults, then story.\n`
|
|
972
|
+
+ `// This is the world's DEFAULT DATA: \`volter up\` runs it after every boot (--no-seed skips it)\n`
|
|
973
|
+
+ `// and \`volter reset\` returns to it. While it runs every twin records what it creates as\n`
|
|
974
|
+
+ `// data that was already there — the log stays empty and nothing is pending.\n`
|
|
975
|
+
+ `// It runs from your app's directory, so its node_modules resolve any vendor SDK a seed imports.\n`
|
|
976
|
+
+ seedImports.map((c) => `import { seed as ${c.vendor.replace(/[^a-zA-Z0-9]/g, '_')}Defaults } from './seeds/defaults/${c.vendor}.ts';`).join('\n')
|
|
977
|
+
+ `\nimport { story } from './seeds/story.ts';\n\n`
|
|
978
|
+
+ seedImports.map((c) => `await ${c.vendor.replace(/[^a-zA-Z0-9]/g, '_')}Defaults();`).join('\n')
|
|
979
|
+
+ `\nawait story();\n`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Plan, emit, and PROVE. The coverage proof runs against the just-written config (by path, so it
|
|
984
|
+
* reads exactly the bytes on disk rather than the in-memory plan) — `ok` is the proof's verdict and
|
|
985
|
+
* the caller's exit code. Deliberately does NOT boot the world.
|
|
986
|
+
*/
|
|
987
|
+
export function initWorld(name, repoPath, options = {}) {
|
|
988
|
+
const plan = planWorldInit(name, repoPath, options);
|
|
989
|
+
writeWorldInit(plan, { force: options.force ?? false });
|
|
990
|
+
const proof = coverWorld(plan.configPath, plan.repo, {
|
|
991
|
+
root: resolve(options.root ?? process.cwd()),
|
|
992
|
+
allowUnknown: options.allowUnknown ?? false,
|
|
993
|
+
acknowledge: options.acknowledge ?? {},
|
|
994
|
+
});
|
|
995
|
+
// The proof was addressed BY PATH (so it reads the bytes just written, not the in-memory plan);
|
|
996
|
+
// report it under the world's id, which is what every other verb takes.
|
|
997
|
+
const coverage = { ...proof, world: plan.name };
|
|
998
|
+
const next = plan.inRepo && resolve(options.root ?? plan.repo) === plan.repo
|
|
999
|
+
? [
|
|
1000
|
+
'volter world up # starts the twins and loads the default data; the live env lands in .volter/world.env',
|
|
1001
|
+
"volter world run -- <your app's dev command> # the app sees the twins as the vendors",
|
|
1002
|
+
'volter world log # every write the app made',
|
|
1003
|
+
'volter world down',
|
|
1004
|
+
]
|
|
1005
|
+
: [
|
|
1006
|
+
`volter-world up ${plan.configPath} --name ${plan.name} --env-file=${resolve(plan.out, 'world.env')} # up WRITES the live env (twin URLs) there; app.env is init's static preview of the same map`,
|
|
1007
|
+
...(plan.seedEntry === null ? [] : [`volter-world seed ${plan.name} --cwd ${plan.repo} # load the default data AFTER every up: it lands as data that was already there, the log empty; reset = down, up, seed`]),
|
|
1008
|
+
`volter-world env ${plan.name} -- <your app's dev command> # run the app inside the world`,
|
|
1009
|
+
`volter-world down ${plan.name}`,
|
|
1010
|
+
];
|
|
1011
|
+
return { plan, coverage, ok: coverage.ok, next };
|
|
1012
|
+
}
|
|
1013
|
+
// ---------------------------------------------------------------------------------------------
|
|
1014
|
+
// Reporting
|
|
1015
|
+
// ---------------------------------------------------------------------------------------------
|
|
1016
|
+
function table(header, rows) {
|
|
1017
|
+
const widths = header.map((title, column) => Math.max(title.length, ...rows.map((row) => row[column].length)));
|
|
1018
|
+
const render = (row) => row.map((cell, column) => cell.padEnd(widths[column])).join(' | ').trimEnd();
|
|
1019
|
+
return [render(header), widths.map((width) => '-'.repeat(width)).join('-|-'), ...rows.map(render)];
|
|
1020
|
+
}
|
|
1021
|
+
export function formatInitReport(result) {
|
|
1022
|
+
const { plan } = result;
|
|
1023
|
+
const lines = [];
|
|
1024
|
+
lines.push(`World ${plan.name}`);
|
|
1025
|
+
if (plan.inRepo) {
|
|
1026
|
+
lines.push(`App ${plan.repo}`);
|
|
1027
|
+
lines.push(`Config ${plan.configPath} (commit .volter/ — the story; its .gitignore keeps the running state out)`);
|
|
1028
|
+
}
|
|
1029
|
+
else {
|
|
1030
|
+
lines.push(`Repo ${plan.repo} (unmodified — nothing was written here)`);
|
|
1031
|
+
lines.push(`Config ${plan.configPath}`);
|
|
1032
|
+
}
|
|
1033
|
+
lines.push(`Env ${plan.envPath} (a dotenv preview of the config's env; \`up\` writes the live one)`);
|
|
1034
|
+
if (plan.composePath !== null)
|
|
1035
|
+
lines.push(`Infra World-managed (${plan.compose.length} service${plan.compose.length === 1 ? '' : 's'})`);
|
|
1036
|
+
if (plan.infra.length > 0) {
|
|
1037
|
+
// detected infra with no managed recipe must be SAID here, not only left
|
|
1038
|
+
// as a stub the operator may never open — silence reads as provisioned
|
|
1039
|
+
lines.push(`Infra NOT World-managed: ${plan.infra.map((stub) => stub.kind).join(', ')} — fill in the //infra stub${plan.infra.length === 1 ? '' : 's'} in the config (env holds a placeholder until then)`);
|
|
1040
|
+
}
|
|
1041
|
+
lines.push('');
|
|
1042
|
+
lines.push(`Vendors (${plan.vendors.length} detected, ${plan.vendors.filter((v) => v.service !== null).length} emitted):`);
|
|
1043
|
+
if (plan.vendors.length === 0)
|
|
1044
|
+
lines.push(' (no external vendor dependencies detected in the repo)');
|
|
1045
|
+
else {
|
|
1046
|
+
lines.push(...table(['vendor', 'wiring', 'inject-env', 'detected-via'], plan.vendors.map((vendor) => [
|
|
1047
|
+
vendor.vendor,
|
|
1048
|
+
vendor.wiring,
|
|
1049
|
+
[vendor.service?.injectEnv, ...Object.keys(vendor.service?.injectEnvTemplates ?? {})].filter(Boolean).join(', ') || '-',
|
|
1050
|
+
vendor.detectedVia.join('; '),
|
|
1051
|
+
])).map((line) => ` ${line}`));
|
|
1052
|
+
for (const vendor of plan.vendors) {
|
|
1053
|
+
if (vendor.wiring === 'injector')
|
|
1054
|
+
continue; // the zero-edit default needs no explanation
|
|
1055
|
+
lines.push(` · ${vendor.vendor}: ${vendor.note}`);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
lines.push('');
|
|
1059
|
+
const counts = (disposition) => plan.envRows.filter((row) => row.disposition === disposition);
|
|
1060
|
+
lines.push(`Env (${plan.envRows.length} name(s) from ${plan.envSources.join(', ') || 'no example env file'}):`);
|
|
1061
|
+
for (const disposition of ['faked', 'kept', 'compose', 'placeholder', 'unknown']) {
|
|
1062
|
+
const rows = counts(disposition);
|
|
1063
|
+
if (rows.length === 0)
|
|
1064
|
+
continue;
|
|
1065
|
+
lines.push(` ${disposition.padEnd(11)} ${String(rows.length).padStart(3)} ${rows.map((row) => row.name).join(', ')}`);
|
|
1066
|
+
}
|
|
1067
|
+
if (plan.mintedEnv.length > 0) {
|
|
1068
|
+
lines.push(` (${plan.mintedEnv.join(', ')} hold${plan.mintedEnv.length === 1 ? 's' : ''} a freshly minted throwaway key — the only value(s) that change between runs)`);
|
|
1069
|
+
}
|
|
1070
|
+
if (plan.bootRisks.length > 0) {
|
|
1071
|
+
lines.push('');
|
|
1072
|
+
lines.push(`BOOT RISKS (${plan.bootRisks.length} empty example value(s) read by production source):`);
|
|
1073
|
+
for (const risk of plan.bootRisks) {
|
|
1074
|
+
lines.push(` ${risk.name} — empty in ${risk.source}; read at ${risk.readAt.join(', ')}`);
|
|
1075
|
+
}
|
|
1076
|
+
lines.push(' Resolve these before expecting the app to listen. Vendor coverage can be green while the app rejects its own env.');
|
|
1077
|
+
}
|
|
1078
|
+
if (plan.compose.length > 0) {
|
|
1079
|
+
lines.push('');
|
|
1080
|
+
lines.push('Infrastructure (owned by the World lifecycle):');
|
|
1081
|
+
for (const service of plan.compose) {
|
|
1082
|
+
lines.push(` ${service.kind}: ${service.image} on 127.0.0.1:${service.hostPort} — signalled by ${service.signals.join(', ')}; env URLs point at it`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
if (plan.infra.length > 0) {
|
|
1086
|
+
lines.push('');
|
|
1087
|
+
lines.push(`Infra stubs (documented under the config's "//infra" key — fill in and move into "services"):`);
|
|
1088
|
+
for (const stub of plan.infra)
|
|
1089
|
+
lines.push(` ${stub.kind}: signalled by ${stub.signals.join(', ')}`);
|
|
1090
|
+
}
|
|
1091
|
+
if (plan.seedAdditions.length > 0) {
|
|
1092
|
+
lines.push('', 'Existing seed.ts preserved. Add these default seeds in your chosen order:');
|
|
1093
|
+
for (const addition of plan.seedAdditions)
|
|
1094
|
+
lines.push(` ${addition.import}`, ` ${addition.call}`);
|
|
1095
|
+
}
|
|
1096
|
+
lines.push('');
|
|
1097
|
+
lines.push('--- coverage proof (init\'s exit code IS this proof\'s) ---');
|
|
1098
|
+
lines.push(formatCoverageReport(result.coverage).trimEnd());
|
|
1099
|
+
lines.push('');
|
|
1100
|
+
if (result.ok && plan.bootRisks.length > 0) {
|
|
1101
|
+
lines.push('COVERAGE READY; APP BOOT AT RISK — resolve the empty values above, then boot it yourself:');
|
|
1102
|
+
}
|
|
1103
|
+
else if (result.ok) {
|
|
1104
|
+
lines.push('READY. Boot it yourself — init diagnoses, it does not boot:');
|
|
1105
|
+
}
|
|
1106
|
+
else {
|
|
1107
|
+
lines.push('NOT READY — the worklist above is what stands between this pilot and a world that covers the repo.');
|
|
1108
|
+
lines.push('Once it is green (or every remaining finding is --acknowledge\'d with a reason), boot it with:');
|
|
1109
|
+
}
|
|
1110
|
+
for (const command of result.next)
|
|
1111
|
+
lines.push(` ${command}`);
|
|
1112
|
+
lines.push('');
|
|
1113
|
+
lines.push('The two moves: if the twin stores it, create it through the vendor\'s own API (seed.ts,');
|
|
1114
|
+
lines.push('as the person, clock first for history). Everything else — judgment, lookups, faults —');
|
|
1115
|
+
lines.push('is a handler in handlers/<vendor>.json. Every twin explains itself at GET <url>/twin.');
|
|
1116
|
+
return `${lines.join('\n')}\n`;
|
|
1117
|
+
}
|