@hobocode/thought-layer 0.5.0 → 0.6.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/core/index.ts CHANGED
@@ -16,6 +16,7 @@ export * from "./stage-map.ts";
16
16
  export * from "./state-file.ts";
17
17
  export * from "./state-ops.ts";
18
18
  export * from "./backend.ts";
19
+ export * from "./backend-io.ts";
19
20
  export * from "./scaffold.ts";
20
21
  export * from "./scaffold-io.ts";
21
22
  export * from "./deploy.ts";
package/dist/tl.js CHANGED
@@ -671,10 +671,10 @@ function runScaffold(opts, ctx) {
671
671
  }
672
672
 
673
673
  // core/deploy-io.ts
674
- import { spawnSync } from "child_process";
674
+ import { spawnSync as spawnSync2 } from "child_process";
675
675
  import { randomBytes } from "crypto";
676
- import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync, writeFileSync as writeFileSync3 } from "fs";
677
- import { dirname as dirname3, join as join3, relative, resolve as resolve3 } from "path";
676
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync, writeFileSync as writeFileSync4 } from "fs";
677
+ import { dirname as dirname3, join as join4, relative, resolve as resolve3 } from "path";
678
678
 
679
679
  // core/deploy.ts
680
680
  import { createHash } from "crypto";
@@ -715,12 +715,243 @@ function deployRecord(input) {
715
715
  return { app: "thought-layer", kind: "deploy", version: 1, provider: "netlify", ...input };
716
716
  }
717
717
 
718
- // core/deploy-io.ts
718
+ // core/backend.ts
719
+ function envName(raw) {
720
+ const cleaned = String(raw || "").trim().toUpperCase().replace(/[^A-Z0-9_]+/g, "_").replace(/^_+|_+$/g, "");
721
+ return cleaned || "VAR";
722
+ }
723
+ function dedupeSortEnv(envVars) {
724
+ const seen = /* @__PURE__ */ new Set();
725
+ const out = [];
726
+ for (const v of envVars || []) {
727
+ const name = envName(v?.name ?? "");
728
+ if (seen.has(name)) continue;
729
+ seen.add(name);
730
+ out.push({ name, required: v?.required !== false, description: String(v?.description ?? "") });
731
+ }
732
+ return out.sort((a, b) => a.name.localeCompare(b.name));
733
+ }
734
+ function normalizeDatabase(raw) {
735
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
736
+ const r = raw;
737
+ const str = (v, fb) => typeof v === "string" && v.trim() ? v.trim() : fb;
738
+ return {
739
+ provider: str(r["provider"], "neon"),
740
+ schemaFile: str(r["schemaFile"], "schema.sql"),
741
+ envVar: str(r["envVar"], "DATABASE_URL")
742
+ };
743
+ }
744
+ function normalizeEnvVars(raw) {
745
+ if (!Array.isArray(raw)) return [];
746
+ const seen = /* @__PURE__ */ new Set();
747
+ const out = [];
748
+ for (const item of raw) {
749
+ if (!item || typeof item !== "object") continue;
750
+ const r = item;
751
+ const name = typeof r["name"] === "string" ? r["name"].trim() : "";
752
+ if (!name) continue;
753
+ if (seen.has(name)) continue;
754
+ seen.add(name);
755
+ out.push({
756
+ name,
757
+ required: typeof r["required"] === "boolean" ? r["required"] : true,
758
+ description: typeof r["description"] === "string" ? r["description"] : ""
759
+ });
760
+ }
761
+ return out;
762
+ }
763
+ function normalizeBackendMeta(raw) {
764
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
765
+ const r = raw;
766
+ const kindRaw = r["backendKind"];
767
+ const kind = kindRaw === "server" ? "server" : kindRaw === "serverless" ? "serverless" : null;
768
+ const envVars = normalizeEnvVars(r["envVars"]);
769
+ const database = normalizeDatabase(r["database"]);
770
+ const hasFunctionsDir = typeof r["functionsDir"] === "string" && r["functionsDir"].trim().length > 0;
771
+ if (kind === null && envVars.length === 0 && database === null && !hasFunctionsDir) return null;
772
+ const str = (v, fb) => typeof v === "string" && v.trim() ? v.trim() : fb;
773
+ return {
774
+ backendKind: kind ?? "serverless",
775
+ functionsDir: str(r["functionsDir"], "netlify/functions"),
776
+ runtime: str(r["runtime"], "nodejs20.x"),
777
+ nodeVersion: str(r["nodeVersion"], "20"),
778
+ envVars,
779
+ database,
780
+ guide: str(r["guide"], "BACKEND.md")
781
+ };
782
+ }
783
+ var SECRET_NAME_RE = /(KEY|SECRET|TOKEN|PASSWORD|PASSWD|DATABASE_URL|DB_URL|CONN|DSN|CREDENTIAL|PRIVATE|AUTH)/;
784
+ function looksSecret(name) {
785
+ return SECRET_NAME_RE.test(name.toUpperCase());
786
+ }
787
+ function planEnvVars(backend) {
788
+ const dbVar = backend.database?.envVar ? backend.database.envVar : "";
789
+ const merged = [
790
+ ...backend.envVars || [],
791
+ ...dbVar ? [{ name: dbVar, required: true, description: "" }] : []
792
+ ];
793
+ return dedupeSortEnv(merged).map((v) => ({
794
+ name: v.name,
795
+ scopes: ["builds", "functions"],
796
+ isSecret: looksSecret(v.name),
797
+ context: "all"
798
+ }));
799
+ }
800
+
801
+ // core/backend-io.ts
802
+ import { spawnSync } from "child_process";
803
+ import { mkdtempSync, writeFileSync as writeFileSync3, unlinkSync, existsSync as existsSync2 } from "fs";
804
+ import { tmpdir } from "os";
805
+ import { join as join3 } from "path";
719
806
  var NETLIFY_API = "https://api.netlify.com/api/v1";
