@hobocode/thought-layer 0.4.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 +1 -1
- package/core/deploy-io.ts +76 -28
- package/core/deploy.ts +15 -10
- package/dist/tl.js +53 -27
- package/package.json +1 -1
- package/prompts/tl-deploy.md +1 -1
- package/skills/thought-layer-deploy/SKILL.md +5 -3
package/README.md
CHANGED
|
@@ -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 (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
|
|
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
|
|
package/core/deploy-io.ts
CHANGED
|
@@ -15,13 +15,14 @@
|
|
|
15
15
|
// handshake; we use the vendor tool when it is installed.
|
|
16
16
|
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { randomBytes } from "node:crypto";
|
|
18
19
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
19
20
|
import { dirname, join, relative, resolve } from "node:path";
|
|
20
21
|
import { resolveStatePath } from "./state-file.ts";
|
|
21
22
|
import type { BuildManifest } from "./scaffold.ts";
|
|
22
23
|
import type { StateOpResult } from "./state-ops.ts";
|
|
23
24
|
import {
|
|
24
|
-
buildFileDigests, uploadPath, sanitizeSiteName,
|
|
25
|
+
buildFileDigests, uploadPath, sanitizeSiteName, parseCliDeployOutput, deployRecord,
|
|
25
26
|
type FileMap, type DeployRecord,
|
|
26
27
|
} from "./deploy.ts";
|
|
27
28
|
|
|
@@ -166,7 +167,7 @@ async function digestDeploy(
|
|
|
166
167
|
return { url: siteUrl, adminUrl, siteId: String(siteId), deployId, uploaded, state: state || "uploaded" };
|
|
167
168
|
}
|
|
168
169
|
|
|
169
|
-
// ---- the
|
|
170
|
+
// ---- the no-env-token path: delegate to the Netlify CLI ----------------------
|
|
170
171
|
|
|
171
172
|
export function hasNetlifyCli(): boolean {
|
|
172
173
|
try {
|
|
@@ -189,17 +190,55 @@ export function cliSupportsAnonymous(): boolean {
|
|
|
189
190
|
}
|
|
190
191
|
}
|
|
191
192
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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 });
|
|
198
235
|
const raw = `${r.stdout || ""}\n${r.stderr || ""}`.trim();
|
|
199
236
|
if (r.status !== 0) {
|
|
200
|
-
throw new Error(`netlify
|
|
237
|
+
throw new Error(`netlify ${args.join(" ")} failed (exit ${r.status}). Output:\n${raw.slice(0, 800)}`);
|
|
201
238
|
}
|
|
202
|
-
|
|
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 };
|
|
203
242
|
}
|
|
204
243
|
|
|
205
244
|
// ---- the orchestrator (mirrors runScaffold's result shape) -------------------
|
|
@@ -239,58 +278,67 @@ export async function runDeploy(opts: DeployRunOptions, ctx: { deployedAt: strin
|
|
|
239
278
|
ok: true,
|
|
240
279
|
message:
|
|
241
280
|
`Dry run: would deploy ${fileCount} files from ${publishDirAbs} (entry ${manifest.entry}) to Netlify ` +
|
|
242
|
-
`via the ${opts.anonymous ? "
|
|
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}`,
|
|
243
282
|
details: { dryRun: true, publishDir: publishDirAbs, entry: manifest.entry, fileCount, files: Object.keys(digests), hasBackend: manifest.hasBackend },
|
|
244
283
|
};
|
|
245
284
|
}
|
|
246
285
|
|
|
247
|
-
// ---
|
|
248
|
-
const
|
|
249
|
-
if (
|
|
250
|
-
// The three honest ways to go live, shown whenever
|
|
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.
|
|
251
290
|
const guide = (lead: string, needs: string): StateOpResult => ({
|
|
252
291
|
ok: false,
|
|
253
292
|
message:
|
|
254
293
|
lead +
|
|
255
294
|
`To go live, choose one:\n` +
|
|
256
295
|
` 1. BYO token (deploys into your own account, owned immediately): set NETLIFY_AUTH_TOKEN and re-run.\n` +
|
|
257
|
-
` 2.
|
|
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` +
|
|
258
297
|
` 3. Manual: drag ${publishDirAbs} onto https://app.netlify.com/drop.`,
|
|
259
298
|
details: { publishDir: publishDirAbs, needs },
|
|
260
299
|
});
|
|
261
300
|
if (!hasNetlifyCli()) {
|
|
262
301
|
return guide(
|
|
263
302
|
opts.anonymous
|
|
264
|
-
? "
|
|
303
|
+
? "A CLI deploy needs the Netlify CLI, which is not installed. "
|
|
265
304
|
: "No NETLIFY_AUTH_TOKEN is set and the Netlify CLI is not installed. ",
|
|
266
305
|
"token-or-cli",
|
|
267
306
|
);
|
|
268
307
|
}
|
|
269
|
-
|
|
308
|
+
const loggedIn = cliLoggedIn();
|
|
309
|
+
if (!loggedIn && !cliSupportsAnonymous()) {
|
|
270
310
|
return guide(
|
|
271
|
-
"
|
|
272
|
-
"newer-cli-or-token",
|
|
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",
|
|
273
313
|
);
|
|
274
314
|
}
|
|
275
315
|
try {
|
|
276
|
-
const { url, claimUrl, raw } =
|
|
316
|
+
const { url, claimUrl, owned, siteName, raw } = cliDeploy(publishDirAbs, { siteName: opts.siteName, siteId: opts.siteId }, loggedIn);
|
|
277
317
|
const recPath = writeRecord(
|
|
278
318
|
deployRecord({
|
|
279
|
-
deployedAt: ctx.deployedAt, mode: "anonymous", publishDir: manifest.publishDir, fileCount,
|
|
319
|
+
deployedAt: ctx.deployedAt, mode: owned ? "cli" : "anonymous", publishDir: manifest.publishDir, fileCount,
|
|
280
320
|
url, adminUrl: null, claimUrl, siteId: null, deployId: null,
|
|
281
321
|
hasBackend: manifest.hasBackend, backendNote: manifest.backendNote, buildProducer: manifest.producer, stateFile,
|
|
282
322
|
}),
|
|
283
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
|
+
: "";
|
|
284
329
|
return {
|
|
285
330
|
ok: true,
|
|
286
|
-
message:
|
|
287
|
-
`Deployed
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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 },
|
|
291
339
|
};
|
|
292
340
|
} catch (e) {
|
|
293
|
-
return { ok: false, message: (e as Error).message, details: { mode: "
|
|
341
|
+
return { ok: false, message: (e as Error).message, details: { mode: "cli" } };
|
|
294
342
|
}
|
|
295
343
|
}
|
|
296
344
|
|
package/core/deploy.ts
CHANGED
|
@@ -68,20 +68,25 @@ export function sanitizeSiteName(raw: string): string {
|
|
|
68
68
|
.replace(/-+$/g, "");
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
// Pull the live URL and the claim
|
|
72
|
-
// output. The CLI prints
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
78
83
|
return {
|
|
79
|
-
url:
|
|
84
|
+
url: prod || all[0] || null,
|
|
80
85
|
claimUrl: claim ? stripTrailingPunctuation(claim[0]) : null,
|
|
81
86
|
};
|
|
82
87
|
}
|
|
83
88
|
|
|
84
|
-
const stripTrailingPunctuation = (s: string): string => s.replace(/[)
|
|
89
|
+
const stripTrailingPunctuation = (s: string): string => s.replace(/[)\]>.,'"<]+$/, "");
|
|
85
90
|
|
|
86
91
|
// The deploy record written next to build.json / the state file. Pure provenance
|
|
87
92
|
// so a re-deploy can target the same site and the user can find their URLs.
|
|
@@ -90,7 +95,7 @@ export interface DeployRecord {
|
|
|
90
95
|
kind: "deploy";
|
|
91
96
|
version: 1;
|
|
92
97
|
deployedAt: string;
|
|
93
|
-
mode: "token" | "anonymous" | "dry-run";
|
|
98
|
+
mode: "token" | "cli" | "anonymous" | "dry-run";
|
|
94
99
|
provider: "netlify";
|
|
95
100
|
publishDir: string;
|
|
96
101
|
fileCount: number;
|
package/dist/tl.js
CHANGED
|
@@ -671,6 +671,7 @@ function runScaffold(opts, ctx) {
|
|
|
671
671
|
|
|
672
672
|
// core/deploy-io.ts
|
|
673
673
|
import { spawnSync } from "child_process";
|
|
674
|
+
import { randomBytes } from "crypto";
|
|
674
675
|
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync, writeFileSync as writeFileSync3 } from "fs";
|
|
675
676
|
import { dirname as dirname3, join as join3, relative, resolve as resolve3 } from "path";
|
|
676
677
|
|
|
@@ -698,15 +699,17 @@ function uploadPath(key) {
|
|
|
698
699
|
function sanitizeSiteName(raw) {
|
|
699
700
|
return String(raw || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/g, "");
|
|
700
701
|
}
|
|
701
|
-
function
|
|
702
|
-
const claim = output.match(/https:\/\/app\.netlify\.com\/claim\
|
|
703
|
-
const
|
|
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("--"));
|
|
704
707
|
return {
|
|
705
|
-
url:
|
|
708
|
+
url: prod || all[0] || null,
|
|
706
709
|
claimUrl: claim ? stripTrailingPunctuation(claim[0]) : null
|
|
707
710
|
};
|
|
708
711
|
}
|
|
709
|
-
var stripTrailingPunctuation = (s) => s.replace(/[)
|
|
712
|
+
var stripTrailingPunctuation = (s) => s.replace(/[)\]>.,'"<]+$/, "");
|
|
710
713
|
function deployRecord(input) {
|
|
711
714
|
return { app: "thought-layer", kind: "deploy", version: 1, provider: "netlify", ...input };
|
|
712
715
|
}
|
|
@@ -820,19 +823,36 @@ function cliSupportsAnonymous() {
|
|
|
820
823
|
return false;
|
|
821
824
|
}
|
|
822
825
|
}
|
|
823
|
-
function
|
|
824
|
-
|
|
825
|
-
"netlify",
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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 });
|
|
829
848
|
const raw = `${r.stdout || ""}
|
|
830
849
|
${r.stderr || ""}`.trim();
|
|
831
850
|
if (r.status !== 0) {
|
|
832
|
-
throw new Error(`netlify
|
|
851
|
+
throw new Error(`netlify ${args.join(" ")} failed (exit ${r.status}). Output:
|
|
833
852
|
${raw.slice(0, 800)}`);
|
|
834
853
|
}
|
|
835
|
-
|
|
854
|
+
const parsed = parseCliDeployOutput(raw);
|
|
855
|
+
return { url: parsed.url, claimUrl: loggedIn ? null : parsed.claimUrl, owned: loggedIn, siteName, raw };
|
|
836
856
|
}
|
|
837
857
|
async function runDeploy(opts, ctx) {
|
|
838
858
|
let build;
|
|
@@ -859,38 +879,39 @@ async function runDeploy(opts, ctx) {
|
|
|
859
879
|
const { digests } = buildFileDigests(files);
|
|
860
880
|
return {
|
|
861
881
|
ok: true,
|
|
862
|
-
message: `Dry run: would deploy ${fileCount} files from ${publishDirAbs} (entry ${manifest.entry}) to Netlify via the ${opts.anonymous ? "
|
|
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}`,
|
|
863
883
|
details: { dryRun: true, publishDir: publishDirAbs, entry: manifest.entry, fileCount, files: Object.keys(digests), hasBackend: manifest.hasBackend }
|
|
864
884
|
};
|
|
865
885
|
}
|
|
866
|
-
const
|
|
867
|
-
if (
|
|
886
|
+
const wantCli = opts.anonymous || !token;
|
|
887
|
+
if (wantCli) {
|
|
868
888
|
const guide = (lead, needs) => ({
|
|
869
889
|
ok: false,
|
|
870
890
|
message: lead + `To go live, choose one:
|
|
871
891
|
1. BYO token (deploys into your own account, owned immediately): set NETLIFY_AUTH_TOKEN and re-run.
|
|
872
|
-
2.
|
|
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.
|
|
873
893
|
3. Manual: drag ${publishDirAbs} onto https://app.netlify.com/drop.`,
|
|
874
894
|
details: { publishDir: publishDirAbs, needs }
|
|
875
895
|
});
|
|
876
896
|
if (!hasNetlifyCli()) {
|
|
877
897
|
return guide(
|
|
878
|
-
opts.anonymous ? "
|
|
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. ",
|
|
879
899
|
"token-or-cli"
|
|
880
900
|
);
|
|
881
901
|
}
|
|
882
|
-
|
|
902
|
+
const loggedIn = cliLoggedIn();
|
|
903
|
+
if (!loggedIn && !cliSupportsAnonymous()) {
|
|
883
904
|
return guide(
|
|
884
|
-
"
|
|
885
|
-
"newer-cli-or-token"
|
|
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"
|
|
886
907
|
);
|
|
887
908
|
}
|
|
888
909
|
try {
|
|
889
|
-
const { url, claimUrl, raw } =
|
|
910
|
+
const { url, claimUrl, owned, siteName, raw } = cliDeploy(publishDirAbs, { siteName: opts.siteName, siteId: opts.siteId }, loggedIn);
|
|
890
911
|
const recPath = writeRecord(
|
|
891
912
|
deployRecord({
|
|
892
913
|
deployedAt: ctx.deployedAt,
|
|
893
|
-
mode: "anonymous",
|
|
914
|
+
mode: owned ? "cli" : "anonymous",
|
|
894
915
|
publishDir: manifest.publishDir,
|
|
895
916
|
fileCount,
|
|
896
917
|
url,
|
|
@@ -904,16 +925,21 @@ async function runDeploy(opts, ctx) {
|
|
|
904
925
|
stateFile
|
|
905
926
|
})
|
|
906
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.)` : "";
|
|
907
930
|
return {
|
|
908
931
|
ok: true,
|
|
909
|
-
message: `Deployed
|
|
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 ? `
|
|
910
936
|
Claim it within 1 hour (transfers ownership to your account): ${claimUrl}` : ""}
|
|
911
937
|
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 }
|
|
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 }
|
|
914
940
|
};
|
|
915
941
|
} catch (e) {
|
|
916
|
-
return { ok: false, message: e.message, details: { mode: "
|
|
942
|
+
return { ok: false, message: e.message, details: { mode: "cli" } };
|
|
917
943
|
}
|
|
918
944
|
}
|
|
919
945
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hobocode/thought-layer",
|
|
3
|
-
"version": "0.4.
|
|
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>",
|
package/prompts/tl-deploy.md
CHANGED
|
@@ -2,6 +2,6 @@ Apply the **thought-layer-deploy** skill. Take the built site live to a URL I ow
|
|
|
2
2
|
|
|
3
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
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
|
|
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
6
|
|
|
7
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
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
|
|
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
4
|
---
|
|
5
5
|
|
|
6
6
|
# Deploy it: the build goes live to a URL you own
|
|
@@ -27,9 +27,11 @@ Always **dry-run first** and show the user the file list and the target, then de
|
|
|
27
27
|
The tool picks automatically; explain which one ran.
|
|
28
28
|
|
|
29
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
|
-
- **
|
|
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).
|
|
31
33
|
|
|
32
|
-
If neither a token nor a usable CLI is available, relay the tool's guidance honestly instead of pretending it deployed.
|
|
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.
|
|
33
35
|
|
|
34
36
|
## Static-first honesty
|
|
35
37
|
|