@hobocode/thought-layer 0.2.2 → 0.4.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 CHANGED
@@ -20,8 +20,9 @@ This is open source and BYOK by design. The point is to help people build real t
20
20
 
21
21
  **A Pi package** that adds, on top of the skills:
22
22
 
23
- - **Deterministic tools** the agent can call so the math is exact and never re-derived: `tl_score` (confidence to status and grade), `tl_domains` (availability, BYOK), `tl_project` (the numeric business projection).
24
- - **Slash commands** (prompt templates): `/tl` runs the whole flow, and `/tl-panel`, `/tl-grill`, `/tl-prd`, `/tl-naming` run each stage.
23
+ - **Deterministic tools** the agent can call so the math is exact and never re-derived: `tl_score` (confidence to status and grade), `tl_domains` (availability, BYOK), `tl_project` (the numeric business projection), `tl_state` (the portable progress file), `tl_scaffold` (a deterministic, deployable static site from the spec + brand), and `deploy` (take the build live to a URL you own).
24
+ - **Slash commands** (prompt templates): `/tl` runs the whole flow; `/tl-speedrun` is the fast unranked path; `/tl-panel`, `/tl-grill`, `/tl-prd`, `/tl-naming` run each stage; `/tl-build` builds the hardened PRD into a deploy-ready artifact; `/tl-deploy` takes it live.
25
+ - **A `tl` CLI** for any shell agent (`npx -y @hobocode/thought-layer tl ...`): `read`/`list`/`answer`/`feedback`/`artifact`/`cursor`/`export` for the shared progress file, `scaffold` for the deployable static-site floor, and `deploy` to take the build live.
25
26
 
26
27
  ## Install
27
28
 