807
+ var NEON_API = "https://console.neon.tech/api/v2";
808
+ async function pushEnvVarsApi(siteId, token, plan, env, accountSlug) {
809
+ const set = [];
810
+ const missing = [];
811
+ const body = plan.filter((p) => {
812
+ const has = typeof env[p.name] === "string" && env[p.name] !== "";
813
+ (has ? set : missing).push(p.name);
814
+ return has;
815
+ }).map((p) => ({
816
+ key: p.name,
817
+ scopes: p.scopes,
818
+ is_secret: p.isSecret,
819
+ values: [{ value: env[p.name], context: p.context }]
820
+ }));
821
+ if (body.length === 0) {
822
+ return { method: "api", set, missing, note: "no declared env var had a value in the deploy environment; nothing pushed" };
823
+ }
824
+ const slug = accountSlug || await getAccountSlug(siteId, token);
825
+ const res = await fetch(`${NETLIFY_API}/accounts/${encodeURIComponent(slug)}/env?site_id=${encodeURIComponent(siteId)}`, {
826
+ method: "POST",
827
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
828
+ body: JSON.stringify(body)
829
+ });
830
+ if (!res.ok) {
831
+ throw new Error(`Netlify env API ${res.status} ${res.statusText} when setting ${set.join(", ")}`);
832
+ }
833
+ return { method: "api", set, missing, note: "set via the Netlify API (secret-capable, scoped to builds and functions)" };
834
+ }
835
+ async function getAccountSlug(siteId, token) {
836
+ const res = await fetch(`${NETLIFY_API}/sites/${encodeURIComponent(siteId)}`, {
837
+ headers: { Authorization: `Bearer ${token}` }
838
+ });
839
+ if (!res.ok) throw new Error(`Netlify site lookup ${res.status} for env push`);
840
+ const site = await res.json();
841
+ const slug = String(site["account_slug"] || site["account_id"] || "");
842
+ if (!slug) throw new Error("could not resolve the account for env push");
843
+ return slug;
844
+ }
845
+ function cliImportEnv(plan, env, siteId) {
846
+ const set = [];
847
+ const missing = [];
848
+ const lines = [];
849
+ for (const p of plan) {
850
+ const v = env[p.name];
851
+ if (typeof v === "string" && v !== "") {
852
+ set.push(p.name);
853
+ lines.push(`${p.name}=${v}`);
854
+ } else {
855
+ missing.push(p.name);
856
+ }
857
+ }
858
+ if (lines.length === 0) {
859
+ return { method: "cli", set, missing, note: "no declared env var had a value in the deploy environment; nothing imported" };
860
+ }
861
+ const dir = mkdtempSync(join3(tmpdir(), "tl-env-"));
862
+ const file = join3(dir, ".env.import");
863
+ try {
864
+ writeFileSync3(file, lines.join("\n") + "\n", { mode: 384 });
865
+ const args = ["env:import", "--force", file, ...siteId ? ["--site", siteId] : []];
866
+ const r = spawnSync("netlify", args, { encoding: "utf8", timeout: 6e4 });
867
+ if (r.status !== 0) {
868
+ throw new Error(`netlify env:import failed (exit ${r.status}) for ${set.join(", ")}`);
869
+ }
870
+ return { method: "cli", set, missing, note: "imported via the Netlify CLI (applies all scopes; cannot mark secret)" };
871
+ } finally {
872
+ try {
873
+ unlinkSync(file);
874
+ } catch {
875
+ }
876
+ }
877
+ }
878
+ function resolveDbUrl(env) {
879
+ for (const name of ["DATABASE_URL", "NETLIFY_DATABASE_URL", "NETLIFY_DATABASE_URL_UNPOOLED"]) {
880
+ const v = env[name];
881
+ if (typeof v === "string" && v !== "") return { name, value: v };
882
+ }
883
+ return { name: null, value: null };
884
+ }
885
+ async function provisionNeon(env) {
886
+ const key = env["NEON_API_KEY"] || "";
887
+ if (!key) {
888
+ return { provisioned: false, url: null, note: "set NEON_API_KEY to provision, or set DATABASE_URL to bring your own database" };
889
+ }
890
+ try {
891
+ const res = await fetch(`${NEON_API}/projects`, {
892
+ method: "POST",
893
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", Accept: "application/json" },
894
+ body: JSON.stringify({ project: {} })
895
+ });
896
+ if (!res.ok) {
897
+ return { provisioned: false, url: null, note: `Neon API ${res.status} ${res.statusText} when creating the project` };
898
+ }
899
+ const data = await res.json();
900
+ const uris = Array.isArray(data["connection_uris"]) ? data["connection_uris"] : [];
901
+ const uri = uris.length ? String(uris[0]?.["connection_uri"] || "") : "";
902
+ if (!uri) return { provisioned: false, url: null, note: "Neon created the project but returned no connection string" };
903
+ return { provisioned: true, url: uri, note: "provisioned a Neon project in your account (connection string set for this deploy only)" };
904
+ } catch (e) {
905
+ return { provisioned: false, url: null, note: `Neon provisioning error: ${e.message}` };
906
+ }
907
+ }
908
+ function applySchema(schemaPath, dbUrl, env) {
909
+ if (!existsSync2(schemaPath)) {
910
+ return { applied: false, note: `schema file not found at ${schemaPath}` };
911
+ }
912
+ if (!dbUrl) {
913
+ return { applied: false, note: "no database connection string available; set DATABASE_URL or use --provision-db" };
914
+ }
915
+ const probe = spawnSync("psql", ["--version"], { encoding: "utf8", timeout: 15e3 });
916
+ if (probe.status !== 0) {
917
+ return { applied: false, note: `psql is not installed; apply it manually: psql "$DATABASE_URL" -f ${schemaPath}` };
918
+ }
919
+ let pg;
920
+ try {
921
+ pg = libpqEnv(dbUrl);
922
+ } catch {
923
+ return { applied: false, note: "could not parse the database connection string" };
924
+ }
925
+ const r = spawnSync("psql", ["-v", "ON_ERROR_STOP=1", "-f", schemaPath], {
926
+ encoding: "utf8",
927
+ timeout: 12e4,
928
+ env: { ...env, ...pg }
929
+ });
930
+ if (r.status !== 0) {
931
+ return { applied: false, note: `psql exited ${r.status} applying the schema` };
932
+ }
933
+ return { applied: true, note: "applied schema.sql with psql" };
934
+ }
935
+ function libpqEnv(dbUrl) {
936
+ const u = new URL(dbUrl);
937
+ const pg = {
938
+ PGHOST: u.hostname,
939
+ PGPORT: u.port || "5432",
940
+ PGDATABASE: decodeURIComponent(u.pathname.replace(/^\//, "")) || "neondb"
941
+ };
942
+ if (u.username) pg["PGUSER"] = decodeURIComponent(u.username);
943
+ if (u.password) pg["PGPASSWORD"] = decodeURIComponent(u.password);
944
+ const sslmode = u.searchParams.get("sslmode");
945
+ pg["PGSSLMODE"] = sslmode || "require";
946
+ return pg;
947
+ }
948
+
949
+ // core/deploy-io.ts
950
+ var NETLIFY_API2 = "https://api.netlify.com/api/v1";
720
951
  function readBuild(target) {
721
952
  const statePath = resolveStatePath(target);
722
- const manifestPath = join3(dirname3(statePath), "build.json");
723
- if (!existsSync2(manifestPath)) {
953
+ const manifestPath = join4(dirname3(statePath), "build.json");
954
+ if (!existsSync3(manifestPath)) {
724
955
  throw new Error(
725
956
  `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.`
726
957
  );
@@ -732,7 +963,7 @@ function readBuild(target) {
732
963
  }
733
964
  function resolvePublishDir(publishDir, projectRoot) {
734
965
  const candidates = [resolve3(projectRoot, publishDir), resolve3(process.cwd(), publishDir)];
735
- for (const c of candidates) if (existsSync2(c)) return c;
966
+ for (const c of candidates) if (existsSync3(c)) return c;
736
967
  throw new Error(
737
968
  `Publish dir "${publishDir}" from build.json does not exist (looked in ${candidates.join(" and ")}). Re-run the build, or fix publishDir in build.json.`
738
969
  );
@@ -741,7 +972,7 @@ function walkPublishDir(dir) {
741
972
  const files = {};
742
973
  const walk = (d) => {
743
974
  for (const name of readdirSync2(d)) {
744
- const full = join3(d, name);
975
+ const full = join4(d, name);
745
976
  const st = statSync(full);
746
977
  if (st.isDirectory()) walk(full);
747
978
  else if (st.isFile()) {
@@ -770,14 +1001,14 @@ async function digestDeploy(files, opts) {
770
1001
  let siteUrl = "";
771
1002
  if (!siteId) {
772
1003
  const body = opts.siteName ? JSON.stringify({ name: sanitizeSiteName(opts.siteName) }) : JSON.stringify({});
773
- const site = await netlifyJson(`${NETLIFY_API}/sites`, { method: "POST", headers: { "Content-Type": "application/json" }, body }, opts.token);
1004
+ const site = await netlifyJson(`${NETLIFY_API2}/sites`, { method: "POST", headers: { "Content-Type": "application/json" }, body }, opts.token);
774
1005
  siteId = String(site["id"] || "");
775
1006
  adminUrl = String(site["admin_url"] || "");
776
1007
  siteUrl = String(site["ssl_url"] || site["url"] || "");
777
1008
  }
778
1009
  const { digests, pathForDigest } = buildFileDigests(files);
779
1010
  const deploy = await netlifyJson(
780
- `${NETLIFY_API}/sites/${siteId}/deploys`,
1011
+ `${NETLIFY_API2}/sites/${siteId}/deploys`,
781
1012
  { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ files: digests }) },
782
1013
  opts.token
783
1014
  );
@@ -789,7 +1020,7 @@ async function digestDeploy(files, opts) {
789
1020
  const key = pathForDigest[sha];
790
1021
  const buf = key ? files[key] : void 0;
791
1022
  if (!key || !buf) continue;
792
- const r = await fetch(`${NETLIFY_API}/deploys/${deployId}/files/${uploadPath(key)}`, {
1023
+ const r = await fetch(`${NETLIFY_API2}/deploys/${deployId}/files/${uploadPath(key)}`, {
793
1024
  method: "PUT",
794
1025
  headers: { Authorization: `Bearer ${opts.token}`, "Content-Type": "application/octet-stream" },
795
1026
  body: new Uint8Array(buf)
@@ -801,7 +1032,7 @@ async function digestDeploy(files, opts) {
801
1032
  let state = String(deploy["state"] || "");
802
1033
  for (let i = 0; i < 30 && state !== "ready" && state !== "error"; i++) {
803
1034
  await new Promise((r) => setTimeout(r, 1e3));
804
- const d = await netlifyJson(`${NETLIFY_API}/deploys/${deployId}`, { method: "GET" }, opts.token);
1035
+ const d = await netlifyJson(`${NETLIFY_API2}/deploys/${deployId}`, { method: "GET" }, opts.token);
805
1036
  state = String(d["state"] || "");
806
1037
  if (!siteUrl) siteUrl = String(d["ssl_url"] || d["deploy_ssl_url"] || "");
807
1038
  }
@@ -810,7 +1041,7 @@ async function digestDeploy(files, opts) {
810
1041
  }
811
1042
  function hasNetlifyCli() {
812
1043
  try {
813
- const r = spawnSync("netlify", ["--version"], { encoding: "utf8", timeout: 15e3 });
1044
+ const r = spawnSync2("netlify", ["--version"], { encoding: "utf8", timeout: 15e3 });
814
1045
  return r.status === 0;
815
1046
  } catch {
816
1047
  return false;
@@ -818,7 +1049,7 @@ function hasNetlifyCli() {
818
1049
  }
819
1050
  function cliSupportsAnonymous() {
820
1051
  try {
821
- const r = spawnSync("netlify", ["deploy", "--help"], { encoding: "utf8", timeout: 15e3 });
1052
+ const r = spawnSync2("netlify", ["deploy", "--help"], { encoding: "utf8", timeout: 15e3 });
822
1053
  return `${r.stdout || ""}${r.stderr || ""}`.includes("--allow-anonymous");
823
1054
  } catch {
824
1055
  return false;
@@ -826,7 +1057,7 @@ function cliSupportsAnonymous() {
826
1057
  }
827
1058
  function cliLoggedIn() {
828
1059
  try {
829
- const r = spawnSync("netlify", ["api", "getCurrentUser", "--data", "{}"], { encoding: "utf8", timeout: 2e4 });
1060
+ const r = spawnSync2("netlify", ["api", "getCurrentUser", "--data", "{}"], { encoding: "utf8", timeout: 2e4 });
830
1061
  return r.status === 0 && (r.stdout || "").trim().startsWith("{");
831
1062
  } catch {
832
1063
  return false;
@@ -845,7 +1076,7 @@ function cliDeploy(publishDirAbs, opts, loggedIn) {
845
1076
  } else {
846
1077
  args = [...base, "--allow-anonymous"];
847
1078
  }
848
- const r = spawnSync("netlify", args, { encoding: "utf8", timeout: 18e4 });
1079
+ const r = spawnSync2("netlify", args, { encoding: "utf8", timeout: 18e4 });
849
1080
  const raw = `${r.stdout || ""}
850
1081
  ${r.stderr || ""}`.trim();
851
1082
  if (r.status !== 0) {
@@ -855,6 +1086,63 @@ ${raw.slice(0, 800)}`);
855
1086
  const parsed = parseCliDeployOutput(raw);
856
1087
  return { url: parsed.url, claimUrl: loggedIn ? null : parsed.claimUrl, owned: loggedIn, siteName, raw };
857
1088
  }
1089
+ function resolveFunctionsDir(functionsDir, projectRoot) {
1090
+ const candidates = [resolve3(projectRoot, functionsDir), resolve3(process.cwd(), functionsDir)];
1091
+ for (const c of candidates) if (existsSync3(c)) return c;
1092
+ return null;
1093
+ }
1094
+ function countFunctionFiles(dir) {
1095
+ try {
1096
+ return readdirSync2(dir).filter((n) => !n.startsWith(".")).length;
1097
+ } catch {
1098
+ return 0;
1099
+ }
1100
+ }
1101
+ async function createSiteApi(token, name) {
1102
+ const body = name ? JSON.stringify({ name: sanitizeSiteName(name) }) : JSON.stringify({});
1103
+ const site = await netlifyJson(`${NETLIFY_API2}/sites`, { method: "POST", headers: { "Content-Type": "application/json" }, body }, token);
1104
+ return {
1105
+ id: String(site["id"] || ""),
1106
+ slug: String(site["account_slug"] || site["account_id"] || ""),
1107
+ adminUrl: String(site["admin_url"] || ""),
1108
+ url: String(site["ssl_url"] || site["url"] || "")
1109
+ };
1110
+ }
1111
+ function createSiteCli(name) {
1112
+ const payload = JSON.stringify(name ? { name: sanitizeSiteName(name) } : {});
1113
+ const r = spawnSync2("netlify", ["api", "createSite", "--data", payload], { encoding: "utf8", timeout: 6e4 });
1114
+ if (r.status !== 0) {
1115
+ throw new Error(`netlify api createSite failed (exit ${r.status}): ${(r.stderr || r.stdout || "").slice(0, 300)}`);
1116
+ }
1117
+ let id = "";
1118
+ try {
1119
+ id = String(JSON.parse(r.stdout || "{}")["id"] || "");
1120
+ } catch {
1121
+ }
1122
+ if (!id) throw new Error("could not parse the new site id from the Netlify CLI");
1123
+ return { id };
1124
+ }
1125
+ function cliDeployWithFunctions(publishDirAbs, functionsDirAbs, siteId, token) {
1126
+ const args = [
1127
+ "deploy",
1128
+ "--prod",
1129
+ "--dir",
1130
+ publishDirAbs,
1131
+ "--no-build",
1132
+ ...functionsDirAbs ? ["--functions", functionsDirAbs] : [],
1133
+ "--site",
1134
+ siteId
1135
+ ];
1136
+ const childEnv = token ? { ...process.env, NETLIFY_AUTH_TOKEN: token } : process.env;
1137
+ const r = spawnSync2("netlify", args, { encoding: "utf8", timeout: 3e5, env: childEnv });
1138
+ const raw = `${r.stdout || ""}
1139
+ ${r.stderr || ""}`.trim();
1140
+ if (r.status !== 0) {
1141
+ throw new Error(`netlify ${args.join(" ")} failed (exit ${r.status}). Output:
1142
+ ${raw.slice(0, 800)}`);
1143
+ }
1144
+ return { url: parseCliDeployOutput(raw).url, raw };
1145
+ }
858
1146
  async function runDeploy(opts, ctx) {
859
1147
  let build;
860
1148
  try {
@@ -868,32 +1156,59 @@ async function runDeploy(opts, ctx) {
868
1156
  if (fileCount === 0) {
869
1157
  return { ok: false, message: `Publish dir ${publishDirAbs} is empty - nothing to deploy.`, details: {} };
870
1158
  }
871
- const backend = manifest.backend ?? null;
872
- const backendWarn = manifest.hasBackend ? (() => {
1159
+ const backend = normalizeBackendMeta(manifest.backend);
1160
+ const shipBackend = manifest.hasBackend && backend?.backendKind === "serverless" && !opts.staticOnly;
1161
+ const backendWarn = manifest.hasBackend && !shipBackend ? (() => {
873
1162
  const guide = backend?.guide || "BACKEND.md";
874
1163
  const dbEnv = backend?.database?.envVar || "DATABASE_URL";
875
1164
  const names = (backend?.envVars || []).map((v) => v.name).filter(Boolean);
876
1165
  const others = names.filter((n) => n !== dbEnv);
877
1166
  const envList = others.length ? `${dbEnv} plus ${others.join(", ")}` : dbEnv;
878
- return `
1167
+ const lead = opts.staticOnly ? `
879
1168
 
880
- Static deploy: the front end goes live now. This project also has a backend (build.json hasBackend is true${manifest.backendNote ? `: ${manifest.backendNote}` : ""}). The backend artifact (serverless functions, schema.sql, a names-only .env.example) is built but not auto-deployed yet; backend deploy automation is a follow-up. To run it yourself, follow ${guide}: provision Neon Postgres, set ${envList} in your host environment, then run netlify deploy with the functions present.`;
1169
+ Static only: the front end is live, the backend was not shipped (you passed --static-only). Re-run without it to ship the backend.` : `
1170
+
1171
+ Static deploy: the front end is live. This build also declares a backend that this deploy cannot ship automatically.`;
1172
+ return `${lead} To run the backend, follow ${guide}: provision Neon Postgres, set ${envList} in your host environment, then run netlify deploy with the functions present.`;
881
1173
  })() : "";
882
1174
  const token = process.env.NETLIFY_AUTH_TOKEN || process.env.NETLIFY_TOKEN || "";
883
1175
  const writeRecord = (rec) => {
884
- const recPath = join3(dirname3(stateFile), "deploy.json");
1176
+ const recPath = join4(dirname3(stateFile), "deploy.json");
885
1177
  mkdirSync3(dirname3(recPath), { recursive: true });
886
- writeFileSync3(recPath, JSON.stringify(rec, null, 2) + "\n");
1178
+ writeFileSync4(recPath, JSON.stringify(rec, null, 2) + "\n");
887
1179
  return recPath;
888
1180
  };
889
1181
  if (opts.dryRun) {
890
1182
  const { digests } = buildFileDigests(files);
1183
+ let backendPlanMsg = backendWarn;
1184
+ let backendPlan = null;
1185
+ if (shipBackend && backend) {
1186
+ const fnDir = resolveFunctionsDir(backend.functionsDir, dirname3(dirname3(stateFile)));
1187
+ const fnCount = fnDir ? countFunctionFiles(fnDir) : 0;
1188
+ const plan = planEnvVars(backend);
1189
+ const names = plan.map((p) => p.name);
1190
+ const missing = names.filter((n) => !(typeof process.env[n] === "string" && process.env[n] !== ""));
1191
+ const db = resolveDbUrl(process.env);
1192
+ const path = token ? "Netlify CLI for functions plus the token API for env" : "Netlify CLI (logged in) for functions and env import";
1193
+ backendPlanMsg = `
1194
+
1195
+ Backend plan: ship ${fnCount} function${fnCount === 1 ? "" : "s"} from ${backend.functionsDir} via the ${path}. Env var names (${names.length}): ${names.join(", ") || "none"}.${missing.length ? ` Missing from this environment: ${missing.join(", ")}.` : ""}${db.name ? ` Database url from ${db.name}.` : " No database url found (set DATABASE_URL, or use --provision-db)."}${opts.provisionDb ? " Would provision Neon (--provision-db)." : ""}${opts.applySchema ? " Would apply schema.sql (--apply-schema)." : ""}`;
1196
+ backendPlan = { functionsDir: backend.functionsDir, functionCount: fnCount, envVarNames: names, envVarsMissing: missing, dbUrlFrom: db.name, provisionDb: !!opts.provisionDb, applySchema: !!opts.applySchema };
1197
+ }
891
1198
  return {
892
1199
  ok: true,
893
- 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}`,
894
- details: { dryRun: true, publishDir: publishDirAbs, entry: manifest.entry, fileCount, files: Object.keys(digests), hasBackend: manifest.hasBackend }
1200
+ 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.${backendPlanMsg}`,
1201
+ details: { dryRun: true, publishDir: publishDirAbs, entry: manifest.entry, fileCount, files: Object.keys(digests), hasBackend: manifest.hasBackend, shipBackend, backendPlan }
895
1202
  };
896
1203
  }
1204
+ if (shipBackend && backend) {
1205
+ return runBackendDeploy(
1206
+ { manifest, backend, publishDirAbs, stateFile, fileCount, files },
1207
+ opts,
1208
+ ctx,
1209
+ { token, writeRecord }
1210
+ );
1211
+ }
897
1212
  const wantCli = opts.anonymous || !token;
898
1213
  if (wantCli) {
899
1214
  const guide = (lead, needs) => ({
@@ -986,6 +1301,151 @@ Recorded ${recPath}.${backendWarn}`,
986
1301
  return { ok: false, message: `Deploy failed: ${e.message}`, details: { mode: "token" } };
987
1302
  }
988
1303
  }
1304
+ async function runBackendDeploy(build, opts, ctx, io) {
1305
+ const { manifest, backend, publishDirAbs, stateFile, fileCount, files } = build;
1306
+ const { token, writeRecord } = io;
1307
+ const projectRoot = dirname3(dirname3(stateFile));
1308
+ const plan = planEnvVars(backend);
1309
+ const guide = backend.guide || "BACKEND.md";
1310
+ const notes = [];
1311
+ let dbUrl = resolveDbUrl(process.env).value;
1312
+ let dbProvisioned = false;
1313
+ if (opts.provisionDb) {
1314
+ const pr = await provisionNeon(process.env);
1315
+ notes.push(pr.note);
1316
+ if (pr.provisioned && pr.url) {
1317
+ dbUrl = pr.url;
1318
+ dbProvisioned = true;
1319
+ }
1320
+ }
1321
+ const functionsDirAbs = resolveFunctionsDir(backend.functionsDir, projectRoot);
1322
+ if (!hasNetlifyCli()) {
1323
+ if (!token) {
1324
+ return {
1325
+ ok: false,
1326
+ message: `This build has a backend, which needs the Netlify CLI to bundle and ship the functions, and the CLI is not installed. Install it (npm i -g netlify-cli@latest) and re-run, or set NETLIFY_AUTH_TOKEN to at least take the front end live, or follow ${guide}.`,
1327
+ details: { backendMode: "static-only-fallback", functionsShipped: false, needs: "netlify-cli" }
1328
+ };
1329
+ }
1330
+ try {
1331
+ const r = await digestDeploy(files, { token, siteName: opts.siteName, siteId: opts.siteId });
1332
+ let env2 = null;
1333
+ try {
1334
+ env2 = await pushEnvVarsApi(r.siteId, token, plan, process.env);
1335
+ } catch (e) {
1336
+ notes.push(`env push failed: ${e.message}`);
1337
+ }
1338
+ const recPath2 = writeRecord(deployRecord({
1339
+ deployedAt: ctx.deployedAt,
1340
+ mode: "token",
1341
+ publishDir: manifest.publishDir,
1342
+ fileCount,
1343
+ url: r.url || null,
1344
+ adminUrl: r.adminUrl || null,
1345
+ claimUrl: null,
1346
+ siteId: r.siteId,
1347
+ deployId: r.deployId,
1348
+ hasBackend: true,
1349
+ backendNote: manifest.backendNote,
1350
+ backendKind: backend.backendKind,
1351
+ backendMode: "static-only-fallback",
1352
+ functionsShipped: false,
1353
+ functionsDir: backend.functionsDir,
1354
+ envVarsSet: env2?.set ?? [],
1355
+ envVarsMissing: env2?.missing ?? plan.map((p) => p.name),
1356
+ dbProvisioned,
1357
+ schemaApplied: false,
1358
+ buildProducer: manifest.producer,
1359
+ stateFile
1360
+ }));
1361
+ return {
1362
+ ok: true,
1363
+ message: `Deployed the static front end to your Netlify account${r.url ? ` (live: ${r.url})` : ""}. The functions were NOT shipped: the Netlify CLI bundles them and it is not installed. Install it (npm i -g netlify-cli@latest) and re-run to ship the backend, or follow ${guide}.` + (env2?.set.length ? `
1364
+ Set env var names: ${env2.set.join(", ")}.` : "") + (env2?.missing.length ? `
1365
+ Declared but missing from this environment: ${env2.missing.join(", ")}.` : "") + `
1366
+ Recorded ${recPath2}.${notes.length ? `
1367
+ ${notes.join("\n")}` : ""}`,
1368
+ details: { mode: "token", backendMode: "static-only-fallback", functionsShipped: false, url: r.url, siteId: r.siteId, envVarsSet: env2?.set, envVarsMissing: env2?.missing }
1369
+ };
1370
+ } catch (e) {
1371
+ return { ok: false, message: `Static front end deploy failed: ${e.message}`, details: { backendMode: "static-only-fallback" } };
1372
+ }
1373
+ }
1374
+ if (!functionsDirAbs) {
1375
+ notes.push(`functions dir "${backend.functionsDir}" was not found on disk; shipping the front end only`);
1376
+ }
1377
+ let siteId = opts.siteId || "";
1378
+ let accountSlug;
1379
+ try {
1380
+ if (!siteId) {
1381
+ if (token) {
1382
+ const s = await createSiteApi(token, opts.siteName);
1383
+ siteId = s.id;
1384
+ accountSlug = s.slug;
1385
+ } else {
1386
+ siteId = createSiteCli(opts.siteName).id;
1387
+ }
1388
+ }
1389
+ } catch (e) {
1390
+ return { ok: false, message: `Could not create the Netlify site for the backend deploy: ${e.message}`, details: {} };
1391
+ }
1392
+ let env;
1393
+ try {
1394
+ env = token ? await pushEnvVarsApi(siteId, token, plan, process.env, accountSlug) : cliImportEnv(plan, process.env, siteId);
1395
+ } catch (e) {
1396
+ env = { method: token ? "api" : "cli", set: [], missing: plan.map((p) => p.name), note: `env push failed: ${e.message}` };
1397
+ notes.push(env.note);
1398
+ }
1399
+ let schemaApplied = false;
1400
+ if (opts.applySchema) {
1401
+ const schemaPath = resolve3(projectRoot, backend.database?.schemaFile || "schema.sql");
1402
+ const sr = applySchema(schemaPath, dbUrl, process.env);
1403
+ schemaApplied = sr.applied;
1404
+ notes.push(sr.note);
1405
+ }
1406
+ let url = null;
1407
+ try {
1408
+ const d = cliDeployWithFunctions(publishDirAbs, functionsDirAbs, siteId, token);
1409
+ url = d.url;
1410
+ } catch (e) {
1411
+ return { ok: false, message: `Backend deploy failed: ${e.message}${notes.length ? `
1412
+ ${notes.join("\n")}` : ""}`, details: { siteId, envVarsSet: env.set } };
1413
+ }
1414
+ const functionsShipped = !!functionsDirAbs;
1415
+ const recPath = writeRecord(deployRecord({
1416
+ deployedAt: ctx.deployedAt,
1417
+ mode: "cli",
1418
+ publishDir: manifest.publishDir,
1419
+ fileCount,
1420
+ url,
1421
+ adminUrl: null,
1422
+ claimUrl: null,
1423
+ siteId,
1424
+ deployId: null,
1425
+ hasBackend: true,
1426
+ backendNote: manifest.backendNote,
1427
+ backendKind: backend.backendKind,
1428
+ backendMode: "cli",
1429
+ functionsShipped,
1430
+ functionsDir: backend.functionsDir,
1431
+ envVarsSet: env.set,
1432
+ envVarsMissing: env.missing,
1433
+ dbProvisioned,
1434
+ schemaApplied,
1435
+ buildProducer: manifest.producer,
1436
+ stateFile
1437
+ }));
1438
+ return {
1439
+ ok: true,
1440
+ message: `Deployed your backend to your Netlify account via the CLI${url ? ` (live: ${url})` : ""}. ${functionsShipped ? `Functions shipped from ${backend.functionsDir}.` : `No functions were found on disk (${backend.functionsDir}); front end only.`} It is owned by your account, no claim needed. Re-deploy to the same site with --site ${siteId}.` + (env.set.length ? `
1441
+ Set env var names (${env.method}): ${env.set.join(", ")}.` : "") + (env.missing.length ? `
1442
+ Declared but missing from this environment (set them, then re-run): ${env.missing.join(", ")}.` : "") + `
1443
+ Recorded ${recPath}.${notes.length ? `
1444
+ ${notes.join("\n")}` : ""}` + (!url ? `
1445
+ (Could not parse a live URL from the CLI output; re-run to confirm.)` : ""),
1446
+ details: { mode: "cli", backendMode: "cli", url, siteId, functionsShipped, functionsDir: backend.functionsDir, envVarsSet: env.set, envVarsMissing: env.missing, dbProvisioned, schemaApplied }
1447
+ };
1448
+ }
989
1449
 
990
1450
  // bin/tl.ts
991
1451
  var HELP = `tl - read/write a portable Thought Layer state file (default: .thought-layer/state.json)
@@ -994,6 +1454,7 @@ var HELP = `tl - read/write a portable Thought Layer state file (default: .thoug
994
1454
  tl list [dir] list the state files under .thought-layer/ (juggle several ideas)
995
1455
  tl scaffold [--out dist] [--domain x.com] [--founder "Name"] deterministic deployable static site from the spec + brand
996
1456
  tl deploy [--dry-run] [--anonymous] [--name x] [--site id] take build.json's publish dir live to a user-owned Netlify URL
1457
+ [--static-only] [--provision-db] [--apply-schema] when build.json has a backend: ships functions+env by default; flags opt out or add Neon provision/schema
997
1458
  tl export [path] handoff check
998
1459
  tl answer <qId> <value> [path] record an answer
999
1460
  tl feedback --data '<json>' record a panel verdict ({qId,mode,personas,endState,round})
@@ -1089,7 +1550,10 @@ function main() {
1089
1550
  dryRun: flags["dry-run"] === true,
1090
1551
  anonymous: flags["anonymous"] === true,
1091
1552
  siteName: typeof flags["name"] === "string" ? flags["name"] : void 0,
1092
- siteId: typeof flags["site"] === "string" ? flags["site"] : void 0
1553
+ siteId: typeof flags["site"] === "string" ? flags["site"] : void 0,
1554
+ staticOnly: flags["static-only"] === true,
1555
+ provisionDb: flags["provision-db"] === true,
1556
+ applySchema: flags["apply-schema"] === true
1093
1557
  },
1094
1558
  { deployedAt: (/* @__PURE__ */ new Date()).toISOString() }
1095
1559
  ).then((r2) => {