@hobocode/thought-layer 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/core/deploy-io.ts +319 -0
- package/core/deploy.ts +110 -0
- package/core/index.ts +4 -0
- package/core/scaffold-io.ts +56 -0
- package/core/scaffold.ts +276 -0
- package/dist/tl.js +586 -2
- package/extensions/thought-layer.ts +55 -1
- package/package.json +5 -1
- package/prompts/tl-build.md +7 -0
- package/prompts/tl-deploy.md +7 -0
- package/skills/thought-layer-build/SKILL.md +98 -0
- package/skills/thought-layer-deploy/SKILL.md +49 -0
- package/skills/thought-layer-framework/SKILL.md +2 -0
- package/skills/thought-layer-speedrun/SKILL.md +2 -0
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;
|
|
@@ -397,11 +397,564 @@ Pick one with --path .thought-layer/<name>.json, or set ${STATE_ENV} for the ses
|
|
|
397
397
|
}
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
+
// core/scaffold-io.ts
|
|
401
|
+
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
402
|
+
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2, resolve as resolve2 } from "path";
|
|
403
|
+
|
|
404
|
+
// core/scaffold.ts
|
|
405
|
+
var esc = (s) => String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
406
|
+
var fam = (f) => f.trim().replace(/\s+/g, "+");
|
|
407
|
+
function extractScaffoldSpec(state) {
|
|
408
|
+
const obj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
409
|
+
const str = (v) => typeof v === "string" ? v : "";
|
|
410
|
+
const brand = obj(state.brand);
|
|
411
|
+
const guide = obj(brand["guide"]);
|
|
412
|
+
const answers = state.answers || {};
|
|
413
|
+
const palette = Array.isArray(guide["palette"]) ? guide["palette"] : [];
|
|
414
|
+
const role = (re, fb) => {
|
|
415
|
+
const m = palette.find((p) => p?.name && new RegExp(re, "i").test(p.name));
|
|
416
|
+
return m?.hex || fb;
|
|
417
|
+
};
|
|
418
|
+
const typography = obj(guide["typography"]);
|
|
419
|
+
const fontOf = (slot, fb) => str(obj(slot)["family"]) || fb;
|
|
420
|
+
const voice = obj(guide["voice"]);
|
|
421
|
+
const logos = Array.isArray(brand["logos"]) ? brand["logos"] : [];
|
|
422
|
+
const chosen = logos.find((l) => l?.id === brand["chosenLogoId"]) || logos[0];
|
|
423
|
+
return {
|
|
424
|
+
brandName: str(guide["brandName"]) || "Your Product",
|
|
425
|
+
tagline: str(guide["tagline"]),
|
|
426
|
+
pitch: str(answers["pitch"]) || str(answers["what-statement"]),
|
|
427
|
+
positioning: str(guide["positioning"]),
|
|
428
|
+
personality: Array.isArray(guide["personality"]) ? guide["personality"].filter((x) => typeof x === "string") : [],
|
|
429
|
+
palette: {
|
|
430
|
+
primary: role("primary", palette[0]?.hex || "#1f3a5f"),
|
|
431
|
+
accent: role("accent|secondary", palette[1]?.hex || "#e8743b"),
|
|
432
|
+
ink: role("ink|text|dark|black", "#16202b"),
|
|
433
|
+
surface: role("surface|background|light|paper|off.?white|cream", "#f7f8fa"),
|
|
434
|
+
muted: role("muted|gray|grey|neutral|border", "#8a9099")
|
|
435
|
+
},
|
|
436
|
+
displayFont: fontOf(typography["display"], "Inter"),
|
|
437
|
+
bodyFont: fontOf(typography["body"], "Inter"),
|
|
438
|
+
voiceTone: str(voice["tone"]),
|
|
439
|
+
logoSvg: chosen?.svg || void 0,
|
|
440
|
+
pricing: str(answers["pricing-model"]) || void 0
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
function indexHtml(spec, opts) {
|
|
444
|
+
const domain = (opts.domain || "https://example.com").replace(/\/+$/, "");
|
|
445
|
+
const founder = opts.founderName || "";
|
|
446
|
+
const social = opts.socialImage || `${domain}/og-image.png`;
|
|
447
|
+
const name = esc(spec.brandName);
|
|
448
|
+
const headline = esc(spec.tagline || spec.brandName);
|
|
449
|
+
const lead = esc(spec.pitch || spec.positioning);
|
|
450
|
+
const desc = esc(spec.pitch || spec.positioning || spec.brandName);
|
|
451
|
+
const fonts = `https://fonts.googleapis.com/css2?family=${fam(spec.displayFont)}:wght@400;600;700;800&family=${fam(spec.bodyFont)}:wght@400;500;600&display=swap`;
|
|
452
|
+
const graph = [
|
|
453
|
+
{ "@type": "Organization", "@id": `${domain}/#org`, name: spec.brandName, url: `${domain}/`, ...founder ? { founder: { "@id": `${domain}/#founder` } } : {} },
|
|
454
|
+
...founder ? [{ "@type": "Person", "@id": `${domain}/#founder`, name: founder, worksFor: { "@id": `${domain}/#org` } }] : [],
|
|
455
|
+
{ "@type": "WebSite", "@id": `${domain}/#website`, url: `${domain}/`, name: spec.brandName, publisher: { "@id": `${domain}/#org` } },
|
|
456
|
+
{ "@type": "WebPage", "@id": `${domain}/#webpage`, url: `${domain}/`, name: `${spec.brandName}${spec.tagline ? " - " + spec.tagline : ""}`, isPartOf: { "@id": `${domain}/#website` }, about: { "@id": `${domain}/#org` }, description: spec.pitch || spec.positioning || spec.brandName }
|
|
457
|
+
];
|
|
458
|
+
const jsonLd = JSON.stringify({ "@context": "https://schema.org", "@graph": graph }).replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
|
|
459
|
+
const lockup = spec.logoSvg ? spec.logoSvg : `<span class="wordmark">${name}</span>`;
|
|
460
|
+
const pills = spec.personality.map((t) => `<li class="pill">${esc(t)}</li>`).join("");
|
|
461
|
+
const positioningSection = spec.positioning ? `<section class="value" aria-labelledby="value-h"><div class="wrap"><h2 id="value-h">Who it is for</h2><p>${esc(spec.positioning)}</p>${pills ? `<ul class="pills" aria-label="What it stands for">${pills}</ul>` : ""}</div></section>` : "";
|
|
462
|
+
const pricingSection = spec.pricing ? `<section class="pricing" aria-labelledby="pricing-h"><div class="wrap"><h2 id="pricing-h">Pricing</h2><p class="price">${esc(spec.pricing)}</p></div></section>` : "";
|
|
463
|
+
return `<!DOCTYPE html>
|
|
464
|
+
<html lang="en">
|
|
465
|
+
<head>
|
|
466
|
+
<meta charset="UTF-8">
|
|
467
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
468
|
+
<title>${name}${spec.tagline ? " - " + headline : ""}</title>
|
|
469
|
+
<meta name="description" content="${desc}">
|
|
470
|
+
<link rel="canonical" href="${domain}/">
|
|
471
|
+
<meta property="og:type" content="website">
|
|
472
|
+
<meta property="og:title" content="${name}">
|
|
473
|
+
<meta property="og:description" content="${desc}">
|
|
474
|
+
<meta property="og:url" content="${domain}/">
|
|
475
|
+
<meta property="og:image" content="${esc(social)}">
|
|
476
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
477
|
+
<meta name="twitter:title" content="${name}">
|
|
478
|
+
<meta name="twitter:description" content="${desc}">
|
|
479
|
+
<meta name="twitter:image" content="${esc(social)}">
|
|
480
|
+
<link href="${esc(fonts)}" rel="stylesheet">
|
|
481
|
+
<script type="application/ld+json">${jsonLd}</script>
|
|
482
|
+
<style>
|
|
483
|
+
:root{--p:${spec.palette.primary};--a:${spec.palette.accent};--ink:${spec.palette.ink};--su:${spec.palette.surface};--mu:${spec.palette.muted};--disp:'${spec.displayFont}',system-ui,sans-serif;--body:'${spec.bodyFont}',system-ui,sans-serif}
|
|
484
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
485
|
+
html{scroll-behavior:smooth}
|
|
486
|
+
body{background:var(--su);color:var(--ink);font-family:var(--body);line-height:1.6;-webkit-font-smoothing:antialiased}
|
|
487
|
+
.wrap{max-width:960px;margin:0 auto;padding:0 24px}
|
|
488
|
+
a{color:var(--a)}
|
|
489
|
+
:focus-visible{outline:3px solid var(--a);outline-offset:2px}
|
|
490
|
+
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
|
|
491
|
+
header{padding:24px 0}
|
|
492
|
+
header .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px}
|
|
493
|
+
.wordmark{font-family:var(--disp);font-weight:800;font-size:22px;color:var(--p)}
|
|
494
|
+
.logo svg,.logo img{height:36px;width:auto}
|
|
495
|
+
.cta{display:inline-block;background:var(--p);color:#fff;text-decoration:none;font-weight:600;padding:12px 22px;border-radius:10px;border:0;cursor:pointer;font-size:15px}
|
|
496
|
+
.cta.alt{background:var(--a)}
|
|
497
|
+
.hero{padding:72px 0 56px}
|
|
498
|
+
.hero h1{font-family:var(--disp);font-weight:800;font-size:clamp(34px,6vw,60px);line-height:1.05;color:var(--ink);letter-spacing:-0.02em;max-width:14ch}
|
|
499
|
+
.hero p.lead{font-size:clamp(17px,2.4vw,22px);opacity:.82;margin:20px 0 28px;max-width:42ch}
|
|
500
|
+
.signup{display:flex;gap:10px;flex-wrap:wrap;max-width:460px}
|
|
501
|
+
.signup input[type=email]{flex:1 1 220px;padding:12px 14px;border:1px solid var(--mu);border-radius:10px;font:inherit;background:#fff;color:var(--ink)}
|
|
502
|
+
.value,.pricing{padding:48px 0;border-top:1px solid color-mix(in srgb,var(--mu) 40%,transparent)}
|
|
503
|
+
.value h2,.pricing h2{font-family:var(--disp);font-weight:700;font-size:28px;margin-bottom:12px}
|
|
504
|
+
.value p{max-width:54ch;opacity:.85}
|
|
505
|
+
ul.pills{list-style:none;display:flex;flex-wrap:wrap;gap:10px;margin-top:22px}
|
|
506
|
+
.pill{font-size:13px;font-weight:600;color:var(--p);background:color-mix(in srgb,var(--p) 12%,transparent);padding:6px 14px;border-radius:999px}
|
|
507
|
+
.pricing .price{font-family:var(--disp);font-size:24px;font-weight:700;color:var(--p);margin-top:8px}
|
|
508
|
+
footer{padding:48px 0;border-top:1px solid color-mix(in srgb,var(--mu) 40%,transparent);color:var(--mu);font-size:13px}
|
|
509
|
+
@media (max-width:640px){.hero{padding:48px 0 40px}header .wrap{flex-direction:column;align-items:flex-start}}
|
|
510
|
+
</style>
|
|
511
|
+
</head>
|
|
512
|
+
<body>
|
|
513
|
+
<header><div class="wrap"><div class="logo">${lockup}</div><a class="cta" href="#get-started">Get early access</a></div></header>
|
|
514
|
+
<main>
|
|
515
|
+
<section class="hero" aria-labelledby="hero-h"><div class="wrap">
|
|
516
|
+
<h1 id="hero-h">${headline}</h1>
|
|
517
|
+
<p class="lead">${lead}</p>
|
|
518
|
+
<form name="signups" id="get-started" method="POST" data-netlify="true" class="signup">
|
|
519
|
+
<input type="hidden" name="form-name" value="signups">
|
|
520
|
+
<label class="sr-only" for="email">Email address</label>
|
|
521
|
+
<input id="email" type="email" name="email" placeholder="you@email.com" required>
|
|
522
|
+
<button type="submit" class="cta alt">Get early access</button>
|
|
523
|
+
</form>
|
|
524
|
+
</div></section>
|
|
525
|
+
${positioningSection}
|
|
526
|
+
${pricingSection}
|
|
527
|
+
</main>
|
|
528
|
+
<footer><div class="wrap"><p>${name}${founder ? ` - by ${esc(founder)}` : ""}. Scaffolded by The Thought Layer.</p></div></footer>
|
|
529
|
+
</body>
|
|
530
|
+
</html>
|
|
531
|
+
`;
|
|
532
|
+
}
|
|
533
|
+
function companionFiles(spec, opts) {
|
|
534
|
+
const domain = (opts.domain || "https://example.com").replace(/\/+$/, "");
|
|
535
|
+
const name = spec.brandName;
|
|
536
|
+
const summary = spec.pitch || spec.positioning || name;
|
|
537
|
+
const llms = `# ${name}
|
|
538
|
+
|
|
539
|
+
> ${summary}
|
|
540
|
+
|
|
541
|
+
## About
|
|
542
|
+
${name}${spec.tagline ? ` - ${spec.tagline}` : ""}. ${spec.positioning || summary}
|
|
543
|
+
|
|
544
|
+
## Pages
|
|
545
|
+
- [Home](${domain}/) - ${spec.tagline || summary}
|
|
546
|
+
|
|
547
|
+
## FAQ
|
|
548
|
+
- What is ${name}? ${summary}
|
|
549
|
+
- Who is it for? ${spec.positioning || "See the home page."}
|
|
550
|
+
`;
|
|
551
|
+
const robots = `User-agent: *
|
|
552
|
+
Allow: /
|
|
553
|
+
|
|
554
|
+
User-agent: GPTBot
|
|
555
|
+
Allow: /
|
|
556
|
+
|
|
557
|
+
User-agent: ClaudeBot
|
|
558
|
+
Allow: /
|
|
559
|
+
|
|
560
|
+
User-agent: PerplexityBot
|
|
561
|
+
Allow: /
|
|
562
|
+
|
|
563
|
+
User-agent: Google-Extended
|
|
564
|
+
Allow: /
|
|
565
|
+
|
|
566
|
+
Sitemap: ${domain}/sitemap.xml
|
|
567
|
+
`;
|
|
568
|
+
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
|
569
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
570
|
+
<url><loc>${domain}/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url>
|
|
571
|
+
</urlset>
|
|
572
|
+
`;
|
|
573
|
+
const redirects = `/* /index.html 200
|
|
574
|
+
`;
|
|
575
|
+
const netlifyToml = `[build]
|
|
576
|
+
publish = "."
|
|
577
|
+
|
|
578
|
+
[[redirects]]
|
|
579
|
+
from = "/*"
|
|
580
|
+
to = "/index.html"
|
|
581
|
+
status = 200
|
|
582
|
+
`;
|
|
583
|
+
const seoDoc = `# SEO and deployment
|
|
584
|
+
|
|
585
|
+
This static site was scaffolded deterministically by The Thought Layer. It is ready to deploy (drag the publish folder onto https://app.netlify.com/drop, or use the deploy step). Everything below is already in place; the lines marked TO FILL need your input.
|
|
586
|
+
|
|
587
|
+
## In place
|
|
588
|
+
- **Structured data**: schema.org JSON-LD (@graph) with Organization, WebSite, and WebPage in index.html.
|
|
589
|
+
- **/llms.txt**: an AI-crawler summary at the site root.
|
|
590
|
+
- **robots.txt**: allows search + AI crawlers and points to the sitemap.
|
|
591
|
+
- **sitemap.xml**: lists the home page.
|
|
592
|
+
- **Canonical + Open Graph + Twitter Card** meta on the page.
|
|
593
|
+
- **_redirects** and **netlify.toml**: SPA fallback (/* -> /index.html).
|
|
594
|
+
- **Email capture**: a Netlify Forms signup ("signups") - submissions appear in your Netlify dashboard after deploy, no backend needed.
|
|
595
|
+
- Semantic, accessible HTML (landmarks, heading order, labelled form, visible focus).
|
|
596
|
+
|
|
597
|
+
## TO FILL
|
|
598
|
+
- **Domain**: replace ${domain} with your real domain in index.html (canonical/OG), llms.txt, robots.txt, sitemap.xml. Pass --domain to the scaffold tool to set it up front.
|
|
599
|
+
- **Founder + sameAs**: add a Person with real sameAs profile links (LinkedIn, X) to the JSON-LD, and cross-reference the Organization. Pass --founder to seed the name.
|
|
600
|
+
- **Social image**: add a 1200x630 image at /og-image.png (referenced by og:image / twitter:image).
|
|
601
|
+
- **The product**: this is a landing/coming-soon page. Build the actual product with the thought-layer-build skill (/tl-build).
|
|
602
|
+
`;
|
|
603
|
+
return {
|
|
604
|
+
"llms.txt": llms,
|
|
605
|
+
"robots.txt": robots,
|
|
606
|
+
"sitemap.xml": sitemap,
|
|
607
|
+
"_redirects": redirects,
|
|
608
|
+
"netlify.toml": netlifyToml,
|
|
609
|
+
"SEO.md": seoDoc
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
function buildStarterSite(spec, opts = {}) {
|
|
613
|
+
return { files: { "index.html": indexHtml(spec, opts), ...companionFiles(spec, opts) } };
|
|
614
|
+
}
|
|
615
|
+
function scaffoldManifest(publishDir, builtAt, provenance) {
|
|
616
|
+
return {
|
|
617
|
+
app: "thought-layer",
|
|
618
|
+
kind: "build",
|
|
619
|
+
version: 1,
|
|
620
|
+
builtAt,
|
|
621
|
+
producer: "scaffold",
|
|
622
|
+
publishDir,
|
|
623
|
+
entry: "index.html",
|
|
624
|
+
stack: "static",
|
|
625
|
+
hasBackend: false,
|
|
626
|
+
backendNote: null,
|
|
627
|
+
buildCommand: null,
|
|
628
|
+
installCommand: null,
|
|
629
|
+
nodeVersion: "20",
|
|
630
|
+
provenance,
|
|
631
|
+
requirements: { total: 0, built: 0, deferred: 0, deferredIds: [] },
|
|
632
|
+
seo: { jsonLd: true, llmsTxt: true, sitemap: true, robots: true, canonical: true, openGraph: true, socialImage: false, semanticHtml: true, seoDoc: true, netlifyToml: true },
|
|
633
|
+
artifacts: { traceability: null, decisions: null, seo: "SEO.md" },
|
|
634
|
+
verified: { buildRan: true, publishDirExists: true, entryLoads: true, notes: "deterministic static scaffold; landing page + SEO files written" }
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// core/scaffold-io.ts
|
|
639
|
+
function runScaffold(opts, ctx) {
|
|
640
|
+
try {
|
|
641
|
+
const loaded = loadStateFile(opts.path);
|
|
642
|
+
const spec = extractScaffoldSpec(loaded.state);
|
|
643
|
+
const { files } = buildStarterSite(spec, { domain: opts.domain, founderName: opts.founderName, socialImage: opts.socialImage });
|
|
644
|
+
const outDir = opts.outDir || "dist";
|
|
645
|
+
const outAbs = isAbsolute2(outDir) ? outDir : resolve2(process.cwd(), outDir);
|
|
646
|
+
mkdirSync2(outAbs, { recursive: true });
|
|
647
|
+
for (const [name, content] of Object.entries(files)) {
|
|
648
|
+
writeFileSync2(join2(outAbs, name), content);
|
|
649
|
+
}
|
|
650
|
+
const prd = loaded.state.prd && typeof loaded.state.prd === "object" ? loaded.state.prd : null;
|
|
651
|
+
const grill = loaded.state.grill && typeof loaded.state.grill === "object" ? loaded.state.grill : null;
|
|
652
|
+
const manifest = scaffoldManifest(outDir, ctx.builtAt, {
|
|
653
|
+
stateFile: loaded.path,
|
|
654
|
+
prdTs: prd && typeof prd["ts"] === "number" ? prd["ts"] : null,
|
|
655
|
+
grillDone: !!(grill && grill["done"] === true),
|
|
656
|
+
fromSpeedrun: loaded.state.kit?.cursor?.phase === "speedrun"
|
|
657
|
+
});
|
|
658
|
+
const manifestPath = join2(dirname2(loaded.path), "build.json");
|
|
659
|
+
mkdirSync2(dirname2(manifestPath), { recursive: true });
|
|
660
|
+
writeFileSync2(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
661
|
+
const names = Object.keys(files);
|
|
662
|
+
return {
|
|
663
|
+
ok: true,
|
|
664
|
+
message: `Scaffolded a deployable static site for "${spec.brandName}" -> ${outAbs} (${names.length} files: ${names.join(", ")}). Manifest: ${manifestPath}. Deploy the publish dir, or build the full product with /tl-build.`,
|
|
665
|
+
details: { publishDir: outDir, outAbs, files: names, manifestPath, brandName: spec.brandName, hadBrand: loaded.state.brand != null }
|
|
666
|
+
};
|
|
667
|
+
} catch (e) {
|
|
668
|
+
return { ok: false, message: `tl_scaffold error: ${e.message}`, details: {} };
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// core/deploy-io.ts
|
|
673
|
+
import { spawnSync } from "child_process";
|
|
674
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync, writeFileSync as writeFileSync3 } from "fs";
|
|
675
|
+
import { dirname as dirname3, join as join3, relative, resolve as resolve3 } from "path";
|
|
676
|
+
|
|
677
|
+
// core/deploy.ts
|
|
678
|
+
import { createHash } from "crypto";
|
|
679
|
+
var sha1Hex = (data) => createHash("sha1").update(data).digest("hex");
|
|
680
|
+
function buildFileDigests(files) {
|
|
681
|
+
const digests = {};
|
|
682
|
+
const pathForDigest = {};
|
|
683
|
+
for (const [path, buf] of Object.entries(files)) {
|
|
684
|
+
const key = normalizeKey(path);
|
|
685
|
+
const sha = sha1Hex(buf);
|
|
686
|
+
digests[key] = sha;
|
|
687
|
+
if (!(sha in pathForDigest)) pathForDigest[sha] = key;
|
|
688
|
+
}
|
|
689
|
+
return { digests, pathForDigest };
|
|
690
|
+
}
|
|
691
|
+
function normalizeKey(path) {
|
|
692
|
+
const posix = path.replace(/\\/g, "/").replace(/^\.?\/*/, "");
|
|
693
|
+
return "/" + posix;
|
|
694
|
+
}
|
|
695
|
+
function uploadPath(key) {
|
|
696
|
+
return key.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/");
|
|
697
|
+
}
|
|
698
|
+
function sanitizeSiteName(raw) {
|
|
699
|
+
return String(raw || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/g, "");
|
|
700
|
+
}
|
|
701
|
+
function parseAnonymousOutput(output) {
|
|
702
|
+
const claim = output.match(/https:\/\/app\.netlify\.com\/claim\S*/);
|
|
703
|
+
const live = output.match(/https:\/\/[a-z0-9-]+\.netlify\.app\S*/i);
|
|
704
|
+
return {
|
|
705
|
+
url: live ? stripTrailingPunctuation(live[0]) : null,
|
|
706
|
+
claimUrl: claim ? stripTrailingPunctuation(claim[0]) : null
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
var stripTrailingPunctuation = (s) => s.replace(/[).,]+$/, "");
|
|
710
|
+
function deployRecord(input) {
|
|
711
|
+
return { app: "thought-layer", kind: "deploy", version: 1, provider: "netlify", ...input };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// core/deploy-io.ts
|
|
715
|
+
var NETLIFY_API = "https://api.netlify.com/api/v1";
|
|
716
|
+
function readBuild(target) {
|
|
717
|
+
const statePath = resolveStatePath(target);
|
|
718
|
+
const manifestPath = join3(dirname3(statePath), "build.json");
|
|
719
|
+
if (!existsSync2(manifestPath)) {
|
|
720
|
+
throw new Error(
|
|
721
|
+
`No build.json found at ${manifestPath}. Run the build first: the thought-layer-build skill (/tl-build) or the tl_scaffold tool (\`tl scaffold\`) writes the manifest the deploy reads.`
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
const manifest = JSON.parse(readFileSync2(manifestPath, "utf8"));
|
|
725
|
+
const projectRoot = dirname3(dirname3(statePath));
|
|
726
|
+
const publishDirAbs = resolvePublishDir(manifest.publishDir, projectRoot);
|
|
727
|
+
return { manifest, manifestPath, publishDirAbs, stateFile: statePath };
|
|
728
|
+
}
|
|
729
|
+
function resolvePublishDir(publishDir, projectRoot) {
|
|
730
|
+
const candidates = [resolve3(projectRoot, publishDir), resolve3(process.cwd(), publishDir)];
|
|
731
|
+
for (const c of candidates) if (existsSync2(c)) return c;
|
|
732
|
+
throw new Error(
|
|
733
|
+
`Publish dir "${publishDir}" from build.json does not exist (looked in ${candidates.join(" and ")}). Re-run the build, or fix publishDir in build.json.`
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
function walkPublishDir(dir) {
|
|
737
|
+
const files = {};
|
|
738
|
+
const walk = (d) => {
|
|
739
|
+
for (const name of readdirSync2(d)) {
|
|
740
|
+
const full = join3(d, name);
|
|
741
|
+
const st = statSync(full);
|
|
742
|
+
if (st.isDirectory()) walk(full);
|
|
743
|
+
else if (st.isFile()) {
|
|
744
|
+
const rel = relative(dir, full).split(/[\\/]/).join("/");
|
|
745
|
+
files["/" + rel] = readFileSync2(full);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
walk(dir);
|
|
750
|
+
return files;
|
|
751
|
+
}
|
|
752
|
+
async function netlifyJson(url, init, token) {
|
|
753
|
+
const res = await fetch(url, {
|
|
754
|
+
...init,
|
|
755
|
+
headers: { Authorization: `Bearer ${token}`, ...init.headers || {} }
|
|
756
|
+
});
|
|
757
|
+
const body = await res.text();
|
|
758
|
+
if (!res.ok) {
|
|
759
|
+
throw new Error(`Netlify API ${res.status} ${res.statusText} on ${init.method || "GET"} ${url}: ${body.slice(0, 400)}`);
|
|
760
|
+
}
|
|
761
|
+
return body ? JSON.parse(body) : {};
|
|
762
|
+
}
|
|
763
|
+
async function digestDeploy(files, opts) {
|
|
764
|
+
let siteId = opts.siteId;
|
|
765
|
+
let adminUrl = "";
|
|
766
|
+
let siteUrl = "";
|
|
767
|
+
if (!siteId) {
|
|
768
|
+
const body = opts.siteName ? JSON.stringify({ name: sanitizeSiteName(opts.siteName) }) : JSON.stringify({});
|
|
769
|
+
const site = await netlifyJson(`${NETLIFY_API}/sites`, { method: "POST", headers: { "Content-Type": "application/json" }, body }, opts.token);
|
|
770
|
+
siteId = String(site["id"] || "");
|
|
771
|
+
adminUrl = String(site["admin_url"] || "");
|
|
772
|
+
siteUrl = String(site["ssl_url"] || site["url"] || "");
|
|
773
|
+
}
|
|
774
|
+
const { digests, pathForDigest } = buildFileDigests(files);
|
|
775
|
+
const deploy = await netlifyJson(
|
|
776
|
+
`${NETLIFY_API}/sites/${siteId}/deploys`,
|
|
777
|
+
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ files: digests }) },
|
|
778
|
+
opts.token
|
|
779
|
+
);
|
|
780
|
+
const deployId = String(deploy["id"] || "");
|
|
781
|
+
const required = Array.isArray(deploy["required"]) ? deploy["required"] : [];
|
|
782
|
+
if (!adminUrl) adminUrl = String(deploy["admin_url"] || "");
|
|
783
|
+
let uploaded = 0;
|
|
784
|
+
for (const sha of required) {
|
|
785
|
+
const key = pathForDigest[sha];
|
|
786
|
+
const buf = key ? files[key] : void 0;
|
|
787
|
+
if (!key || !buf) continue;
|
|
788
|
+
const r = await fetch(`${NETLIFY_API}/deploys/${deployId}/files/${uploadPath(key)}`, {
|
|
789
|
+
method: "PUT",
|
|
790
|
+
headers: { Authorization: `Bearer ${opts.token}`, "Content-Type": "application/octet-stream" },
|
|
791
|
+
body: new Uint8Array(buf)
|
|
792
|
+
// Buffer is a Uint8Array; this satisfies BodyInit cleanly.
|
|
793
|
+
});
|
|
794
|
+
if (!r.ok) throw new Error(`Netlify upload ${r.status} for ${key}: ${(await r.text()).slice(0, 200)}`);
|
|
795
|
+
uploaded++;
|
|
796
|
+
}
|
|
797
|
+
let state = String(deploy["state"] || "");
|
|
798
|
+
for (let i = 0; i < 30 && state !== "ready" && state !== "error"; i++) {
|
|
799
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
800
|
+
const d = await netlifyJson(`${NETLIFY_API}/deploys/${deployId}`, { method: "GET" }, opts.token);
|
|
801
|
+
state = String(d["state"] || "");
|
|
802
|
+
if (!siteUrl) siteUrl = String(d["ssl_url"] || d["deploy_ssl_url"] || "");
|
|
803
|
+
}
|
|
804
|
+
if (state === "error") throw new Error(`Netlify deploy ${deployId} reported state "error".`);
|
|
805
|
+
return { url: siteUrl, adminUrl, siteId: String(siteId), deployId, uploaded, state: state || "uploaded" };
|
|
806
|
+
}
|
|
807
|
+
function hasNetlifyCli() {
|
|
808
|
+
try {
|
|
809
|
+
const r = spawnSync("netlify", ["--version"], { encoding: "utf8", timeout: 15e3 });
|
|
810
|
+
return r.status === 0;
|
|
811
|
+
} catch {
|
|
812
|
+
return false;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
function cliSupportsAnonymous() {
|
|
816
|
+
try {
|
|
817
|
+
const r = spawnSync("netlify", ["deploy", "--help"], { encoding: "utf8", timeout: 15e3 });
|
|
818
|
+
return `${r.stdout || ""}${r.stderr || ""}`.includes("--allow-anonymous");
|
|
819
|
+
} catch {
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
function anonymousDeploy(publishDirAbs) {
|
|
824
|
+
const r = spawnSync(
|
|
825
|
+
"netlify",
|
|
826
|
+
["deploy", "--dir", publishDirAbs, "--prod", "--allow-anonymous"],
|
|
827
|
+
{ encoding: "utf8", timeout: 18e4 }
|
|
828
|
+
);
|
|
829
|
+
const raw = `${r.stdout || ""}
|
|
830
|
+
${r.stderr || ""}`.trim();
|
|
831
|
+
if (r.status !== 0) {
|
|
832
|
+
throw new Error(`netlify deploy --allow-anonymous failed (exit ${r.status}). Output:
|
|
833
|
+
${raw.slice(0, 800)}`);
|
|
834
|
+
}
|
|
835
|
+
return { ...parseAnonymousOutput(raw), raw };
|
|
836
|
+
}
|
|
837
|
+
async function runDeploy(opts, ctx) {
|
|
838
|
+
let build;
|
|
839
|
+
try {
|
|
840
|
+
build = readBuild(opts.path);
|
|
841
|
+
} catch (e) {
|
|
842
|
+
return { ok: false, message: e.message, details: {} };
|
|
843
|
+
}
|
|
844
|
+
const { manifest, publishDirAbs, stateFile } = build;
|
|
845
|
+
const files = walkPublishDir(publishDirAbs);
|
|
846
|
+
const fileCount = Object.keys(files).length;
|
|
847
|
+
if (fileCount === 0) {
|
|
848
|
+
return { ok: false, message: `Publish dir ${publishDirAbs} is empty - nothing to deploy.`, details: {} };
|
|
849
|
+
}
|
|
850
|
+
const backendWarn = manifest.hasBackend ? ` WARNING: build.json says hasBackend:true${manifest.backendNote ? ` (${manifest.backendNote})` : ""}; this static deploy publishes only the front end - the server part needs serverless functions or a separate host.` : "";
|
|
851
|
+
const token = process.env.NETLIFY_AUTH_TOKEN || process.env.NETLIFY_TOKEN || "";
|
|
852
|
+
const writeRecord = (rec) => {
|
|
853
|
+
const recPath = join3(dirname3(stateFile), "deploy.json");
|
|
854
|
+
mkdirSync3(dirname3(recPath), { recursive: true });
|
|
855
|
+
writeFileSync3(recPath, JSON.stringify(rec, null, 2) + "\n");
|
|
856
|
+
return recPath;
|
|
857
|
+
};
|
|
858
|
+
if (opts.dryRun) {
|
|
859
|
+
const { digests } = buildFileDigests(files);
|
|
860
|
+
return {
|
|
861
|
+
ok: true,
|
|
862
|
+
message: `Dry run: would deploy ${fileCount} files from ${publishDirAbs} (entry ${manifest.entry}) to Netlify via the ${opts.anonymous ? "anonymous CLI" : token ? "BYO-token digest" : "(no token set - would use the anonymous CLI or guide you)"} path.${backendWarn}`,
|
|
863
|
+
details: { dryRun: true, publishDir: publishDirAbs, entry: manifest.entry, fileCount, files: Object.keys(digests), hasBackend: manifest.hasBackend }
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
const wantAnonymous = opts.anonymous || !token;
|
|
867
|
+
if (wantAnonymous) {
|
|
868
|
+
const guide = (lead, needs) => ({
|
|
869
|
+
ok: false,
|
|
870
|
+
message: lead + `To go live, choose one:
|
|
871
|
+
1. BYO token (deploys into your own account, owned immediately): set NETLIFY_AUTH_TOKEN and re-run.
|
|
872
|
+
2. No account: a current Netlify CLI (\`npm i -g netlify-cli@latest\`) then re-run - uses netlify deploy --allow-anonymous for a 1-hour claimable URL.
|
|
873
|
+
3. Manual: drag ${publishDirAbs} onto https://app.netlify.com/drop.`,
|
|
874
|
+
details: { publishDir: publishDirAbs, needs }
|
|
875
|
+
});
|
|
876
|
+
if (!hasNetlifyCli()) {
|
|
877
|
+
return guide(
|
|
878
|
+
opts.anonymous ? "Anonymous deploy needs the Netlify CLI, which is not installed. " : "No NETLIFY_AUTH_TOKEN is set and the Netlify CLI is not installed. ",
|
|
879
|
+
"token-or-cli"
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
if (!cliSupportsAnonymous()) {
|
|
883
|
+
return guide(
|
|
884
|
+
"Your Netlify CLI is too old for --allow-anonymous (it shipped 2026-03). ",
|
|
885
|
+
"newer-cli-or-token"
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
try {
|
|
889
|
+
const { url, claimUrl, raw } = anonymousDeploy(publishDirAbs);
|
|
890
|
+
const recPath = writeRecord(
|
|
891
|
+
deployRecord({
|
|
892
|
+
deployedAt: ctx.deployedAt,
|
|
893
|
+
mode: "anonymous",
|
|
894
|
+
publishDir: manifest.publishDir,
|
|
895
|
+
fileCount,
|
|
896
|
+
url,
|
|
897
|
+
adminUrl: null,
|
|
898
|
+
claimUrl,
|
|
899
|
+
siteId: null,
|
|
900
|
+
deployId: null,
|
|
901
|
+
hasBackend: manifest.hasBackend,
|
|
902
|
+
backendNote: manifest.backendNote,
|
|
903
|
+
buildProducer: manifest.producer,
|
|
904
|
+
stateFile
|
|
905
|
+
})
|
|
906
|
+
);
|
|
907
|
+
return {
|
|
908
|
+
ok: true,
|
|
909
|
+
message: `Deployed anonymously.${url ? ` Live: ${url}` : ""}${claimUrl ? `
|
|
910
|
+
Claim it within 1 hour (transfers ownership to your account): ${claimUrl}` : ""}
|
|
911
|
+
Recorded ${recPath}.${backendWarn}` + (!url || !claimUrl ? `
|
|
912
|
+
(Could not parse a URL from the CLI output - see details.raw.)` : ""),
|
|
913
|
+
details: { mode: "anonymous", url, claimUrl, fileCount, raw }
|
|
914
|
+
};
|
|
915
|
+
} catch (e) {
|
|
916
|
+
return { ok: false, message: e.message, details: { mode: "anonymous" } };
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
const r = await digestDeploy(files, { token, siteName: opts.siteName, siteId: opts.siteId });
|
|
921
|
+
const recPath = writeRecord(
|
|
922
|
+
deployRecord({
|
|
923
|
+
deployedAt: ctx.deployedAt,
|
|
924
|
+
mode: "token",
|
|
925
|
+
publishDir: manifest.publishDir,
|
|
926
|
+
fileCount,
|
|
927
|
+
url: r.url || null,
|
|
928
|
+
adminUrl: r.adminUrl || null,
|
|
929
|
+
claimUrl: null,
|
|
930
|
+
siteId: r.siteId,
|
|
931
|
+
deployId: r.deployId,
|
|
932
|
+
hasBackend: manifest.hasBackend,
|
|
933
|
+
backendNote: manifest.backendNote,
|
|
934
|
+
buildProducer: manifest.producer,
|
|
935
|
+
stateFile
|
|
936
|
+
})
|
|
937
|
+
);
|
|
938
|
+
return {
|
|
939
|
+
ok: true,
|
|
940
|
+
message: `Deployed to your Netlify account (${r.uploaded} file${r.uploaded === 1 ? "" : "s"} uploaded, state ${r.state}).${r.url ? ` Live: ${r.url}` : ""}${r.adminUrl ? `
|
|
941
|
+
Manage: ${r.adminUrl}` : ""}
|
|
942
|
+
It is owned by your account - no claim needed. Re-deploy to the same site with --site ${r.siteId}.
|
|
943
|
+
Recorded ${recPath}.${backendWarn}`,
|
|
944
|
+
details: { mode: "token", url: r.url, adminUrl: r.adminUrl, siteId: r.siteId, deployId: r.deployId, uploaded: r.uploaded, state: r.state }
|
|
945
|
+
};
|
|
946
|
+
} catch (e) {
|
|
947
|
+
return { ok: false, message: `Deploy failed: ${e.message}`, details: { mode: "token" } };
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
400
951
|
// bin/tl.ts
|
|
401
952
|
var HELP = `tl - read/write a portable Thought Layer state file (default: .thought-layer/state.json)
|
|
402
953
|
|
|
403
954
|
tl read [path] [--json] where the run stands
|
|
404
955
|
tl list [dir] list the state files under .thought-layer/ (juggle several ideas)
|
|
956
|
+
tl scaffold [--out dist] [--domain x.com] [--founder "Name"] deterministic deployable static site from the spec + brand
|
|
957
|
+
tl deploy [--dry-run] [--anonymous] [--name x] [--site id] take build.json's publish dir live to a user-owned Netlify URL
|
|
405
958
|
tl export [path] handoff check
|
|
406
959
|
tl answer <qId> <value> [path] record an answer
|
|
407
960
|
tl feedback --data '<json>' record a panel verdict ({qId,mode,personas,endState,round})
|
|
@@ -436,7 +989,7 @@ function parseArgs(argv) {
|
|
|
436
989
|
function readData(flags) {
|
|
437
990
|
const d = flags["data"];
|
|
438
991
|
if (d === void 0) return void 0;
|
|
439
|
-
const raw = d === "-" || d === true ?
|
|
992
|
+
const raw = d === "-" || d === true ? readFileSync3(0, "utf8") : String(d);
|
|
440
993
|
try {
|
|
441
994
|
return JSON.parse(raw);
|
|
442
995
|
} catch {
|
|
@@ -476,6 +1029,37 @@ function main() {
|
|
|
476
1029
|
console.log(HELP);
|
|
477
1030
|
process.exit(0);
|
|
478
1031
|
}
|
|
1032
|
+
if (args[0] === "scaffold") {
|
|
1033
|
+
const r2 = runScaffold(
|
|
1034
|
+
{
|
|
1035
|
+
path: typeof flags["path"] === "string" ? flags["path"] : void 0,
|
|
1036
|
+
outDir: typeof flags["out"] === "string" ? flags["out"] : void 0,
|
|
1037
|
+
domain: typeof flags["domain"] === "string" ? flags["domain"] : void 0,
|
|
1038
|
+
founderName: typeof flags["founder"] === "string" ? flags["founder"] : void 0
|
|
1039
|
+
},
|
|
1040
|
+
{ builtAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
1041
|
+
);
|
|
1042
|
+
if (flags["json"]) console.log(JSON.stringify(r2.details, null, 2));
|
|
1043
|
+
else console.log(r2.message);
|
|
1044
|
+
process.exit(r2.ok ? 0 : 1);
|
|
1045
|
+
}
|
|
1046
|
+
if (args[0] === "deploy") {
|
|
1047
|
+
runDeploy(
|
|
1048
|
+
{
|
|
1049
|
+
path: typeof flags["path"] === "string" ? flags["path"] : void 0,
|
|
1050
|
+
dryRun: flags["dry-run"] === true,
|
|
1051
|
+
anonymous: flags["anonymous"] === true,
|
|
1052
|
+
siteName: typeof flags["name"] === "string" ? flags["name"] : void 0,
|
|
1053
|
+
siteId: typeof flags["site"] === "string" ? flags["site"] : void 0
|
|
1054
|
+
},
|
|
1055
|
+
{ deployedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
1056
|
+
).then((r2) => {
|
|
1057
|
+
if (flags["json"]) console.log(JSON.stringify(r2.details, null, 2));
|
|
1058
|
+
else console.log(r2.message);
|
|
1059
|
+
process.exit(r2.ok ? 0 : 1);
|
|
1060
|
+
});
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
479
1063
|
let payload;
|
|
480
1064
|
try {
|
|
481
1065
|
payload = buildOp(args, flags);
|