@hobocode/thought-layer 0.3.0 → 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,9 +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), `tl_state` (the portable progress file), and `tl_scaffold` (a deterministic, deployable static site from the spec + brand).
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.
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, and `scaffold` for the deployable static-site floor.
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.
26
26
 
27
27
  ## Install
28
28
 
@@ -64,7 +64,7 @@ The hosted version of the rigor lives at [weareallproductmanagersnow.com](https:
64
64
 
65
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
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:** a `deploy` step that publishes it to a live URL you own (Netlify deploy-and-claim by default), closing the loop.
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.
68
68
 
69
69
  ## Notes for contributors
70
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
@@ -17,3 +17,5 @@ export * from "./state-file.ts";
17
17
  export * from "./state-ops.ts";
18
18
  export * from "./scaffold.ts";
19
19
  export * from "./scaffold-io.ts";
20
+ export * from "./deploy.ts";
21
+ export * from "./deploy-io.ts";
package/dist/tl.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // bin/tl.ts
4
- import { readFileSync as readFileSync2 } from "fs";
4
+ import { readFileSync as readFileSync3 } from "fs";
5
5
 
6
6
  // core/scoring.ts
7
7
  var CONFIDENCE_GOAL = 0.85;
@@ -669,12 +669,292 @@ function runScaffold(opts, ctx) {
669
669
  }
670
670
  }
671
671
 
