@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 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 account it uses the Netlify CLI's `--allow-anonymous` flow for an instant URL plus a one-hour claim link. BYOK, no central account, no lock-in. `--dry-run` shows the plan first.
67
+ - **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, parseAnonymousOutput, deployRecord,
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 anonymous path: delegate to the Netlify CLI -------------------------
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
- 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
- );
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 deploy --allow-anonymous failed (exit ${r.status}). Output:\n${raw.slice(0, 800)}`);
237
+ throw new Error(`netlify ${args.join(" ")} failed (exit ${r.status}). Output:\n${raw.slice(0, 800)}`);
201
238
  }
202
- return { ...parseAnonymousOutput(raw), raw };
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 ? "anonymous CLI" : token ? "BYO-token digest" : "(no token set - would use the anonymous CLI or guide you)"} path.${backendWarn}`,
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
- // --- 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.
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. 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` +
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
- ? "Anonymous deploy needs the Netlify CLI, which is not installed. "
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
- if (!cliSupportsAnonymous()) {
308
+ const loggedIn = cliLoggedIn();
309
+ if (!loggedIn && !cliSupportsAnonymous()) {
270
310
  return guide(
271
- "Your Netlify CLI is too old for --allow-anonymous (it shipped 2026-03). ",
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 } = anonymousDeploy(publishDirAbs);
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 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 },
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: "anonymous" } };
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 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);
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: live ? stripTrailingPunctuation(live[0]) : null,
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 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);
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: live ? stripTrailingPunctuation(live[0]) : null,
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 anonymousDeploy(publishDirAbs) {
824
- const r = spawnSync(
825
- "netlify",
826
- ["deploy", "--dir", publishDirAbs, "--prod", "--allow-anonymous"],
827
- { encoding: "utf8", timeout: 18e4 }
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 deploy --allow-anonymous failed (exit ${r.status}). Output:
851
+ throw new Error(`netlify ${args.join(" ")} failed (exit ${r.status}). Output:
833
852
  ${raw.slice(0, 800)}`);
834
853
  }
835
- return { ...parseAnonymousOutput(raw), raw };
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 ? "anonymous CLI" : token ? "BYO-token digest" : "(no token set - would use the anonymous CLI or guide you)"} path.${backendWarn}`,
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 wantAnonymous = opts.anonymous || !token;
867
- if (wantAnonymous) {
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. 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.
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 ? "Anonymous deploy needs the Netlify CLI, which is not installed. " : "No NETLIFY_AUTH_TOKEN is set and the Netlify CLI is not installed. ",
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
- if (!cliSupportsAnonymous()) {
902
+ const loggedIn = cliLoggedIn();
903
+ if (!loggedIn && !cliSupportsAnonymous()) {
883
904
  return guide(
884
- "Your Netlify CLI is too old for --allow-anonymous (it shipped 2026-03). ",
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 } = anonymousDeploy(publishDirAbs);
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 anonymously.${url ? ` Live: ${url}` : ""}${claimUrl ? `
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: "anonymous" } };
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.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>",
@@ -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 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.
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 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."
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
- - **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).
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