@hobocode/thought-layer 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/core/deploy-io.ts +367 -0
- package/core/deploy.ts +115 -0
- package/core/index.ts +2 -0
- package/dist/tl.js +325 -2
- package/extensions/thought-layer.ts +31 -1
- package/package.json +3 -1
- package/prompts/tl-deploy.md +7 -0
- package/skills/thought-layer-build/SKILL.md +1 -1
- package/skills/thought-layer-deploy/SKILL.md +51 -0
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),
|
|
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,
|
|
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
|
|
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 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.
|
|
68
68
|
|
|
69
69
|
## Notes for contributors
|
|
70
70
|
|
|
@@ -0,0 +1,367 @@
|
|
|
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 { randomBytes } from "node:crypto";
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
21
|
+
import { resolveStatePath } from "./state-file.ts";
|
|
22
|
+
import type { BuildManifest } from "./scaffold.ts";
|
|
23
|
+
import type { StateOpResult } from "./state-ops.ts";
|
|
24
|
+
import {
|
|
25
|
+
buildFileDigests, uploadPath, sanitizeSiteName, parseCliDeployOutput, deployRecord,
|
|
26
|
+
type FileMap, type DeployRecord,
|
|
27
|
+
} from "./deploy.ts";
|
|
28
|
+
|
|
29
|
+
const NETLIFY_API = "https://api.netlify.com/api/v1";
|
|
30
|
+
|
|
31
|
+
export interface DeployRunOptions {
|
|
32
|
+
path?: string; // state file / project dir, selects which build.json to read
|
|
33
|
+
dryRun?: boolean; // plan only: walk + digest, no network or spawn
|
|
34
|
+
anonymous?: boolean; // force the no-account CLI path even if a token is set
|
|
35
|
+
siteName?: string; // create the site under this name (else Netlify auto-names)
|
|
36
|
+
siteId?: string; // deploy to an existing site (re-deploy) instead of creating one
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---- locate + read the build manifest ----------------------------------------
|
|
40
|
+
|
|
41
|
+
interface ResolvedBuild {
|
|
42
|
+
manifest: BuildManifest;
|
|
43
|
+
manifestPath: string;
|
|
44
|
+
publishDirAbs: string;
|
|
45
|
+
stateFile: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readBuild(target?: string): ResolvedBuild {
|
|
49
|
+
const statePath = resolveStatePath(target);
|
|
50
|
+
const manifestPath = join(dirname(statePath), "build.json");
|
|
51
|
+
if (!existsSync(manifestPath)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`No build.json found at ${manifestPath}. Run the build first: the thought-layer-build skill (/tl-build) ` +
|
|
54
|
+
`or the tl_scaffold tool (\`tl scaffold\`) writes the manifest the deploy reads.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as BuildManifest;
|
|
58
|
+
// publishDir is relative to the project root (the parent of .thought-layer/),
|
|
59
|
+
// falling back to cwd; absolute wins as-is.
|
|
60
|
+
const projectRoot = dirname(dirname(statePath));
|
|
61
|
+
const publishDirAbs = resolvePublishDir(manifest.publishDir, projectRoot);
|
|
62
|
+
return { manifest, manifestPath, publishDirAbs, stateFile: statePath };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolvePublishDir(publishDir: string, projectRoot: string): string {
|
|
66
|
+
const candidates = [resolve(projectRoot, publishDir), resolve(process.cwd(), publishDir)];
|
|
67
|
+
for (const c of candidates) if (existsSync(c)) return c;
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Publish dir "${publishDir}" from build.json does not exist (looked in ${candidates.join(" and ")}). ` +
|
|
70
|
+
`Re-run the build, or fix publishDir in build.json.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- walk the publish dir into an in-memory file map --------------------------
|
|
75
|
+
|
|
76
|
+
function walkPublishDir(dir: string): FileMap {
|
|
77
|
+
const files: FileMap = {};
|
|
78
|
+
const walk = (d: string): void => {
|
|
79
|
+
for (const name of readdirSync(d)) {
|
|
80
|
+
const full = join(d, name);
|
|
81
|
+
const st = statSync(full);
|
|
82
|
+
if (st.isDirectory()) walk(full);
|
|
83
|
+
else if (st.isFile()) {
|
|
84
|
+
const rel = relative(dir, full).split(/[\\/]/).join("/");
|
|
85
|
+
files["/" + rel] = readFileSync(full);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
walk(dir);
|
|
90
|
+
return files;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- the Netlify file-digest deploy (BYO token) ------------------------------
|
|
94
|
+
|
|
95
|
+
interface DigestResult {
|
|
96
|
+
url: string;
|
|
97
|
+
adminUrl: string;
|
|
98
|
+
siteId: string;
|
|
99
|
+
deployId: string;
|
|
100
|
+
uploaded: number;
|
|
101
|
+
state: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function netlifyJson(url: string, init: RequestInit, token: string): Promise<Record<string, unknown>> {
|
|
105
|
+
const res = await fetch(url, {
|
|
106
|
+
...init,
|
|
107
|
+
headers: { Authorization: `Bearer ${token}`, ...(init.headers || {}) },
|
|
108
|
+
});
|
|
109
|
+
const body = await res.text();
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
throw new Error(`Netlify API ${res.status} ${res.statusText} on ${init.method || "GET"} ${url}: ${body.slice(0, 400)}`);
|
|
112
|
+
}
|
|
113
|
+
return body ? (JSON.parse(body) as Record<string, unknown>) : {};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function digestDeploy(
|
|
117
|
+
files: FileMap,
|
|
118
|
+
opts: { token: string; siteName?: string; siteId?: string },
|
|
119
|
+
): Promise<DigestResult> {
|
|
120
|
+
let siteId = opts.siteId;
|
|
121
|
+
let adminUrl = "";
|
|
122
|
+
let siteUrl = "";
|
|
123
|
+
|
|
124
|
+
if (!siteId) {
|
|
125
|
+
const body = opts.siteName ? JSON.stringify({ name: sanitizeSiteName(opts.siteName) }) : JSON.stringify({});
|
|
126
|
+
const site = await netlifyJson(`${NETLIFY_API}/sites`, { method: "POST", headers: { "Content-Type": "application/json" }, body }, opts.token);
|
|
127
|
+
siteId = String(site["id"] || "");
|
|
128
|
+
adminUrl = String(site["admin_url"] || "");
|
|
129
|
+
siteUrl = String(site["ssl_url"] || site["url"] || "");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { digests, pathForDigest } = buildFileDigests(files);
|
|
133
|
+
const deploy = await netlifyJson(
|
|
134
|
+
`${NETLIFY_API}/sites/${siteId}/deploys`,
|
|
135
|
+
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ files: digests }) },
|
|
136
|
+
opts.token,
|
|
137
|
+
);
|
|
138
|
+
const deployId = String(deploy["id"] || "");
|
|
139
|
+
const required = Array.isArray(deploy["required"]) ? (deploy["required"] as string[]) : [];
|
|
140
|
+
if (!adminUrl) adminUrl = String(deploy["admin_url"] || "");
|
|
141
|
+
|
|
142
|
+
// Upload one file per unique required sha1.
|
|
143
|
+
let uploaded = 0;
|
|
144
|
+
for (const sha of required) {
|
|
145
|
+
const key = pathForDigest[sha];
|
|
146
|
+
const buf = key ? files[key] : undefined;
|
|
147
|
+
if (!key || !buf) continue; // Netlify asked for a digest we did not send; skip.
|
|
148
|
+
const r = await fetch(`${NETLIFY_API}/deploys/${deployId}/files/${uploadPath(key)}`, {
|
|
149
|
+
method: "PUT",
|
|
150
|
+
headers: { Authorization: `Bearer ${opts.token}`, "Content-Type": "application/octet-stream" },
|
|
151
|
+
body: new Uint8Array(buf), // Buffer is a Uint8Array; this satisfies BodyInit cleanly.
|
|
152
|
+
});
|
|
153
|
+
if (!r.ok) throw new Error(`Netlify upload ${r.status} for ${key}: ${(await r.text()).slice(0, 200)}`);
|
|
154
|
+
uploaded++;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Poll until the deploy is live (small static sites are usually instant).
|
|
158
|
+
let state = String(deploy["state"] || "");
|
|
159
|
+
for (let i = 0; i < 30 && state !== "ready" && state !== "error"; i++) {
|
|
160
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
161
|
+
const d = await netlifyJson(`${NETLIFY_API}/deploys/${deployId}`, { method: "GET" }, opts.token);
|
|
162
|
+
state = String(d["state"] || "");
|
|
163
|
+
if (!siteUrl) siteUrl = String(d["ssl_url"] || d["deploy_ssl_url"] || "");
|
|
164
|
+
}
|
|
165
|
+
if (state === "error") throw new Error(`Netlify deploy ${deployId} reported state "error".`);
|
|
166
|
+
|
|
167
|
+
return { url: siteUrl, adminUrl, siteId: String(siteId), deployId, uploaded, state: state || "uploaded" };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- the no-env-token path: delegate to the Netlify CLI ----------------------
|
|
171
|
+
|
|
172
|
+
export function hasNetlifyCli(): boolean {
|
|
173
|
+
try {
|
|
174
|
+
const r = spawnSync("netlify", ["--version"], { encoding: "utf8", timeout: 15000 });
|
|
175
|
+
return r.status === 0;
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --allow-anonymous shipped in the Netlify CLI in 2026-03; older CLIs reject it.
|
|
182
|
+
// Probe `netlify deploy --help` so we guide instead of spawning a deploy that
|
|
183
|
+
// errors on the unknown flag (and would otherwise prompt interactively).
|
|
184
|
+
export function cliSupportsAnonymous(): boolean {
|
|
185
|
+
try {
|
|
186
|
+
const r = spawnSync("netlify", ["deploy", "--help"], { encoding: "utf8", timeout: 15000 });
|
|
187
|
+
return `${r.stdout || ""}${r.stderr || ""}`.includes("--allow-anonymous");
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Is the CLI logged in via its OWN stored config (independent of our env)? This
|
|
194
|
+
// matters because --allow-anonymous is a no-op when logged in, and a plain
|
|
195
|
+
// deploy then needs a target site - so a logged-in CLI must create a site in the
|
|
196
|
+
// user's account instead. getCurrentUser exits 0 with JSON when logged in.
|
|
197
|
+
export function cliLoggedIn(): boolean {
|
|
198
|
+
try {
|
|
199
|
+
const r = spawnSync("netlify", ["api", "getCurrentUser", "--data", "{}"], { encoding: "utf8", timeout: 20000 });
|
|
200
|
+
return r.status === 0 && (r.stdout || "").trim().startsWith("{");
|
|
201
|
+
} catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface CliDeployResult {
|
|
207
|
+
url: string | null;
|
|
208
|
+
claimUrl: string | null;
|
|
209
|
+
owned: boolean;
|
|
210
|
+
siteName: string | null;
|
|
211
|
+
raw: string;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Deploy via the Netlify CLI, picking flags from the CLI's own login state:
|
|
215
|
+
// logged in -> create (or, with siteId, reuse) a site in the user's account,
|
|
216
|
+
// owned immediately, no claim step.
|
|
217
|
+
// logged out -> an anonymous, claimable site (one-hour window).
|
|
218
|
+
// --no-build forces a plain publish of our already-built static dir (no
|
|
219
|
+
// framework detection / build step). The caller has decided the CLI path is
|
|
220
|
+
// wanted and that the CLI exists + supports what is needed.
|
|
221
|
+
function cliDeploy(publishDirAbs: string, opts: { siteName?: string; siteId?: string }, loggedIn: boolean): CliDeployResult {
|
|
222
|
+
const base = ["deploy", "--dir", publishDirAbs, "--prod", "--no-build"];
|
|
223
|
+
let args: string[];
|
|
224
|
+
let siteName: string | null = null;
|
|
225
|
+
if (loggedIn) {
|
|
226
|
+
if (opts.siteId) args = [...base, "--site", opts.siteId];
|
|
227
|
+
else {
|
|
228
|
+
siteName = sanitizeSiteName(opts.siteName || "") || `thought-layer-${randomBytes(4).toString("hex")}`;
|
|
229
|
+
args = [...base, "--create-site", siteName];
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
args = [...base, "--allow-anonymous"];
|
|
233
|
+
}
|
|
234
|
+
const r = spawnSync("netlify", args, { encoding: "utf8", timeout: 180000 });
|
|
235
|
+
const raw = `${r.stdout || ""}\n${r.stderr || ""}`.trim();
|
|
236
|
+
if (r.status !== 0) {
|
|
237
|
+
throw new Error(`netlify ${args.join(" ")} failed (exit ${r.status}). Output:\n${raw.slice(0, 800)}`);
|
|
238
|
+
}
|
|
239
|
+
const parsed = parseCliDeployOutput(raw);
|
|
240
|
+
// A claim link only applies to the anonymous (logged-out) site.
|
|
241
|
+
return { url: parsed.url, claimUrl: loggedIn ? null : parsed.claimUrl, owned: loggedIn, siteName, raw };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---- the orchestrator (mirrors runScaffold's result shape) -------------------
|
|
245
|
+
|
|
246
|
+
export async function runDeploy(opts: DeployRunOptions, ctx: { deployedAt: string }): Promise<StateOpResult> {
|
|
247
|
+
let build: ResolvedBuild;
|
|
248
|
+
try {
|
|
249
|
+
build = readBuild(opts.path);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
return { ok: false, message: (e as Error).message, details: {} };
|
|
252
|
+
}
|
|
253
|
+
const { manifest, publishDirAbs, stateFile } = build;
|
|
254
|
+
const files = walkPublishDir(publishDirAbs);
|
|
255
|
+
const fileCount = Object.keys(files).length;
|
|
256
|
+
if (fileCount === 0) {
|
|
257
|
+
return { ok: false, message: `Publish dir ${publishDirAbs} is empty - nothing to deploy.`, details: {} };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const backendWarn = manifest.hasBackend
|
|
261
|
+
? ` WARNING: build.json says hasBackend:true${manifest.backendNote ? ` (${manifest.backendNote})` : ""}; ` +
|
|
262
|
+
`this static deploy publishes only the front end - the server part needs serverless functions or a separate host.`
|
|
263
|
+
: "";
|
|
264
|
+
|
|
265
|
+
const token = process.env.NETLIFY_AUTH_TOKEN || process.env.NETLIFY_TOKEN || "";
|
|
266
|
+
|
|
267
|
+
const writeRecord = (rec: DeployRecord): string => {
|
|
268
|
+
const recPath = join(dirname(stateFile), "deploy.json");
|
|
269
|
+
mkdirSync(dirname(recPath), { recursive: true });
|
|
270
|
+
writeFileSync(recPath, JSON.stringify(rec, null, 2) + "\n");
|
|
271
|
+
return recPath;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// --- dry run: plan only, no network, no spawn ---
|
|
275
|
+
if (opts.dryRun) {
|
|
276
|
+
const { digests } = buildFileDigests(files);
|
|
277
|
+
return {
|
|
278
|
+
ok: true,
|
|
279
|
+
message:
|
|
280
|
+
`Dry run: would deploy ${fileCount} files from ${publishDirAbs} (entry ${manifest.entry}) to Netlify ` +
|
|
281
|
+
`via the ${token && !opts.anonymous ? "BYO-token digest" : "Netlify CLI (logged in -> a site in your account; logged out -> an anonymous claimable site)"} path.${backendWarn}`,
|
|
282
|
+
details: { dryRun: true, publishDir: publishDirAbs, entry: manifest.entry, fileCount, files: Object.keys(digests), hasBackend: manifest.hasBackend },
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// --- CLI path: explicit (--anonymous), or the fallback when no env token ---
|
|
287
|
+
const wantCli = opts.anonymous || !token;
|
|
288
|
+
if (wantCli) {
|
|
289
|
+
// The three honest ways to go live, shown whenever the CLI path cannot run.
|
|
290
|
+
const guide = (lead: string, needs: string): StateOpResult => ({
|
|
291
|
+
ok: false,
|
|
292
|
+
message:
|
|
293
|
+
lead +
|
|
294
|
+
`To go live, choose one:\n` +
|
|
295
|
+
` 1. BYO token (deploys into your own account, owned immediately): set NETLIFY_AUTH_TOKEN and re-run.\n` +
|
|
296
|
+
` 2. Netlify CLI (\`npm i -g netlify-cli@latest\`) then re-run - logged in it creates a site in your account; logged out it deploys anonymously with a one-hour claim link.\n` +
|
|
297
|
+
` 3. Manual: drag ${publishDirAbs} onto https://app.netlify.com/drop.`,
|
|
298
|
+
details: { publishDir: publishDirAbs, needs },
|
|
299
|
+
});
|
|
300
|
+
if (!hasNetlifyCli()) {
|
|
301
|
+
return guide(
|
|
302
|
+
opts.anonymous
|
|
303
|
+
? "A CLI deploy needs the Netlify CLI, which is not installed. "
|
|
304
|
+
: "No NETLIFY_AUTH_TOKEN is set and the Netlify CLI is not installed. ",
|
|
305
|
+
"token-or-cli",
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
const loggedIn = cliLoggedIn();
|
|
309
|
+
if (!loggedIn && !cliSupportsAnonymous()) {
|
|
310
|
+
return guide(
|
|
311
|
+
"You are not logged into the Netlify CLI and it is too old for --allow-anonymous (that shipped 2026-03). ",
|
|
312
|
+
"newer-cli-or-login-or-token",
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
const { url, claimUrl, owned, siteName, raw } = cliDeploy(publishDirAbs, { siteName: opts.siteName, siteId: opts.siteId }, loggedIn);
|
|
317
|
+
const recPath = writeRecord(
|
|
318
|
+
deployRecord({
|
|
319
|
+
deployedAt: ctx.deployedAt, mode: owned ? "cli" : "anonymous", publishDir: manifest.publishDir, fileCount,
|
|
320
|
+
url, adminUrl: null, claimUrl, siteId: null, deployId: null,
|
|
321
|
+
hasBackend: manifest.hasBackend, backendNote: manifest.backendNote, buildProducer: manifest.producer, stateFile,
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
// If anonymity was explicitly asked for but the CLI is logged in, it went
|
|
325
|
+
// to the account instead - say so rather than implying it was anonymous.
|
|
326
|
+
const anonNote = opts.anonymous && owned
|
|
327
|
+
? `\n(Note: you asked for an anonymous deploy, but the Netlify CLI is logged in, so this went to your account. Run \`netlify logout\` first for a truly anonymous, claimable deploy.)`
|
|
328
|
+
: "";
|
|
329
|
+
return {
|
|
330
|
+
ok: true,
|
|
331
|
+
message: owned
|
|
332
|
+
? `Deployed to your Netlify account via the CLI${siteName ? ` (new site ${siteName})` : ""}.${url ? ` Live: ${url}` : ""}` +
|
|
333
|
+
`\nIt is owned by your account - no claim needed.\nRecorded ${recPath}.${anonNote}${backendWarn}` +
|
|
334
|
+
(!url ? `\n(Could not parse a URL from the CLI output - see details.raw.)` : "")
|
|
335
|
+
: `Deployed anonymously.${url ? ` Live: ${url}` : ""}${claimUrl ? `\nClaim it within 1 hour (transfers ownership to your account): ${claimUrl}` : ""}` +
|
|
336
|
+
`\nRecorded ${recPath}.${backendWarn}` +
|
|
337
|
+
(!url || !claimUrl ? `\n(Could not parse a URL/claim link from the CLI output - see details.raw.)` : ""),
|
|
338
|
+
details: { mode: owned ? "cli" : "anonymous", url, claimUrl, owned, siteName, fileCount, raw },
|
|
339
|
+
};
|
|
340
|
+
} catch (e) {
|
|
341
|
+
return { ok: false, message: (e as Error).message, details: { mode: "cli" } };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// --- BYO-token path: deploy into the user's own account ---
|
|
346
|
+
try {
|
|
347
|
+
const r = await digestDeploy(files, { token, siteName: opts.siteName, siteId: opts.siteId });
|
|
348
|
+
const recPath = writeRecord(
|
|
349
|
+
deployRecord({
|
|
350
|
+
deployedAt: ctx.deployedAt, mode: "token", publishDir: manifest.publishDir, fileCount,
|
|
351
|
+
url: r.url || null, adminUrl: r.adminUrl || null, claimUrl: null, siteId: r.siteId, deployId: r.deployId,
|
|
352
|
+
hasBackend: manifest.hasBackend, backendNote: manifest.backendNote, buildProducer: manifest.producer, stateFile,
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
return {
|
|
356
|
+
ok: true,
|
|
357
|
+
message:
|
|
358
|
+
`Deployed to your Netlify account (${r.uploaded} file${r.uploaded === 1 ? "" : "s"} uploaded, state ${r.state}).` +
|
|
359
|
+
`${r.url ? ` Live: ${r.url}` : ""}${r.adminUrl ? `\nManage: ${r.adminUrl}` : ""}` +
|
|
360
|
+
`\nIt is owned by your account - no claim needed. Re-deploy to the same site with --site ${r.siteId}.` +
|
|
361
|
+
`\nRecorded ${recPath}.${backendWarn}`,
|
|
362
|
+
details: { mode: "token", url: r.url, adminUrl: r.adminUrl, siteId: r.siteId, deployId: r.deployId, uploaded: r.uploaded, state: r.state },
|
|
363
|
+
};
|
|
364
|
+
} catch (e) {
|
|
365
|
+
return { ok: false, message: `Deploy failed: ${(e as Error).message}`, details: { mode: "token" } };
|
|
366
|
+
}
|
|
367
|
+
}
|
package/core/deploy.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
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 (anonymous only) the claim link out of `netlify deploy`
|
|
72
|
+
// output. The CLI prints several *.netlify.app URLs - the production site URL
|
|
73
|
+
// and a unique per-deploy URL (whose host carries a "--" prefix); prefer the
|
|
74
|
+
// production one. The claim link only appears for an anonymous, unclaimed site;
|
|
75
|
+
// we never synthesize it ourselves (the handshake is Netlify's, not ours).
|
|
76
|
+
export function parseCliDeployOutput(output: string): { url: string | null; claimUrl: string | null } {
|
|
77
|
+
// Stop the URL at whitespace OR a wrapping bracket/quote: the CLI sometimes
|
|
78
|
+
// prints links as <url> or in a box, and a greedy \S* would swallow the ">".
|
|
79
|
+
const claim = output.match(/https:\/\/app\.netlify\.com\/claim[^\s<>"')\]]*/);
|
|
80
|
+
const all = (output.match(/https:\/\/[a-z0-9-]+\.netlify\.app[^\s<>"')\]]*/gi) || []).map(stripTrailingPunctuation);
|
|
81
|
+
const host = (u: string): string => u.replace(/^https:\/\//, "").split("/")[0]!;
|
|
82
|
+
const prod = all.find((u) => !host(u).includes("--")); // skip the per-deploy URL
|
|
83
|
+
return {
|
|
84
|
+
url: prod || all[0] || null,
|
|
85
|
+
claimUrl: claim ? stripTrailingPunctuation(claim[0]) : null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const stripTrailingPunctuation = (s: string): string => s.replace(/[)\]>.,'"<]+$/, "");
|
|
90
|
+
|
|
91
|
+
// The deploy record written next to build.json / the state file. Pure provenance
|
|
92
|
+
// so a re-deploy can target the same site and the user can find their URLs.
|
|
93
|
+
export interface DeployRecord {
|
|
94
|
+
app: "thought-layer";
|
|
95
|
+
kind: "deploy";
|
|
96
|
+
version: 1;
|
|
97
|
+
deployedAt: string;
|
|
98
|
+
mode: "token" | "cli" | "anonymous" | "dry-run";
|
|
99
|
+
provider: "netlify";
|
|
100
|
+
publishDir: string;
|
|
101
|
+
fileCount: number;
|
|
102
|
+
url: string | null;
|
|
103
|
+
adminUrl: string | null;
|
|
104
|
+
claimUrl: string | null;
|
|
105
|
+
siteId: string | null;
|
|
106
|
+
deployId: string | null;
|
|
107
|
+
hasBackend: boolean;
|
|
108
|
+
backendNote: string | null;
|
|
109
|
+
buildProducer: "agent" | "scaffold" | null;
|
|
110
|
+
stateFile: string | null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function deployRecord(input: Omit<DeployRecord, "app" | "kind" | "version" | "provider">): DeployRecord {
|
|
114
|
+
return { app: "thought-layer", kind: "deploy", version: 1, provider: "netlify", ...input };
|
|
115
|
+
}
|
package/core/index.ts
CHANGED
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
|
|
4
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
5
5
|
|
|
6
6
|
// core/scoring.ts
|
|
7
7
|
var CONFIDENCE_GOAL = 0.85;
|
|
@@ -669,12 +669,318 @@ function runScaffold(opts, ctx) {
|
|
|
669
669
|
}
|
|
670
670
|
}
|
|
671
671
|
|
|
672
|
+
// core/deploy-io.ts
|
|
673
|
+
import { spawnSync } from "child_process";
|
|
674
|
+
import { randomBytes } from "crypto";
|
|
675
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync, writeFileSync as writeFileSync3 } from "fs";
|
|
676
|
+
import { dirname as dirname3, join as join3, relative, resolve as resolve3 } from "path";
|
|
677
|
+
|
|
678
|
+
// core/deploy.ts
|
|
679
|
+
import { createHash } from "crypto";
|
|
680
|
+
var sha1Hex = (data) => createHash("sha1").update(data).digest("hex");
|
|
681
|
+
function buildFileDigests(files) {
|
|
682
|
+
const digests = {};
|
|
683
|
+
const pathForDigest = {};
|
|
684
|
+
for (const [path, buf] of Object.entries(files)) {
|
|
685
|
+
const key = normalizeKey(path);
|
|
686
|
+
const sha = sha1Hex(buf);
|
|
687
|
+
digests[key] = sha;
|
|
688
|
+
if (!(sha in pathForDigest)) pathForDigest[sha] = key;
|
|
689
|
+
}
|
|
690
|
+
return { digests, pathForDigest };
|
|
691
|
+
}
|
|
692
|
+
function normalizeKey(path) {
|
|
693
|
+
const posix = path.replace(/\\/g, "/").replace(/^\.?\/*/, "");
|
|
694
|
+
return "/" + posix;
|
|
695
|
+
}
|
|
696
|
+
function uploadPath(key) {
|
|
697
|
+
return key.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/");
|
|
698
|
+
}
|
|
699
|
+
function sanitizeSiteName(raw) {
|
|
700
|
+
return String(raw || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/g, "");
|
|
701
|
+
}
|
|
702
|
+
function parseCliDeployOutput(output) {
|
|
703
|
+
const claim = output.match(/https:\/\/app\.netlify\.com\/claim[^\s<>"')\]]*/);
|
|
704
|
+
const all = (output.match(/https:\/\/[a-z0-9-]+\.netlify\.app[^\s<>"')\]]*/gi) || []).map(stripTrailingPunctuation);
|
|
705
|
+
const host = (u) => u.replace(/^https:\/\//, "").split("/")[0];
|
|
706
|
+
const prod = all.find((u) => !host(u).includes("--"));
|
|
707
|
+
return {
|
|
708
|
+
url: prod || all[0] || null,
|
|
709
|
+
claimUrl: claim ? stripTrailingPunctuation(claim[0]) : null
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
var stripTrailingPunctuation = (s) => s.replace(/[)\]>.,'"<]+$/, "");
|
|
713
|
+
function deployRecord(input) {
|
|
714
|
+
return { app: "thought-layer", kind: "deploy", version: 1, provider: "netlify", ...input };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// core/deploy-io.ts
|
|
718
|
+
var NETLIFY_API = "https://api.netlify.com/api/v1";
|
|
719
|
+
function readBuild(target) {
|
|
720
|
+
const statePath = resolveStatePath(target);
|
|
721
|
+
const manifestPath = join3(dirname3(statePath), "build.json");
|
|
722
|
+
if (!existsSync2(manifestPath)) {
|
|
723
|
+
throw new Error(
|
|
724
|
+
`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.`
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
const manifest = JSON.parse(readFileSync2(manifestPath, "utf8"));
|
|
728
|
+
const projectRoot = dirname3(dirname3(statePath));
|
|
729
|
+
const publishDirAbs = resolvePublishDir(manifest.publishDir, projectRoot);
|
|
730
|
+
return { manifest, manifestPath, publishDirAbs, stateFile: statePath };
|
|
731
|
+
}
|
|
732
|
+
function resolvePublishDir(publishDir, projectRoot) {
|
|
733
|
+
const candidates = [resolve3(projectRoot, publishDir), resolve3(process.cwd(), publishDir)];
|
|
734
|
+
for (const c of candidates) if (existsSync2(c)) return c;
|
|
735
|
+
throw new Error(
|
|
736
|
+
`Publish dir "${publishDir}" from build.json does not exist (looked in ${candidates.join(" and ")}). Re-run the build, or fix publishDir in build.json.`
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
function walkPublishDir(dir) {
|
|
740
|
+
const files = {};
|
|
741
|
+
const walk = (d) => {
|
|
742
|
+
for (const name of readdirSync2(d)) {
|
|
743
|
+
const full = join3(d, name);
|
|
744
|
+
const st = statSync(full);
|
|
745
|
+
if (st.isDirectory()) walk(full);
|
|
746
|
+
else if (st.isFile()) {
|
|
747
|
+
const rel = relative(dir, full).split(/[\\/]/).join("/");
|
|
748
|
+
files["/" + rel] = readFileSync2(full);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
walk(dir);
|
|
753
|
+
return files;
|
|
754
|
+
}
|
|
755
|
+
async function netlifyJson(url, init, token) {
|
|
756
|
+
const res = await fetch(url, {
|
|
757
|
+
...init,
|
|
758
|
+
headers: { Authorization: `Bearer ${token}`, ...init.headers || {} }
|
|
759
|
+
});
|
|
760
|
+
const body = await res.text();
|
|
761
|
+
if (!res.ok) {
|
|
762
|
+
throw new Error(`Netlify API ${res.status} ${res.statusText} on ${init.method || "GET"} ${url}: ${body.slice(0, 400)}`);
|
|
763
|
+
}
|
|
764
|
+
return body ? JSON.parse(body) : {};
|
|
765
|
+
}
|
|
766
|
+
async function digestDeploy(files, opts) {
|
|
767
|
+
let siteId = opts.siteId;
|
|
768
|
+
let adminUrl = "";
|
|
769
|
+
let siteUrl = "";
|
|
770
|
+
if (!siteId) {
|
|
771
|
+
const body = opts.siteName ? JSON.stringify({ name: sanitizeSiteName(opts.siteName) }) : JSON.stringify({});
|
|
772
|
+
const site = await netlifyJson(`${NETLIFY_API}/sites`, { method: "POST", headers: { "Content-Type": "application/json" }, body }, opts.token);
|
|
773
|
+
siteId = String(site["id"] || "");
|
|
774
|
+
adminUrl = String(site["admin_url"] || "");
|
|
775
|
+
siteUrl = String(site["ssl_url"] || site["url"] || "");
|
|
776
|
+
}
|
|
777
|
+
const { digests, pathForDigest } = buildFileDigests(files);
|
|
778
|
+
const deploy = await netlifyJson(
|
|
779
|
+
`${NETLIFY_API}/sites/${siteId}/deploys`,
|
|
780
|
+
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ files: digests }) },
|
|
781
|
+
opts.token
|
|
782
|
+
);
|
|
783
|
+
const deployId = String(deploy["id"] || "");
|
|
784
|
+
const required = Array.isArray(deploy["required"]) ? deploy["required"] : [];
|
|
785
|
+
if (!adminUrl) adminUrl = String(deploy["admin_url"] || "");
|
|
786
|
+
let uploaded = 0;
|
|
787
|
+
for (const sha of required) {
|
|
788
|
+
const key = pathForDigest[sha];
|
|
789
|
+
const buf = key ? files[key] : void 0;
|
|
790
|
+
if (!key || !buf) continue;
|
|
791
|
+
const r = await fetch(`${NETLIFY_API}/deploys/${deployId}/files/${uploadPath(key)}`, {
|
|
792
|
+
method: "PUT",
|
|
793
|
+
headers: { Authorization: `Bearer ${opts.token}`, "Content-Type": "application/octet-stream" },
|
|
794
|
+
body: new Uint8Array(buf)
|
|
795
|
+
// Buffer is a Uint8Array; this satisfies BodyInit cleanly.
|
|
796
|
+
});
|
|
797
|
+
if (!r.ok) throw new Error(`Netlify upload ${r.status} for ${key}: ${(await r.text()).slice(0, 200)}`);
|
|
798
|
+
uploaded++;
|
|
799
|
+
}
|
|
800
|
+
let state = String(deploy["state"] || "");
|
|
801
|
+
for (let i = 0; i < 30 && state !== "ready" && state !== "error"; i++) {
|
|
802
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
803
|
+
const d = await netlifyJson(`${NETLIFY_API}/deploys/${deployId}`, { method: "GET" }, opts.token);
|
|
804
|
+
state = String(d["state"] || "");
|
|
805
|
+
if (!siteUrl) siteUrl = String(d["ssl_url"] || d["deploy_ssl_url"] || "");
|
|
806
|
+
}
|
|
807
|
+
if (state === "error") throw new Error(`Netlify deploy ${deployId} reported state "error".`);
|
|
808
|
+
return { url: siteUrl, adminUrl, siteId: String(siteId), deployId, uploaded, state: state || "uploaded" };
|
|
809
|
+
}
|
|
810
|
+
function hasNetlifyCli() {
|
|
811
|
+
try {
|
|
812
|
+
const r = spawnSync("netlify", ["--version"], { encoding: "utf8", timeout: 15e3 });
|
|
813
|
+
return r.status === 0;
|
|
814
|
+
} catch {
|
|
815
|
+
return false;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
function cliSupportsAnonymous() {
|
|
819
|
+
try {
|
|
820
|
+
const r = spawnSync("netlify", ["deploy", "--help"], { encoding: "utf8", timeout: 15e3 });
|
|
821
|
+
return `${r.stdout || ""}${r.stderr || ""}`.includes("--allow-anonymous");
|
|
822
|
+
} catch {
|
|
823
|
+
return false;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
function cliLoggedIn() {
|
|
827
|
+
try {
|
|
828
|
+
const r = spawnSync("netlify", ["api", "getCurrentUser", "--data", "{}"], { encoding: "utf8", timeout: 2e4 });
|
|
829
|
+
return r.status === 0 && (r.stdout || "").trim().startsWith("{");
|
|
830
|
+
} catch {
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
function cliDeploy(publishDirAbs, opts, loggedIn) {
|
|
835
|
+
const base = ["deploy", "--dir", publishDirAbs, "--prod", "--no-build"];
|
|
836
|
+
let args;
|
|
837
|
+
let siteName = null;
|
|
838
|
+
if (loggedIn) {
|
|
839
|
+
if (opts.siteId) args = [...base, "--site", opts.siteId];
|
|
840
|
+
else {
|
|
841
|
+
siteName = sanitizeSiteName(opts.siteName || "") || `thought-layer-${randomBytes(4).toString("hex")}`;
|
|
842
|
+
args = [...base, "--create-site", siteName];
|
|
843
|
+
}
|
|
844
|
+
} else {
|
|
845
|
+
args = [...base, "--allow-anonymous"];
|
|
846
|
+
}
|
|
847
|
+
const r = spawnSync("netlify", args, { encoding: "utf8", timeout: 18e4 });
|
|
848
|
+
const raw = `${r.stdout || ""}
|
|
849
|
+
${r.stderr || ""}`.trim();
|
|
850
|
+
if (r.status !== 0) {
|
|
851
|
+
throw new Error(`netlify ${args.join(" ")} failed (exit ${r.status}). Output:
|
|
852
|
+
${raw.slice(0, 800)}`);
|
|
853
|
+
}
|
|
854
|
+
const parsed = parseCliDeployOutput(raw);
|
|
855
|
+
return { url: parsed.url, claimUrl: loggedIn ? null : parsed.claimUrl, owned: loggedIn, siteName, raw };
|
|
856
|
+
}
|
|
857
|
+
async function runDeploy(opts, ctx) {
|
|
858
|
+
let build;
|
|
859
|
+
try {
|
|
860
|
+
build = readBuild(opts.path);
|
|
861
|
+
} catch (e) {
|
|
862
|
+
return { ok: false, message: e.message, details: {} };
|
|
863
|
+
}
|
|
864
|
+
const { manifest, publishDirAbs, stateFile } = build;
|
|
865
|
+
const files = walkPublishDir(publishDirAbs);
|
|
866
|
+
const fileCount = Object.keys(files).length;
|
|
867
|
+
if (fileCount === 0) {
|
|
868
|
+
return { ok: false, message: `Publish dir ${publishDirAbs} is empty - nothing to deploy.`, details: {} };
|
|
869
|
+
}
|
|
870
|
+
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.` : "";
|
|
871
|
+
const token = process.env.NETLIFY_AUTH_TOKEN || process.env.NETLIFY_TOKEN || "";
|
|
872
|
+
const writeRecord = (rec) => {
|
|
873
|
+
const recPath = join3(dirname3(stateFile), "deploy.json");
|
|
874
|
+
mkdirSync3(dirname3(recPath), { recursive: true });
|
|
875
|
+
writeFileSync3(recPath, JSON.stringify(rec, null, 2) + "\n");
|
|
876
|
+
return recPath;
|
|
877
|
+
};
|
|
878
|
+
if (opts.dryRun) {
|
|
879
|
+
const { digests } = buildFileDigests(files);
|
|
880
|
+
return {
|
|
881
|
+
ok: true,
|
|
882
|
+
message: `Dry run: would deploy ${fileCount} files from ${publishDirAbs} (entry ${manifest.entry}) to Netlify via the ${token && !opts.anonymous ? "BYO-token digest" : "Netlify CLI (logged in -> a site in your account; logged out -> an anonymous claimable site)"} path.${backendWarn}`,
|
|
883
|
+
details: { dryRun: true, publishDir: publishDirAbs, entry: manifest.entry, fileCount, files: Object.keys(digests), hasBackend: manifest.hasBackend }
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
const wantCli = opts.anonymous || !token;
|
|
887
|
+
if (wantCli) {
|
|
888
|
+
const guide = (lead, needs) => ({
|
|
889
|
+
ok: false,
|
|
890
|
+
message: lead + `To go live, choose one:
|
|
891
|
+
1. BYO token (deploys into your own account, owned immediately): set NETLIFY_AUTH_TOKEN and re-run.
|
|
892
|
+
2. Netlify CLI (\`npm i -g netlify-cli@latest\`) then re-run - logged in it creates a site in your account; logged out it deploys anonymously with a one-hour claim link.
|
|
893
|
+
3. Manual: drag ${publishDirAbs} onto https://app.netlify.com/drop.`,
|
|
894
|
+
details: { publishDir: publishDirAbs, needs }
|
|
895
|
+
});
|
|
896
|
+
if (!hasNetlifyCli()) {
|
|
897
|
+
return guide(
|
|
898
|
+
opts.anonymous ? "A CLI deploy needs the Netlify CLI, which is not installed. " : "No NETLIFY_AUTH_TOKEN is set and the Netlify CLI is not installed. ",
|
|
899
|
+
"token-or-cli"
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
const loggedIn = cliLoggedIn();
|
|
903
|
+
if (!loggedIn && !cliSupportsAnonymous()) {
|
|
904
|
+
return guide(
|
|
905
|
+
"You are not logged into the Netlify CLI and it is too old for --allow-anonymous (that shipped 2026-03). ",
|
|
906
|
+
"newer-cli-or-login-or-token"
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
try {
|
|
910
|
+
const { url, claimUrl, owned, siteName, raw } = cliDeploy(publishDirAbs, { siteName: opts.siteName, siteId: opts.siteId }, loggedIn);
|
|
911
|
+
const recPath = writeRecord(
|
|
912
|
+
deployRecord({
|
|
913
|
+
deployedAt: ctx.deployedAt,
|
|
914
|
+
mode: owned ? "cli" : "anonymous",
|
|
915
|
+
publishDir: manifest.publishDir,
|
|
916
|
+
fileCount,
|
|
917
|
+
url,
|
|
918
|
+
adminUrl: null,
|
|
919
|
+
claimUrl,
|
|
920
|
+
siteId: null,
|
|
921
|
+
deployId: null,
|
|
922
|
+
hasBackend: manifest.hasBackend,
|
|
923
|
+
backendNote: manifest.backendNote,
|
|
924
|
+
buildProducer: manifest.producer,
|
|
925
|
+
stateFile
|
|
926
|
+
})
|
|
927
|
+
);
|
|
928
|
+
const anonNote = opts.anonymous && owned ? `
|
|
929
|
+
(Note: you asked for an anonymous deploy, but the Netlify CLI is logged in, so this went to your account. Run \`netlify logout\` first for a truly anonymous, claimable deploy.)` : "";
|
|
930
|
+
return {
|
|
931
|
+
ok: true,
|
|
932
|
+
message: owned ? `Deployed to your Netlify account via the CLI${siteName ? ` (new site ${siteName})` : ""}.${url ? ` Live: ${url}` : ""}
|
|
933
|
+
It is owned by your account - no claim needed.
|
|
934
|
+
Recorded ${recPath}.${anonNote}${backendWarn}` + (!url ? `
|
|
935
|
+
(Could not parse a URL from the CLI output - see details.raw.)` : "") : `Deployed anonymously.${url ? ` Live: ${url}` : ""}${claimUrl ? `
|
|
936
|
+
Claim it within 1 hour (transfers ownership to your account): ${claimUrl}` : ""}
|
|
937
|
+
Recorded ${recPath}.${backendWarn}` + (!url || !claimUrl ? `
|
|
938
|
+
(Could not parse a URL/claim link from the CLI output - see details.raw.)` : ""),
|
|
939
|
+
details: { mode: owned ? "cli" : "anonymous", url, claimUrl, owned, siteName, fileCount, raw }
|
|
940
|
+
};
|
|
941
|
+
} catch (e) {
|
|
942
|
+
return { ok: false, message: e.message, details: { mode: "cli" } };
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
try {
|
|
946
|
+
const r = await digestDeploy(files, { token, siteName: opts.siteName, siteId: opts.siteId });
|
|
947
|
+
const recPath = writeRecord(
|
|
948
|
+
deployRecord({
|
|
949
|
+
deployedAt: ctx.deployedAt,
|
|
950
|
+
mode: "token",
|
|
951
|
+
publishDir: manifest.publishDir,
|
|
952
|
+
fileCount,
|
|
953
|
+
url: r.url || null,
|
|
954
|
+
adminUrl: r.adminUrl || null,
|
|
955
|
+
claimUrl: null,
|
|
956
|
+
siteId: r.siteId,
|
|
957
|
+
deployId: r.deployId,
|
|
958
|
+
hasBackend: manifest.hasBackend,
|
|
959
|
+
backendNote: manifest.backendNote,
|
|
960
|
+
buildProducer: manifest.producer,
|
|
961
|
+
stateFile
|
|
962
|
+
})
|
|
963
|
+
);
|
|
964
|
+
return {
|
|
965
|
+
ok: true,
|
|
966
|
+
message: `Deployed to your Netlify account (${r.uploaded} file${r.uploaded === 1 ? "" : "s"} uploaded, state ${r.state}).${r.url ? ` Live: ${r.url}` : ""}${r.adminUrl ? `
|
|
967
|
+
Manage: ${r.adminUrl}` : ""}
|
|
968
|
+
It is owned by your account - no claim needed. Re-deploy to the same site with --site ${r.siteId}.
|
|
969
|
+
Recorded ${recPath}.${backendWarn}`,
|
|
970
|
+
details: { mode: "token", url: r.url, adminUrl: r.adminUrl, siteId: r.siteId, deployId: r.deployId, uploaded: r.uploaded, state: r.state }
|
|
971
|
+
};
|
|
972
|
+
} catch (e) {
|
|
973
|
+
return { ok: false, message: `Deploy failed: ${e.message}`, details: { mode: "token" } };
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
672
977
|
// bin/tl.ts
|
|
673
978
|
var HELP = `tl - read/write a portable Thought Layer state file (default: .thought-layer/state.json)
|
|
674
979
|
|
|
675
980
|
tl read [path] [--json] where the run stands
|
|
676
981
|
tl list [dir] list the state files under .thought-layer/ (juggle several ideas)
|
|
677
982
|
tl scaffold [--out dist] [--domain x.com] [--founder "Name"] deterministic deployable static site from the spec + brand
|
|
983
|
+
tl deploy [--dry-run] [--anonymous] [--name x] [--site id] take build.json's publish dir live to a user-owned Netlify URL
|
|
678
984
|
tl export [path] handoff check
|
|
679
985
|
tl answer <qId> <value> [path] record an answer
|
|
680
986
|
tl feedback --data '<json>' record a panel verdict ({qId,mode,personas,endState,round})
|
|
@@ -709,7 +1015,7 @@ function parseArgs(argv) {
|
|
|
709
1015
|
function readData(flags) {
|
|
710
1016
|
const d = flags["data"];
|
|
711
1017
|
if (d === void 0) return void 0;
|
|
712
|
-
const raw = d === "-" || d === true ?
|
|
1018
|
+
const raw = d === "-" || d === true ? readFileSync3(0, "utf8") : String(d);
|
|
713
1019
|
try {
|
|
714
1020
|
return JSON.parse(raw);
|
|
715
1021
|
} catch {
|
|
@@ -763,6 +1069,23 @@ function main() {
|
|
|
763
1069
|
else console.log(r2.message);
|
|
764
1070
|
process.exit(r2.ok ? 0 : 1);
|
|
765
1071
|
}
|
|
1072
|
+
if (args[0] === "deploy") {
|
|
1073
|
+
runDeploy(
|
|
1074
|
+
{
|
|
1075
|
+
path: typeof flags["path"] === "string" ? flags["path"] : void 0,
|
|
1076
|
+
dryRun: flags["dry-run"] === true,
|
|
1077
|
+
anonymous: flags["anonymous"] === true,
|
|
1078
|
+
siteName: typeof flags["name"] === "string" ? flags["name"] : void 0,
|
|
1079
|
+
siteId: typeof flags["site"] === "string" ? flags["site"] : void 0
|
|
1080
|
+
},
|
|
1081
|
+
{ deployedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
1082
|
+
).then((r2) => {
|
|
1083
|
+
if (flags["json"]) console.log(JSON.stringify(r2.details, null, 2));
|
|
1084
|
+
else console.log(r2.message);
|
|
1085
|
+
process.exit(r2.ok ? 0 : 1);
|
|
1086
|
+
});
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
766
1089
|
let payload;
|
|
767
1090
|
try {
|
|
768
1091
|
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
|
+
"version": "0.4.1",
|
|
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 delegate to my Netlify CLI - logged in it creates a site in my account, logged out it deploys anonymously with 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
|
|
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,51 @@
|
|
|
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 delegates to the user's Netlify CLI (logged in: a new site in their account; logged out: an anonymous, claimable URL with 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
|
+
- **Netlify CLI (when no token is set).** Delegates to the user's installed Netlify CLI, and branches on the CLI's own login state (a logged-in CLI ignores `--allow-anonymous`, so the tool checks):
|
|
31
|
+
- **logged in** -> creates a new site in the user's own account (`--create-site`, owned immediately, **no claim**), or re-deploys to `--site <id>`.
|
|
32
|
+
- **logged out** -> an **anonymous, claimable** site (`--allow-anonymous`) with 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 CLI (`npm i -g netlify-cli@latest`; the flag shipped 2026-03).
|
|
33
|
+
|
|
34
|
+
If neither a token nor a usable CLI is available, relay the tool's guidance honestly (set a token, install/update the CLI, or drag the publish dir onto https://app.netlify.com/drop) instead of pretending it deployed. If the user explicitly asked for an anonymous deploy but the CLI is logged in, the tool says it went to their account instead and that `netlify logout` first would make it anonymous - pass that on.
|
|
35
|
+
|
|
36
|
+
## Static-first honesty
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
## After it is live
|
|
41
|
+
|
|
42
|
+
Report, plainly:
|
|
43
|
+
- the **live URL**, and (anonymous only) the **claim link** with the one-hour window called out;
|
|
44
|
+
- for the token path, that it is already owned by their account and how to re-deploy (`--site <id>`);
|
|
45
|
+
- that a `.thought-layer/deploy.json` record was written (URL, mode, site/deploy ids) next to `build.json`.
|
|
46
|
+
|
|
47
|
+
If the tool returned `ok: false`, do not claim success - surface its message (the next honest step) verbatim.
|
|
48
|
+
|
|
49
|
+
## Persisting
|
|
50
|
+
|
|
51
|
+
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.
|