672
+ // core/deploy-io.ts
673
+ import { spawnSync } from "child_process";
674
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync, writeFileSync as writeFileSync3 } from "fs";
675
+ import { dirname as dirname3, join as join3, relative, resolve as resolve3 } from "path";
676
+
677
+ // core/deploy.ts
678
+ import { createHash } from "crypto";
679
+ var sha1Hex = (data) => createHash("sha1").update(data).digest("hex");
680
+ function buildFileDigests(files) {
681
+ const digests = {};
682
+ const pathForDigest = {};
683
+ for (const [path, buf] of Object.entries(files)) {
684
+ const key = normalizeKey(path);
685
+ const sha = sha1Hex(buf);
686
+ digests[key] = sha;
687
+ if (!(sha in pathForDigest)) pathForDigest[sha] = key;
688
+ }
689
+ return { digests, pathForDigest };
690
+ }
691
+ function normalizeKey(path) {
692
+ const posix = path.replace(/\\/g, "/").replace(/^\.?\/*/, "");
693
+ return "/" + posix;
694
+ }
695
+ function uploadPath(key) {
696
+ return key.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/");
697
+ }
698
+ function sanitizeSiteName(raw) {
699
+ return String(raw || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/g, "");
700
+ }
701
+ function parseAnonymousOutput(output) {
702
+ const claim = output.match(/https:\/\/app\.netlify\.com\/claim\S*/);
703
+ const live = output.match(/https:\/\/[a-z0-9-]+\.netlify\.app\S*/i);
704
+ return {
705
+ url: live ? stripTrailingPunctuation(live[0]) : null,
706
+ claimUrl: claim ? stripTrailingPunctuation(claim[0]) : null
707
+ };
708
+ }
709
+ var stripTrailingPunctuation = (s) => s.replace(/[).,]+$/, "");
710
+ function deployRecord(input) {
711
+ return { app: "thought-layer", kind: "deploy", version: 1, provider: "netlify", ...input };
712
+ }
713
+
714
+ // core/deploy-io.ts
715
+ var NETLIFY_API = "https://api.netlify.com/api/v1";
716
+ function readBuild(target) {
717
+ const statePath = resolveStatePath(target);
718
+ const manifestPath = join3(dirname3(statePath), "build.json");
719
+ if (!existsSync2(manifestPath)) {
720
+ throw new Error(
721
+ `No build.json found at ${manifestPath}. Run the build first: the thought-layer-build skill (/tl-build) or the tl_scaffold tool (\`tl scaffold\`) writes the manifest the deploy reads.`
722
+ );
723
+ }
724
+ const manifest = JSON.parse(readFileSync2(manifestPath, "utf8"));
725
+ const projectRoot = dirname3(dirname3(statePath));
726
+ const publishDirAbs = resolvePublishDir(manifest.publishDir, projectRoot);
727
+ return { manifest, manifestPath, publishDirAbs, stateFile: statePath };
728
+ }
729
+ function resolvePublishDir(publishDir, projectRoot) {
730
+ const candidates = [resolve3(projectRoot, publishDir), resolve3(process.cwd(), publishDir)];
731
+ for (const c of candidates) if (existsSync2(c)) return c;
732
+ throw new Error(
733
+ `Publish dir "${publishDir}" from build.json does not exist (looked in ${candidates.join(" and ")}). Re-run the build, or fix publishDir in build.json.`
734
+ );
735
+ }
736
+ function walkPublishDir(dir) {
737
+ const files = {};
738
+ const walk = (d) => {
739
+ for (const name of readdirSync2(d)) {
740
+ const full = join3(d, name);
741
+ const st = statSync(full);
742
+ if (st.isDirectory()) walk(full);
743
+ else if (st.isFile()) {
744
+ const rel = relative(dir, full).split(/[\\/]/).join("/");
745
+ files["/" + rel] = readFileSync2(full);
746
+ }
747
+ }
748
+ };
749
+ walk(dir);
750
+ return files;
751
+ }
752
+ async function netlifyJson(url, init, token) {
753
+ const res = await fetch(url, {
754
+ ...init,
755
+ headers: { Authorization: `Bearer ${token}`, ...init.headers || {} }
756
+ });
757
+ const body = await res.text();
758
+ if (!res.ok) {
759
+ throw new Error(`Netlify API ${res.status} ${res.statusText} on ${init.method || "GET"} ${url}: ${body.slice(0, 400)}`);
760
+ }
761
+ return body ? JSON.parse(body) : {};
762
+ }
763
+ async function digestDeploy(files, opts) {
764
+ let siteId = opts.siteId;
765
+ let adminUrl = "";
766
+ let siteUrl = "";
767
+ if (!siteId) {
768
+ const body = opts.siteName ? JSON.stringify({ name: sanitizeSiteName(opts.siteName) }) : JSON.stringify({});
769
+ const site = await netlifyJson(`${NETLIFY_API}/sites`, { method: "POST", headers: { "Content-Type": "application/json" }, body }, opts.token);
770
+ siteId = String(site["id"] || "");
771
+ adminUrl = String(site["admin_url"] || "");
772
+ siteUrl = String(site["ssl_url"] || site["url"] || "");
773
+ }
774
+ const { digests, pathForDigest } = buildFileDigests(files);
775
+ const deploy = await netlifyJson(
776
+ `${NETLIFY_API}/sites/${siteId}/deploys`,
777
+ { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ files: digests }) },
778
+ opts.token
779
+ );
780
+ const deployId = String(deploy["id"] || "");
781
+ const required = Array.isArray(deploy["required"]) ? deploy["required"] : [];
782
+ if (!adminUrl) adminUrl = String(deploy["admin_url"] || "");
783
+ let uploaded = 0;
784
+ for (const sha of required) {
785
+ const key = pathForDigest[sha];
786
+ const buf = key ? files[key] : void 0;
787
+ if (!key || !buf) continue;
788
+ const r = await fetch(`${NETLIFY_API}/deploys/${deployId}/files/${uploadPath(key)}`, {
789
+ method: "PUT",
790
+ headers: { Authorization: `Bearer ${opts.token}`, "Content-Type": "application/octet-stream" },
791
+ body: new Uint8Array(buf)
792
+ // Buffer is a Uint8Array; this satisfies BodyInit cleanly.
793
+ });
794
+ if (!r.ok) throw new Error(`Netlify upload ${r.status} for ${key}: ${(await r.text()).slice(0, 200)}`);
795
+ uploaded++;
796
+ }
797
+ let state = String(deploy["state"] || "");
798
+ for (let i = 0; i < 30 && state !== "ready" && state !== "error"; i++) {
799
+ await new Promise((r) => setTimeout(r, 1e3));
800
+ const d = await netlifyJson(`${NETLIFY_API}/deploys/${deployId}`, { method: "GET" }, opts.token);
801
+ state = String(d["state"] || "");
802
+ if (!siteUrl) siteUrl = String(d["ssl_url"] || d["deploy_ssl_url"] || "");
803
+ }
804
+ if (state === "error") throw new Error(`Netlify deploy ${deployId} reported state "error".`);
805
+ return { url: siteUrl, adminUrl, siteId: String(siteId), deployId, uploaded, state: state || "uploaded" };
806
+ }
807
+ function hasNetlifyCli() {
808
+ try {
809
+ const r = spawnSync("netlify", ["--version"], { encoding: "utf8", timeout: 15e3 });
810
+ return r.status === 0;
811
+ } catch {
812
+ return false;
813
+ }
814
+ }
815
+ function cliSupportsAnonymous() {
816
+ try {
817
+ const r = spawnSync("netlify", ["deploy", "--help"], { encoding: "utf8", timeout: 15e3 });
818
+ return `${r.stdout || ""}${r.stderr || ""}`.includes("--allow-anonymous");
819
+ } catch {
820
+ return false;
821
+ }
822
+ }
823
+ function anonymousDeploy(publishDirAbs) {
824
+ const r = spawnSync(
825
+ "netlify",
826
+ ["deploy", "--dir", publishDirAbs, "--prod", "--allow-anonymous"],
827
+ { encoding: "utf8", timeout: 18e4 }
828
+ );
829
+ const raw = `${r.stdout || ""}
830
+ ${r.stderr || ""}`.trim();
831
+ if (r.status !== 0) {
832
+ throw new Error(`netlify deploy --allow-anonymous failed (exit ${r.status}). Output:
833
+ ${raw.slice(0, 800)}`);
834
+ }
835
+ return { ...parseAnonymousOutput(raw), raw };
836
+ }
837
+ async function runDeploy(opts, ctx) {
838
+ let build;
839
+ try {
840
+ build = readBuild(opts.path);
841
+ } catch (e) {
842
+ return { ok: false, message: e.message, details: {} };
843
+ }
844
+ const { manifest, publishDirAbs, stateFile } = build;
845
+ const files = walkPublishDir(publishDirAbs);
846
+ const fileCount = Object.keys(files).length;
847
+ if (fileCount === 0) {
848
+ return { ok: false, message: `Publish dir ${publishDirAbs} is empty - nothing to deploy.`, details: {} };
849
+ }
850
+ const backendWarn = manifest.hasBackend ? ` WARNING: build.json says hasBackend:true${manifest.backendNote ? ` (${manifest.backendNote})` : ""}; this static deploy publishes only the front end - the server part needs serverless functions or a separate host.` : "";
851
+ const token = process.env.NETLIFY_AUTH_TOKEN || process.env.NETLIFY_TOKEN || "";
852
+ const writeRecord = (rec) => {
853
+ const recPath = join3(dirname3(stateFile), "deploy.json");
854
+ mkdirSync3(dirname3(recPath), { recursive: true });
855
+ writeFileSync3(recPath, JSON.stringify(rec, null, 2) + "\n");
856
+ return recPath;
857
+ };
858
+ if (opts.dryRun) {
859
+ const { digests } = buildFileDigests(files);
860
+ return {
861
+ ok: true,
862
+ message: `Dry run: would deploy ${fileCount} files from ${publishDirAbs} (entry ${manifest.entry}) to Netlify via the ${opts.anonymous ? "anonymous CLI" : token ? "BYO-token digest" : "(no token set - would use the anonymous CLI or guide you)"} path.${backendWarn}`,
863
+ details: { dryRun: true, publishDir: publishDirAbs, entry: manifest.entry, fileCount, files: Object.keys(digests), hasBackend: manifest.hasBackend }
864
+ };
865
+ }
866
+ const wantAnonymous = opts.anonymous || !token;
867
+ if (wantAnonymous) {
868
+ const guide = (lead, needs) => ({
869
+ ok: false,
870
+ message: lead + `To go live, choose one:
871
+ 1. BYO token (deploys into your own account, owned immediately): set NETLIFY_AUTH_TOKEN and re-run.
872
+ 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.
873
+ 3. Manual: drag ${publishDirAbs} onto https://app.netlify.com/drop.`,
874
+ details: { publishDir: publishDirAbs, needs }
875
+ });
876
+ if (!hasNetlifyCli()) {
877
+ return guide(
878
+ opts.anonymous ? "Anonymous deploy needs the Netlify CLI, which is not installed. " : "No NETLIFY_AUTH_TOKEN is set and the Netlify CLI is not installed. ",
879
+ "token-or-cli"
880
+ );
881
+ }
882
+ if (!cliSupportsAnonymous()) {
883
+ return guide(
884
+ "Your Netlify CLI is too old for --allow-anonymous (it shipped 2026-03). ",
885
+ "newer-cli-or-token"
886
+ );
887
+ }
888
+ try {
889
+ const { url, claimUrl, raw } = anonymousDeploy(publishDirAbs);
890
+ const recPath = writeRecord(
891
+ deployRecord({
892
+ deployedAt: ctx.deployedAt,
893
+ mode: "anonymous",
894
+ publishDir: manifest.publishDir,
895
+ fileCount,
896
+ url,
897
+ adminUrl: null,
898
+ claimUrl,
899
+ siteId: null,
900
+ deployId: null,
901
+ hasBackend: manifest.hasBackend,
902
+ backendNote: manifest.backendNote,
903
+ buildProducer: manifest.producer,
904
+ stateFile
905
+ })
906
+ );
907
+ return {
908
+ ok: true,
909
+ message: `Deployed anonymously.${url ? ` Live: ${url}` : ""}${claimUrl ? `
910
+ Claim it within 1 hour (transfers ownership to your account): ${claimUrl}` : ""}
911
+ Recorded ${recPath}.${backendWarn}` + (!url || !claimUrl ? `
912
+ (Could not parse a URL from the CLI output - see details.raw.)` : ""),
913
+ details: { mode: "anonymous", url, claimUrl, fileCount, raw }
914
+ };
915
+ } catch (e) {
916
+ return { ok: false, message: e.message, details: { mode: "anonymous" } };
917
+ }
918
+ }
919
+ try {
920
+ const r = await digestDeploy(files, { token, siteName: opts.siteName, siteId: opts.siteId });
921
+ const recPath = writeRecord(
922
+ deployRecord({
923
+ deployedAt: ctx.deployedAt,
924
+ mode: "token",
925
+ publishDir: manifest.publishDir,
926
+ fileCount,
927
+ url: r.url || null,
928
+ adminUrl: r.adminUrl || null,
929
+ claimUrl: null,
930
+ siteId: r.siteId,
931
+ deployId: r.deployId,
932
+ hasBackend: manifest.hasBackend,
933
+ backendNote: manifest.backendNote,
934
+ buildProducer: manifest.producer,
935
+ stateFile
936
+ })
937
+ );
938
+ return {
939
+ ok: true,
940
+ message: `Deployed to your Netlify account (${r.uploaded} file${r.uploaded === 1 ? "" : "s"} uploaded, state ${r.state}).${r.url ? ` Live: ${r.url}` : ""}${r.adminUrl ? `
941
+ Manage: ${r.adminUrl}` : ""}
942
+ It is owned by your account - no claim needed. Re-deploy to the same site with --site ${r.siteId}.
943
+ Recorded ${recPath}.${backendWarn}`,
944
+ details: { mode: "token", url: r.url, adminUrl: r.adminUrl, siteId: r.siteId, deployId: r.deployId, uploaded: r.uploaded, state: r.state }
945
+ };
946
+ } catch (e) {
947
+ return { ok: false, message: `Deploy failed: ${e.message}`, details: { mode: "token" } };
948
+ }
949
+ }
950
+
672
951
  // bin/tl.ts
