@hobocode/thought-layer 0.5.0 → 0.6.1

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 CHANGED
@@ -70,7 +70,7 @@ The hosted version of the rigor lives at [weareallproductmanagersnow.com](https:
70
70
  - **Phase 3 (done):** a `build` step that turns the hardened PRD into a deploy-ready artifact, built by your own agent (`/tl-build`), with a deterministic `tl_scaffold` tool that writes an instantly-deployable branded static site as the floor.
71
71
  - **Phase 4 (done):** a `deploy` step (`/tl-deploy`, the `deploy` tool, or `tl deploy`) that takes the build live to a URL you own, closing the loop. With a Netlify token it deploys into your own account via the file-digest API (owned immediately, no claim step); with no token it delegates to your Netlify CLI - logged in it creates a site in your account, logged out it deploys anonymously with a one-hour claim link. BYOK, no central account, no lock-in. `--dry-run` shows the plan first.
72
72
  - **Backend-capable build (done):** when the three-question backend test shows a product genuinely needs a server, `/tl-build` emits a real backend alongside the static front end (serverless functions per backend requirement, a `schema.sql`, a names-only `.env.example`, an updated `netlify.toml`, and a `BACKEND.md` guide), with Neon Postgres as the documented default and overridable to any Postgres. Static stays the default, gated by the same backend test.
73
- - **Next:** automated backend deploy. `tl deploy` ships the static front end today and points at `BACKEND.md` for the one-time backend steps; teaching the deploy step to provision the database and ship the functions into your own account is the follow-up.
73
+ - **Backend deploy (done):** when `build.json` declares a backend, `/tl-deploy` (the `deploy` tool, or `tl deploy`) ships it automatically alongside the front end: the functions go up via your Netlify CLI and the declared env var names are set on the site (values read only from your environment, BYOK). `DATABASE_URL` is bring-your-own by default; `--provision-db` (your own Neon key) and `--apply-schema` (psql) are opt in, and `--static-only` ships just the front end. Owned, no lock-in.
74
74
 
75
75
  ## Notes for contributors
76
76
 
package/SECURITY.md CHANGED
@@ -13,7 +13,13 @@ follow from that.
13
13
  never written to disk. The `deploy.json` record stores the resulting URLs and
14
14
  ids, never the token. When a build emits a backend, the generated `.env.example`
15
15
  is a names-only contract (every line is a bare `NAME=`): real values live only
16
- in the host environment, never in a committed file.
16
+ in the host environment, never in a committed file. When the deploy sets those
17
+ env vars on your site, it reads the VALUES only from the deploy environment and
18
+ pushes them to your own Netlify account (in the API request body, or a
19
+ `0600`-mode temp file consumed by the Netlify CLI and deleted after); the
20
+ database connection string reaches `psql` through child-process environment
21
+ variables, never a command line. Values are never logged, never placed on a
22
+ command line, and the `deploy.json` record names env vars only, never values.
17
23
  - **Deploys go to your own account.** With a token, the deploy uses Netlify's
18
24
  file-digest API to publish into your account. With no token, it delegates to
19
25
  your installed Netlify CLI (a site in your account when logged in, or an
@@ -0,0 +1,231 @@
1
+ // Node IO for the backend deploy automation: push env vars to the user's own
2
+ // Netlify site, optionally provision Neon (the user's own key), and optionally
3
+ // apply schema.sql. The deploy orchestration lives in deploy-io.ts; this file
4
+ // holds the backend-specific side effects so deploy-io.ts stays focused and
5
+ // core/backend.ts stays pure.
6
+ //
7
+ // SECRET DISCIPLINE (the whole point): secret VALUES are read ONLY from the
8
+ // process environment inside these functions. A value never arrives as a
9
+ // function/CLI parameter from the user, never lands in a deploy.json field,
10
+ // never appears in a returned message, and never rides on a command line (argv).
11
+ // Env values travel in an HTTPS request body (API) or a 0600 temp file
12
+ // (CLI import); the database URL reaches psql through child-process env vars,
13
+ // not argv. Env vars are recorded by NAME only.
14
+ //
15
+ // Copy rule for any user-facing string here: no em-dashes, no en-dashes, no
16
+ // spaced hyphen dashes.
17
+
18
+ import { spawnSync } from "node:child_process";
19
+ import { mkdtempSync, writeFileSync, unlinkSync, existsSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import type { EnvVarPlanItem } from "./backend.ts";
23
+
24
+ const NETLIFY_API = "https://api.netlify.com/api/v1";
25
+ const NEON_API = "https://console.neon.tech/api/v2";
26
+
27
+ type Env = Record<string, string | undefined>;
28
+
29
+ export interface EnvPushResult {
30
+ method: "api" | "cli";
31
+ set: string[]; // names actually pushed (value present in env)
32
+ missing: string[]; // names declared but absent from the deploy environment
33
+ note: string;
34
+ }
35
+
36
+ // ---- env vars via the account-level Netlify API (token path) -----------------
37
+
38
+ // GET the site to learn its account_slug, then POST the env vars (values pulled
39
+ // from `env`) to /accounts/{slug}/env. The value lives only in the JSON body.
40
+ export async function pushEnvVarsApi(
41
+ siteId: string,
42
+ token: string,
43
+ plan: EnvVarPlanItem[],
44
+ env: Env,
45
+ accountSlug?: string,
46
+ ): Promise<EnvPushResult> {
47
+ const set: string[] = [];
48
+ const missing: string[] = [];
49
+ const body = plan
50
+ .filter((p) => {
51
+ const has = typeof env[p.name] === "string" && env[p.name] !== "";
52
+ (has ? set : missing).push(p.name);
53
+ return has;
54
+ })
55
+ .map((p) => ({
56
+ key: p.name,
57
+ scopes: p.scopes,
58
+ is_secret: p.isSecret,
59
+ values: [{ value: env[p.name] as string, context: p.context }],
60
+ }));
61
+
62
+ if (body.length === 0) {
63
+ return { method: "api", set, missing, note: "no declared env var had a value in the deploy environment; nothing pushed" };
64
+ }
65
+
66
+ const slug = accountSlug || (await getAccountSlug(siteId, token));
67
+ const res = await fetch(`${NETLIFY_API}/accounts/${encodeURIComponent(slug)}/env?site_id=${encodeURIComponent(siteId)}`, {
68
+ method: "POST",
69
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
70
+ body: JSON.stringify(body),
71
+ });
72
+ if (!res.ok) {
73
+ // Never echo the request body (it holds values); report status + names only.
74
+ throw new Error(`Netlify env API ${res.status} ${res.statusText} when setting ${set.join(", ")}`);
75
+ }
76
+ return { method: "api", set, missing, note: "set via the Netlify API (secret-capable, scoped to builds and functions)" };
77
+ }
78
+
79
+ async function getAccountSlug(siteId: string, token: string): Promise<string> {
80
+ const res = await fetch(`${NETLIFY_API}/sites/${encodeURIComponent(siteId)}`, {
81
+ headers: { Authorization: `Bearer ${token}` },
82
+ });
83
+ if (!res.ok) throw new Error(`Netlify site lookup ${res.status} for env push`);
84
+ const site = (await res.json()) as Record<string, unknown>;
85
+ const slug = String(site["account_slug"] || site["account_id"] || "");
86
+ if (!slug) throw new Error("could not resolve the account for env push");
87
+ return slug;
88
+ }
89
+
90
+ // ---- env vars via the Netlify CLI (no-token path) ----------------------------
91
+
92
+ // Write the values to a 0600 temp file (NAME=value), import them, and delete the
93
+ // file in a finally. The CLI cannot mark a var secret and applies all scopes;
94
+ // the API path is preferred when a token is present.
95
+ export function cliImportEnv(plan: EnvVarPlanItem[], env: Env, siteId?: string): EnvPushResult {
96
+ const set: string[] = [];
97
+ const missing: string[] = [];
98
+ const lines: string[] = [];
99
+ for (const p of plan) {
100
+ const v = env[p.name];
101
+ if (typeof v === "string" && v !== "") {
102
+ set.push(p.name);
103
+ lines.push(`${p.name}=${v}`);
104
+ } else {
105
+ missing.push(p.name);
106
+ }
107
+ }
108
+ if (lines.length === 0) {
109
+ return { method: "cli", set, missing, note: "no declared env var had a value in the deploy environment; nothing imported" };
110
+ }
111
+
112
+ const dir = mkdtempSync(join(tmpdir(), "tl-env-"));
113
+ const file = join(dir, ".env.import");
114
+ try {
115
+ writeFileSync(file, lines.join("\n") + "\n", { mode: 0o600 });
116
+ const args = ["env:import", "--force", file, ...(siteId ? ["--site", siteId] : [])];
117
+ const r = spawnSync("netlify", args, { encoding: "utf8", timeout: 60000 });
118
+ if (r.status !== 0) {
119
+ throw new Error(`netlify env:import failed (exit ${r.status}) for ${set.join(", ")}`);
120
+ }
121
+ return { method: "cli", set, missing, note: "imported via the Netlify CLI (applies all scopes; cannot mark secret)" };
122
+ } finally {
123
+ try { unlinkSync(file); } catch { /* best effort */ }
124
+ }
125
+ }
126
+
127
+ // ---- database url resolution (BYO, env only) ---------------------------------
128
+
129
+ export interface DbUrlResult {
130
+ name: string | null; // which env var name carried the value
131
+ value: string | null; // the connection string (in memory only, never recorded)
132
+ }
133
+
134
+ // Prefer the portable DATABASE_URL, then the names Netlify managed Neon injects.
135
+ export function resolveDbUrl(env: Env): DbUrlResult {
136
+ for (const name of ["DATABASE_URL", "NETLIFY_DATABASE_URL", "NETLIFY_DATABASE_URL_UNPOOLED"]) {
137
+ const v = env[name];
138
+ if (typeof v === "string" && v !== "") return { name, value: v };
139
+ }
140
+ return { name: null, value: null };
141
+ }
142
+
143
+ // ---- opt-in Neon provisioning (the user's own key) ---------------------------
144
+
145
+ export interface ProvisionResult {
146
+ provisioned: boolean;
147
+ url: string | null; // connection string in memory only
148
+ note: string;
149
+ }
150
+
151
+ // Create a Neon project with the user's OWN NEON_API_KEY (BYOK; no central
152
+ // account). Returns the connection string in memory. The default deploy path
153
+ // does NOT call this; it runs only behind the explicit --provision-db flag.
154
+ export async function provisionNeon(env: Env): Promise<ProvisionResult> {
155
+ const key = env["NEON_API_KEY"] || "";
156
+ if (!key) {
157
+ return { provisioned: false, url: null, note: "set NEON_API_KEY to provision, or set DATABASE_URL to bring your own database" };
158
+ }
159
+ try {
160
+ const res = await fetch(`${NEON_API}/projects`, {
161
+ method: "POST",
162
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", Accept: "application/json" },
163
+ body: JSON.stringify({ project: {} }),
164
+ });
165
+ if (!res.ok) {
166
+ return { provisioned: false, url: null, note: `Neon API ${res.status} ${res.statusText} when creating the project` };
167
+ }
168
+ const data = (await res.json()) as Record<string, unknown>;
169
+ const uris = Array.isArray(data["connection_uris"]) ? (data["connection_uris"] as Array<Record<string, unknown>>) : [];
170
+ const uri = uris.length ? String(uris[0]?.["connection_uri"] || "") : "";
171
+ if (!uri) return { provisioned: false, url: null, note: "Neon created the project but returned no connection string" };
172
+ return { provisioned: true, url: uri, note: "provisioned a Neon project in your account (connection string set for this deploy only)" };
173
+ } catch (e) {
174
+ return { provisioned: false, url: null, note: `Neon provisioning error: ${(e as Error).message}` };
175
+ }
176
+ }
177
+
178
+ // ---- opt-in schema apply via psql --------------------------------------------
179
+
180
+ export interface SchemaResult {
181
+ applied: boolean;
182
+ note: string;
183
+ }
184
+
185
+ // Apply schema.sql to the database with psql. The connection string reaches psql
186
+ // through libpq environment variables (PGHOST, PGUSER, ...), never argv, so the
187
+ // value cannot leak onto a command line. Runs only behind the --apply-schema
188
+ // flag. If psql is absent, print the manual command and report applied:false.
189
+ export function applySchema(schemaPath: string, dbUrl: string | null, env: Env): SchemaResult {
190
+ if (!existsSync(schemaPath)) {
191
+ return { applied: false, note: `schema file not found at ${schemaPath}` };
192
+ }
193
+ if (!dbUrl) {
194
+ return { applied: false, note: "no database connection string available; set DATABASE_URL or use --provision-db" };
195
+ }
196
+ const probe = spawnSync("psql", ["--version"], { encoding: "utf8", timeout: 15000 });
197
+ if (probe.status !== 0) {
198
+ return { applied: false, note: `psql is not installed; apply it manually: psql "$DATABASE_URL" -f ${schemaPath}` };
199
+ }
200
+ let pg: Env;
201
+ try {
202
+ pg = libpqEnv(dbUrl);
203
+ } catch {
204
+ return { applied: false, note: "could not parse the database connection string" };
205
+ }
206
+ const r = spawnSync("psql", ["-v", "ON_ERROR_STOP=1", "-f", schemaPath], {
207
+ encoding: "utf8",
208
+ timeout: 120000,
209
+ env: { ...env, ...pg },
210
+ });
211
+ if (r.status !== 0) {
212
+ return { applied: false, note: `psql exited ${r.status} applying the schema` };
213
+ }
214
+ return { applied: true, note: "applied schema.sql with psql" };
215
+ }
216
+
217
+ // Split a Postgres connection string into libpq env vars so the secret never
218
+ // touches argv. Throws on an unparseable URL.
219
+ function libpqEnv(dbUrl: string): Env {
220
+ const u = new URL(dbUrl);
221
+ const pg: Env = {
222
+ PGHOST: u.hostname,
223
+ PGPORT: u.port || "5432",
224
+ PGDATABASE: decodeURIComponent(u.pathname.replace(/^\//, "")) || "neondb",
225
+ };
226
+ if (u.username) pg["PGUSER"] = decodeURIComponent(u.username);
227
+ if (u.password) pg["PGPASSWORD"] = decodeURIComponent(u.password);
228
+ const sslmode = u.searchParams.get("sslmode");
229
+ pg["PGSSLMODE"] = sslmode || "require";
230
+ return pg;
231
+ }
package/core/backend.ts CHANGED
@@ -270,3 +270,49 @@ export function normalizeBackendMeta(raw: unknown): BackendMeta | null {
270
270
  guide: str(r["guide"], "BACKEND.md"),
271
271
  };
272
272
  }
273
+
274
+ // ---- deploy planning (pure; the deploy step's node IO consumes these) --------
275
+
276
+ // One environment variable the deploy will set on the user's site, expressed as
277
+ // NAME + policy only. There is deliberately no value field: the deploy reads the
278
+ // value from its own process.env at run time and never carries it through here.
279
+ export interface EnvVarPlanItem {
280
+ name: string;
281
+ scopes: string[]; // Netlify env scopes; functions need "functions", builds keep "builds"
282
+ isSecret: boolean; // mark connection strings / keys / tokens as secret in the Netlify API
283
+ context: string; // Netlify deploy context the value applies to
284
+ }
285
+
286
+ // Names that should be marked secret on Netlify (write-only there). A coarse,
287
+ // safe-by-default match: anything that looks like a credential is secret.
288
+ const SECRET_NAME_RE = /(KEY|SECRET|TOKEN|PASSWORD|PASSWD|DATABASE_URL|DB_URL|CONN|DSN|CREDENTIAL|PRIVATE|AUTH)/;
289
+
290
+ function looksSecret(name: string): boolean {
291
+ return SECRET_NAME_RE.test(name.toUpperCase());
292
+ }
293
+
294
+ // Plan the env vars to push: the backend's declared names plus the database
295
+ // connection var, deduped and sorted (via dedupeSortEnv, so names are sanitized
296
+ // the same way the .env.example is). Names + policy only, never values.
297
+ export function planEnvVars(backend: BackendMeta): EnvVarPlanItem[] {
298
+ const dbVar = backend.database?.envVar ? backend.database.envVar : "";
299
+ const merged: EnvVarSpec[] = [
300
+ ...(backend.envVars || []),
301
+ ...(dbVar ? [{ name: dbVar, required: true, description: "" }] : []),
302
+ ];
303
+ return dedupeSortEnv(merged).map((v) => ({
304
+ name: v.name,
305
+ scopes: ["builds", "functions"],
306
+ isSecret: looksSecret(v.name),
307
+ context: "all",
308
+ }));
309
+ }
310
+
311
+ export interface FunctionsPlan {
312
+ functionsDir: string; // relative dir from build.json (e.g. "netlify/functions")
313
+ runtime: string; // Netlify function runtime; serverless TypeScript bundles as "js"
314
+ }
315
+
316
+ export function planFunctions(backend: BackendMeta): FunctionsPlan {
317
+ return { functionsDir: backend.functionsDir || "netlify/functions", runtime: "js" };
318
+ }
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,20 +336,28 @@ 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
- // build.json may predate the backend block, so read it defensively.
261
- const backend = manifest.backend ?? null;
262
- const backendWarn = manifest.hasBackend
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
263
349
  ? (() => {
264
350
  const guide = backend?.guide || "BACKEND.md";
265
351
  const dbEnv = backend?.database?.envVar || "DATABASE_URL";
266
352
  const names = (backend?.envVars || []).map((v) => v.name).filter(Boolean);
267
353
  const others = names.filter((n) => n !== dbEnv);
268
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.`;
269
358
  return (
270
- `\n\nStatic deploy: the front end goes live now. This project also has a backend ` +
271
- `(build.json hasBackend is true${manifest.backendNote ? `: ${manifest.backendNote}` : ""}). ` +
272
- `The backend artifact (serverless functions, schema.sql, a names-only .env.example) is built but not auto-deployed yet; backend deploy automation is a follow-up. ` +
273
- `To run it yourself, follow ${guide}: provision Neon Postgres, set ${envList} in your host environment, then run netlify deploy with the functions present.`
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.`
274
361
  );
275
362
  })()
276
363
  : "";
@@ -287,15 +374,45 @@ export async function runDeploy(opts: DeployRunOptions, ctx: { deployedAt: strin
287
374
  // --- dry run: plan only, no network, no spawn ---
288
375
  if (opts.dryRun) {
289
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
+ }
290
397
  return {
291
398
  ok: true,
292
399
  message:
293
400
  `Dry run: would deploy ${fileCount} files from ${publishDirAbs} (entry ${manifest.entry}) to Netlify ` +
294
- `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}`,
295
- 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 },
296
403
  };
297
404
  }
298
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
+
299
416
  // --- CLI path: explicit (--anonymous), or the fallback when no env token ---
300
417
  const wantCli = opts.anonymous || !token;
301
418
  if (wantCli) {
@@ -380,3 +497,143 @@ export async function runDeploy(opts: DeployRunOptions, ctx: { deployedAt: strin
380
497
  return { ok: false, message: `Deploy failed: ${(e as Error).message}`, details: { mode: "token" } };
381
498
  }
382
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
@@ -108,9 +108,17 @@ export interface DeployRecord {
108
108
  hasBackend: boolean;
109
109
  backendNote: string | null;
110
110
  // Provenance of the backend payload from build.json (null for a static build).
111
- // The static deploy does not ship a backend; this just records what the build
112
- // declared, for the deploy-automation follow-up to read.
113
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;
114
122
  buildProducer: "agent" | "scaffold" | null;
115
123
  stateFile: string | null;
116
124
  }