@@ -61,9 +62,9 @@ The hosted version of the rigor lives at [weareallproductmanagersnow.com](https:
61
62
 
62
63
  ## Roadmap
63
64
 
64
- - **Done:** the rigor as portable skills, and a Pi package with deterministic tools + slash commands.
65
- - **Phase 3:** a `build` step that turns the PRD into a deploy-ready artifact, built by your own agent.
66
- - **Phase 4:** a `deploy` step that publishes it to a live URL you own (Netlify deploy-and-claim by default), closing the loop.
65
+ - **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.
66
+ - **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.
67
+ - **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 (owned immediately, no claim step); with no account it uses the Netlify CLI's `--allow-anonymous` flow for an instant URL plus a one-hour claim link. BYOK, no central account, no lock-in. `--dry-run` shows the plan first.
67
68
 
68
69
  ## Notes for contributors
69
70
 
@@ -0,0 +1,319 @@
1
+ // Node IO for the deploy step, shared by the deploy Pi tool and the `tl deploy`
2
+ // CLI. The pure transforms live in deploy.ts; this reads build.json, walks the
3
+ // publish dir, talks to the Netlify API (BYO token, file-digest method), or
4
+ // delegates the zero-account path to the Netlify CLI's own --allow-anonymous
5
+ // flow, then writes a deploy.json record.
6
+ //
7
+ // Two deploy models, both keeping ownership with the user and nothing phoning a
8
+ // central account (the kit has no server):
9
+ // token - deploy into the user's OWN Netlify account via NETLIFY_AUTH_TOKEN.
10
+ // The site is theirs from the first second. This is the primary,
11
+ // fully-in-process path (sha1 + fetch, no zip, no deps).
12
+ // anonymous - the user has no account yet: shell out to `netlify deploy
13
+ // --allow-anonymous` (Netlify's own supported flow) for a live URL
14
+ // plus a one-hour claim link. We never reverse-engineer that
15
+ // handshake; we use the vendor tool when it is installed.
16
+
17
+ import { spawnSync } from "node:child_process";
18
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
19
+ import { dirname, join, relative, resolve } from "node:path";
20
+ import { resolveStatePath } from "./state-file.ts";
21
+ import type { BuildManifest } from "./scaffold.ts";
22
+ import type { StateOpResult } from "./state-ops.ts";
23
+ import {
24
+ buildFileDigests, uploadPath, sanitizeSiteName, parseAnonymousOutput, deployRecord,
25
+ type FileMap, type DeployRecord,
26
+ } from "./deploy.ts";
27
+
28
+ const NETLIFY_API = "https://api.netlify.com/api/v1";
29
+
30
+ export interface DeployRunOptions {
31
+ path?: string; // state file / project dir, selects which build.json to read
32
+ dryRun?: boolean; // plan only: walk + digest, no network or spawn
33
+ anonymous?: boolean; // force the no-account CLI path even if a token is set
34
+ siteName?: string; // create the site under this name (else Netlify auto-names)
35
+ siteId?: string; // deploy to an existing site (re-deploy) instead of creating one
36
+ }
37
+
38
+ // ---- locate + read the build manifest ----------------------------------------
39
+
40
+ interface ResolvedBuild {
41
+ manifest: BuildManifest;
42
+ manifestPath: string;
43
+ publishDirAbs: string;
44
+ stateFile: string;
45
+ }
46
+
47
+ function readBuild(target?: string): ResolvedBuild {
48
+ const statePath = resolveStatePath(target);
49
+ const manifestPath = join(dirname(statePath), "build.json");
50
+ if (!existsSync(manifestPath)) {
51
+ throw new Error(
52
+ `No build.json found at ${manifestPath}. Run the build first: the thought-layer-build skill (/tl-build) ` +
53
+ `or the tl_scaffold tool (\`tl scaffold\`) writes the manifest the deploy reads.`,
54
+ );
55
+ }
56
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as BuildManifest;
57
+ // publishDir is relative to the project root (the parent of .thought-layer/),
58
+ // falling back to cwd; absolute wins as-is.
59
+ const projectRoot = dirname(dirname(statePath));
60
+ const publishDirAbs = resolvePublishDir(manifest.publishDir, projectRoot);
61
+ return { manifest, manifestPath, publishDirAbs, stateFile: statePath };
62
+ }
63
+
64
+ function resolvePublishDir(publishDir: string, projectRoot: string): string {
65
+ const candidates = [resolve(projectRoot, publishDir), resolve(process.cwd(), publishDir)];
66
+ for (const c of candidates) if (existsSync(c)) return c;
67
+ throw new Error(
68
+ `Publish dir "${publishDir}" from build.json does not exist (looked in ${candidates.join(" and ")}). ` +
69
+ `Re-run the build, or fix publishDir in build.json.`,
70
+ );
71
+ }
72
+
73
+ // ---- walk the publish dir into an in-memory file map --------------------------
74
+
75
+ function walkPublishDir(dir: string): FileMap {
76
+ const files: FileMap = {};
77
+ const walk = (d: string): void => {
78
+ for (const name of readdirSync(d)) {
79
+ const full = join(d, name);
80
+ const st = statSync(full);
81
+ if (st.isDirectory()) walk(full);
82
+ else if (st.isFile()) {
83
+ const rel = relative(dir, full).split(/[\\/]/).join("/");
84
+ files["/" + rel] = readFileSync(full);
85
+ }
86
+ }
87
+ };
88
+ walk(dir);
89
+ return files;
90
+ }
91
+
92
+ // ---- the Netlify file-digest deploy (BYO token) ------------------------------
93
+
94
+ interface DigestResult {
95
+ url: string;
96
+ adminUrl: string;
97
+ siteId: string;
98
+ deployId: string;
99
+ uploaded: number;
100
+ state: string;
101
+ }
102
+
103
+ async function netlifyJson(url: string, init: RequestInit, token: string): Promise<Record<string, unknown>> {
104
+ const res = await fetch(url, {
105
+ ...init,
106
+ headers: { Authorization: `Bearer ${token}`, ...(init.headers || {}) },
107
+ });
108
+ const body = await res.text();
109
+ if (!res.ok) {
110
+ throw new Error(`Netlify API ${res.status} ${res.statusText} on ${init.method || "GET"} ${url}: ${body.slice(0, 400)}`);
111
+ }
112
+ return body ? (JSON.parse(body) as Record<string, unknown>) : {};
113
+ }
114
+
115
+ async function digestDeploy(
116
+ files: FileMap,
117
+ opts: { token: string; siteName?: string; siteId?: string },
118
+ ): Promise<DigestResult> {
119
+ let siteId = opts.siteId;
120
+ let adminUrl = "";
121
+ let siteUrl = "";
122
+
123
+ if (!siteId) {
124
+ const body = opts.siteName ? JSON.stringify({ name: sanitizeSiteName(opts.siteName) }) : JSON.stringify({});
125
+ const site = await netlifyJson(`${NETLIFY_API}/sites`, { method: "POST", headers: { "Content-Type": "application/json" }, body }, opts.token);
126
+ siteId = String(site["id"] || "");
127
+ adminUrl = String(site["admin_url"] || "");
128
+ siteUrl = String(site["ssl_url"] || site["url"] || "");
129
+ }
130
+
131
+ const { digests, pathForDigest } = buildFileDigests(files);
132
+ const deploy = await netlifyJson(
133
+ `${NETLIFY_API}/sites/${siteId}/deploys`,
134
+ { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ files: digests }) },
135
+ opts.token,
136
+ );
137
+ const deployId = String(deploy["id"] || "");
138
+ const required = Array.isArray(deploy["required"]) ? (deploy["required"] as string[]) : [];
139
+ if (!adminUrl) adminUrl = String(deploy["admin_url"] || "");
140
+
141
+ // Upload one file per unique required sha1.
142
+ let uploaded = 0;
143
+ for (const sha of required) {
144
+ const key = pathForDigest[sha];
145
+ const buf = key ? files[key] : undefined;
146
+ if (!key || !buf) continue; // Netlify asked for a digest we did not send; skip.
147
+ const r = await fetch(`${NETLIFY_API}/deploys/${deployId}/files/${uploadPath(key)}`, {
148
+ method: "PUT",
149
+ headers: { Authorization: `Bearer ${opts.token}`, "Content-Type": "application/octet-stream" },
150
+ body: new Uint8Array(buf), // Buffer is a Uint8Array; this satisfies BodyInit cleanly.
151
+ });
152
+ if (!r.ok) throw new Error(`Netlify upload ${r.status} for ${key}: ${(await r.text()).slice(0, 200)}`);
153
+ uploaded++;
154
+ }
155
+
156
+ // Poll until the deploy is live (small static sites are usually instant).
157
+ let state = String(deploy["state"] || "");
158
+ for (let i = 0; i < 30 && state !== "ready" && state !== "error"; i++) {
159
+ await new Promise((r) => setTimeout(r, 1000));
160
+ const d = await netlifyJson(`${NETLIFY_API}/deploys/${deployId}`, { method: "GET" }, opts.token);
161
+ state = String(d["state"] || "");
162
+ if (!siteUrl) siteUrl = String(d["ssl_url"] || d["deploy_ssl_url"] || "");
163
+ }
164
+ if (state === "error") throw new Error(`Netlify deploy ${deployId} reported state "error".`);
165
+
166
+ return { url: siteUrl, adminUrl, siteId: String(siteId), deployId, uploaded, state: state || "uploaded" };
167
+ }
168
+
169
+ // ---- the anonymous path: delegate to the Netlify CLI -------------------------
170
+
171
+ export function hasNetlifyCli(): boolean {
172
+ try {
173
+ const r = spawnSync("netlify", ["--version"], { encoding: "utf8", timeout: 15000 });
174
+ return r.status === 0;
175
+ } catch {
176
+ return false;
177
+ }
178
+ }
179
+
180
+ // --allow-anonymous shipped in the Netlify CLI in 2026-03; older CLIs reject it.
181
+ // Probe `netlify deploy --help` so we guide instead of spawning a deploy that
182
+ // errors on the unknown flag (and would otherwise prompt interactively).
183
+ export function cliSupportsAnonymous(): boolean {
184
+ try {
185
+ const r = spawnSync("netlify", ["deploy", "--help"], { encoding: "utf8", timeout: 15000 });
186
+ return `${r.stdout || ""}${r.stderr || ""}`.includes("--allow-anonymous");
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
191
+
192
+ function anonymousDeploy(publishDirAbs: string): { url: string | null; claimUrl: string | null; raw: string } {
193
+ const r = spawnSync(
194
+ "netlify",
195
+ ["deploy", "--dir", publishDirAbs, "--prod", "--allow-anonymous"],
196
+ { encoding: "utf8", timeout: 180000 },
197
+ );
198
+ const raw = `${r.stdout || ""}\n${r.stderr || ""}`.trim();
199
+ if (r.status !== 0) {
200
+ throw new Error(`netlify deploy --allow-anonymous failed (exit ${r.status}). Output:\n${raw.slice(0, 800)}`);
201
+ }
202
+ return { ...parseAnonymousOutput(raw), raw };
203
+ }
204
+
205
+ // ---- the orchestrator (mirrors runScaffold's result shape) -------------------
206
+
207
+ export async function runDeploy(opts: DeployRunOptions, ctx: { deployedAt: string }): Promise<StateOpResult> {
208
+ let build: ResolvedBuild;
209
+ try {
210
+ build = readBuild(opts.path);
211
+ } catch (e) {
212
+ return { ok: false, message: (e as Error).message, details: {} };
213
+ }
214
+ const { manifest, publishDirAbs, stateFile } = build;
215
+ const files = walkPublishDir(publishDirAbs);
216
+ const fileCount = Object.keys(files).length;
217
+ if (fileCount === 0) {
218
+ return { ok: false, message: `Publish dir ${publishDirAbs} is empty - nothing to deploy.`, details: {} };
219
+ }
220
+
221
+ const backendWarn = manifest.hasBackend
222
+ ? ` WARNING: build.json says hasBackend:true${manifest.backendNote ? ` (${manifest.backendNote})` : ""}; ` +
223
+ `this static deploy publishes only the front end - the server part needs serverless functions or a separate host.`
224
+ : "";
225
+
226
+ const token = process.env.NETLIFY_AUTH_TOKEN || process.env.NETLIFY_TOKEN || "";
227
+
228
+ const writeRecord = (rec: DeployRecord): string => {
229
+ const recPath = join(dirname(stateFile), "deploy.json");
230
+ mkdirSync(dirname(recPath), { recursive: true });
231
+ writeFileSync(recPath, JSON.stringify(rec, null, 2) + "\n");
232
+ return recPath;
233
+ };
234
+
235
+ // --- dry run: plan only, no network, no spawn ---
236
+ if (opts.dryRun) {
237
+ const { digests } = buildFileDigests(files);
238
+ return {
239
+ ok: true,
240
+ message:
241
+ `Dry run: would deploy ${fileCount} files from ${publishDirAbs} (entry ${manifest.entry}) to Netlify ` +
242
+ `via the ${opts.anonymous ? "anonymous CLI" : token ? "BYO-token digest" : "(no token set - would use the anonymous CLI or guide you)"} path.${backendWarn}`,
243
+ details: { dryRun: true, publishDir: publishDirAbs, entry: manifest.entry, fileCount, files: Object.keys(digests), hasBackend: manifest.hasBackend },
244
+ };
245
+ }
246
+
247
+ // --- anonymous path: explicit, or the fallback when no token is set ---
248
+ const wantAnonymous = opts.anonymous || !token;
249
+ if (wantAnonymous) {
250
+ // The three honest ways to go live, shown whenever we cannot run anonymous.
251
+ const guide = (lead: string, needs: string): StateOpResult => ({
252
+ ok: false,
253
+ message:
254
+ lead +
255
+ `To go live, choose one:\n` +
256
+ ` 1. BYO token (deploys into your own account, owned immediately): set NETLIFY_AUTH_TOKEN and re-run.\n` +
257
+ ` 2. No account: a current Netlify CLI (\`npm i -g netlify-cli@latest\`) then re-run - uses netlify deploy --allow-anonymous for a 1-hour claimable URL.\n` +
258
+ ` 3. Manual: drag ${publishDirAbs} onto https://app.netlify.com/drop.`,
259
+ details: { publishDir: publishDirAbs, needs },
260
+ });
261
+ if (!hasNetlifyCli()) {
262
+ return guide(
263
+ opts.anonymous
264
+ ? "Anonymous deploy needs the Netlify CLI, which is not installed. "
265
+ : "No NETLIFY_AUTH_TOKEN is set and the Netlify CLI is not installed. ",
266
+ "token-or-cli",
267
+ );
268
+ }
269
+ if (!cliSupportsAnonymous()) {
270
+ return guide(
271
+ "Your Netlify CLI is too old for --allow-anonymous (it shipped 2026-03). ",
272
+ "newer-cli-or-token",
273
+ );
274
+ }
275
+ try {
276
+ const { url, claimUrl, raw } = anonymousDeploy(publishDirAbs);
277
+ const recPath = writeRecord(
278
+ deployRecord({
279
+ deployedAt: ctx.deployedAt, mode: "anonymous", publishDir: manifest.publishDir, fileCount,
280
+ url, adminUrl: null, claimUrl, siteId: null, deployId: null,
281
+ hasBackend: manifest.hasBackend, backendNote: manifest.backendNote, buildProducer: manifest.producer, stateFile,
282
+ }),
283
+ );
284
+ return {
285
+ ok: true,
286
+ message:
287
+ `Deployed anonymously.${url ? ` Live: ${url}` : ""}${claimUrl ? `\nClaim it within 1 hour (transfers ownership to your account): ${claimUrl}` : ""}` +
288
+ `\nRecorded ${recPath}.${backendWarn}` +
289
+ (!url || !claimUrl ? `\n(Could not parse a URL from the CLI output - see details.raw.)` : ""),
290
+ details: { mode: "anonymous", url, claimUrl, fileCount, raw },
291
+ };
292
+ } catch (e) {
293
+ return { ok: false, message: (e as Error).message, details: { mode: "anonymous" } };
294
+ }
295
+ }
296
+
297
+ // --- BYO-token path: deploy into the user's own account ---
298
+ try {
299
+ const r = await digestDeploy(files, { token, siteName: opts.siteName, siteId: opts.siteId });
300
+ const recPath = writeRecord(
301
+ deployRecord({
302
+ deployedAt: ctx.deployedAt, mode: "token", publishDir: manifest.publishDir, fileCount,
303
+ url: r.url || null, adminUrl: r.adminUrl || null, claimUrl: null, siteId: r.siteId, deployId: r.deployId,
304
+ hasBackend: manifest.hasBackend, backendNote: manifest.backendNote, buildProducer: manifest.producer, stateFile,
305
+ }),
306
+ );
307
+ return {
308
+ ok: true,
309
+ message:
310
+ `Deployed to your Netlify account (${r.uploaded} file${r.uploaded === 1 ? "" : "s"} uploaded, state ${r.state}).` +
311
+ `${r.url ? ` Live: ${r.url}` : ""}${r.adminUrl ? `\nManage: ${r.adminUrl}` : ""}` +
312
+ `\nIt is owned by your account - no claim needed. Re-deploy to the same site with --site ${r.siteId}.` +
313
+ `\nRecorded ${recPath}.${backendWarn}`,
314
+ details: { mode: "token", url: r.url, adminUrl: r.adminUrl, siteId: r.siteId, deployId: r.deployId, uploaded: r.uploaded, state: r.state },
315
+ };
316
+ } catch (e) {
317
+ return { ok: false, message: `Deploy failed: ${(e as Error).message}`, details: { mode: "token" } };
318
+ }
319
+ }
package/core/deploy.ts ADDED
@@ -0,0 +1,110 @@
1
+ // Deterministic, no-IO helpers for the deploy step: hash the publish dir into
2
+ // the Netlify file-digest map, derive a safe site name, build the deploy.json
3
+ // record, and parse the anonymous-CLI output. The fs walk and the network calls
4
+ // live in deploy-io.ts; keeping these pure means the digest + record logic is
5
+ // unit-testable without touching disk or the Netlify API.
6
+ //
7
+ // Netlify's file-digest deploy (the path we use with a BYO token) needs no zip
8
+ // and no dependencies: POST a { "/path": sha1 } map, then PUT the raw bytes for
9
+ // each sha1 Netlify reports back as `required`. crypto.createHash is the only
10
+ // import and it is pure (content in, hex out).
11
+
12
+ import { createHash } from "node:crypto";
13
+
14
+ export const sha1Hex = (data: Buffer | string): string =>
15
+ createHash("sha1").update(data).digest("hex");
16
+
17
+ // A file map is keyed by the site-relative path WITH a leading slash, posix
18
+ // separators (e.g. "/index.html", "/assets/app.js") - the shape Netlify's
19
+ // deploys endpoint expects in its `files` object.
20
+ export type FileMap = Record<string, Buffer>;
21
+
22
+ export interface FileDigests {
23
+ // path (leading slash) -> sha1, sent as the deploy `files` body.
24
+ digests: Record<string, string>;
25
+ // sha1 -> one path that has it, so an upload happens once per unique sha1
26
+ // even when several files share content.
27
+ pathForDigest: Record<string, string>;
28
+ }
29
+
30
+ export function buildFileDigests(files: FileMap): FileDigests {
31
+ const digests: Record<string, string> = {};
32
+ const pathForDigest: Record<string, string> = {};
33
+ for (const [path, buf] of Object.entries(files)) {
34
+ const key = normalizeKey(path);
35
+ const sha = sha1Hex(buf);
36
+ digests[key] = sha;
37
+ if (!(sha in pathForDigest)) pathForDigest[sha] = key;
38
+ }
39
+ return { digests, pathForDigest };
40
+ }
41
+
42
+ // Normalize a relative path to a leading-slash posix key.
43
+ export function normalizeKey(path: string): string {
44
+ const posix = path.replace(/\\/g, "/").replace(/^\.?\/*/, "");
45
+ return "/" + posix;
46
+ }
47
+
48
+ // The URL segment for a PUT files/<path> upload: drop the leading slash and
49
+ // percent-encode each segment (Netlify forbids raw # and ? in the path), while
50
+ // preserving the slashes that separate directories.
51
+ export function uploadPath(key: string): string {
52
+ return key
53
+ .replace(/^\/+/, "")
54
+ .split("/")
55
+ .map(encodeURIComponent)
56
+ .join("/");
57
+ }
58
+
59
+ // Netlify site names are a global namespace of [a-z0-9-]. Derive a clean
60
+ // candidate from a brand/product name; empty -> "" so the caller lets Netlify
61
+ // assign a random subdomain instead (no collision risk).
62
+ export function sanitizeSiteName(raw: string): string {
63
+ return String(raw || "")
64
+ .toLowerCase()
65
+ .replace(/[^a-z0-9]+/g, "-")
66
+ .replace(/^-+|-+$/g, "")
67
+ .slice(0, 40)
68
+ .replace(/-+$/g, "");
69
+ }
70
+
71
+ // Pull the live URL and the claim URL out of `netlify deploy --allow-anonymous`
72
+ // output. The CLI prints a one-hour claim link for the unclaimed site; we never
73
+ // synthesize it ourselves (the anonymous handshake is Netlify's, not ours).
74
+ export function parseAnonymousOutput(output: string): { url: string | null; claimUrl: string | null } {
75
+ const claim = output.match(/https:\/\/app\.netlify\.com\/claim\S*/);
76
+ // The live site URL: prefer a *.netlify.app that is not the admin/app host.
77
+ const live = output.match(/https:\/\/[a-z0-9-]+\.netlify\.app\S*/i);
78
+ return {
79
+ url: live ? stripTrailingPunctuation(live[0]) : null,
80
+ claimUrl: claim ? stripTrailingPunctuation(claim[0]) : null,
81
+ };
82
+ }
83
+
84
+ const stripTrailingPunctuation = (s: string): string => s.replace(/[).,]+$/, "");
85
+
86
+ // The deploy record written next to build.json / the state file. Pure provenance
87
+ // so a re-deploy can target the same site and the user can find their URLs.
88
+ export interface DeployRecord {
89
+ app: "thought-layer";
90
+ kind: "deploy";
91
+ version: 1;
92
+ deployedAt: string;
93
+ mode: "token" | "anonymous" | "dry-run";
94
+ provider: "netlify";
95
+ publishDir: string;
96
+ fileCount: number;
97
+ url: string | null;
98
+ adminUrl: string | null;
99
+ claimUrl: string | null;
100
+ siteId: string | null;
101
+ deployId: string | null;
102
+ hasBackend: boolean;
103
+ backendNote: string | null;
104
+ buildProducer: "agent" | "scaffold" | null;
105
+ stateFile: string | null;
106
+ }
107
+
108
+ export function deployRecord(input: Omit<DeployRecord, "app" | "kind" | "version" | "provider">): DeployRecord {
109
+ return { app: "thought-layer", kind: "deploy", version: 1, provider: "netlify", ...input };
110
+ }
package/core/index.ts CHANGED
@@ -15,3 +15,7 @@ export * from "./stages.ts";
15
15
  export * from "./stage-map.ts";
16
16
  export * from "./state-file.ts";
17
17
  export * from "./state-ops.ts";
18
+ export * from "./scaffold.ts";
19
+ export * from "./scaffold-io.ts";
20
+ export * from "./deploy.ts";
21
+ export * from "./deploy-io.ts";
@@ -0,0 +1,56 @@
1
+ // Node IO for the deterministic scaffold, shared by both frontends (the
2
+ // tl_scaffold Pi tool and the `tl scaffold` CLI). The pure generation lives in
3
+ // scaffold.ts; this loads the state file, writes the site to a publish dir, and
4
+ // writes the build.json manifest co-located with the selected state file.
5
+
6
+ import { mkdirSync, writeFileSync } from "node:fs";
7
+ import { dirname, isAbsolute, join, resolve } from "node:path";
8
+ import { loadStateFile } from "./state-file.ts";
9
+ import { extractScaffoldSpec, buildStarterSite, scaffoldManifest, type ScaffoldOptions } from "./scaffold.ts";
10
+ import type { StateOpResult } from "./state-ops.ts";
11
+
12
+ export interface ScaffoldRunOptions extends ScaffoldOptions {
13
+ path?: string;
14
+ outDir?: string;
15
+ }
16
+
17
+ export function runScaffold(opts: ScaffoldRunOptions, ctx: { builtAt: string }): StateOpResult {
18
+ try {
19
+ const loaded = loadStateFile(opts.path);
20
+ const spec = extractScaffoldSpec(loaded.state);
21
+ const { files } = buildStarterSite(spec, { domain: opts.domain, founderName: opts.founderName, socialImage: opts.socialImage });
22
+
23
+ const outDir = opts.outDir || "dist";
24
+ const outAbs = isAbsolute(outDir) ? outDir : resolve(process.cwd(), outDir);
25
+ mkdirSync(outAbs, { recursive: true });
26
+ for (const [name, content] of Object.entries(files)) {
27
+ writeFileSync(join(outAbs, name), content);
28
+ }
29
+
30
+ const prd = loaded.state.prd && typeof loaded.state.prd === "object" ? (loaded.state.prd as Record<string, unknown>) : null;
31
+ const grill = loaded.state.grill && typeof loaded.state.grill === "object" ? (loaded.state.grill as Record<string, unknown>) : null;
32
+ const manifest = scaffoldManifest(outDir, ctx.builtAt, {
33
+ stateFile: loaded.path,
34
+ prdTs: prd && typeof prd["ts"] === "number" ? (prd["ts"] as number) : null,
35
+ grillDone: !!(grill && grill["done"] === true),
36
+ fromSpeedrun: loaded.state.kit?.cursor?.phase === "speedrun",
37
+ });
38
+
39
+ // build.json sits in the selected state file's .thought-layer/ dir.
40
+ const manifestPath = join(dirname(loaded.path), "build.json");
41
+ mkdirSync(dirname(manifestPath), { recursive: true });
42
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
43
+
44
+ const names = Object.keys(files);
45
+ return {
46
+ ok: true,
47
+ message:
48
+ `Scaffolded a deployable static site for "${spec.brandName}" -> ${outAbs} ` +
49
+ `(${names.length} files: ${names.join(", ")}). Manifest: ${manifestPath}. ` +
50
+ `Deploy the publish dir, or build the full product with /tl-build.`,
51
+ details: { publishDir: outDir, outAbs, files: names, manifestPath, brandName: spec.brandName, hadBrand: loaded.state.brand != null },
52
+ };
53
+ } catch (e) {
54
+ return { ok: false, message: `tl_scaffold error: ${(e as Error).message}`, details: {} };
55
+ }
56
+ }