@topolo/cli 0.5.1 → 0.6.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/dist/generate-wrangler.mjs +911 -0
- package/dist/index.js +1286 -875
- package/package.json +4 -4
|
@@ -0,0 +1,911 @@
|
|
|
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
|
+
const deployableEnvironments = new Set(
|
|
595
|
+
deployableTargets.flatMap((target) => target.environments || ENVS),
|
|
596
|
+
);
|
|
597
|
+
cc.deploy_method = deployableTargets.length ? 'wrangler' : 'none';
|
|
598
|
+
cc.ci_provider = existing.ci_provider || 'github_actions';
|
|
599
|
+
cc.quality_policy = existing.quality_policy || {
|
|
600
|
+
profile: 'first_party_app',
|
|
601
|
+
coverage: { mode: 'ratchet' },
|
|
602
|
+
staging_e2e: [],
|
|
603
|
+
};
|
|
604
|
+
cc.repo_identity = existing.repo_identity || cc.repo_identity;
|
|
605
|
+
|
|
606
|
+
cc.environments = ENVS.filter((env) => deployableEnvironments.has(env)).map((env) => {
|
|
607
|
+
const publicTarget = firstPublicTarget(env);
|
|
608
|
+
const host = publicTarget ? primaryHost(publicTarget, env) : null;
|
|
609
|
+
return {
|
|
610
|
+
name: env,
|
|
611
|
+
url: host ? `https://${host}` : null,
|
|
612
|
+
branch: env === 'production' ? 'main' : env,
|
|
613
|
+
status: env === 'production' ? 'unknown' : 'active',
|
|
614
|
+
};
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
const existingDeployTargets = new Map(
|
|
618
|
+
(existing.deploy_targets || [])
|
|
619
|
+
.filter((target) => target && target.environment && target.target_name)
|
|
620
|
+
.map((target) => [`${target.environment}:${target.target_name}`, target])
|
|
621
|
+
);
|
|
622
|
+
const cloudControlOnlyFields = ['sdk_app_id', 'agent_callable'];
|
|
623
|
+
|
|
624
|
+
cc.deploy_targets = [];
|
|
625
|
+
for (const env of ENVS) {
|
|
626
|
+
for (const [tname, t] of Object.entries(manifest.targets)) {
|
|
627
|
+
if (t.kind === 'none') continue;
|
|
628
|
+
if (t.environments && !t.environments.includes(env)) continue;
|
|
629
|
+
const host = primaryHost(t, env);
|
|
630
|
+
const routes = explicitRoutes(t, env);
|
|
631
|
+
// cloudcontrol-only host resolution. A target that is fronted by a sibling
|
|
632
|
+
// worker (Nexus' gateway sits behind the dashboard's service binding) owns
|
|
633
|
+
// no route of its own, so it has no `subdomain` and primaryHost() is null —
|
|
634
|
+
// but it still has a public URL that fleet tooling needs. Fall back to
|
|
635
|
+
// `publicUrl` for the reported domain WITHOUT feeding it into `routes`,
|
|
636
|
+
// which must keep reflecting only what this worker actually serves.
|
|
637
|
+
// Deliberately not folded into deriveHost(): that is shared with the
|
|
638
|
+
// wrangler.toml route emitter, and adding a route there would try to
|
|
639
|
+
// attach the fronting worker's custom domain to the backing worker.
|
|
640
|
+
const reportedHost = host || publicUrlHost(t, env);
|
|
641
|
+
const reportedRoutes = routes.length ? routes : (host ? [host] : []);
|
|
642
|
+
const d1Migrations = (t.bindings?.d1 || [])
|
|
643
|
+
.filter((binding) => bindingAppliesToEnv(binding, env) && binding[env])
|
|
644
|
+
.map((binding) => ({
|
|
645
|
+
binding: binding.binding,
|
|
646
|
+
...(binding.migrationsDir || binding.migrations_dir
|
|
647
|
+
? { migrations_dir: binding.migrationsDir || binding.migrations_dir }
|
|
648
|
+
: {}),
|
|
649
|
+
}));
|
|
650
|
+
const targetName = deployTargetName(t, env);
|
|
651
|
+
const existingTarget = existingDeployTargets.get(`${env}:${targetName}`) || {};
|
|
652
|
+
const preservedCloudControlFields = Object.fromEntries(
|
|
653
|
+
cloudControlOnlyFields
|
|
654
|
+
.filter((field) => Object.prototype.hasOwnProperty.call(existingTarget, field))
|
|
655
|
+
.map((field) => [field, existingTarget[field]])
|
|
656
|
+
);
|
|
657
|
+
cc.deploy_targets.push({
|
|
658
|
+
kind: t.kind,
|
|
659
|
+
environment: env,
|
|
660
|
+
target_name: targetName,
|
|
661
|
+
cloudflare_account_id: manifest.environments[env].accountId,
|
|
662
|
+
config_path: targetConfigPath(t),
|
|
663
|
+
workdir: t.dir || '.',
|
|
664
|
+
build_command: t.buildCommand !== undefined ? (valueForEnv(t.buildCommand, env) ?? null) : null,
|
|
665
|
+
output_dir: null,
|
|
666
|
+
production_branch: env === 'production' ? 'main' : env,
|
|
667
|
+
healthcheck_path: t.healthcheckPath || '/',
|
|
668
|
+
domains: reportedHost ? [reportedHost] : [],
|
|
669
|
+
routes: reportedRoutes,
|
|
670
|
+
...(reportedHost ? { api_url: `https://${reportedHost}` } : {}),
|
|
671
|
+
...(Array.isArray(t.requiredSecrets) && t.requiredSecrets.length
|
|
672
|
+
? { required_secrets: t.requiredSecrets }
|
|
673
|
+
: {}),
|
|
674
|
+
...(d1Migrations.length ? { migrations: { d1: d1Migrations } } : {}),
|
|
675
|
+
...preservedCloudControlFields,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Preserve externally-managed deploy targets the manifest doesn't drive
|
|
681
|
+
// (e.g. Astro's generated dist/server/wrangler.json runtime). Identified by a
|
|
682
|
+
// config_path no manifest target produces.
|
|
683
|
+
const manifestConfigPaths = new Set(
|
|
684
|
+
Object.values(manifest.targets).filter((t) => t.kind !== 'none').map((t) => targetConfigPath(t))
|
|
685
|
+
);
|
|
686
|
+
for (const dt of (existing.deploy_targets || [])) {
|
|
687
|
+
if (!ENVS.includes(dt.environment)) continue;
|
|
688
|
+
if (manifestConfigPaths.has(dt.config_path)) continue;
|
|
689
|
+
// Only preserve configs GENERATED by another build tool (live under a build
|
|
690
|
+
// output dir, e.g. Astro's dist/server/wrangler.json). Hand-authored configs
|
|
691
|
+
// we manage (wrangler.toml / wrangler.jsonc at source paths) must NOT be
|
|
692
|
+
// preserved — otherwise a jsonc→toml conversion leaves a stale jsonc target.
|
|
693
|
+
const p = dt.config_path || '';
|
|
694
|
+
if (!/\/dist\/|^dist\//.test(p)) continue;
|
|
695
|
+
cc.deploy_targets.push(dt);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
cc.resource_bindings = [];
|
|
699
|
+
for (const env of ENVS) {
|
|
700
|
+
const accountId = manifest.environments[env].accountId;
|
|
701
|
+
for (const [, t] of Object.entries(manifest.targets)) {
|
|
702
|
+
if (t.kind === 'none') continue;
|
|
703
|
+
if (t.environments && !t.environments.includes(env)) continue;
|
|
704
|
+
if (!t.bindings) continue;
|
|
705
|
+
for (const d of t.bindings.d1 || []) {
|
|
706
|
+
if (!bindingAppliesToEnv(d, env) || !d[env]) continue;
|
|
707
|
+
cc.resource_bindings.push({
|
|
708
|
+
resource_type: 'd1',
|
|
709
|
+
binding: d.binding,
|
|
710
|
+
environment: env,
|
|
711
|
+
account_id: accountId,
|
|
712
|
+
resource_name: d[env].databaseName,
|
|
713
|
+
resource_id: d[env].databaseId ?? null,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
for (const r of t.bindings.r2 || []) {
|
|
717
|
+
if (!bindingAppliesToEnv(r, env) || !r[env]) continue;
|
|
718
|
+
cc.resource_bindings.push({
|
|
719
|
+
resource_type: 'r2',
|
|
720
|
+
binding: r.binding,
|
|
721
|
+
environment: env,
|
|
722
|
+
account_id: accountId,
|
|
723
|
+
resource_name: r[env].bucketName,
|
|
724
|
+
resource_id: null,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
for (const kv of t.bindings.kv || []) {
|
|
728
|
+
if (!bindingAppliesToEnv(kv, env) || !kv[env]) continue;
|
|
729
|
+
cc.resource_bindings.push({
|
|
730
|
+
resource_type: 'kv',
|
|
731
|
+
binding: kv.binding,
|
|
732
|
+
environment: env,
|
|
733
|
+
account_id: accountId,
|
|
734
|
+
resource_name: null,
|
|
735
|
+
resource_id: kv[env].id,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
for (const q of (t.bindings.queues?.producers || [])) {
|
|
739
|
+
if (!bindingAppliesToEnv(q, env) || !q[env]) continue;
|
|
740
|
+
cc.resource_bindings.push({
|
|
741
|
+
resource_type: 'queue',
|
|
742
|
+
binding: q.binding,
|
|
743
|
+
environment: env,
|
|
744
|
+
account_id: accountId,
|
|
745
|
+
resource_name: q[env].queue,
|
|
746
|
+
resource_id: null,
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
for (const a of (t.bindings.analyticsEngine || [])) {
|
|
750
|
+
if (!bindingAppliesToEnv(a, env)) continue;
|
|
751
|
+
const dataset = a[env]?.dataset ?? a.dataset;
|
|
752
|
+
if (!dataset) continue;
|
|
753
|
+
cc.resource_bindings.push({
|
|
754
|
+
resource_type: 'analytics_engine',
|
|
755
|
+
binding: a.binding,
|
|
756
|
+
environment: env,
|
|
757
|
+
account_id: accountId,
|
|
758
|
+
resource_name: dataset,
|
|
759
|
+
resource_id: null,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
for (const v of (t.bindings.vectorize || [])) {
|
|
763
|
+
if (!bindingAppliesToEnv(v, env)) continue;
|
|
764
|
+
const indexName = v[env]?.indexName ?? v.indexName;
|
|
765
|
+
if (!indexName) continue;
|
|
766
|
+
cc.resource_bindings.push({
|
|
767
|
+
resource_type: 'vectorize',
|
|
768
|
+
binding: v.binding,
|
|
769
|
+
environment: env,
|
|
770
|
+
account_id: accountId,
|
|
771
|
+
resource_name: indexName,
|
|
772
|
+
resource_id: null,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
cc.resource_bindings = Array.from(new Map(cc.resource_bindings.map((entry) => [
|
|
779
|
+
[
|
|
780
|
+
entry.resource_type,
|
|
781
|
+
entry.binding,
|
|
782
|
+
entry.environment,
|
|
783
|
+
entry.account_id,
|
|
784
|
+
entry.resource_name,
|
|
785
|
+
entry.resource_id,
|
|
786
|
+
].join('\0'),
|
|
787
|
+
entry,
|
|
788
|
+
])).values());
|
|
789
|
+
|
|
790
|
+
cc.ci_workflows = existing.ci_workflows || [];
|
|
791
|
+
// Reconcile ci_targets' deploy_targets references to the canonical target
|
|
792
|
+
// names. Production targets are suffixed `-production`; a preserved ci_target
|
|
793
|
+
// that still names the bare worker (the pre-canonicalization prod name) would
|
|
794
|
+
// make `standard_ci plan` reject the deploy ("references unknown deploy
|
|
795
|
+
// target"). Map bare -> `<name>-production` when that resolves to a real target.
|
|
796
|
+
{
|
|
797
|
+
const validTargetNames = new Set(cc.deploy_targets.map((d) => d.target_name));
|
|
798
|
+
const coveredTargetNames = new Set();
|
|
799
|
+
cc.ci_targets = (existing.ci_targets || []).map((ct) => {
|
|
800
|
+
if (!ct || !Array.isArray(ct.deploy_targets)) return ct;
|
|
801
|
+
const deployTargets = ct.deploy_targets
|
|
802
|
+
.map((nm) => {
|
|
803
|
+
if (validTargetNames.has(nm)) return nm;
|
|
804
|
+
if (manifest.governed === true && typeof nm === 'string' && nm.endsWith('-production')) {
|
|
805
|
+
const bareName = nm.slice(0, -'-production'.length);
|
|
806
|
+
if (validTargetNames.has(bareName)) return bareName;
|
|
807
|
+
}
|
|
808
|
+
if (validTargetNames.has(`${nm}-production`)) return `${nm}-production`;
|
|
809
|
+
// Drop references to targets this manifest doesn't emit — e.g.
|
|
810
|
+
// TopoloWeb's Astro `web-runtime`, which is excluded from the
|
|
811
|
+
// generator and deploys via its own wrangler.jsonc + CI. Leaving it
|
|
812
|
+
// would make standard_ci plan reject the whole deploy.
|
|
813
|
+
return null;
|
|
814
|
+
})
|
|
815
|
+
.filter(Boolean);
|
|
816
|
+
for (const targetName of deployTargets) coveredTargetNames.add(targetName);
|
|
817
|
+
return {
|
|
818
|
+
...ct,
|
|
819
|
+
deploy_targets: deployTargets,
|
|
820
|
+
};
|
|
821
|
+
});
|
|
822
|
+
const firstCiTarget = cc.ci_targets.find((ct) => ct && Array.isArray(ct.deploy_targets));
|
|
823
|
+
const ownsDeployTarget = (ciTarget, deployTarget) => {
|
|
824
|
+
if (!ciTarget?.path || !deployTarget) return false;
|
|
825
|
+
const ciPath = `${ciTarget.path}`.replace(/\/+$/, '');
|
|
826
|
+
const workdir = `${deployTarget.workdir || ''}`.replace(/\/+$/, '');
|
|
827
|
+
const configPath = `${deployTarget.config_path || ''}`;
|
|
828
|
+
return workdir === ciPath || workdir.startsWith(`${ciPath}/`) || configPath.startsWith(`${ciPath}/`);
|
|
829
|
+
};
|
|
830
|
+
const deployTargetByName = new Map(cc.deploy_targets.map((target) => [target.target_name, target]));
|
|
831
|
+
const missingGeneratedTargets = cc.deploy_targets
|
|
832
|
+
.map((target) => target.target_name)
|
|
833
|
+
.filter((targetName) => validTargetNames.has(targetName) && !coveredTargetNames.has(targetName));
|
|
834
|
+
for (const targetName of missingGeneratedTargets) {
|
|
835
|
+
const deployTarget = deployTargetByName.get(targetName);
|
|
836
|
+
const owningCiTarget = cc.ci_targets.find((ct) =>
|
|
837
|
+
ct && Array.isArray(ct.deploy_targets) && ownsDeployTarget(ct, deployTarget)
|
|
838
|
+
) || firstCiTarget;
|
|
839
|
+
if (owningCiTarget) {
|
|
840
|
+
owningCiTarget.deploy_targets = [
|
|
841
|
+
...owningCiTarget.deploy_targets,
|
|
842
|
+
targetName,
|
|
843
|
+
];
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
cc.tags = existing.tags || ['application', 'cloudflare', 'github-actions', 'javascript'];
|
|
848
|
+
cc.cloudflare_account_id = manifest.environments.production.accountId;
|
|
849
|
+
|
|
850
|
+
return JSON.stringify(cc, null, 2) + '\n';
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function firstPublicTarget(env) {
|
|
854
|
+
if (manifest.primaryTarget) {
|
|
855
|
+
const target = manifest.targets[manifest.primaryTarget];
|
|
856
|
+
if (!target) fail(`primaryTarget references unknown target: ${manifest.primaryTarget}`);
|
|
857
|
+
if (target.kind === 'none') return null;
|
|
858
|
+
if (targetsIncludingEnv(target, env) && primaryHost(target, env)) return target;
|
|
859
|
+
}
|
|
860
|
+
return Object.values(manifest.targets).filter((t) => t.kind !== 'none' && targetsIncludingEnv(t, env)).find((t) => primaryHost(t, env))
|
|
861
|
+
|| Object.values(manifest.targets).find((t) => t.kind !== 'none' && targetsIncludingEnv(t, env))
|
|
862
|
+
|| null;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// --------------------------------------------------------------------------
|
|
866
|
+
// Write outputs
|
|
867
|
+
// --------------------------------------------------------------------------
|
|
868
|
+
const outputs = [];
|
|
869
|
+
for (const [name, target] of Object.entries(manifest.targets)) {
|
|
870
|
+
if (target.kind === 'none') continue;
|
|
871
|
+
if (target.generateConfig === false) continue;
|
|
872
|
+
const dir = resolve(CWD, target.dir);
|
|
873
|
+
const outPath = join(dir, target.configFile || 'wrangler.toml');
|
|
874
|
+
outputs.push({ path: outPath, content: emitWranglerToml(name, target), kind: 'wrangler', target: name });
|
|
875
|
+
}
|
|
876
|
+
outputs.push({ path: resolve(CWD, 'topolo.cloudcontrol.json'), content: emitCloudcontrol(), kind: 'cloudcontrol' });
|
|
877
|
+
|
|
878
|
+
if (DRY_RUN) {
|
|
879
|
+
for (const o of outputs) {
|
|
880
|
+
console.log(`\n=== ${relative(CWD, o.path)} (${o.kind}${o.target ? ':' + o.target : ''}) ===`);
|
|
881
|
+
process.stdout.write(o.content);
|
|
882
|
+
}
|
|
883
|
+
process.exit(0);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (CHECK) {
|
|
887
|
+
let drift = false;
|
|
888
|
+
for (const o of outputs) {
|
|
889
|
+
if (!existsSync(o.path)) {
|
|
890
|
+
console.error(`[generate --check] missing: ${relative(CWD, o.path)}`);
|
|
891
|
+
drift = true;
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
const cur = readFileSync(o.path, 'utf8');
|
|
895
|
+
if (cur !== o.content) {
|
|
896
|
+
console.error(`[generate --check] drift in ${relative(CWD, o.path)}`);
|
|
897
|
+
const tmp = `/tmp/genwrangler-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
898
|
+
writeFileSync(tmp, o.content);
|
|
899
|
+
const diff = spawnSync('diff', ['-u', o.path, tmp], { encoding: 'utf8' });
|
|
900
|
+
if (diff.stdout) process.stderr.write(diff.stdout);
|
|
901
|
+
drift = true;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
process.exit(drift ? 1 : 0);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
for (const o of outputs) {
|
|
908
|
+
mkdirSync(dirname(o.path), { recursive: true });
|
|
909
|
+
writeFileSync(o.path, o.content, 'utf8');
|
|
910
|
+
console.log(`[generate] wrote ${relative(CWD, o.path)}`);
|
|
911
|
+
}
|