@hobocode/thought-layer 0.4.2 → 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/core/deploy-io.ts CHANGED
@@ -25,6 +25,10 @@ import {
25
25
  buildFileDigests, uploadPath, sanitizeSiteName, parseCliDeployOutput, deployRecord,
26
26
  type FileMap, type DeployRecord,
27
27
  } from "./deploy.ts";
28
+ import { normalizeBackendMeta, planEnvVars, type BackendMeta } from "./backend.ts";
29
+ import {
30
+ pushEnvVarsApi, cliImportEnv, resolveDbUrl, provisionNeon, applySchema, type EnvPushResult,
31
+ } from "./backend-io.ts";
28
32
 
29
33
  const NETLIFY_API = "https://api.netlify.com/api/v1";
30
34
 
@@ -34,6 +38,9 @@ export interface DeployRunOptions {
34
38
  anonymous?: boolean; // force the no-account CLI path even if a token is set
35
39
  siteName?: string; // create the site under this name (else Netlify auto-names)
36
40
  siteId?: string; // deploy to an existing site (re-deploy) instead of creating one
41
+ staticOnly?: boolean; // ship only the front end even when build.json has a backend
42
+ provisionDb?: boolean; // opt in: provision Neon with the user's own NEON_API_KEY
43
+ applySchema?: boolean; // opt in: apply schema.sql with psql after the DB is reachable
37
44
  }
38
45
 
39
46
  // ---- locate + read the build manifest ----------------------------------------
