@topolo/cli 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,908 @@
1
+ #!/usr/bin/env node
2
+ // Generate every wrangler.toml + topolo.cloudcontrol.json from topolo.app.json.
3
+ //
4
+ // Run from an app's repo root:
5
+ // node ../../topolo-platform/tooling/generate-wrangler.mjs
6
+ //
7
+ // The generated files are gitignored — they are build artifacts, not hand-edited
8
+ // source files. Drift is impossible by construction.
9
+ //
10
+ // Usage:
11
+ // node generate-wrangler.mjs [--manifest=<path>] [--dry-run] [--check]
12
+ //
13
+ // Defaults:
14
+ // --manifest=$PWD/topolo.app.json
15
+ //
16
+ // Flags:
17
+ // --dry-run Print to stdout, don't write.
18
+ // --check Generate to temp + diff against existing tracked files; non-zero on drift. Used in CI.
19
+
20
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
21
+ import { resolve, dirname, join, relative } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { spawnSync } from 'node:child_process';
24
+
25
+ const HERE = dirname(fileURLToPath(import.meta.url));
26
+
27
+ const args = Object.fromEntries(
28
+ process.argv.slice(2)
29
+ .filter((a) => a.startsWith('--'))
30
+ .map((a) => {
31
+ const m = a.match(/^--([^=]+)(?:=(.*))?$/);
32
+ return [m[1], m[2] === undefined ? true : m[2]];
33
+ })
34
+ );
35
+
36
+ const MANIFEST_INPUT = resolve(process.cwd(), args.manifest || 'topolo.app.json');
37
+ const DRY_RUN = !!args['dry-run'];
38
+ const CHECK = !!args.check;
39
+
40
+ if (!existsSync(MANIFEST_INPUT)) {
41
+ console.error(`[generate] manifest not found: ${MANIFEST_INPUT}`);
42
+ process.exit(2);
43
+ }
44
+
45
+ // Resolve both through realpath so they agree about symlinked roots (macOS
46
+ // /tmp -> /private/tmp, symlinked checkouts). If they disagree, the `# Manifest:`
47
+ // header below — relative(<target dir>, MANIFEST_PATH) — escapes the app and
48
+ // bakes a machine-specific absolute path into a COMMITTED artifact, which then
49
+ // reads as drift on every other machine.
50
+ const CWD = realpathSync(process.cwd());
51
+ const MANIFEST_PATH = realpathSync(MANIFEST_INPUT);
52
+
53
+ const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
54
+
55
+ // --------------------------------------------------------------------------
56
+ // Helpers
57
+ // --------------------------------------------------------------------------
58
+ const ENV_PRIORITY = ['development', 'staging', 'production'];
59
+ const WORKER_RUNTIME_PACKAGE = '@topolo-io/worker-runtime';
60
+ const FRONTEND_PACKAGES = new Set(['react', 'react-dom', 'astro', 'next']);
61
+ const NEXUS_GATEWAY_URLS = {
62
+ development: 'https://nexus.topolo.dev',
63
+ staging: 'https://nexus.stg.topolo.us',
64
+ production: 'https://nexus.topolo.app',
65
+ };
66
+ const ENVS = Object.keys(manifest.environments || {}).sort((a, b) => {
67
+ const ai = ENV_PRIORITY.indexOf(a);
68
+ const bi = ENV_PRIORITY.indexOf(b);
69
+ if (ai !== -1 || bi !== -1) {
70
+ if (ai === -1) return 1;
71
+ if (bi === -1) return -1;
72
+ return ai - bi;
73
+ }
74
+ return a.localeCompare(b);
75
+ });
76
+ const ENV_SET = new Set(ENVS);
77
+
78
+ function fail(msg) { console.error(`[generate] ERROR: ${msg}`); process.exit(2); }
79
+
80
+ function isPerEnvObject(v) {
81
+ return v && typeof v === 'object' && !Array.isArray(v)
82
+ && Object.keys(v).some((k) => ENV_SET.has(k))
83
+ && Object.keys(v).every((k) => ENV_SET.has(k));
84
+ }
85
+
86
+ function valueForEnv(v, env) {
87
+ if (isPerEnvObject(v)) return v[env];
88
+ return v;
89
+ }
90
+
91
+ function tomlString(v) {
92
+ if (typeof v === 'string') return JSON.stringify(v);
93
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
94
+ return JSON.stringify(v);
95
+ }
96
+
97
+ function tomlValue(v) {
98
+ if (Array.isArray(v)) return `[${v.map(tomlValue).join(', ')}]`;
99
+ if (v && typeof v === 'object') {
100
+ return `{ ${Object.entries(v).map(([key, value]) => `${key} = ${tomlValue(value)}`).join(', ')} }`;
101
+ }
102
+ return tomlString(v);
103
+ }
104
+
105
+ function deriveHost(target, env) {
106
+ if (target.hostOverride && target.hostOverride[env]) return target.hostOverride[env];
107
+ if (!target.subdomain) return null;
108
+ return `${target.subdomain}.${manifest.environments[env].domain}`;
109
+ }
110
+
111
+ // The canonical public host for cloudcontrol domains/url. Prefers the
112
+ // subdomain-derived host; falls back to the first custom_domain route for the
113
+ // env (for complex multi-route targets that don't use a subdomain).
114
+ function primaryHost(target, env) {
115
+ const h = deriveHost(target, env);
116
+ if (h) return h;
117
+ for (const r of target.routes || []) {
118
+ if (r.environments && !r.environments.includes(env)) continue;
119
+ return r.pattern.replace(/\/\*$/, '');
120
+ }
121
+ return null;
122
+ }
123
+
124
+ // Host taken from a target's declared `publicUrl` (per-env string or map).
125
+ // Used only for cloudcontrol reporting — see the note in emitCloudcontrol.
126
+ function publicUrlHost(target, env) {
127
+ const url = valueForEnv(target.publicUrl, env);
128
+ if (!url) return null;
129
+ try {
130
+ return new URL(url).host;
131
+ } catch {
132
+ return String(url).replace(/^https?:\/\//, '').replace(/\/.*$/, '') || null;
133
+ }
134
+ }
135
+
136
+ function explicitRoutes(target, env) {
137
+ return (target.routes || [])
138
+ .filter((r) => !r.environments || r.environments.includes(env))
139
+ .map((r) => r.pattern);
140
+ }
141
+
142
+ function targetsIncludingEnv(target, env) {
143
+ return !target.environments || target.environments.includes(env);
144
+ }
145
+
146
+ function bindingAppliesToEnv(binding, env) {
147
+ if (!binding.environments) return true;
148
+ return binding.environments.includes(env);
149
+ }
150
+
151
+ function deployTargetName(target, env) {
152
+ if (env === 'staging') return `${target.wranglerName}-staging`;
153
+ if (env === 'development') return `${target.wranglerName}-development`;
154
+ return manifest.governed === true ? target.wranglerName : `${target.wranglerName}-production`;
155
+ }
156
+
157
+ function targetConfigPath(target) {
158
+ const configFile = target.deployConfigFile || target.configFile || 'wrangler.toml';
159
+ return target.dir === '.' ? configFile : `${target.dir}/${configFile}`;
160
+ }
161
+
162
+ function readDeps(packagePath) {
163
+ try {
164
+ const pkg = JSON.parse(readFileSync(packagePath, 'utf8'));
165
+ return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ function targetPackageDeps(target) {
172
+ let packagePath = resolve(CWD, target.dir, 'package.json');
173
+ while (!existsSync(packagePath)) {
174
+ const currentDir = dirname(packagePath);
175
+ if (currentDir === CWD || currentDir === dirname(currentDir)) return {};
176
+ packagePath = resolve(dirname(currentDir), 'package.json');
177
+ }
178
+ return readDeps(packagePath) ?? {};
179
+ }
180
+
181
+ // Only the target's own package.json — never an ancestor's. Returns null when
182
+ // the target doesn't have one, which the caller must treat as "unknown", not
183
+ // as "no deps".
184
+ function ownPackageDeps(target) {
185
+ const packagePath = resolve(CWD, target.dir, 'package.json');
186
+ if (!existsSync(packagePath)) return null;
187
+ return readDeps(packagePath);
188
+ }
189
+
190
+ function usesWorkerRuntime(target) {
191
+ return Object.prototype.hasOwnProperty.call(targetPackageDeps(target), WORKER_RUNTIME_PACKAGE);
192
+ }
193
+
194
+ // Frontend-ness is a property of the TARGET, so it must be decided from a
195
+ // per-target signal. targetPackageDeps() walks up to the app root when a target
196
+ // has no package.json of its own, and those hoisted deps describe the whole app
197
+ // — so a root-level `react` would mark every worker in the app a frontend and
198
+ // suppress the NEXUS_GATEWAY_URL injection below, leaving backends to fall back
199
+ // to their prod-Nexus default. Prefer the target's own manifest instead.
200
+ function isFrontendWorkerTarget(target) {
201
+ const ownDeps = ownPackageDeps(target);
202
+ // The target declares its own deps: authoritative, and precise enough to tell
203
+ // an SSR frontend (astro/next) from a worker that merely serves assets.
204
+ if (ownDeps) return Object.keys(ownDeps).some((dep) => FRONTEND_PACKAGES.has(dep));
205
+ // Deps are inherited, so they can't discriminate. Serving a built asset
206
+ // directory is what makes a target a frontend in that case.
207
+ return hasWorkerAssets(target);
208
+ }
209
+
210
+ function isRuntimeBackendTarget(target) {
211
+ return target.kind === 'worker' && usesWorkerRuntime(target) && !isFrontendWorkerTarget(target);
212
+ }
213
+
214
+ // --------------------------------------------------------------------------
215
+ // Validation
216
+ // --------------------------------------------------------------------------
217
+ for (const k of ['name', 'appId', 'environments', 'targets']) {
218
+ if (!manifest[k]) fail(`manifest missing required field: ${k}`);
219
+ }
220
+ if (/\b(?:srv|svc)_/.test(String(manifest.appId))) {
221
+ fail(`appId must be an app_* identifier, not a retired legacy-prefixed id: ${manifest.appId}`);
222
+ }
223
+ for (const env of ENVS) {
224
+ const e = manifest.environments[env];
225
+ if (!e?.accountId || !e?.domain) fail(`environments.${env} missing accountId or domain`);
226
+ }
227
+ for (const [name, t] of Object.entries(manifest.targets)) {
228
+ if (!t.kind || !t.dir) fail(`targets.${name} missing kind/dir`);
229
+ if (!['worker', 'none'].includes(t.kind)) fail(`targets.${name}: kind must be "worker" or "none"`);
230
+ if (t.kind === 'worker' && !t.wranglerName) fail(`targets.${name}: kind=worker requires "wranglerName"`);
231
+ if (t.kind === 'worker' && !t.main) fail(`targets.${name}: kind=worker requires "main"`);
232
+ if (t.kind === 'none' && !t.reason) fail(`targets.${name}: kind=none requires "reason"`);
233
+ }
234
+
235
+ // A production build_command must never select a non-production build flavour.
236
+ // Vite bakes the API hosts into the bundle at build time, so `build:staging` on
237
+ // the production target serves the staging bundle from the production host: it
238
+ // points at *.stg.topolo.us and never at the production API. A single flat
239
+ // buildCommand string is the trap — it is reused verbatim for every env, so an
240
+ // env-specific one silently reaches production. Declare buildCommand per-env.
241
+ //
242
+ // Deliberately asymmetric: `--mode production` on a staging target is NOT an
243
+ // error. It is a legitimate "optimized build" where the staging hosts are passed
244
+ // explicitly as VITE_* vars on the command line.
245
+ const NON_PROD_BUILD_FLAVOUR = /(?:\bbuild:|--mode[ =]|\bCLOUDFLARE_ENV=|\bDEPLOY_ENVIRONMENT=)(development|staging)\b/;
246
+ if (ENV_SET.has('production')) {
247
+ for (const [name, t] of Object.entries(manifest.targets)) {
248
+ if (t.kind === 'none' || t.buildCommand === undefined) continue;
249
+ if (t.environments && !t.environments.includes('production')) continue;
250
+ const cmd = valueForEnv(t.buildCommand, 'production');
251
+ if (typeof cmd !== 'string') continue;
252
+ const hit = cmd.match(NON_PROD_BUILD_FLAVOUR);
253
+ if (!hit) continue;
254
+ fail(
255
+ `targets.${name}.buildCommand builds the ${hit[1]} flavour for production (matched "${hit[0]}"):\n`
256
+ + ` ${cmd}\n`
257
+ + ` Production would serve the ${hit[1]} bundle, pointing at ${hit[1]} APIs.\n`
258
+ + ` Declare buildCommand per-environment instead:\n`
259
+ + ` "buildCommand": { "staging": "<staging build>", "production": "<production build>" }`
260
+ );
261
+ }
262
+ }
263
+
264
+ // --------------------------------------------------------------------------
265
+ // Per-target wrangler.toml emission
266
+ // --------------------------------------------------------------------------
267
+ function emitWranglerToml(targetName, target) {
268
+ const lines = [];
269
+ lines.push(`# THIS FILE IS GENERATED from topolo.app.json by topolo-platform/tooling/generate-wrangler.mjs`);
270
+ lines.push(`# DO NOT HAND-EDIT — your changes will be overwritten on next build/deploy.`);
271
+ lines.push(`# Manifest: ${relative(resolve(CWD, target.dir), MANIFEST_PATH)}`);
272
+ lines.push(`# Target: ${targetName}`);
273
+ lines.push('');
274
+
275
+ lines.push(`name = ${tomlString(target.wranglerName)}`);
276
+ lines.push(`compatibility_date = ${tomlString(manifest.compatibilityDate || '2026-05-01')}`);
277
+ if (target.compatibilityFlags?.length) {
278
+ lines.push(`compatibility_flags = ${JSON.stringify(target.compatibilityFlags)}`);
279
+ }
280
+ if (target.main) {
281
+ lines.push(`main = ${tomlString(target.main)}`);
282
+ }
283
+ lines.push('');
284
+
285
+ // [assets] block — Worker static assets. Top-level plus an env mirror below.
286
+ if (hasWorkerAssets(target)) {
287
+ lines.push('[assets]');
288
+ lines.push(`directory = ${tomlString(target.assets.directory || target.outputDir || 'dist')}`);
289
+ if (target.assets.binding) lines.push(`binding = ${tomlString(target.assets.binding)}`);
290
+ else lines.push(`binding = "ASSETS"`);
291
+ if (target.assets.notFoundHandling) lines.push(`not_found_handling = ${tomlString(target.assets.notFoundHandling)}`);
292
+ if (target.assets.runWorkerFirst !== undefined) lines.push(`run_worker_first = ${tomlString(target.assets.runWorkerFirst)}`);
293
+ lines.push('');
294
+ }
295
+
296
+ // Durable Object migrations — top-level [[migrations]] blocks (DO class
297
+ // lifecycle tags). Required for workers that declare durable objects.
298
+ for (const m of target.migrations || []) {
299
+ lines.push('[[migrations]]');
300
+ if (m.tag) lines.push(`tag = ${tomlString(m.tag)}`);
301
+ if (m.new_classes) lines.push(`new_classes = ${tomlValue(m.new_classes)}`);
302
+ if (m.new_sqlite_classes) lines.push(`new_sqlite_classes = ${tomlValue(m.new_sqlite_classes)}`);
303
+ if (m.renamed_classes) lines.push(`renamed_classes = ${tomlValue(m.renamed_classes)}`);
304
+ if (m.deleted_classes) lines.push(`deleted_classes = ${tomlValue(m.deleted_classes)}`);
305
+ lines.push('');
306
+ }
307
+
308
+ // Top-level [vars]: ONLY values stable across envs (appId identity echo +
309
+ // sharedVars resolved to scalars). Per-target vars are NEVER hoisted to
310
+ // top-level because
311
+ // wrangler treats top-level [vars] as a fallback — and the fallback should
312
+ // never carry env-specific values that could leak across envs.
313
+ const topVars = collectTopLevelVars();
314
+ if (Object.keys(topVars).length) {
315
+ lines.push('[vars]');
316
+ for (const [k, v] of Object.entries(topVars)) {
317
+ if (v === undefined) continue;
318
+ lines.push(`${k} = ${tomlString(v)}`);
319
+ }
320
+ lines.push('');
321
+ }
322
+
323
+ if (target.observability !== false) {
324
+ lines.push('[observability]');
325
+ lines.push('enabled = true');
326
+ lines.push('');
327
+ }
328
+
329
+ // [cache] — Cloudflare Workers Cache. Emit explicit true and false values so
330
+ // a target can reliably turn off caching that an earlier version enabled.
331
+ // Omitting the block during that transition can leave the remote setting on.
332
+ if (typeof target.cache?.enabled === 'boolean') {
333
+ lines.push('[cache]');
334
+ lines.push(`enabled = ${target.cache.enabled}`);
335
+ lines.push('');
336
+ }
337
+
338
+ for (const env of ENVS) {
339
+ // Honor a target-level environments filter (e.g. production-only redirect worker).
340
+ if (target.environments && !target.environments.includes(env)) continue;
341
+ lines.push(`# ──────── ${env} ────────`);
342
+ const accountId = manifest.environments[env].accountId;
343
+ // [env.X] header — emit worker account_id / workers_dev scalars.
344
+ const envHeaderLines = [];
345
+ envHeaderLines.push(`account_id = ${tomlString(accountId)}`);
346
+ const wd = (target.workersDev !== undefined) ? target.workersDev : (env !== 'production');
347
+ envHeaderLines.push(`workers_dev = ${wd ? 'true' : 'false'}`);
348
+ if (envHeaderLines.length) {
349
+ lines.push(`[env.${env}]`);
350
+ lines.push(...envHeaderLines);
351
+ lines.push('');
352
+ }
353
+
354
+ // Vars
355
+ const envVars = collectVars(target, env);
356
+ const envVarEntries = Object.entries(envVars).filter(([, v]) => v !== undefined);
357
+ if (envVarEntries.length) {
358
+ lines.push(`[env.${env}.vars]`);
359
+ for (const [k, v] of envVarEntries) {
360
+ lines.push(`${k} = ${tomlString(v)}`);
361
+ }
362
+ lines.push('');
363
+ }
364
+
365
+ // Custom-domain route (derived from subdomain + domain, or hostOverride)
366
+ const host = deriveHost(target, env);
367
+ if (host) {
368
+ lines.push(`[[env.${env}.routes]]`);
369
+ lines.push(`pattern = ${tomlString(host)}`);
370
+ lines.push(`custom_domain = true`);
371
+ lines.push('');
372
+ }
373
+
374
+ // Explicit routes (zone routes / non-custom-domain patterns)
375
+ for (const r of target.routes || []) {
376
+ if (r.environments && !r.environments.includes(env)) continue;
377
+ lines.push(`[[env.${env}.routes]]`);
378
+ lines.push(`pattern = ${tomlString(r.pattern)}`);
379
+ if (r.customDomain) {
380
+ lines.push(`custom_domain = true`);
381
+ } else if (r.zoneName) {
382
+ lines.push(`zone_name = ${tomlString(r.zoneName)}`);
383
+ }
384
+ lines.push('');
385
+ }
386
+
387
+ // Per-env [env.X.assets] mirror — wrangler does NOT inherit top-level
388
+ // [assets] into named environments, so it must be repeated per env.
389
+ if (hasWorkerAssets(target)) {
390
+ lines.push(`[env.${env}.assets]`);
391
+ lines.push(`directory = ${tomlString(target.assets.directory || target.outputDir || 'dist')}`);
392
+ lines.push(`binding = ${tomlString(target.assets.binding || 'ASSETS')}`);
393
+ if (target.assets.notFoundHandling) lines.push(`not_found_handling = ${tomlString(target.assets.notFoundHandling)}`);
394
+ if (target.assets.runWorkerFirst !== undefined) lines.push(`run_worker_first = ${tomlString(target.assets.runWorkerFirst)}`);
395
+ lines.push('');
396
+ }
397
+
398
+ // Bindings
399
+ if (target.bindings) {
400
+ emitBindings(lines, target.bindings, env);
401
+ }
402
+
403
+ // Crons
404
+ const crons = isPerEnvObject(target.crons) ? target.crons[env] : target.crons;
405
+ if (crons?.length) {
406
+ lines.push(`[env.${env}.triggers]`);
407
+ lines.push(`crons = ${JSON.stringify(crons)}`);
408
+ lines.push('');
409
+ }
410
+ }
411
+
412
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
413
+ }
414
+
415
+ // A gateway the manifest states explicitly, at either scope, outranks the
416
+ // injected default below. Resolved PER ENV, not by key presence: a per-env map
417
+ // that omits an environment (staging+production but no development is a common
418
+ // shape — the CLI scaffolds it) declares nothing for the env it omits, and that
419
+ // env must still get a default rather than silently inheriting the prod Nexus.
420
+ function declaresGatewayForEnv(target, env) {
421
+ const declared = (o) => Boolean(o)
422
+ && Object.prototype.hasOwnProperty.call(o, 'NEXUS_GATEWAY_URL')
423
+ && valueForEnv(o.NEXUS_GATEWAY_URL, env) !== undefined;
424
+ return declared(target.vars) || declared(manifest.sharedVars);
425
+ }
426
+
427
+ // Copy a var block into `out` for one env. A per-env map that omits this env
428
+ // contributes NOTHING — assigning the undefined would clobber a default set
429
+ // earlier (the emitter drops undefined, so the var would vanish entirely).
430
+ function assignForEnv(out, vars, env) {
431
+ if (!vars) return;
432
+ for (const [k, v] of Object.entries(vars)) {
433
+ const value = valueForEnv(v, env);
434
+ if (value !== undefined) out[k] = value;
435
+ }
436
+ }
437
+
438
+ function collectVars(target, env) {
439
+ const out = {};
440
+ // Runtime identity binding (env.APP_ID) + the VITE_ var index.html reads into
441
+ // window.__TOPOLO_APP_ID__ (%VITE_TOPOLO_APP_ID%). Both carry manifest.appId.
442
+ out.APP_ID = manifest.appId;
443
+ out.VITE_TOPOLO_APP_ID = manifest.appId;
444
+ assignForEnv(out, manifest.sharedVars, env);
445
+ // A backend with no gateway declared anywhere falls back to the PROD Nexus at
446
+ // runtime, so give it its own environment's. Skipped rather than overridden
447
+ // when the manifest declares one, so a declared gateway keeps the position the
448
+ // manifest gives it: assigning here and letting vars overwrite would pin the
449
+ // key to *this* position and reorder every app that declares it.
450
+ if (isRuntimeBackendTarget(target) && !declaresGatewayForEnv(target, env)) {
451
+ out.NEXUS_GATEWAY_URL = NEXUS_GATEWAY_URLS[env];
452
+ }
453
+ assignForEnv(out, target.vars, env);
454
+ if (hasWorkerAssets(target)) assignForEnv(out, target.viteVars, env);
455
+ return out;
456
+ }
457
+
458
+ function collectTopLevelVars() {
459
+ const out = {};
460
+ out.APP_ID = manifest.appId;
461
+ out.VITE_TOPOLO_APP_ID = manifest.appId;
462
+ if (manifest.sharedVars) {
463
+ for (const [k, v] of Object.entries(manifest.sharedVars)) {
464
+ // Hoist only if scalar (same across envs). Per-env objects stay in [env.*.vars] only.
465
+ if (!isPerEnvObject(v)) out[k] = v;
466
+ }
467
+ }
468
+ return out;
469
+ }
470
+
471
+ function hasWorkerAssets(target) {
472
+ return target.kind === 'worker' && !!target.assets;
473
+ }
474
+
475
+ function emitBindings(lines, bindings, env) {
476
+ for (const d of bindings.d1 || []) {
477
+ if (!bindingAppliesToEnv(d, env)) continue;
478
+ const cfg = d[env];
479
+ if (!cfg) continue;
480
+ lines.push(`[[env.${env}.d1_databases]]`);
481
+ lines.push(`binding = ${tomlString(d.binding)}`);
482
+ lines.push(`database_name = ${tomlString(cfg.databaseName)}`);
483
+ if (cfg.databaseId) lines.push(`database_id = ${tomlString(cfg.databaseId)}`);
484
+ if (d.migrationsDir) lines.push(`migrations_dir = ${tomlString(d.migrationsDir)}`);
485
+ lines.push('');
486
+ }
487
+ for (const r of bindings.r2 || []) {
488
+ if (!bindingAppliesToEnv(r, env)) continue;
489
+ const cfg = r[env];
490
+ if (!cfg) continue;
491
+ lines.push(`[[env.${env}.r2_buckets]]`);
492
+ lines.push(`binding = ${tomlString(r.binding)}`);
493
+ lines.push(`bucket_name = ${tomlString(cfg.bucketName)}`);
494
+ lines.push('');
495
+ }
496
+ for (const kv of bindings.kv || []) {
497
+ if (!bindingAppliesToEnv(kv, env)) continue;
498
+ const cfg = kv[env];
499
+ if (!cfg) continue;
500
+ lines.push(`[[env.${env}.kv_namespaces]]`);
501
+ lines.push(`binding = ${tomlString(kv.binding)}`);
502
+ lines.push(`id = ${tomlString(cfg.id)}`);
503
+ lines.push('');
504
+ }
505
+ for (const d of bindings.durableObjects || []) {
506
+ if (!bindingAppliesToEnv(d, env)) continue;
507
+ lines.push(`[[env.${env}.durable_objects.bindings]]`);
508
+ lines.push(`name = ${tomlString(d.binding)}`);
509
+ lines.push(`class_name = ${tomlString(d.className)}`);
510
+ if (d.scriptName) lines.push(`script_name = ${tomlString(d.scriptName)}`);
511
+ lines.push('');
512
+ }
513
+ for (const q of bindings.queues?.producers || []) {
514
+ if (!bindingAppliesToEnv(q, env)) continue;
515
+ const cfg = q[env];
516
+ if (!cfg) continue;
517
+ lines.push(`[[env.${env}.queues.producers]]`);
518
+ lines.push(`binding = ${tomlString(q.binding)}`);
519
+ lines.push(`queue = ${tomlString(cfg.queue)}`);
520
+ lines.push('');
521
+ }
522
+ for (const q of bindings.queues?.consumers || []) {
523
+ if (!bindingAppliesToEnv(q, env)) continue;
524
+ const cfg = q[env];
525
+ if (!cfg) continue;
526
+ lines.push(`[[env.${env}.queues.consumers]]`);
527
+ lines.push(`queue = ${tomlString(cfg.queue)}`);
528
+ if (q.maxBatchSize !== undefined) lines.push(`max_batch_size = ${q.maxBatchSize}`);
529
+ if (q.maxBatchTimeout !== undefined) lines.push(`max_batch_timeout = ${q.maxBatchTimeout}`);
530
+ if (q.maxRetries !== undefined) lines.push(`max_retries = ${q.maxRetries}`);
531
+ const deadLetterQueue = valueForEnv(q.deadLetterQueue, env);
532
+ if (deadLetterQueue !== undefined) lines.push(`dead_letter_queue = ${tomlString(deadLetterQueue)}`);
533
+ lines.push('');
534
+ }
535
+ for (const a of bindings.analyticsEngine || []) {
536
+ if (!bindingAppliesToEnv(a, env)) continue;
537
+ const dataset = a[env]?.dataset ?? a.dataset;
538
+ if (!dataset) continue;
539
+ lines.push(`[[env.${env}.analytics_engine_datasets]]`);
540
+ lines.push(`binding = ${tomlString(a.binding)}`);
541
+ lines.push(`dataset = ${tomlString(dataset)}`);
542
+ lines.push('');
543
+ }
544
+ for (const v of bindings.vectorize || []) {
545
+ if (!bindingAppliesToEnv(v, env)) continue;
546
+ // indexName may be a shared scalar OR per-env (v[env].indexName).
547
+ const indexName = v[env]?.indexName ?? v.indexName;
548
+ if (!indexName) continue;
549
+ lines.push(`[[env.${env}.vectorize]]`);
550
+ lines.push(`binding = ${tomlString(v.binding)}`);
551
+ lines.push(`index_name = ${tomlString(indexName)}`);
552
+ lines.push('');
553
+ }
554
+ for (const w of bindings.workflows || []) {
555
+ if (!bindingAppliesToEnv(w, env)) continue;
556
+ // name may be a shared scalar OR per-env (w[env].name); class_name is shared.
557
+ const name = w[env]?.name ?? w.name;
558
+ if (!name || !w.className) continue;
559
+ lines.push(`[[env.${env}.workflows]]`);
560
+ lines.push(`binding = ${tomlString(w.binding)}`);
561
+ lines.push(`name = ${tomlString(name)}`);
562
+ lines.push(`class_name = ${tomlString(w.className)}`);
563
+ if (w.scriptName) lines.push(`script_name = ${tomlString(valueForEnv(w.scriptName, env))}`);
564
+ lines.push('');
565
+ }
566
+ for (const s of bindings.services || []) {
567
+ if (!bindingAppliesToEnv(s, env)) continue;
568
+ const service = valueForEnv(s.service, env);
569
+ if (!service) continue;
570
+ lines.push(`[[env.${env}.services]]`);
571
+ lines.push(`binding = ${tomlString(s.binding)}`);
572
+ lines.push(`service = ${tomlString(service)}`);
573
+ lines.push('');
574
+ }
575
+ for (const a of bindings.ai || []) {
576
+ lines.push(`[env.${env}.ai]`);
577
+ lines.push(`binding = ${tomlString(a.binding)}`);
578
+ lines.push('');
579
+ }
580
+ }
581
+
582
+ // --------------------------------------------------------------------------
583
+ // topolo.cloudcontrol.json emission — kept in sync because fleet tooling
584
+ // (standard_deploy.py, fleet-rollout, fleet-promote) consumes it.
585
+ // --------------------------------------------------------------------------
586
+ function emitCloudcontrol() {
587
+ const existing = existsSync(resolve(CWD, 'topolo.cloudcontrol.json'))
588
+ ? JSON.parse(readFileSync(resolve(CWD, 'topolo.cloudcontrol.json'), 'utf8'))
589
+ : {};
590
+
591
+ const cc = { ...existing };
592
+ cc.name = manifest.name;
593
+ const deployableTargets = Object.values(manifest.targets).filter((t) => t.kind !== 'none');
594
+ cc.deploy_method = deployableTargets.length ? 'wrangler' : 'none';
595
+ cc.ci_provider = existing.ci_provider || 'github_actions';
596
+ cc.quality_policy = existing.quality_policy || {
597
+ profile: 'first_party_app',
598
+ coverage: { mode: 'ratchet' },
599
+ staging_e2e: [],
600
+ };
601
+ cc.repo_identity = existing.repo_identity || cc.repo_identity;
602
+
603
+ cc.environments = ENVS.map((env) => {
604
+ const publicTarget = firstPublicTarget(env);
605
+ const host = publicTarget ? primaryHost(publicTarget, env) : null;
606
+ return {
607
+ name: env,
608
+ url: host ? `https://${host}` : null,
609
+ branch: env === 'production' ? 'main' : env,
610
+ status: env === 'production' ? 'unknown' : 'active',
611
+ };
612
+ });
613
+
614
+ const existingDeployTargets = new Map(
615
+ (existing.deploy_targets || [])
616
+ .filter((target) => target && target.environment && target.target_name)
617
+ .map((target) => [`${target.environment}:${target.target_name}`, target])
618
+ );
619
+ const cloudControlOnlyFields = ['sdk_app_id', 'agent_callable'];
620
+
621
+ cc.deploy_targets = [];
622
+ for (const env of ENVS) {
623
+ for (const [tname, t] of Object.entries(manifest.targets)) {
624
+ if (t.kind === 'none') continue;
625
+ if (t.environments && !t.environments.includes(env)) continue;
626
+ const host = primaryHost(t, env);
627
+ const routes = explicitRoutes(t, env);
628
+ // cloudcontrol-only host resolution. A target that is fronted by a sibling
629
+ // worker (Nexus' gateway sits behind the dashboard's service binding) owns
630
+ // no route of its own, so it has no `subdomain` and primaryHost() is null —
631
+ // but it still has a public URL that fleet tooling needs. Fall back to
632
+ // `publicUrl` for the reported domain WITHOUT feeding it into `routes`,
633
+ // which must keep reflecting only what this worker actually serves.
634
+ // Deliberately not folded into deriveHost(): that is shared with the
635
+ // wrangler.toml route emitter, and adding a route there would try to
636
+ // attach the fronting worker's custom domain to the backing worker.
637
+ const reportedHost = host || publicUrlHost(t, env);
638
+ const reportedRoutes = routes.length ? routes : (host ? [host] : []);
639
+ const d1Migrations = (t.bindings?.d1 || [])
640
+ .filter((binding) => bindingAppliesToEnv(binding, env) && binding[env])
641
+ .map((binding) => ({
642
+ binding: binding.binding,
643
+ ...(binding.migrationsDir || binding.migrations_dir
644
+ ? { migrations_dir: binding.migrationsDir || binding.migrations_dir }
645
+ : {}),
646
+ }));
647
+ const targetName = deployTargetName(t, env);
648
+ const existingTarget = existingDeployTargets.get(`${env}:${targetName}`) || {};
649
+ const preservedCloudControlFields = Object.fromEntries(
650
+ cloudControlOnlyFields
651
+ .filter((field) => Object.prototype.hasOwnProperty.call(existingTarget, field))
652
+ .map((field) => [field, existingTarget[field]])
653
+ );
654
+ cc.deploy_targets.push({
655
+ kind: t.kind,
656
+ environment: env,
657
+ target_name: targetName,
658
+ cloudflare_account_id: manifest.environments[env].accountId,
659
+ config_path: targetConfigPath(t),
660
+ workdir: t.dir || '.',
661
+ build_command: t.buildCommand !== undefined ? (valueForEnv(t.buildCommand, env) ?? null) : null,
662
+ output_dir: null,
663
+ production_branch: env === 'production' ? 'main' : env,
664
+ healthcheck_path: t.healthcheckPath || '/',
665
+ domains: reportedHost ? [reportedHost] : [],
666
+ routes: reportedRoutes,
667
+ ...(reportedHost ? { api_url: `https://${reportedHost}` } : {}),
668
+ ...(Array.isArray(t.requiredSecrets) && t.requiredSecrets.length
669
+ ? { required_secrets: t.requiredSecrets }
670
+ : {}),
671
+ ...(d1Migrations.length ? { migrations: { d1: d1Migrations } } : {}),
672
+ ...preservedCloudControlFields,
673
+ });
674
+ }
675
+ }
676
+
677
+ // Preserve externally-managed deploy targets the manifest doesn't drive
678
+ // (e.g. Astro's generated dist/server/wrangler.json runtime). Identified by a
679
+ // config_path no manifest target produces.
680
+ const manifestConfigPaths = new Set(
681
+ Object.values(manifest.targets).filter((t) => t.kind !== 'none').map((t) => targetConfigPath(t))
682
+ );
683
+ for (const dt of (existing.deploy_targets || [])) {
684
+ if (!ENVS.includes(dt.environment)) continue;
685
+ if (manifestConfigPaths.has(dt.config_path)) continue;
686
+ // Only preserve configs GENERATED by another build tool (live under a build
687
+ // output dir, e.g. Astro's dist/server/wrangler.json). Hand-authored configs
688
+ // we manage (wrangler.toml / wrangler.jsonc at source paths) must NOT be
689
+ // preserved — otherwise a jsonc→toml conversion leaves a stale jsonc target.
690
+ const p = dt.config_path || '';
691
+ if (!/\/dist\/|^dist\//.test(p)) continue;
692
+ cc.deploy_targets.push(dt);
693
+ }
694
+
695
+ cc.resource_bindings = [];
696
+ for (const env of ENVS) {
697
+ const accountId = manifest.environments[env].accountId;
698
+ for (const [, t] of Object.entries(manifest.targets)) {
699
+ if (t.kind === 'none') continue;
700
+ if (t.environments && !t.environments.includes(env)) continue;
701
+ if (!t.bindings) continue;
702
+ for (const d of t.bindings.d1 || []) {
703
+ if (!bindingAppliesToEnv(d, env) || !d[env]) continue;
704
+ cc.resource_bindings.push({
705
+ resource_type: 'd1',
706
+ binding: d.binding,
707
+ environment: env,
708
+ account_id: accountId,
709
+ resource_name: d[env].databaseName,
710
+ resource_id: d[env].databaseId ?? null,
711
+ });
712
+ }
713
+ for (const r of t.bindings.r2 || []) {
714
+ if (!bindingAppliesToEnv(r, env) || !r[env]) continue;
715
+ cc.resource_bindings.push({
716
+ resource_type: 'r2',
717
+ binding: r.binding,
718
+ environment: env,
719
+ account_id: accountId,
720
+ resource_name: r[env].bucketName,
721
+ resource_id: null,
722
+ });
723
+ }
724
+ for (const kv of t.bindings.kv || []) {
725
+ if (!bindingAppliesToEnv(kv, env) || !kv[env]) continue;
726
+ cc.resource_bindings.push({
727
+ resource_type: 'kv',
728
+ binding: kv.binding,
729
+ environment: env,
730
+ account_id: accountId,
731
+ resource_name: null,
732
+ resource_id: kv[env].id,
733
+ });
734
+ }
735
+ for (const q of (t.bindings.queues?.producers || [])) {
736
+ if (!bindingAppliesToEnv(q, env) || !q[env]) continue;
737
+ cc.resource_bindings.push({
738
+ resource_type: 'queue',
739
+ binding: q.binding,
740
+ environment: env,
741
+ account_id: accountId,
742
+ resource_name: q[env].queue,
743
+ resource_id: null,
744
+ });
745
+ }
746
+ for (const a of (t.bindings.analyticsEngine || [])) {
747
+ if (!bindingAppliesToEnv(a, env)) continue;
748
+ const dataset = a[env]?.dataset ?? a.dataset;
749
+ if (!dataset) continue;
750
+ cc.resource_bindings.push({
751
+ resource_type: 'analytics_engine',
752
+ binding: a.binding,
753
+ environment: env,
754
+ account_id: accountId,
755
+ resource_name: dataset,
756
+ resource_id: null,
757
+ });
758
+ }
759
+ for (const v of (t.bindings.vectorize || [])) {
760
+ if (!bindingAppliesToEnv(v, env)) continue;
761
+ const indexName = v[env]?.indexName ?? v.indexName;
762
+ if (!indexName) continue;
763
+ cc.resource_bindings.push({
764
+ resource_type: 'vectorize',
765
+ binding: v.binding,
766
+ environment: env,
767
+ account_id: accountId,
768
+ resource_name: indexName,
769
+ resource_id: null,
770
+ });
771
+ }
772
+ }
773
+ }
774
+
775
+ cc.resource_bindings = Array.from(new Map(cc.resource_bindings.map((entry) => [
776
+ [
777
+ entry.resource_type,
778
+ entry.binding,
779
+ entry.environment,
780
+ entry.account_id,
781
+ entry.resource_name,
782
+ entry.resource_id,
783
+ ].join('\0'),
784
+ entry,
785
+ ])).values());
786
+
787
+ cc.ci_workflows = existing.ci_workflows || [];
788
+ // Reconcile ci_targets' deploy_targets references to the canonical target
789
+ // names. Production targets are suffixed `-production`; a preserved ci_target
790
+ // that still names the bare worker (the pre-canonicalization prod name) would
791
+ // make `standard_ci plan` reject the deploy ("references unknown deploy
792
+ // target"). Map bare -> `<name>-production` when that resolves to a real target.
793
+ {
794
+ const validTargetNames = new Set(cc.deploy_targets.map((d) => d.target_name));
795
+ const coveredTargetNames = new Set();
796
+ cc.ci_targets = (existing.ci_targets || []).map((ct) => {
797
+ if (!ct || !Array.isArray(ct.deploy_targets)) return ct;
798
+ const deployTargets = ct.deploy_targets
799
+ .map((nm) => {
800
+ if (validTargetNames.has(nm)) return nm;
801
+ if (manifest.governed === true && typeof nm === 'string' && nm.endsWith('-production')) {
802
+ const bareName = nm.slice(0, -'-production'.length);
803
+ if (validTargetNames.has(bareName)) return bareName;
804
+ }
805
+ if (validTargetNames.has(`${nm}-production`)) return `${nm}-production`;
806
+ // Drop references to targets this manifest doesn't emit — e.g.
807
+ // TopoloWeb's Astro `web-runtime`, which is excluded from the
808
+ // generator and deploys via its own wrangler.jsonc + CI. Leaving it
809
+ // would make standard_ci plan reject the whole deploy.
810
+ return null;
811
+ })
812
+ .filter(Boolean);
813
+ for (const targetName of deployTargets) coveredTargetNames.add(targetName);
814
+ return {
815
+ ...ct,
816
+ deploy_targets: deployTargets,
817
+ };
818
+ });
819
+ const firstCiTarget = cc.ci_targets.find((ct) => ct && Array.isArray(ct.deploy_targets));
820
+ const ownsDeployTarget = (ciTarget, deployTarget) => {
821
+ if (!ciTarget?.path || !deployTarget) return false;
822
+ const ciPath = `${ciTarget.path}`.replace(/\/+$/, '');
823
+ const workdir = `${deployTarget.workdir || ''}`.replace(/\/+$/, '');
824
+ const configPath = `${deployTarget.config_path || ''}`;
825
+ return workdir === ciPath || workdir.startsWith(`${ciPath}/`) || configPath.startsWith(`${ciPath}/`);
826
+ };
827
+ const deployTargetByName = new Map(cc.deploy_targets.map((target) => [target.target_name, target]));
828
+ const missingGeneratedTargets = cc.deploy_targets
829
+ .map((target) => target.target_name)
830
+ .filter((targetName) => validTargetNames.has(targetName) && !coveredTargetNames.has(targetName));
831
+ for (const targetName of missingGeneratedTargets) {
832
+ const deployTarget = deployTargetByName.get(targetName);
833
+ const owningCiTarget = cc.ci_targets.find((ct) =>
834
+ ct && Array.isArray(ct.deploy_targets) && ownsDeployTarget(ct, deployTarget)
835
+ ) || firstCiTarget;
836
+ if (owningCiTarget) {
837
+ owningCiTarget.deploy_targets = [
838
+ ...owningCiTarget.deploy_targets,
839
+ targetName,
840
+ ];
841
+ }
842
+ }
843
+ }
844
+ cc.tags = existing.tags || ['application', 'cloudflare', 'github-actions', 'javascript'];
845
+ cc.cloudflare_account_id = manifest.environments.production.accountId;
846
+
847
+ return JSON.stringify(cc, null, 2) + '\n';
848
+ }
849
+
850
+ function firstPublicTarget(env) {
851
+ if (manifest.primaryTarget) {
852
+ const target = manifest.targets[manifest.primaryTarget];
853
+ if (!target) fail(`primaryTarget references unknown target: ${manifest.primaryTarget}`);
854
+ if (target.kind === 'none') return null;
855
+ if (targetsIncludingEnv(target, env) && primaryHost(target, env)) return target;
856
+ }
857
+ return Object.values(manifest.targets).filter((t) => t.kind !== 'none' && targetsIncludingEnv(t, env)).find((t) => primaryHost(t, env))
858
+ || Object.values(manifest.targets).find((t) => t.kind !== 'none' && targetsIncludingEnv(t, env))
859
+ || null;
860
+ }
861
+
862
+ // --------------------------------------------------------------------------
863
+ // Write outputs
864
+ // --------------------------------------------------------------------------
865
+ const outputs = [];
866
+ for (const [name, target] of Object.entries(manifest.targets)) {
867
+ if (target.kind === 'none') continue;
868
+ if (target.generateConfig === false) continue;
869
+ const dir = resolve(CWD, target.dir);
870
+ const outPath = join(dir, target.configFile || 'wrangler.toml');
871
+ outputs.push({ path: outPath, content: emitWranglerToml(name, target), kind: 'wrangler', target: name });
872
+ }
873
+ outputs.push({ path: resolve(CWD, 'topolo.cloudcontrol.json'), content: emitCloudcontrol(), kind: 'cloudcontrol' });
874
+
875
+ if (DRY_RUN) {
876
+ for (const o of outputs) {
877
+ console.log(`\n=== ${relative(CWD, o.path)} (${o.kind}${o.target ? ':' + o.target : ''}) ===`);
878
+ process.stdout.write(o.content);
879
+ }
880
+ process.exit(0);
881
+ }
882
+
883
+ if (CHECK) {
884
+ let drift = false;
885
+ for (const o of outputs) {
886
+ if (!existsSync(o.path)) {
887
+ console.error(`[generate --check] missing: ${relative(CWD, o.path)}`);
888
+ drift = true;
889
+ continue;
890
+ }
891
+ const cur = readFileSync(o.path, 'utf8');
892
+ if (cur !== o.content) {
893
+ console.error(`[generate --check] drift in ${relative(CWD, o.path)}`);
894
+ const tmp = `/tmp/genwrangler-${process.pid}-${Math.random().toString(36).slice(2)}`;
895
+ writeFileSync(tmp, o.content);
896
+ const diff = spawnSync('diff', ['-u', o.path, tmp], { encoding: 'utf8' });
897
+ if (diff.stdout) process.stderr.write(diff.stdout);
898
+ drift = true;
899
+ }
900
+ }
901
+ process.exit(drift ? 1 : 0);
902
+ }
903
+
904
+ for (const o of outputs) {
905
+ mkdirSync(dirname(o.path), { recursive: true });
906
+ writeFileSync(o.path, o.content, 'utf8');
907
+ console.log(`[generate] wrote ${relative(CWD, o.path)}`);
908
+ }