673
952
  var HELP = `tl - read/write a portable Thought Layer state file (default: .thought-layer/state.json)
674
953
 
675
954
  tl read [path] [--json] where the run stands
676
955
  tl list [dir] list the state files under .thought-layer/ (juggle several ideas)
677
956
  tl scaffold [--out dist] [--domain x.com] [--founder "Name"] deterministic deployable static site from the spec + brand
957
+ tl deploy [--dry-run] [--anonymous] [--name x] [--site id] take build.json's publish dir live to a user-owned Netlify URL
678
958
  tl export [path] handoff check
679
959
  tl answer <qId> <value> [path] record an answer
680
960
  tl feedback --data '<json>' record a panel verdict ({qId,mode,personas,endState,round})
@@ -709,7 +989,7 @@ function parseArgs(argv) {
709
989
  function readData(flags) {
710
990
  const d = flags["data"];
711
991
  if (d === void 0) return void 0;
712
- const raw = d === "-" || d === true ? readFileSync2(0, "utf8") : String(d);
992
+ const raw = d === "-" || d === true ? readFileSync3(0, "utf8") : String(d);
713
993
  try {
714
994
  return JSON.parse(raw);
715
995
  } catch {
@@ -763,6 +1043,23 @@ function main() {
763
1043
  else console.log(r2.message);
764
1044
  process.exit(r2.ok ? 0 : 1);
765
1045
  }
1046
+ if (args[0] === "deploy") {
1047
+ runDeploy(
1048
+ {
1049
+ path: typeof flags["path"] === "string" ? flags["path"] : void 0,
1050
+ dryRun: flags["dry-run"] === true,
1051
+ anonymous: flags["anonymous"] === true,
1052
+ siteName: typeof flags["name"] === "string" ? flags["name"] : void 0,
1053
+ siteId: typeof flags["site"] === "string" ? flags["site"] : void 0
1054
+ },
1055
+ { deployedAt: (/* @__PURE__ */ new Date()).toISOString() }
1056
+ ).then((r2) => {
1057
+ if (flags["json"]) console.log(JSON.stringify(r2.details, null, 2));
1058
+ else console.log(r2.message);
1059
+ process.exit(r2.ok ? 0 : 1);
1060
+ });
1061
+ return;
1062
+ }
766
1063
  let payload;
767
1064
  try {
768
1065
  payload = buildOp(args, flags);
@@ -9,7 +9,7 @@ import {
9
9
  aggregateConfidence, statusFromConfidence, gradeFromConfidence,
10
10
  checkDomains, registrarSearchUrl,
11
11
  computeProjection, fmtMoney,
12
- applyStateOp, runScaffold,
12
+ applyStateOp, runScaffold, runDeploy,
13
13
  type Assumptions, type StateOp,
14
14
  } from "../core/index.ts";
15
15
 
@@ -203,4 +203,34 @@ export default function (pi: ExtensionAPI) {
203
203
  return text(r.message, r.details);
204
204
  },
205
205
  });
206
+
207
+ // deploy: take the build output (read from build.json's publishDir) live to a
208
+ // user-owned URL. Two models, both keeping ownership with the user and nothing
209
+ // phoning a central account: a BYO Netlify token (NETLIFY_AUTH_TOKEN, deploys
210
+ // into their own account via the file-digest API) or, with no token, the
211
+ // Netlify CLI's own --allow-anonymous flow for a 1-hour claimable URL. The
212
+ // token is read ONLY from the environment (BYOK), never passed as a parameter.
213
+ pi.registerTool({
214
+ name: "deploy",
215
+ label: "Thought Layer: deploy",
216
+ description:
217
+ "Take the built site live to a user-owned URL. Reads build.json (publishDir/entry) next to the state file, then deploys to Netlify. " +
218
+ "Two models, both BYOK with no lock-in: with NETLIFY_AUTH_TOKEN set (read from the environment only, never a parameter) it deploys into the user's OWN account via the file-digest API (no zip); with no token it uses the Netlify CLI's --allow-anonymous flow for an instant live URL plus a one-hour claim link. " +
219
+ "Run the build first (thought-layer-build / tl_scaffold). Use dryRun:true to preview the file plan with no network call. Static-first: if build.json has hasBackend:true it warns that only the front end ships this way.",
220
+ parameters: Type.Object({
221
+ path: Type.Optional(Type.String({ description: "State file (or project dir) whose build.json to deploy. Defaults to ./.thought-layer/state.json; honors a named file." })),
222
+ dryRun: Type.Optional(Type.Boolean({ description: "Plan only: walk the publish dir and report the files + target, with no network call or CLI spawn." })),
223
+ anonymous: Type.Optional(Type.Boolean({ description: "Force the no-account path (Netlify CLI --allow-anonymous) even if a token is set. Default: token path when NETLIFY_AUTH_TOKEN is set, else anonymous." })),
224
+ siteName: Type.Optional(Type.String({ description: "Token path only: create the site under this name (a-z0-9-). Omit to let Netlify assign a random subdomain." })),
225
+ siteId: Type.Optional(Type.String({ description: "Token path only: re-deploy to an existing site id instead of creating a new one." })),
226
+ }),
227
+ async execute(_id, params): Promise<ToolResult> {
228
+ const p = params as { path?: string; dryRun?: boolean; anonymous?: boolean; siteName?: string; siteId?: string };
229
+ const r = await runDeploy(
230
+ { path: p.path, dryRun: p.dryRun, anonymous: p.anonymous, siteName: p.siteName, siteId: p.siteId },
231
+ { deployedAt: new Date().toISOString() },
232
+ );
233
+ return text(r.message, r.details);
234
+ },
235
+ });
206
236
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hobocode/thought-layer",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "The Thought Layer: rigor for building. Validate an idea, grill it into a buildable spec, then build and deploy it, inside the agent you already use. BYOK, no telemetry.",
5
5
  "license": "MIT",
6
6
  "author": "Hobocode LLC <jerm@hobocode.net>",
@@ -66,6 +66,8 @@
66
66
  "core/state-ops.ts",
67
67
  "core/scaffold.ts",
68
68
  "core/scaffold-io.ts",
69
+ "core/deploy.ts",
70
+ "core/deploy-io.ts",
69
71
  "dist",
70
72
  "README.md",
71
73
  "LICENSE"
@@ -0,0 +1,7 @@
1
+ Apply the **thought-layer-deploy** skill. Take the built site live to a URL I own, with no lock-in.
2
+
3
+ Read `.thought-layer/build.json` (next to the state file; honor `--path` / `THOUGHT_LAYER_STATE` if a named file is in use) for the publish directory and entry. If there is no `build.json`, say so and point me to `/tl-build` (or the `tl_scaffold` tool) rather than guessing - the build has to run first.
4
+
5
+ Default to a dry run first so I can see exactly which files would ship and where. Then deploy: if `NETLIFY_AUTH_TOKEN` is set, deploy into my own Netlify account (owned immediately); otherwise use the Netlify CLI's `--allow-anonymous` flow for an instant live URL plus a one-hour claim link. Read the token only from the environment, never ask me to paste it. If `build.json` says `hasBackend: true`, warn me clearly that this static deploy publishes only the front end.
6
+
7
+ After it is live, tell me the URL and (anonymous only) the claim link, and that a `.thought-layer/deploy.json` record was written.
@@ -95,4 +95,4 @@ Write three files with your own file tools (the manifest is NOT a `tl_state` art
95
95
 
96
96
  ## Persisting
97
97
 
98
- The build output and the three files live on disk; the portable `state.json` stays focused on validation and design. The only optional state touch is a best-effort cursor bump - `tl_state cursor` (or `tl cursor`) with `{ "backboneStage": 15, "phase": "built" }` - pure provenance; if the tool is absent or it fails, the build still succeeded. Tell the user where the artifact is (`<publishDir>`) and that the next step is the deploy (Phase 4, `/tl-deploy`, coming) which reads `build.json`.
98
+ The build output and the three files live on disk; the portable `state.json` stays focused on validation and design. The only optional state touch is a best-effort cursor bump - `tl_state cursor` (or `tl cursor`) with `{ "backboneStage": 15, "phase": "built" }` - pure provenance; if the tool is absent or it fails, the build still succeeded. Tell the user where the artifact is (`<publishDir>`) and that the next step is the deploy - the **thought-layer-deploy** skill (`/tl-deploy`) or the `deploy` tool / `tl deploy` CLI, which reads `build.json` and takes it live to a URL they own.
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: thought-layer-deploy
3
+ description: "Take the built site live to a user-owned URL with no lock-in, the last step after the build. Reads .thought-layer/build.json (the publish dir + entry) next to the state file, then deploys to Netlify by one of two BYOK models: with NETLIFY_AUTH_TOKEN set it deploys into the user's OWN account via the file-digest API (owned immediately, no claim), and with no token it uses the Netlify CLI's own --allow-anonymous flow for an instant live URL plus a one-hour claim link. Static-first: if build.json says hasBackend it warns that only the front end ships this way. Prefers the deploy tool (Pi) or the tl deploy CLI (any shell agent) so the deploy is one mechanical, honest step, never hand-rolled. Run it after thought-layer-build (or tl_scaffold) has produced build.json."
4
+ ---
5
+
6
+ # Deploy it: the build goes live to a URL you own
7
+
8
+ This is the last step of the loop: rigor -> spec -> build -> **a live, owned URL**. You are not writing a deploy script by hand; you run the kit's deploy tool, which reads the build manifest and takes the publish directory live. BYOK, nothing phones a central account, no lock-in.
9
+
10
+ ## Precondition: a build must exist
11
+
12
+ The deploy reads `.thought-layer/build.json` (written by the **thought-layer-build** skill or the **tl_scaffold** tool), co-located with the state file. Honor the same selection as the rest of the kit: an explicit `--path` / tool `path` wins, then `THOUGHT_LAYER_STATE`, then the default `.thought-layer/state.json`.
13
+
14
+ - **Required:** a `build.json` with a `publishDir` that exists on disk. If it is missing, **stop** and point the user at `/tl-build` (full build) or the `tl_scaffold` tool (an instant deployable landing floor). Do not invent a publish dir.
15
+ - `build.json.publishDir` + `entry` are load-bearing; `hasBackend` decides the static-only warning below.
16
+
17
+ ## How to run it
18
+
19
+ Use the tool, never a hand-written `curl`/`netlify` invocation:
20
+ - **Pi:** the `deploy` tool. Start with `deploy { dryRun: true }` to show the plan, then `deploy {}` to go live (add `anonymous: true` to force the no-account path, `siteId` to re-deploy to the same site).
21
+ - **Any shell agent (Claude Code, CI, a plain terminal):** `tl deploy` (via `npx -y @hobocode/thought-layer tl deploy`). Use `--dry-run` first, then `tl deploy`. Flags: `--anonymous`, `--name <slug>`, `--site <id>`, `--path <file>`.
22
+
23
+ Always **dry-run first** and show the user the file list and the target, then deploy.
24
+
25
+ ## The two models (both keep ownership with the user)
26
+
27
+ The tool picks automatically; explain which one ran.
28
+
29
+ - **BYO token (the default when `NETLIFY_AUTH_TOKEN` is set).** Deploys straight into the user's own Netlify account via the file-digest API (no zip, no extra dependency). The site is theirs from the first second - **no claim step**. Re-deploy to the same site with `--site <id>` (the id is in the deploy output and `deploy.json`). The token is read **only from the environment** - never ask the user to paste it into the chat, and never put it in a tool parameter or a file.
30
+ - **Anonymous (when no token is set).** Delegates to the Netlify CLI's `netlify deploy --allow-anonymous` (Netlify's own supported flow) for an instant live URL plus a **one-hour claim link** that transfers ownership to whatever account the user logs into. We never reverse-engineer that handshake. It needs a current Netlify CLI (`npm i -g netlify-cli@latest`; the flag shipped 2026-03); if the CLI is missing or too old, the tool says exactly what to do (set a token, update the CLI, or drag the publish dir onto https://app.netlify.com/drop).
31
+
32
+ If neither a token nor a usable CLI is available, relay the tool's guidance honestly instead of pretending it deployed.
33
+
34
+ ## Static-first honesty
35
+
36
+ The default deploy publishes a **static** publish directory. If `build.json.hasBackend` is `true`, the tool warns and you must repeat it plainly: only the front end goes live this way; the server part needs serverless functions or a separate host. Do not imply a backend is running when it is not.
37
+
38
+ ## After it is live
39
+
40
+ Report, plainly:
41
+ - the **live URL**, and (anonymous only) the **claim link** with the one-hour window called out;
42
+ - for the token path, that it is already owned by their account and how to re-deploy (`--site <id>`);
43
+ - that a `.thought-layer/deploy.json` record was written (URL, mode, site/deploy ids) next to `build.json`.
44
+
45
+ If the tool returned `ok: false`, do not claim success - surface its message (the next honest step) verbatim.
46
+
47
+ ## Persisting
48
+
49
+ The deploy is a real-world side effect, not validation state, so it lives in `deploy.json` on disk, not in the portable `state.json`. The only optional state touch is a best-effort cursor bump - `tl_state cursor` (or `tl cursor`) with `{ "phase": "deployed" }` - pure provenance; if it fails, the deploy still happened.