@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/README.md +3 -1
- package/SECURITY.md +9 -1
- package/core/backend-io.ts +231 -0
- package/core/backend.ts +318 -0
- package/core/deploy-io.ts +279 -7
- package/core/deploy.ts +13 -0
- package/core/index.ts +2 -0
- package/core/scaffold.ts +7 -0
- package/dist/tl.js +499 -22
- package/extensions/thought-layer.ts +11 -7
- package/package.json +3 -1
- package/prompts/tl-build.md +1 -1
- package/prompts/tl-deploy.md +1 -1
- package/skills/thought-layer-build/SKILL.md +26 -3
- package/skills/thought-layer-deploy/SKILL.md +9 -3
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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.${
|
|
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,
|
|
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,
|
|
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",
|