@@ -241,6 +248,78 @@ function cliDeploy(publishDirAbs: string, opts: { siteName?: string; siteId?: st
241
248
  return { url: parsed.url, claimUrl: loggedIn ? null : parsed.claimUrl, owned: loggedIn, siteName, raw };
242
249
  }
243
250
 
251
+ // ---- backend deploy helpers (functions ship via the user's Netlify CLI) ------
252
+
253
+ // Resolve the functions dir from build.json (relative to the project root, then
254
+ // cwd). Returns null when it is not on disk so the caller can ship the front end
255
+ // only and say so, rather than failing.
256
+ function resolveFunctionsDir(functionsDir: string, projectRoot: string): string | null {
257
+ const candidates = [resolve(projectRoot, functionsDir), resolve(process.cwd(), functionsDir)];
258
+ for (const c of candidates) if (existsSync(c)) return c;
259
+ return null;
260
+ }
261
+
262
+ // Count the top-level function entries (each file or directory is one function),
263
+ // for the dry-run plan only.
264
+ function countFunctionFiles(dir: string): number {
265
+ try {
266
+ return readdirSync(dir).filter((n) => !n.startsWith(".")).length;
267
+ } catch {
268
+ return 0;
269
+ }
270
+ }
271
+
272
+ // Create a site in the user's own account via the API (token path).
273
+ async function createSiteApi(token: string, name?: string): Promise<{ id: string; slug: string; adminUrl: string; url: string }> {
274
+ const body = name ? JSON.stringify({ name: sanitizeSiteName(name) }) : JSON.stringify({});
275
+ const site = await netlifyJson(`${NETLIFY_API}/sites`, { method: "POST", headers: { "Content-Type": "application/json" }, body }, token);
276
+ return {
277
+ id: String(site["id"] || ""),
278
+ slug: String(site["account_slug"] || site["account_id"] || ""),
279
+ adminUrl: String(site["admin_url"] || ""),
280
+ url: String(site["ssl_url"] || site["url"] || ""),
281
+ };
282
+ }
283
+
284
+ // Create a site via the Netlify CLI's own stored auth (no-token path), so the
285
+ // site id is known before env import and deploy.
286
+ function createSiteCli(name?: string): { id: string } {
287
+ const payload = JSON.stringify(name ? { name: sanitizeSiteName(name) } : {});
288
+ const r = spawnSync("netlify", ["api", "createSite", "--data", payload], { encoding: "utf8", timeout: 60000 });
289
+ if (r.status !== 0) {
290
+ throw new Error(`netlify api createSite failed (exit ${r.status}): ${(r.stderr || r.stdout || "").slice(0, 300)}`);
291
+ }
292
+ let id = "";
293
+ try { id = String((JSON.parse(r.stdout || "{}") as Record<string, unknown>)["id"] || ""); } catch { /* unparseable */ }
294
+ if (!id) throw new Error("could not parse the new site id from the Netlify CLI");
295
+ return { id };
296
+ }
297
+
298
+ // Deploy the static dir plus the functions in one CLI invocation, targeting a
299
+ // known site id. Drives functions explicitly with --functions (independent of
300
+ // netlify.toml). --no-build keeps the CLI from re-running a framework build of
301
+ // the already built static dir. NOTE (live-verify gate): if a real run shows
302
+ // --no-build suppresses function bundling, drop it for backend deploys.
303
+ function cliDeployWithFunctions(
304
+ publishDirAbs: string,
305
+ functionsDirAbs: string | null,
306
+ siteId: string,
307
+ token: string,
308
+ ): { url: string | null; raw: string } {
309
+ const args = [
310
+ "deploy", "--prod", "--dir", publishDirAbs, "--no-build",
311
+ ...(functionsDirAbs ? ["--functions", functionsDirAbs] : []),
312
+ "--site", siteId,
313
+ ];
314
+ const childEnv = token ? { ...process.env, NETLIFY_AUTH_TOKEN: token } : process.env;
315
+ const r = spawnSync("netlify", args, { encoding: "utf8", timeout: 300000, env: childEnv });
316
+ const raw = `${r.stdout || ""}\n${r.stderr || ""}`.trim();
317
+ if (r.status !== 0) {
318
+ throw new Error(`netlify ${args.join(" ")} failed (exit ${r.status}). Output:\n${raw.slice(0, 800)}`);
319
+ }
320
+ return { url: parseCliDeployOutput(raw).url, raw };
321
+ }
322
+
244
323
  // ---- the orchestrator (mirrors runScaffold's result shape) -------------------
245
324
 
246
325
  export async function runDeploy(opts: DeployRunOptions, ctx: { deployedAt: string }): Promise<StateOpResult> {
@@ -257,9 +336,30 @@ export async function runDeploy(opts: DeployRunOptions, ctx: { deployedAt: strin
257
336
  return { ok: false, message: `Publish dir ${publishDirAbs} is empty - nothing to deploy.`, details: {} };
258
337
  }
259
338
 
260
- const backendWarn = manifest.hasBackend
261
- ? ` WARNING: build.json says hasBackend:true${manifest.backendNote ? ` (${manifest.backendNote})` : ""}; ` +
262
- `this static deploy publishes only the front end - the server part needs serverless functions or a separate host.`
339
+ // build.json may predate the backend block, so normalize defensively.
340
+ const backend = normalizeBackendMeta(manifest.backend);
341
+ // Ship the backend automatically when the build declares a serverless one,
342
+ // unless the caller forced a static-only deploy. Static deploys with no
343
+ // backend are byte-for-byte unchanged (backend is null, shipBackend false).
344
+ const shipBackend = manifest.hasBackend && backend?.backendKind === "serverless" && !opts.staticOnly;
345
+
346
+ // The note shown only when there is a backend that this deploy will NOT ship
347
+ // (static-only by request, or a non-serverless backend we cannot automate).
348
+ const backendWarn = manifest.hasBackend && !shipBackend
349
+ ? (() => {
350
+ const guide = backend?.guide || "BACKEND.md";
351
+ const dbEnv = backend?.database?.envVar || "DATABASE_URL";
352
+ const names = (backend?.envVars || []).map((v) => v.name).filter(Boolean);
353
+ const others = names.filter((n) => n !== dbEnv);
354
+ const envList = others.length ? `${dbEnv} plus ${others.join(", ")}` : dbEnv;
355
+ const lead = opts.staticOnly
356
+ ? `\n\nStatic only: the front end is live, the backend was not shipped (you passed --static-only). Re-run without it to ship the backend.`
357
+ : `\n\nStatic deploy: the front end is live. This build also declares a backend that this deploy cannot ship automatically.`;
358
+ return (
359
+ `${lead} To run the backend, follow ${guide}: provision Neon Postgres, set ${envList} in your host environment, ` +
360
+ `then run netlify deploy with the functions present.`
361
+ );
362
+ })()
263
363
  : "";
264
364
 
265
365
  const token = process.env.NETLIFY_AUTH_TOKEN || process.env.NETLIFY_TOKEN || "";
@@ -274,15 +374,45 @@ export async function runDeploy(opts: DeployRunOptions, ctx: { deployedAt: strin
274
374
  // --- dry run: plan only, no network, no spawn ---
275
375
  if (opts.dryRun) {
276
376
  const { digests } = buildFileDigests(files);
377
+ let backendPlanMsg = backendWarn;
378
+ let backendPlan: Record<string, unknown> | null = null;
379
+ if (shipBackend && backend) {
380
+ // Names + counts only; never read or print an env value.
381
+ const fnDir = resolveFunctionsDir(backend.functionsDir, dirname(dirname(stateFile)));
382
+ const fnCount = fnDir ? countFunctionFiles(fnDir) : 0;
383
+ const plan = planEnvVars(backend);
384
+ const names = plan.map((p) => p.name);
385
+ const missing = names.filter((n) => !(typeof process.env[n] === "string" && process.env[n] !== ""));
386
+ const db = resolveDbUrl(process.env);
387
+ const path = token ? "Netlify CLI for functions plus the token API for env" : "Netlify CLI (logged in) for functions and env import";
388
+ backendPlanMsg =
389
+ `\n\nBackend plan: ship ${fnCount} function${fnCount === 1 ? "" : "s"} from ${backend.functionsDir} via the ${path}. ` +
390
+ `Env var names (${names.length}): ${names.join(", ") || "none"}.` +
391
+ `${missing.length ? ` Missing from this environment: ${missing.join(", ")}.` : ""}` +
392
+ `${db.name ? ` Database url from ${db.name}.` : " No database url found (set DATABASE_URL, or use --provision-db)."}` +
393
+ `${opts.provisionDb ? " Would provision Neon (--provision-db)." : ""}` +
394
+ `${opts.applySchema ? " Would apply schema.sql (--apply-schema)." : ""}`;
395
+ backendPlan = { functionsDir: backend.functionsDir, functionCount: fnCount, envVarNames: names, envVarsMissing: missing, dbUrlFrom: db.name, provisionDb: !!opts.provisionDb, applySchema: !!opts.applySchema };
396
+ }
277
397
  return {
278
398
  ok: true,
279
399
  message:
280
400
  `Dry run: would deploy ${fileCount} files from ${publishDirAbs} (entry ${manifest.entry}) to Netlify ` +
281
- `via the ${token && !opts.anonymous ? "BYO-token digest" : "Netlify CLI (logged in -> a site in your account; logged out -> an anonymous claimable site)"} path.${backendWarn}`,
282
- details: { dryRun: true, publishDir: publishDirAbs, entry: manifest.entry, fileCount, files: Object.keys(digests), hasBackend: manifest.hasBackend },
401
+ `via the ${token && !opts.anonymous ? "BYO-token digest" : "Netlify CLI (logged in -> a site in your account; logged out -> an anonymous claimable site)"} path.${backendPlanMsg}`,
402
+ details: { dryRun: true, publishDir: publishDirAbs, entry: manifest.entry, fileCount, files: Object.keys(digests), hasBackend: manifest.hasBackend, shipBackend, backendPlan },
283
403
  };
284
404
  }
285
405
 
406
+ // --- backend deploy: ship functions + env into the user's own account ---
407
+ if (shipBackend && backend) {
408
+ return runBackendDeploy(
409
+ { manifest, backend, publishDirAbs, stateFile, fileCount, files },
410
+ opts,
411
+ ctx,
412
+ { token, writeRecord },
413
+ );
414
+ }
415
+
286
416
  // --- CLI path: explicit (--anonymous), or the fallback when no env token ---
287
417
  const wantCli = opts.anonymous || !token;
288
418
  if (wantCli) {
@@ -318,7 +448,8 @@ export async function runDeploy(opts: DeployRunOptions, ctx: { deployedAt: strin
318
448
  deployRecord({
319
449
  deployedAt: ctx.deployedAt, mode: owned ? "cli" : "anonymous", publishDir: manifest.publishDir, fileCount,
320
450
  url, adminUrl: null, claimUrl, siteId: null, deployId: null,
321
- hasBackend: manifest.hasBackend, backendNote: manifest.backendNote, buildProducer: manifest.producer, stateFile,
451
+ hasBackend: manifest.hasBackend, backendNote: manifest.backendNote, backendKind: backend?.backendKind ?? null,
452
+ buildProducer: manifest.producer, stateFile,
322
453
  }),
323
454
  );
324
455
  // If anonymity was explicitly asked for but the CLI is logged in, it went
@@ -349,7 +480,8 @@ export async function runDeploy(opts: DeployRunOptions, ctx: { deployedAt: strin
349
480
  deployRecord({
350
481
  deployedAt: ctx.deployedAt, mode: "token", publishDir: manifest.publishDir, fileCount,
351
482
  url: r.url || null, adminUrl: r.adminUrl || null, claimUrl: null, siteId: r.siteId, deployId: r.deployId,
352
- hasBackend: manifest.hasBackend, backendNote: manifest.backendNote, buildProducer: manifest.producer, stateFile,
483
+ hasBackend: manifest.hasBackend, backendNote: manifest.backendNote, backendKind: backend?.backendKind ?? null,
484
+ buildProducer: manifest.producer, stateFile,
353
485
  }),
354
486
  );
355
487
  return {
@@ -365,3 +497,143 @@ export async function runDeploy(opts: DeployRunOptions, ctx: { deployedAt: strin
365
497
  return { ok: false, message: `Deploy failed: ${(e as Error).message}`, details: { mode: "token" } };
366
498
  }
367
499
  }
500
+
501
+ // Ship the backend (functions + env vars) into the user's own account. Functions
502
+ // are bundled and shipped by the user's Netlify CLI (the digest API cannot bundle
503
+ // TypeScript), so this forces the CLI deploy mechanism even when a token is set;
504
+ // the token, when present, is used for the secret-capable env API and to authorize
505
+ // the CLI non-interactively (owned, no claim). Env var VALUES are read only from
506
+ // process.env inside the backend-io helpers and are never recorded or printed.
507
+ async function runBackendDeploy(
508
+ build: { manifest: BuildManifest; backend: BackendMeta; publishDirAbs: string; stateFile: string; fileCount: number; files: FileMap },
509
+ opts: DeployRunOptions,
510
+ ctx: { deployedAt: string },
511
+ io: { token: string; writeRecord: (rec: DeployRecord) => string },
512
+ ): Promise<StateOpResult> {
513
+ const { manifest, backend, publishDirAbs, stateFile, fileCount, files } = build;
514
+ const { token, writeRecord } = io;
515
+ const projectRoot = dirname(dirname(stateFile));
516
+ const plan = planEnvVars(backend);
517
+ const guide = backend.guide || "BACKEND.md";
518
+ const notes: string[] = [];
519
+
520
+ // Opt in: provision Neon with the user's own key, so the DB url is known.
521
+ let dbUrl = resolveDbUrl(process.env).value;
522
+ let dbProvisioned = false;
523
+ if (opts.provisionDb) {
524
+ const pr = await provisionNeon(process.env);
525
+ notes.push(pr.note);
526
+ if (pr.provisioned && pr.url) { dbUrl = pr.url; dbProvisioned = true; }
527
+ }
528
+
529
+ const functionsDirAbs = resolveFunctionsDir(backend.functionsDir, projectRoot);
530
+
531
+ // No CLI means functions cannot be bundled. Do not half-deploy: with a token,
532
+ // still take the front end live and push env via the API; without one, stop
533
+ // and guide. functionsShipped stays false either way.
534
+ if (!hasNetlifyCli()) {
535
+ if (!token) {
536
+ return {
537
+ ok: false,
538
+ message:
539
+ `This build has a backend, which needs the Netlify CLI to bundle and ship the functions, and the CLI is not installed. ` +
540
+ `Install it (npm i -g netlify-cli@latest) and re-run, or set NETLIFY_AUTH_TOKEN to at least take the front end live, or follow ${guide}.`,
541
+ details: { backendMode: "static-only-fallback", functionsShipped: false, needs: "netlify-cli" },
542
+ };
543
+ }
544
+ try {
545
+ const r = await digestDeploy(files, { token, siteName: opts.siteName, siteId: opts.siteId });
546
+ let env: EnvPushResult | null = null;
547
+ try { env = await pushEnvVarsApi(r.siteId, token, plan, process.env); }
548
+ catch (e) { notes.push(`env push failed: ${(e as Error).message}`); }
549
+ const recPath = writeRecord(deployRecord({
550
+ deployedAt: ctx.deployedAt, mode: "token", publishDir: manifest.publishDir, fileCount,
551
+ url: r.url || null, adminUrl: r.adminUrl || null, claimUrl: null, siteId: r.siteId, deployId: r.deployId,
552
+ hasBackend: true, backendNote: manifest.backendNote, backendKind: backend.backendKind,
553
+ backendMode: "static-only-fallback", functionsShipped: false, functionsDir: backend.functionsDir,
554
+ envVarsSet: env?.set ?? [], envVarsMissing: env?.missing ?? plan.map((p) => p.name),
555
+ dbProvisioned, schemaApplied: false, buildProducer: manifest.producer, stateFile,
556
+ }));
557
+ return {
558
+ ok: true,
559
+ message:
560
+ `Deployed the static front end to your Netlify account${r.url ? ` (live: ${r.url})` : ""}. ` +
561
+ `The functions were NOT shipped: the Netlify CLI bundles them and it is not installed. ` +
562
+ `Install it (npm i -g netlify-cli@latest) and re-run to ship the backend, or follow ${guide}.` +
563
+ (env?.set.length ? `\nSet env var names: ${env.set.join(", ")}.` : "") +
564
+ (env?.missing.length ? `\nDeclared but missing from this environment: ${env.missing.join(", ")}.` : "") +
565
+ `\nRecorded ${recPath}.${notes.length ? `\n${notes.join("\n")}` : ""}`,
566
+ details: { mode: "token", backendMode: "static-only-fallback", functionsShipped: false, url: r.url, siteId: r.siteId, envVarsSet: env?.set, envVarsMissing: env?.missing },
567
+ };
568
+ } catch (e) {
569
+ return { ok: false, message: `Static front end deploy failed: ${(e as Error).message}`, details: { backendMode: "static-only-fallback" } };
570
+ }
571
+ }
572
+
573
+ if (!functionsDirAbs) {
574
+ notes.push(`functions dir "${backend.functionsDir}" was not found on disk; shipping the front end only`);
575
+ }
576
+
577
+ // Resolve or create the target site so env can be set against a known id.
578
+ let siteId = opts.siteId || "";
579
+ let accountSlug: string | undefined;
580
+ try {
581
+ if (!siteId) {
582
+ if (token) { const s = await createSiteApi(token, opts.siteName); siteId = s.id; accountSlug = s.slug; }
583
+ else { siteId = createSiteCli(opts.siteName).id; }
584
+ }
585
+ } catch (e) {
586
+ return { ok: false, message: `Could not create the Netlify site for the backend deploy: ${(e as Error).message}`, details: {} };
587
+ }
588
+
589
+ // Push env vars (API when a token is present, else CLI import).
590
+ let env: EnvPushResult;
591
+ try {
592
+ env = token
593
+ ? await pushEnvVarsApi(siteId, token, plan, process.env, accountSlug)
594
+ : cliImportEnv(plan, process.env, siteId);
595
+ } catch (e) {
596
+ env = { method: token ? "api" : "cli", set: [], missing: plan.map((p) => p.name), note: `env push failed: ${(e as Error).message}` };
597
+ notes.push(env.note);
598
+ }
599
+
600
+ // Opt in: apply schema.sql now that the database url is known.
601
+ let schemaApplied = false;
602
+ if (opts.applySchema) {
603
+ const schemaPath = resolve(projectRoot, backend.database?.schemaFile || "schema.sql");
604
+ const sr = applySchema(schemaPath, dbUrl, process.env);
605
+ schemaApplied = sr.applied;
606
+ notes.push(sr.note);
607
+ }
608
+
609
+ // Deploy the static dir plus the functions in one CLI invocation.
610
+ let url: string | null = null;
611
+ try {
612
+ const d = cliDeployWithFunctions(publishDirAbs, functionsDirAbs, siteId, token);
613
+ url = d.url;
614
+ } catch (e) {
615
+ return { ok: false, message: `Backend deploy failed: ${(e as Error).message}${notes.length ? `\n${notes.join("\n")}` : ""}`, details: { siteId, envVarsSet: env.set } };
616
+ }
617
+ const functionsShipped = !!functionsDirAbs;
618
+
619
+ const recPath = writeRecord(deployRecord({
620
+ deployedAt: ctx.deployedAt, mode: "cli", publishDir: manifest.publishDir, fileCount,
621
+ url, adminUrl: null, claimUrl: null, siteId, deployId: null,
622
+ hasBackend: true, backendNote: manifest.backendNote, backendKind: backend.backendKind,
623
+ backendMode: "cli", functionsShipped, functionsDir: backend.functionsDir,
624
+ envVarsSet: env.set, envVarsMissing: env.missing, dbProvisioned, schemaApplied,
625
+ buildProducer: manifest.producer, stateFile,
626
+ }));
627
+ return {
628
+ ok: true,
629
+ message:
630
+ `Deployed your backend to your Netlify account via the CLI${url ? ` (live: ${url})` : ""}. ` +
631
+ `${functionsShipped ? `Functions shipped from ${backend.functionsDir}.` : `No functions were found on disk (${backend.functionsDir}); front end only.`} ` +
632
+ `It is owned by your account, no claim needed. Re-deploy to the same site with --site ${siteId}.` +
633
+ (env.set.length ? `\nSet env var names (${env.method}): ${env.set.join(", ")}.` : "") +
634
+ (env.missing.length ? `\nDeclared but missing from this environment (set them, then re-run): ${env.missing.join(", ")}.` : "") +
635
+ `\nRecorded ${recPath}.${notes.length ? `\n${notes.join("\n")}` : ""}` +
636
+ (!url ? `\n(Could not parse a live URL from the CLI output; re-run to confirm.)` : ""),
637
+ details: { mode: "cli", backendMode: "cli", url, siteId, functionsShipped, functionsDir: backend.functionsDir, envVarsSet: env.set, envVarsMissing: env.missing, dbProvisioned, schemaApplied },
638
+ };
639
+ }
package/core/deploy.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  // import and it is pure (content in, hex out).
11
11
 
12
12
  import { createHash } from "node:crypto";
13
+ import type { BackendKind } from "./backend.ts";
13
14
 
14
15
  export const sha1Hex = (data: Buffer | string): string =>
15
16
  createHash("sha1").update(data).digest("hex");
@@ -106,6 +107,18 @@ export interface DeployRecord {
106
107
  deployId: string | null;
107
108
  hasBackend: boolean;
108
109
  backendNote: string | null;
110
+ // Provenance of the backend payload from build.json (null for a static build).
111
+ backendKind?: BackendKind | null;
112
+ // Backend deploy provenance (all optional so static deploy.json records, and
113
+ // records written before this field set existed, round-trip unchanged). NEVER
114
+ // a secret value: env vars are recorded by NAME only.
115
+ backendMode?: "none" | "cli" | "static-only-fallback";
116
+ functionsShipped?: boolean;
117
+ functionsDir?: string | null;
118
+ envVarsSet?: string[]; // names only
119
+ envVarsMissing?: string[]; // names declared but absent from the deploy environment
120
+ dbProvisioned?: boolean;
121
+ schemaApplied?: boolean;
109
122
  buildProducer: "agent" | "scaffold" | null;
110
123
  stateFile: string | null;
111
124
  }
package/core/index.ts CHANGED
@@ -15,6 +15,8 @@ export * from "./stages.ts";
15
15
  export * from "./stage-map.ts";
16
16
  export * from "./state-file.ts";
17
17
  export * from "./state-ops.ts";
18
+ export * from "./backend.ts";
19
+ export * from "./backend-io.ts";
18
20
  export * from "./scaffold.ts";
19
21
  export * from "./scaffold-io.ts";
20
22
  export * from "./deploy.ts";
package/core/scaffold.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  // so this emits plain HTML. Pure: no fs, no Date - the caller supplies builtAt.
12
12
 
13
13
  import type { ProgressState } from "./progress.ts";
14
+ import type { BackendMeta } from "./backend.ts";
14
15
 
15
16
  export interface StarterSiteSpec {
16
17
  brandName: string;
@@ -45,6 +46,11 @@ export interface BuildManifest {
45
46
  stack: string;
46
47
  hasBackend: boolean;
47
48
  backendNote: string | null;
49
+ // The structured backend payload, present only when the build emitted a real
50
+ // backend (hasBackend true). Optional + nullable so existing static build.json
51
+ // files round-trip and the deploy reader tolerates its absence. The deploy
52
+ // automation follow-up consumes it; today only deploy messaging reads it.
53
+ backend?: BackendMeta | null;
48
54
  buildCommand: string | null;
49
55
  installCommand: string | null;
50
56
  nodeVersion: string;
@@ -264,6 +270,7 @@ export function scaffoldManifest(
264
270
  stack: "static",
265
271
  hasBackend: false,
266
272
  backendNote: null,
273
+ backend: null,
267
274
  buildCommand: null,
268
275
  installCommand: null,
269
276
  nodeVersion: "20",