@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 +1 -1
- package/SECURITY.md +7 -1
- package/core/backend-io.ts +231 -0
- package/core/backend.ts +46 -0
- package/core/deploy-io.ts +266 -9
- package/core/deploy.ts +10 -2
- package/core/index.ts +1 -0
- package/dist/tl.js +489 -25
- package/extensions/thought-layer.ts +11 -7
- package/package.json +2 -1
- package/prompts/tl-deploy.md +1 -1
- package/skills/thought-layer-build/SKILL.md +2 -2
- package/skills/thought-layer-deploy/SKILL.md +9 -3
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
|
-
- **
|
|
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
|
|
261
|
-
const backend = manifest.backend
|
|
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
|
|
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
|
-
|
|
271
|
-
`
|
|
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.${
|
|
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
|
}
|