@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/README.md
CHANGED
|
@@ -17,7 +17,7 @@ This is open source and BYOK by design. The point is to help people build real t
|
|
|
17
17
|
- **thought-layer-prd.** Draft the complete PRD — with a first-cut domain glossary and testable requirements — from the validated idea and business model. The plan the grill then hardens.
|
|
18
18
|
- **thought-layer-grill.** The last design step: grills the draft PRD against the domain one question at a time, sharpening the glossary and hardening the requirements inline until it is build-ready. Runs after the PRD, not instead of the framework.
|
|
19
19
|
- **thought-layer-naming.** Name the thing, with rationale and domain-ready slugs.
|
|
20
|
-
- **thought-layer-build.** Build the hardened PRD into a static-first, deploy-ready artifact, verified to run, and leave a manifest the deploy step reads.
|
|
20
|
+
- **thought-layer-build.** Build the hardened PRD into a static-first, deploy-ready artifact, verified to run, and leave a manifest the deploy step reads. When a requirement genuinely needs a server, it also emits a real backend (serverless functions, a `schema.sql`, a names-only `.env.example`, and a `BACKEND.md` guide), with Neon Postgres as the documented default.
|
|
21
21
|
- **thought-layer-deploy.** Take the build live to a URL you own, with no lock-in: a Netlify token deploys into your own account, or the Netlify CLI handles a logged-in or anonymous deploy.
|
|
22
22
|
- **thought-layer-speedrun.** A fast, unranked path to a build-ready spec when you do not need the full panel and score.
|
|
23
23
|
- **Optional deep-dives**, pulled in when you want to go further than the backbone: `thought-layer-strategy`, `thought-layer-brand`, `thought-layer-market-research`, and `thought-layer-business-model`.
|
|
@@ -69,6 +69,8 @@ The hosted version of the rigor lives at [weareallproductmanagersnow.com](https:
|
|
|
69
69
|
- **Done:** the rigor as portable skills; a Pi package with deterministic tools + slash commands; the portable progress file (`tl_state` / the `tl` CLI) shared with the web app; and the speedrun.
|
|
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
|
+
- **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
|
+
- **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.
|
|
72
74
|
|
|
73
75
|
## Notes for contributors
|
|
74
76
|
|
package/SECURITY.md
CHANGED
|
@@ -11,7 +11,15 @@ follow from that.
|
|
|
11
11
|
(`THOUGHT_LAYER_DOMAIN_KEY` / `RAPIDAPI_KEY`) are read from `process.env`. They
|
|
12
12
|
are never accepted as tool or CLI parameters, never logged or printed, and
|
|
13
13
|
never written to disk. The `deploy.json` record stores the resulting URLs and
|
|
14
|
-
ids, never the token.
|
|
14
|
+
ids, never the token. When a build emits a backend, the generated `.env.example`
|
|
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. 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.
|
|
15
23
|
- **Deploys go to your own account.** With a token, the deploy uses Netlify's
|
|
16
24
|
file-digest API to publish into your account. With no token, it delegates to
|
|
17
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
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// The typed contract + deterministic boilerplate for the backend-capable build.
|
|
2
|
+
//
|
|
3
|
+
// Static stays the default. When the build skill's three-question backend test
|
|
4
|
+
// says a product genuinely needs a server (a browser-unsafe secret, shared or
|
|
5
|
+
// persistent state, or trusted server-side enforcement), the agent emits a real
|
|
6
|
+
// serverless backend alongside the static front end. This module is the
|
|
7
|
+
// deterministic floor for that path, mirroring scaffold.ts: the agent supplies
|
|
8
|
+
// judgment (which functions, which tables), and these pure helpers render the
|
|
9
|
+
// fixed, dash-free boilerplate (the env contract and the deploy guide) and
|
|
10
|
+
// coerce a hand-written backend block into a clean shape.
|
|
11
|
+
//
|
|
12
|
+
// Pure TypeScript, no node imports. The chosen runtime DB driver
|
|
13
|
+
// (`@neondatabase/serverless`) is a dependency of the GENERATED product, never
|
|
14
|
+
// of the kit. Nothing here holds or provisions credentials: secrets live only
|
|
15
|
+
// in the host environment, and the emitted .env.example carries names, never
|
|
16
|
+
// values.
|
|
17
|
+
//
|
|
18
|
+
// Copy rule (enforced by tests): no em-dashes, no en-dashes, and no spaced
|
|
19
|
+
// hyphen dashes (" - ", " -- ") in any generated prose. Use commas, colons,
|
|
20
|
+
// periods, and parentheses instead.
|
|
21
|
+
|
|
22
|
+
export type BackendKind = "serverless" | "server" | null;
|
|
23
|
+
|
|
24
|
+
// One environment variable the backend reads. Names only: a value never lives
|
|
25
|
+
// in this spec, the manifest, or the emitted .env.example. Real values stay in
|
|
26
|
+
// the host environment.
|
|
27
|
+
export interface EnvVarSpec {
|
|
28
|
+
name: string;
|
|
29
|
+
required: boolean;
|
|
30
|
+
description: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Where the backend keeps shared or persistent state. The kit's documented
|
|
34
|
+
// default is Neon Postgres, reached through the `DATABASE_URL` connection
|
|
35
|
+
// string. Overridable to any Postgres by pointing `DATABASE_URL` elsewhere.
|
|
36
|
+
export interface DatabaseSpec {
|
|
37
|
+
provider: string; // "neon" by default
|
|
38
|
+
schemaFile: string; // "schema.sql"
|
|
39
|
+
envVar: string; // "DATABASE_URL"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The structured backend payload carried on the build manifest. The build skill
|
|
43
|
+
// writes it when hasBackend is true; the deploy-automation follow-up consumes
|
|
44
|
+
// it. Today the deploy step only reads it for messaging.
|
|
45
|
+
export interface BackendMeta {
|
|
46
|
+
backendKind: BackendKind;
|
|
47
|
+
functionsDir: string; // "netlify/functions"
|
|
48
|
+
runtime: string; // "nodejs20.x"
|
|
49
|
+
nodeVersion: string; // "20"
|
|
50
|
+
envVars: EnvVarSpec[];
|
|
51
|
+
database: DatabaseSpec | null;
|
|
52
|
+
guide: string; // "BACKEND.md"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The single documented default database. Neon is also exactly what managed
|
|
56
|
+
// Netlify DB provisions, so it is both the Netlify native choice and portable.
|
|
57
|
+
export const NEON_DEFAULT_DB: DatabaseSpec = {
|
|
58
|
+
provider: "neon",
|
|
59
|
+
schemaFile: "schema.sql",
|
|
60
|
+
envVar: "DATABASE_URL",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ---- helpers -----------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
// Coerce a name to a safe environment-variable identifier. Real env var names
|
|
66
|
+
// are UPPER_SNAKE; this hardens the names-only invariant so a stray space or
|
|
67
|
+
// lowercase letter can never produce a line that fails the `^[A-Z0-9_]+=$`
|
|
68
|
+
// security check.
|
|
69
|
+
function envName(raw: string): string {
|
|
70
|
+
const cleaned = String(raw || "")
|
|
71
|
+
.trim()
|
|
72
|
+
.toUpperCase()
|
|
73
|
+
.replace(/[^A-Z0-9_]+/g, "_")
|
|
74
|
+
.replace(/^_+|_+$/g, "");
|
|
75
|
+
return cleaned || "VAR";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Dedupe by safe name and sort, so the same set of vars always renders byte for
|
|
79
|
+
// byte the same regardless of input order.
|
|
80
|
+
function dedupeSortEnv(envVars: EnvVarSpec[]): EnvVarSpec[] {
|
|
81
|
+
const seen = new Set<string>();
|
|
82
|
+
const out: EnvVarSpec[] = [];
|
|
83
|
+
for (const v of envVars || []) {
|
|
84
|
+
const name = envName(v?.name ?? "");
|
|
85
|
+
if (seen.has(name)) continue;
|
|
86
|
+
seen.add(name);
|
|
87
|
+
out.push({ name, required: v?.required !== false, description: String(v?.description ?? "") });
|
|
88
|
+
}
|
|
89
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---- the names-only env contract ---------------------------------------------
|
|
93
|
+
|
|
94
|
+
// Render a deterministic .env.example. Every variable line is exactly `NAME=`
|
|
95
|
+
// with nothing after the equals sign. The security invariant (no value is ever
|
|
96
|
+
// written) is locked by a unit test that matches `^[A-Z0-9_]+=$` on every
|
|
97
|
+
// non-comment, non-blank line.
|
|
98
|
+
export function renderEnvExample(envVars: EnvVarSpec[]): string {
|
|
99
|
+
const vars = dedupeSortEnv(envVars);
|
|
100
|
+
const lines: string[] = [
|
|
101
|
+
"# Environment variables for this project.",
|
|
102
|
+
"# Names only. Real values live in your host environment (the Netlify UI, or netlify env:set), never in this file.",
|
|
103
|
+
"# Copy this file to .env for local work and fill the values there. .env is gitignored; this .env.example is committed so the contract travels with the repo.",
|
|
104
|
+
];
|
|
105
|
+
if (vars.length === 0) {
|
|
106
|
+
lines.push("", "# No backend environment variables were declared for this build.");
|
|
107
|
+
return lines.join("\n") + "\n";
|
|
108
|
+
}
|
|
109
|
+
for (const v of vars) {
|
|
110
|
+
const note = v.description.trim() ? v.description.trim() : "Set this value in your host environment.";
|
|
111
|
+
lines.push("", `# ${note} (${v.required ? "required" : "optional"})`, `${v.name}=`);
|
|
112
|
+
}
|
|
113
|
+
return lines.join("\n") + "\n";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---- the deploy guide --------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
export interface BackendEndpoint {
|
|
119
|
+
name: string; // function file name without extension, glossary-named
|
|
120
|
+
rid: string; // the backend requirement id it implements
|
|
121
|
+
summary: string; // one line of what it does
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface BackendGuideInput {
|
|
125
|
+
brandName?: string;
|
|
126
|
+
backendKind?: BackendKind;
|
|
127
|
+
functionsDir?: string;
|
|
128
|
+
runtime?: string;
|
|
129
|
+
database?: DatabaseSpec | null;
|
|
130
|
+
envVars?: EnvVarSpec[];
|
|
131
|
+
endpoints?: BackendEndpoint[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const cell = (s: string): string => String(s ?? "").replace(/\|/g, "\\|").replace(/\n+/g, " ").trim();
|
|
135
|
+
|
|
136
|
+
// Render a deterministic BACKEND.md for the Neon-on-Netlify path. The agent
|
|
137
|
+
// supplies the endpoint and env lists; the prose, section order, and the honest
|
|
138
|
+
// "deploy automation is a follow-up" status are fixed and dash-free.
|
|
139
|
+
export function renderBackendGuide(input: BackendGuideInput): string {
|
|
140
|
+
const brand = (input.brandName || "This project").trim() || "This project";
|
|
141
|
+
const functionsDir = (input.functionsDir || "netlify/functions").trim() || "netlify/functions";
|
|
142
|
+
const db = input.database ?? NEON_DEFAULT_DB;
|
|
143
|
+
const envVars = dedupeSortEnv(input.envVars || []);
|
|
144
|
+
const endpoints = [...(input.endpoints || [])].sort(
|
|
145
|
+
(a, b) => String(a.rid).localeCompare(String(b.rid)) || String(a.name).localeCompare(String(b.name)),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const envRows =
|
|
149
|
+
envVars.length > 0
|
|
150
|
+
? envVars
|
|
151
|
+
.map((v) => `| \`${v.name}\` | ${v.required ? "yes" : "no"} | ${cell(v.description) || "Set in your host environment."} |`)
|
|
152
|
+
.join("\n")
|
|
153
|
+
: `| \`${db.envVar}\` | yes | ${db.provider === "neon" ? "Neon Postgres connection string" : "Database connection string"} |`;
|
|
154
|
+
|
|
155
|
+
const fnRows =
|
|
156
|
+
endpoints.length > 0
|
|
157
|
+
? endpoints.map((e) => `| \`${cell(e.name)}\` | ${cell(e.rid)} | ${cell(e.summary) || "See the function source."} |`).join("\n")
|
|
158
|
+
: "| (none emitted) | n/a | This build declared a backend but wrote no functions; see DECISIONS.md. |";
|
|
159
|
+
|
|
160
|
+
return `# Backend deploy guide
|
|
161
|
+
|
|
162
|
+
${brand} needs a server side, so the build emitted a backend alongside the static front end. This guide covers what is in the repo, the honest status of automated backend deploy, and the one-time manual steps to take it live yourself.
|
|
163
|
+
|
|
164
|
+
## What is in the repo
|
|
165
|
+
|
|
166
|
+
- \`${functionsDir}/\`: one serverless function per backend requirement (the table further down maps each to its R-ID). The front end calls a function at \`/.netlify/functions/<name>\`.
|
|
167
|
+
- \`${db.schemaFile}\`: the database schema. Tables and columns use the project glossary terms. Apply it once against your database.
|
|
168
|
+
- \`.env.example\`: the environment variable names this backend reads. Names only, no values. Copy it to \`.env\` for local work and set real values in your host environment.
|
|
169
|
+
- \`netlify.toml\`: declares the functions directory (\`[functions] directory = "${functionsDir}"\`) so Netlify bundles and serves the functions next to the static publish directory.
|
|
170
|
+
|
|
171
|
+
## Status: automated backend deploy is a follow-up
|
|
172
|
+
|
|
173
|
+
\`tl deploy\` publishes the static front end today. It does not yet provision the database or ship the functions for you; that automation is the next piece of work. Until it lands, do the steps below once. The front end deploys and works now; the functions go live when you complete these steps.
|
|
174
|
+
|
|
175
|
+
## 1. Provision the database
|
|
176
|
+
|
|
177
|
+
The documented default is Neon Postgres. Netlify DB is managed Neon, so this is the Netlify native choice as well as a portable one.
|
|
178
|
+
|
|
179
|
+
1. Create a Neon project at neon.tech, or run \`netlify db init\` to provision managed Neon from your own Netlify account.
|
|
180
|
+
2. Copy the connection string. It looks like \`postgresql://USER:PASSWORD@HOST/DB?sslmode=require\`.
|
|
181
|
+
3. Apply the schema once: \`psql "$${db.envVar}" -f ${db.schemaFile}\`.
|
|
182
|
+
|
|
183
|
+
When you provision managed Neon through Netlify, Netlify sets \`NETLIFY_DATABASE_URL\` for you. The code reads \`${db.envVar}\` as the portable name, so set that (or map one to the other) in the next step.
|
|
184
|
+
|
|
185
|
+
To use a different Postgres provider, point \`${db.envVar}\` at it instead. Any standard Postgres works with the Neon serverless driver, so this is not locked to one vendor.
|
|
186
|
+
|
|
187
|
+
## 2. Set the environment variables
|
|
188
|
+
|
|
189
|
+
Set these in your host environment (the Netlify UI under Site settings, Environment variables, or \`netlify env:set NAME value\`). Never commit real values; \`.env.example\` carries names only.
|
|
190
|
+
|
|
191
|
+
| Variable | Required | Purpose |
|
|
192
|
+
| --- | --- | --- |
|
|
193
|
+
${envRows}
|
|
194
|
+
|
|
195
|
+
## 3. The functions
|
|
196
|
+
|
|
197
|
+
Each backend requirement maps to one function. The front end reaches it at \`/.netlify/functions/<name>\`.
|
|
198
|
+
|
|
199
|
+
| Function | Requirement | Does |
|
|
200
|
+
| --- | --- | --- |
|
|
201
|
+
${fnRows}
|
|
202
|
+
|
|
203
|
+
## 4. Deploy with the functions present
|
|
204
|
+
|
|
205
|
+
1. Confirm \`netlify.toml\` declares the functions directory.
|
|
206
|
+
2. Set the environment variables from step 2 on the target site.
|
|
207
|
+
3. From the project root, with the functions in place, run \`netlify deploy --prod\`. (Once the backend deploy automation lands, \`tl deploy\` will do this for you.)
|
|
208
|
+
|
|
209
|
+
\`netlify deploy\` ships both the static publish directory and the functions in one go. After it completes, call a function once to confirm it responds, then use the live site.
|
|
210
|
+
`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- defensive normalizer ----------------------------------------------------
|
|
214
|
+
|
|
215
|
+
function normalizeDatabase(raw: unknown): DatabaseSpec | null {
|
|
216
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
217
|
+
const r = raw as Record<string, unknown>;
|
|
218
|
+
const str = (v: unknown, fb: string): string => (typeof v === "string" && v.trim() ? v.trim() : fb);
|
|
219
|
+
return {
|
|
220
|
+
provider: str(r["provider"], "neon"),
|
|
221
|
+
schemaFile: str(r["schemaFile"], "schema.sql"),
|
|
222
|
+
envVar: str(r["envVar"], "DATABASE_URL"),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function normalizeEnvVars(raw: unknown): EnvVarSpec[] {
|
|
227
|
+
if (!Array.isArray(raw)) return [];
|
|
228
|
+
const seen = new Set<string>();
|
|
229
|
+
const out: EnvVarSpec[] = [];
|
|
230
|
+
for (const item of raw) {
|
|
231
|
+
if (!item || typeof item !== "object") continue;
|
|
232
|
+
const r = item as Record<string, unknown>;
|
|
233
|
+
const name = typeof r["name"] === "string" ? r["name"].trim() : "";
|
|
234
|
+
if (!name) continue; // drop nameless vars
|
|
235
|
+
if (seen.has(name)) continue; // dedupe
|
|
236
|
+
seen.add(name);
|
|
237
|
+
out.push({
|
|
238
|
+
name,
|
|
239
|
+
required: typeof r["required"] === "boolean" ? r["required"] : true,
|
|
240
|
+
description: typeof r["description"] === "string" ? r["description"] : "",
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Coerce a hand-written backend block (the agent fills it into build.json) into
|
|
247
|
+
// a clean BackendMeta, or null when there is nothing recognizable. Mirrors the
|
|
248
|
+
// progress.ts normalizers: forgiving on input, strict on output.
|
|
249
|
+
export function normalizeBackendMeta(raw: unknown): BackendMeta | null {
|
|
250
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
251
|
+
const r = raw as Record<string, unknown>;
|
|
252
|
+
|
|
253
|
+
const kindRaw = r["backendKind"];
|
|
254
|
+
const kind: BackendKind = kindRaw === "server" ? "server" : kindRaw === "serverless" ? "serverless" : null;
|
|
255
|
+
const envVars = normalizeEnvVars(r["envVars"]);
|
|
256
|
+
const database = normalizeDatabase(r["database"]);
|
|
257
|
+
const hasFunctionsDir = typeof r["functionsDir"] === "string" && (r["functionsDir"] as string).trim().length > 0;
|
|
258
|
+
|
|
259
|
+
// Nothing the deploy step could act on: treat as no backend.
|
|
260
|
+
if (kind === null && envVars.length === 0 && database === null && !hasFunctionsDir) return null;
|
|
261
|
+
|
|
262
|
+
const str = (v: unknown, fb: string): string => (typeof v === "string" && v.trim() ? v.trim() : fb);
|
|
263
|
+
return {
|
|
264
|
+
backendKind: kind ?? "serverless",
|
|
265
|
+
functionsDir: str(r["functionsDir"], "netlify/functions"),
|
|
266
|
+
runtime: str(r["runtime"], "nodejs20.x"),
|
|
267
|
+
nodeVersion: str(r["nodeVersion"], "20"),
|
|
268
|
+
envVars,
|
|
269
|
+
database,
|
|
270
|
+
guide: str(r["guide"], "BACKEND.md"),
|
|
271
|
+
};
|
|
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
|
+
}
|