@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/README.md +1 -1
- package/SECURITY.md +7 -1
- package/core/backend-io.ts +231 -0
- package/core/backend.ts +46 -0
- package/core/deploy-io.ts +266 -9
- package/core/deploy.ts +10 -2
- package/core/index.ts +1 -0
- package/dist/tl.js +489 -25
- package/extensions/thought-layer.ts +11 -7
- package/package.json +2 -1
- package/prompts/tl-deploy.md +1 -1
- package/skills/thought-layer-build/SKILL.md +2 -2
- package/skills/thought-layer-deploy/SKILL.md +9 -3
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
|
|
677
|
-
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";
|
|
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/
|
|
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 =
|
|
723
|
-
if (!
|
|
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 (
|
|
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 =
|
|
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(`${
|
|
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
|
-
`${
|
|
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(`${
|
|
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(`${
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
872
|
-
const
|
|
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
|
-
|
|
1167
|
+
const lead = opts.staticOnly ? `
|
|
879
1168
|
|
|
880
|
-
Static
|
|
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 =
|
|
1176
|
+
const recPath = join4(dirname3(stateFile), "deploy.json");
|
|
885
1177
|
mkdirSync3(dirname3(recPath), { recursive: true });
|
|
886
|
-
|
|
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.${
|
|
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) => {
|