@hobocode/thought-layer 0.4.2 → 0.6.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/dist/tl.js CHANGED
@@ -624,6 +624,7 @@ function scaffoldManifest(publishDir, builtAt, provenance) {
624
624
  stack: "static",
625
625
  hasBackend: false,
626
626
  backendNote: null,
627
+ backend: null,
627
628
  buildCommand: null,
628
629
  installCommand: null,
629
630
  nodeVersion: "20",
@@ -670,10 +671,10 @@ function runScaffold(opts, ctx) {
670
671
  }
671
672
 
672
673
  // core/deploy-io.ts
673
- import { spawnSync } from "child_process";
674
+ import { spawnSync as spawnSync2 } from "child_process";
674
675
  import { randomBytes } from "crypto";
675
- import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync, writeFileSync as writeFileSync3 } from "fs";
676
- import { dirname as dirname3, join as join3, relative, resolve as resolve3 } from "path";
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";
677
678
 
678
679
  // core/deploy.ts
679
680
  import { createHash } from "crypto";
@@ -714,12 +715,243 @@ function deployRecord(input) {
714
715
  return { app: "thought-layer", kind: "deploy", version: 1, provider: "netlify", ...input };
715
716
  }
716
717
 
717
- // 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";
718
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";
719
951
  function readBuild(target) {
720
952
  const statePath = resolveStatePath(target);
721
- const manifestPath = join3(dirname3(statePath), "build.json");
722
- if (!existsSync2(manifestPath)) {
953
+ const manifestPath = join4(dirname3(statePath), "build.json");
954
+ if (!existsSync3(manifestPath)) {
723
955
  throw new Error(
724
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.`
725
957
  );
@@ -731,7 +963,7 @@ function readBuild(target) {
731
963
  }
732
964
  function resolvePublishDir(publishDir, projectRoot) {
733
965
  const candidates = [resolve3(projectRoot, publishDir), resolve3(process.cwd(), publishDir)];
734
- for (const c of candidates) if (existsSync2(c)) return c;
966
+ for (const c of candidates) if (existsSync3(c)) return c;
735
967
  throw new Error(
736
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.`
737
969
  );
@@ -740,7 +972,7 @@ function walkPublishDir(dir) {
740
972
  const files = {};
741
973
  const walk = (d) => {
742
974
  for (const name of readdirSync2(d)) {
743
- const full = join3(d, name);
975
+ const full = join4(d, name);
744
976
  const st = statSync(full);
745
977
  if (st.isDirectory()) walk(full);
746
978
  else if (st.isFile()) {
@@ -769,14 +1001,14 @@ async function digestDeploy(files, opts) {
769
1001
  let siteUrl = "";
770
1002
  if (!siteId) {
771
1003
  const body = opts.siteName ? JSON.stringify({ name: sanitizeSiteName(opts.siteName) }) : JSON.stringify({});
772
- const site = await netlifyJson(`${NETLIFY_API}/sites`, { method: "POST", headers: { "Content-Type": "application/json" }, body }, opts.token);
1004
+ const site = await netlifyJson(`${NETLIFY_API2}/sites`, { method: "POST", headers: { "Content-Type": "application/json" }, body }, opts.token);
773
1005
  siteId = String(site["id"] || "");
774
1006
  adminUrl = String(site["admin_url"] || "");
775
1007
  siteUrl = String(site["ssl_url"] || site["url"] || "");
776
1008
  }
777
1009
  const { digests, pathForDigest } = buildFileDigests(files);
778
1010
  const deploy = await netlifyJson(
779
- `${NETLIFY_API}/sites/${siteId}/deploys`,
1011
+ `${NETLIFY_API2}/sites/${siteId}/deploys`,
780
1012
  { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ files: digests }) },
781
1013
  opts.token
782
1014
  );
@@ -788,7 +1020,7 @@ async function digestDeploy(files, opts) {
788
1020
  const key = pathForDigest[sha];
789
1021
  const buf = key ? files[key] : void 0;
790
1022
  if (!key || !buf) continue;
791
- const r = await fetch(`${NETLIFY_API}/deploys/${deployId}/files/${uploadPath(key)}`, {
1023
+ const r = await fetch(`${NETLIFY_API2}/deploys/${deployId}/files/${uploadPath(key)}`, {
792
1024
  method: "PUT",
793
1025
  headers: { Authorization: `Bearer ${opts.token}`, "Content-Type": "application/octet-stream" },
794
1026
  body: new Uint8Array(buf)
@@ -800,7 +1032,7 @@ async function digestDeploy(files, opts) {
800
1032
  let state = String(deploy["state"] || "");
801
1033
  for (let i = 0; i < 30 && state !== "ready" && state !== "error"; i++) {
802
1034
  await new Promise((r) => setTimeout(r, 1e3));
803
- 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);
804
1036
  state = String(d["state"] || "");
805
1037
  if (!siteUrl) siteUrl = String(d["ssl_url"] || d["deploy_ssl_url"] || "");
806
1038
  }
@@ -809,7 +1041,7 @@ async function digestDeploy(files, opts) {
809
1041
  }
810
1042
  function hasNetlifyCli() {
811
1043
  try {
812
- const r = spawnSync("netlify", ["--version"], { encoding: "utf8", timeout: 15e3 });
1044
+ const r = spawnSync2("netlify", ["--version"], { encoding: "utf8", timeout: 15e3 });
813
1045
  return r.status === 0;
814
1046
  } catch {
815
1047
  return false;
@@ -817,7 +1049,7 @@ function hasNetlifyCli() {
817
1049
  }
818
1050
  function cliSupportsAnonymous() {
819
1051
  try {
820
- const r = spawnSync("netlify", ["deploy", "--help"], { encoding: "utf8", timeout: 15e3 });
1052
+ const r = spawnSync2("netlify", ["deploy", "--help"], { encoding: "utf8", timeout: 15e3 });
821
1053
  return `${r.stdout || ""}${r.stderr || ""}`.includes("--allow-anonymous");
822
1054
  } catch {
823
1055
  return false;
@@ -825,7 +1057,7 @@ function cliSupportsAnonymous() {
825
1057
  }
826
1058
  function cliLoggedIn() {
827
1059
  try {
828
- const r = spawnSync("netlify", ["api", "getCurrentUser", "--data", "{}"], { encoding: "utf8", timeout: 2e4 });
1060
+ const r = spawnSync2("netlify", ["api", "getCurrentUser", "--data", "{}"], { encoding: "utf8", timeout: 2e4 });
829
1061
  return r.status === 0 && (r.stdout || "").trim().startsWith("{");
830
1062
  } catch {
831
1063
  return false;
@@ -844,7 +1076,7 @@ function cliDeploy(publishDirAbs, opts, loggedIn) {
844
1076
  } else {
845
1077
  args = [...base, "--allow-anonymous"];
846
1078
  }
847
- const r = spawnSync("netlify", args, { encoding: "utf8", timeout: 18e4 });
1079
+ const r = spawnSync2("netlify", args, { encoding: "utf8", timeout: 18e4 });
848
1080
  const raw = `${r.stdout || ""}
849
1081
  ${r.stderr || ""}`.trim();
850
1082
  if (r.status !== 0) {
@@ -854,6 +1086,63 @@ ${raw.slice(0, 800)}`);
854
1086
  const parsed = parseCliDeployOutput(raw);
855
1087
  return { url: parsed.url, claimUrl: loggedIn ? null : parsed.claimUrl, owned: loggedIn, siteName, raw };
856
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
+ }
857
1146
  async function runDeploy(opts, ctx) {
858
1147
  let build;
859
1148
  try {
@@ -867,22 +1156,59 @@ async function runDeploy(opts, ctx) {
867
1156
  if (fileCount === 0) {
868
1157
  return { ok: false, message: `Publish dir ${publishDirAbs} is empty - nothing to deploy.`, details: {} };
869
1158
  }
870
- const backendWarn = manifest.hasBackend ? ` WARNING: build.json says hasBackend:true${manifest.backendNote ? ` (${manifest.backendNote})` : ""}; this static deploy publishes only the front end - the server part needs serverless functions or a separate host.` : "";
1159
+ const backend = normalizeBackendMeta(manifest.backend);
1160
+ const shipBackend = manifest.hasBackend && backend?.backendKind === "serverless" && !opts.staticOnly;
1161
+ const backendWarn = manifest.hasBackend && !shipBackend ? (() => {
1162
+ const guide = backend?.guide || "BACKEND.md";
1163
+ const dbEnv = backend?.database?.envVar || "DATABASE_URL";
1164
+ const names = (backend?.envVars || []).map((v) => v.name).filter(Boolean);
1165
+ const others = names.filter((n) => n !== dbEnv);
1166
+ const envList = others.length ? `${dbEnv} plus ${others.join(", ")}` : dbEnv;
1167
+ const lead = opts.staticOnly ? `
1168
+
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.`;
1173
+ })() : "";
871
1174
  const token = process.env.NETLIFY_AUTH_TOKEN || process.env.NETLIFY_TOKEN || "";
872
1175
  const writeRecord = (rec) => {
873
- const recPath = join3(dirname3(stateFile), "deploy.json");
1176
+ const recPath = join4(dirname3(stateFile), "deploy.json");
874
1177
  mkdirSync3(dirname3(recPath), { recursive: true });
875
- writeFileSync3(recPath, JSON.stringify(rec, null, 2) + "\n");
1178
+ writeFileSync4(recPath, JSON.stringify(rec, null, 2) + "\n");
876
1179
  return recPath;
877
1180
  };
878
1181
  if (opts.dryRun) {
879
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
+ }
880
1198
  return {
881
1199
  ok: true,
882
- message: `Dry run: would deploy ${fileCount} files from ${publishDirAbs} (entry ${manifest.entry}) to Netlify via the ${token && !opts.anonymous ? "BYO-token digest" : "Netlify CLI (logged in -> a site in your account; logged out -> an anonymous claimable site)"} path.${backendWarn}`,
883
- details: { dryRun: true, publishDir: publishDirAbs, entry: manifest.entry, fileCount, files: Object.keys(digests), hasBackend: manifest.hasBackend }
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 }
884
1202
  };
885
1203
  }
1204
+ if (shipBackend && backend) {
1205
+ return runBackendDeploy(
1206
+ { manifest, backend, publishDirAbs, stateFile, fileCount, files },
1207
+ opts,
1208
+ ctx,
1209
+ { token, writeRecord }
1210
+ );
1211
+ }
886
1212
  const wantCli = opts.anonymous || !token;
887
1213
  if (wantCli) {
888
1214
  const guide = (lead, needs) => ({
@@ -921,6 +1247,7 @@ async function runDeploy(opts, ctx) {
921
1247
  deployId: null,
922
1248
  hasBackend: manifest.hasBackend,
923
1249
  backendNote: manifest.backendNote,
1250
+ backendKind: backend?.backendKind ?? null,
924
1251
  buildProducer: manifest.producer,
925
1252
  stateFile
926
1253
  })
@@ -957,6 +1284,7 @@ Recorded ${recPath}.${backendWarn}` + (!url || !claimUrl ? `
957
1284
  deployId: r.deployId,
958
1285
  hasBackend: manifest.hasBackend,
959
1286
  backendNote: manifest.backendNote,
1287
+ backendKind: backend?.backendKind ?? null,
960
1288
  buildProducer: manifest.producer,
961
1289
  stateFile
962
1290
  })
@@ -973,6 +1301,151 @@ Recorded ${recPath}.${backendWarn}`,
973
1301
  return { ok: false, message: `Deploy failed: ${e.message}`, details: { mode: "token" } };
974
1302
  }
975
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
+ }
976
1449
 
977
1450
  // bin/tl.ts
978
1451
  var HELP = `tl - read/write a portable Thought Layer state file (default: .thought-layer/state.json)
@@ -981,6 +1454,7 @@ var HELP = `tl - read/write a portable Thought Layer state file (default: .thoug
981
1454
  tl list [dir] list the state files under .thought-layer/ (juggle several ideas)
982
1455
  tl scaffold [--out dist] [--domain x.com] [--founder "Name"] deterministic deployable static site from the spec + brand
983
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
984
1458
  tl export [path] handoff check
985
1459
  tl answer <qId> <value> [path] record an answer
986
1460
  tl feedback --data '<json>' record a panel verdict ({qId,mode,personas,endState,round})
@@ -1076,7 +1550,10 @@ function main() {
1076
1550
  dryRun: flags["dry-run"] === true,
1077
1551
  anonymous: flags["anonymous"] === true,
1078
1552
  siteName: typeof flags["name"] === "string" ? flags["name"] : void 0,
1079
- 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
1080
1557
  },
1081
1558
  { deployedAt: (/* @__PURE__ */ new Date()).toISOString() }
1082
1559
  ).then((r2) => {