@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/README.md +3 -1
- package/SECURITY.md +9 -1
- package/core/backend-io.ts +231 -0
- package/core/backend.ts +318 -0
- package/core/deploy-io.ts +279 -7
- package/core/deploy.ts +13 -0
- package/core/index.ts +2 -0
- package/core/scaffold.ts +7 -0
- package/dist/tl.js +499 -22
- package/extensions/thought-layer.ts +11 -7
- package/package.json +3 -1
- package/prompts/tl-build.md +1 -1
- package/prompts/tl-deploy.md +1 -1
- package/skills/thought-layer-build/SKILL.md +26 -3
- package/skills/thought-layer-deploy/SKILL.md +9 -3
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
|
|
676
|
-
import { dirname as dirname3, join as
|
|
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/
|
|
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 =
|
|
722
|
-
if (!
|
|
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 (
|
|
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 =
|
|
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(`${
|
|
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
|
-
`${
|
|
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(`${
|
|
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(`${
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
1176
|
+
const recPath = join4(dirname3(stateFile), "deploy.json");
|
|
874
1177
|
mkdirSync3(dirname3(recPath), { recursive: true });
|
|
875
|
-
|
|
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.${
|
|
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) => {
|