@forgezero/agent 0.1.30 → 0.1.32

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/fz.js CHANGED
@@ -4871,7 +4871,7 @@ async function spawnWith(command, env, report = () => {}) {
4871
4871
 
4872
4872
  // src/cli/index.ts
4873
4873
  init_dist();
4874
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, statSync, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
4874
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, statSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
4875
4875
  import { dirname as dirname2 } from "path";
4876
4876
  import { fileURLToPath } from "url";
4877
4877
 
@@ -4884,14 +4884,26 @@ var MAX_AGENT_TARBALL_BYTES = 32 * 1024 * 1024;
4884
4884
  var AGENT_UPDATE_GROUP = "forgezero-update";
4885
4885
  var AGENT_UPDATE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-agent-update-helper.service";
4886
4886
  var MAX_REQUEST_BYTES = 8 * 1024;
4887
+ var UPDATE_RETRY_BASE_MS = 5 * 60000;
4888
+ var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
4887
4889
 
4888
4890
  // src/version.ts
4889
- var VERSION2 = "0.1.30";
4891
+ var VERSION2 = "0.1.32";
4890
4892
 
4891
4893
  // src/software.ts
4892
4894
  var BUN_INSTALLER_SHA256 = "bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd";
4893
4895
  var ARANGO_SHA256 = "b5a9197b4343f2ed554e1ebc1ef8e6529c7c39cde0035cdc311a4747a3355066";
4894
4896
  var CLOUDFLARED_SHA256 = "9d71c677db00134c1bd4144b7783486b654ad281b1ea62b4972098d19f770f17";
4897
+ var OS_CATALOG = [
4898
+ { id: "ubuntu", version: "26.04", architecture: "x64", status: "active" }
4899
+ ];
4900
+ var SOFTWARE_CATALOG = [
4901
+ { id: "bun", version: "1.3.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
4902
+ { id: "nginx", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
4903
+ { id: "arangodb", version: "3.11.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
4904
+ { id: "cloudflared", version: "2026.7.3", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
4905
+ { id: "ufw", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" }
4906
+ ];
4895
4907
  var UBUNTU_2604_X64 = [
4896
4908
  {
4897
4909
  requirement: { id: "bun", version: "1.3.14" },
@@ -4919,6 +4931,31 @@ var UBUNTU_2604_X64 = [
4919
4931
  install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y ufw"
4920
4932
  }
4921
4933
  ];
4934
+ function validateSoftwareRequirements(value, _options = {}) {
4935
+ if (!Array.isArray(value) || value.length > 32)
4936
+ throw new Error("software requirements must be an array of at most 32 entries");
4937
+ const seen = new Set;
4938
+ return value.map((item) => {
4939
+ if (!item || typeof item !== "object" || Array.isArray(item))
4940
+ throw new Error("software requirement must be an object");
4941
+ const row = item;
4942
+ if (Object.keys(row).some((key) => key !== "id" && key !== "version")) {
4943
+ throw new Error("software requirement contains an unknown field");
4944
+ }
4945
+ if (!["bun", "nginx", "arangodb", "cloudflared", "ufw"].includes(String(row.id)) || typeof row.version !== "string" || !/^[A-Za-z0-9][A-Za-z0-9.-]{0,31}$/.test(row.version)) {
4946
+ throw new Error("software requirement coordinate is invalid");
4947
+ }
4948
+ const requirement = { id: row.id, version: row.version };
4949
+ if (seen.has(requirement.id))
4950
+ throw new Error(`duplicate software requirement: ${requirement.id}`);
4951
+ seen.add(requirement.id);
4952
+ const catalog = SOFTWARE_CATALOG.find((candidate) => candidate.id === requirement.id && candidate.version === requirement.version);
4953
+ if (!catalog || catalog.status !== "active") {
4954
+ throw new Error(`software requirement is not active: ${requirement.id}@${requirement.version}`);
4955
+ }
4956
+ return requirement;
4957
+ });
4958
+ }
4922
4959
 
4923
4960
  // src/software-helper.ts
4924
4961
  var DEFAULT_SOFTWARE_HELPER_SOCKET = "/run/forgezero-software/helper.sock";
@@ -9530,6 +9567,262 @@ function checkProjectContext(rootInput) {
9530
9567
  return { ok: problems.length === 0, problems };
9531
9568
  }
9532
9569
 
9570
+ // src/deploy-file.ts
9571
+ import { createHash as createHash2 } from "crypto";
9572
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
9573
+ import { basename, join as join3 } from "path";
9574
+
9575
+ // src/definition.ts
9576
+ var PIPELINE_VERSION = 2;
9577
+ var DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v2.json";
9578
+
9579
+ class DefinitionError extends Error {
9580
+ constructor(message) {
9581
+ super(message);
9582
+ this.name = "DefinitionError";
9583
+ }
9584
+ }
9585
+ var record = (value, where) => {
9586
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
9587
+ throw new DefinitionError(`${where} must be an object.`);
9588
+ }
9589
+ return value;
9590
+ };
9591
+ var text2 = (value, where) => {
9592
+ if (typeof value !== "string" || value.trim() === "") {
9593
+ throw new DefinitionError(`${where} must be a non-empty string.`);
9594
+ }
9595
+ return value;
9596
+ };
9597
+ var exactKeys = (value, allowed, where) => {
9598
+ const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
9599
+ if (unknown.length > 0)
9600
+ throw new DefinitionError(`${where} contains unknown field(s): ${unknown.join(", ")}.`);
9601
+ };
9602
+ var NAME2 = /^[a-z][a-z0-9-]{0,62}$/;
9603
+ var RESERVED_STEP_ENV = new Set([
9604
+ "PATH",
9605
+ "HOME",
9606
+ "SHELL",
9607
+ "PWD",
9608
+ "BUN_INSTALL",
9609
+ "NODE_OPTIONS",
9610
+ "LD_PRELOAD",
9611
+ "LD_LIBRARY_PATH",
9612
+ "GIT_SSH",
9613
+ "GIT_SSH_COMMAND"
9614
+ ]);
9615
+ function parseDeployDefinition(value, options = {}) {
9616
+ const root = record(value, "pipeline");
9617
+ exactKeys(root, ["$schema", "version", "name", "requireAttestation", "profiles", "steps"], "pipeline");
9618
+ if (root.$schema !== undefined && root.$schema !== DEPLOY_SCHEMA_URL) {
9619
+ throw new DefinitionError(`pipeline.$schema must be ${DEPLOY_SCHEMA_URL}.`);
9620
+ }
9621
+ if (root.version !== PIPELINE_VERSION) {
9622
+ throw new DefinitionError(`pipeline.version must be ${PIPELINE_VERSION}.`);
9623
+ }
9624
+ if (root.requireAttestation !== undefined && typeof root.requireAttestation !== "boolean") {
9625
+ throw new DefinitionError("pipeline.requireAttestation must be a boolean.");
9626
+ }
9627
+ const rawProfiles = record(root.profiles, "pipeline.profiles");
9628
+ const profileEntries = Object.entries(rawProfiles);
9629
+ if (profileEntries.length === 0 || profileEntries.length > 32) {
9630
+ throw new DefinitionError("pipeline.profiles must contain from 1 to 32 named profiles.");
9631
+ }
9632
+ if (!Array.isArray(root.steps) || root.steps.length === 0) {
9633
+ throw new DefinitionError("pipeline.steps must contain at least one step.");
9634
+ }
9635
+ const profiles = {};
9636
+ for (const [name2, raw] of profileEntries) {
9637
+ if (!NAME2.test(name2))
9638
+ throw new DefinitionError(`pipeline profile name is invalid: ${name2}.`);
9639
+ const profile = record(raw, `profiles.${name2}`);
9640
+ exactKeys(profile, ["software"], `profiles.${name2}`);
9641
+ if (!Array.isArray(profile.software)) {
9642
+ throw new DefinitionError(`profiles.${name2}.software must be an array.`);
9643
+ }
9644
+ profiles[name2] = { software: validateSoftwareRequirements(profile.software, options) };
9645
+ }
9646
+ const phases = new Set(["build", "release", "migrate", "health"]);
9647
+ const steps = root.steps.map((raw, index) => {
9648
+ const step = record(raw, `steps[${index}]`);
9649
+ exactKeys(step, ["name", "run", "phase", "scope", "profiles", "secrets", "always", "timeoutMs", "when"], `steps[${index}]`);
9650
+ const phase = text2(step.phase, `steps[${index}].phase`);
9651
+ if (!phases.has(phase))
9652
+ throw new DefinitionError(`steps[${index}].phase is not supported.`);
9653
+ if (step.scope !== "target" && step.scope !== "release") {
9654
+ throw new DefinitionError(`steps[${index}].scope must be target or release.`);
9655
+ }
9656
+ if (step.always !== undefined && typeof step.always !== "boolean") {
9657
+ throw new DefinitionError(`steps[${index}].always must be a boolean.`);
9658
+ }
9659
+ let selectedProfiles;
9660
+ if (step.profiles !== undefined) {
9661
+ if (!Array.isArray(step.profiles) || step.profiles.length === 0 || step.profiles.some((name2) => typeof name2 !== "string" || !Object.hasOwn(profiles, name2))) {
9662
+ throw new DefinitionError(`steps[${index}].profiles must name existing profiles.`);
9663
+ }
9664
+ selectedProfiles = [...step.profiles];
9665
+ if (new Set(selectedProfiles).size !== selectedProfiles.length) {
9666
+ throw new DefinitionError(`steps[${index}].profiles must not contain duplicates.`);
9667
+ }
9668
+ }
9669
+ if (step.secrets !== undefined && (!Array.isArray(step.secrets) || step.secrets.some((name2) => typeof name2 !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(name2)))) {
9670
+ throw new DefinitionError(`steps[${index}].secrets must contain names only.`);
9671
+ }
9672
+ if (Array.isArray(step.secrets) && new Set(step.secrets).size !== step.secrets.length) {
9673
+ throw new DefinitionError(`steps[${index}].secrets must not contain duplicates.`);
9674
+ }
9675
+ if (Array.isArray(step.secrets) && step.secrets.some((name2) => RESERVED_STEP_ENV.has(String(name2)))) {
9676
+ throw new DefinitionError(`steps[${index}].secrets may not replace process-control environment variables.`);
9677
+ }
9678
+ const timeoutMs = step.timeoutMs === undefined ? undefined : Number(step.timeoutMs);
9679
+ if (timeoutMs !== undefined && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 86400000)) {
9680
+ throw new DefinitionError(`steps[${index}].timeoutMs must be an integer from 1 to 86400000.`);
9681
+ }
9682
+ let when;
9683
+ if (step.when !== undefined) {
9684
+ const conditions = record(step.when, `steps[${index}].when`);
9685
+ when = {};
9686
+ for (const [name2, expected] of Object.entries(conditions)) {
9687
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(name2) || typeof expected !== "string" || expected.length === 0) {
9688
+ throw new DefinitionError(`steps[${index}].when must map environment names to non-empty strings.`);
9689
+ }
9690
+ when[name2] = expected;
9691
+ }
9692
+ if (Object.keys(when).length === 0)
9693
+ throw new DefinitionError(`steps[${index}].when must not be empty.`);
9694
+ }
9695
+ return {
9696
+ name: text2(step.name, `steps[${index}].name`),
9697
+ run: text2(step.run, `steps[${index}].run`),
9698
+ phase,
9699
+ scope: step.scope,
9700
+ profiles: selectedProfiles,
9701
+ secrets: step.secrets,
9702
+ always: step.always === true,
9703
+ timeoutMs,
9704
+ when
9705
+ };
9706
+ });
9707
+ if (new Set(steps.map((step) => step.name)).size !== steps.length) {
9708
+ throw new DefinitionError("pipeline.steps must have unique names.");
9709
+ }
9710
+ const name = text2(root.name, "pipeline.name");
9711
+ if (name.length > 120)
9712
+ throw new DefinitionError("pipeline.name must be at most 120 characters.");
9713
+ return {
9714
+ version: PIPELINE_VERSION,
9715
+ name,
9716
+ requireAttestation: root.requireAttestation === true,
9717
+ profiles,
9718
+ steps
9719
+ };
9720
+ }
9721
+
9722
+ // src/deploy-file.ts
9723
+ var DEPLOY_FILE = ".fz/deploy.json";
9724
+ var DEPLOY_TODO_PREFIX = "ForgeZero pipeline TODO:";
9725
+ var stable = (value) => {
9726
+ if (Array.isArray(value))
9727
+ return `[${value.map(stable).join(",")}]`;
9728
+ if (value && typeof value === "object") {
9729
+ return `{${Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => `${JSON.stringify(key)}:${stable(entry)}`).join(",")}}`;
9730
+ }
9731
+ return JSON.stringify(value);
9732
+ };
9733
+ function deployDefinitionDigest(definition) {
9734
+ return `sha256:${createHash2("sha256").update(stable(definition)).digest("hex")}`;
9735
+ }
9736
+ var safeName = (value) => {
9737
+ const normalized = value.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
9738
+ return /^[a-z]/.test(normalized) ? normalized : `app-${normalized || "service"}`.slice(0, 63);
9739
+ };
9740
+ function packageHints(root) {
9741
+ const packagePath = join3(root, "package.json");
9742
+ if (!existsSync2(packagePath))
9743
+ return { bun: existsSync2(join3(root, "bun.lock")) };
9744
+ try {
9745
+ const manifest = JSON.parse(readFileSync2(packagePath, "utf8"));
9746
+ return {
9747
+ name: typeof manifest.name === "string" ? manifest.name : undefined,
9748
+ build: typeof manifest.scripts?.build === "string" ? "bun run build" : undefined,
9749
+ bun: existsSync2(join3(root, "bun.lock")) || existsSync2(join3(root, "bun.lockb"))
9750
+ };
9751
+ } catch {
9752
+ return { bun: existsSync2(join3(root, "bun.lock")) };
9753
+ }
9754
+ }
9755
+ var blocker = (instruction) => `printf '%s\\n' '${DEPLOY_TODO_PREFIX} ${instruction}' >&2; exit 78`;
9756
+ function defaultDeployFile(root, options = {}) {
9757
+ const hints = packageHints(root);
9758
+ const software = options.software ?? (hints.bun ? SOFTWARE_CATALOG.filter((entry) => entry.id === "bun" && entry.status === "active").map(({ id: id2, version }) => ({ id: id2, version })) : []);
9759
+ validateSoftwareRequirements(software, { channel: options.channel });
9760
+ const profile = options.profile ?? "app";
9761
+ const build = hints.build ?? blocker("replace the build step with the project build command");
9762
+ return {
9763
+ $schema: DEPLOY_SCHEMA_URL,
9764
+ version: 2,
9765
+ name: safeName(options.name ?? hints.name ?? basename(root)),
9766
+ ...options.requireAttestation ? { requireAttestation: true } : {},
9767
+ profiles: { [profile]: { software } },
9768
+ steps: [
9769
+ { name: "build", phase: "build", scope: "target", run: build, timeoutMs: 600000 },
9770
+ {
9771
+ name: "promote release",
9772
+ phase: "release",
9773
+ scope: "target",
9774
+ run: blocker("replace the release step with an atomic promotion command"),
9775
+ timeoutMs: 120000
9776
+ },
9777
+ {
9778
+ name: "health check",
9779
+ phase: "health",
9780
+ scope: "target",
9781
+ run: blocker("replace the health step with a bounded local health check"),
9782
+ timeoutMs: 30000
9783
+ }
9784
+ ]
9785
+ };
9786
+ }
9787
+ function inspectDeployFile(root, options = {}) {
9788
+ const path = join3(root, DEPLOY_FILE);
9789
+ if (!existsSync2(path))
9790
+ throw new Error(`${DEPLOY_FILE} does not exist; run \`fz deploy init\`.`);
9791
+ const raw = JSON.parse(readFileSync2(path, "utf8"));
9792
+ const definition = parseDeployDefinition(raw, options);
9793
+ const problems = definition.steps.filter((step) => step.run.includes(DEPLOY_TODO_PREFIX)).map((step) => `${step.name} still contains the safe initialization blocker`);
9794
+ const profiles = Object.keys(definition.profiles).sort();
9795
+ return {
9796
+ path,
9797
+ definition,
9798
+ summary: {
9799
+ path,
9800
+ digest: deployDefinitionDigest(definition),
9801
+ version: definition.version,
9802
+ name: definition.name,
9803
+ profiles,
9804
+ software: Object.fromEntries(profiles.map((profile) => [
9805
+ profile,
9806
+ definition.profiles[profile].software
9807
+ ])),
9808
+ ready: problems.length === 0,
9809
+ problems
9810
+ }
9811
+ };
9812
+ }
9813
+ function initializeDeployFile(root, options = {}) {
9814
+ const path = join3(root, DEPLOY_FILE);
9815
+ if (existsSync2(path) && !options.force) {
9816
+ throw new Error(`${DEPLOY_FILE} already exists; use --force only when replacing it deliberately.`);
9817
+ }
9818
+ const raw = defaultDeployFile(root, options);
9819
+ parseDeployDefinition(raw, { channel: options.channel });
9820
+ mkdirSync2(join3(root, ".fz"), { recursive: true });
9821
+ writeFileSync2(path, `${JSON.stringify(raw, null, 2)}
9822
+ `, { mode: 420 });
9823
+ return inspectDeployFile(root, { channel: options.channel });
9824
+ }
9825
+
9533
9826
  // src/cli/index.ts
9534
9827
  var DEFAULT_MODE = THRESHOLD_MODES[0].id;
9535
9828
  var RECOMMENDED_MODE = (THRESHOLD_MODES.find((mode) => mode.recommended) ?? THRESHOLD_MODES[0]).id;
@@ -9547,6 +9840,10 @@ function parseOptions(argv) {
9547
9840
  email: process.env.FZ_EMAIL ?? "operator@localhost",
9548
9841
  preserveEnv: false,
9549
9842
  projectRoot: process.cwd(),
9843
+ deployProfile: "app",
9844
+ deploySoftware: [],
9845
+ deployChannel: "production",
9846
+ requireAttestation: false,
9550
9847
  force: false
9551
9848
  };
9552
9849
  const positional = [];
@@ -9572,6 +9869,18 @@ function parseOptions(argv) {
9572
9869
  options.projectName = argv[++index];
9573
9870
  else if (token === "--purpose")
9574
9871
  options.projectPurpose = argv[++index];
9872
+ else if (token === "--profile")
9873
+ options.deployProfile = argv[++index] ?? options.deployProfile;
9874
+ else if (token === "--software")
9875
+ options.deploySoftware.push(argv[++index] ?? "");
9876
+ else if (token === "--channel") {
9877
+ const channel = argv[++index];
9878
+ if (channel === "development" || channel === "production")
9879
+ options.deployChannel = channel;
9880
+ else
9881
+ options.optionError = "--channel must be production or development.";
9882
+ } else if (token === "--attestation")
9883
+ options.requireAttestation = true;
9575
9884
  else if (token === "--force")
9576
9885
  options.force = true;
9577
9886
  else if (token === "--key")
@@ -9592,15 +9901,15 @@ function parseOptions(argv) {
9592
9901
  return { command: positional[0] ?? "help", args: positional.slice(1), options };
9593
9902
  }
9594
9903
  var out = {
9595
- line: (text2 = "") => process.stdout.write(`${text2}
9904
+ line: (text3 = "") => process.stdout.write(`${text3}
9596
9905
  `),
9597
- step: (text2) => process.stdout.write(` ${text2}
9906
+ step: (text3) => process.stdout.write(` ${text3}
9598
9907
  `),
9599
- warn: (text2) => process.stderr.write(` ! ${text2}
9908
+ warn: (text3) => process.stderr.write(` ! ${text3}
9600
9909
  `),
9601
- fail: (text2) => process.stderr.write(` \u2717 ${text2}
9910
+ fail: (text3) => process.stderr.write(` \u2717 ${text3}
9602
9911
  `),
9603
- ok: (text2) => process.stdout.write(` \u2713 ${text2}
9912
+ ok: (text3) => process.stdout.write(` \u2713 ${text3}
9604
9913
  `)
9605
9914
  };
9606
9915
  var sessionCookie = null;
@@ -9805,17 +10114,17 @@ async function cmdAgent(options, args) {
9805
10114
  return 0;
9806
10115
  }
9807
10116
  try {
9808
- writeFileSync2(plan.unitPath, plan.unit, { mode: 420 });
10117
+ writeFileSync3(plan.unitPath, plan.unit, { mode: 420 });
9809
10118
  out.ok(`Wrote ${plan.unitPath}`);
9810
10119
  for (const auxiliary of plan.auxiliaryUnits) {
9811
- mkdirSync2(dirname2(auxiliary.path), { recursive: true, mode: 493 });
9812
- writeFileSync2(auxiliary.path, auxiliary.unit, { mode: 420 });
10120
+ mkdirSync3(dirname2(auxiliary.path), { recursive: true, mode: 493 });
10121
+ writeFileSync3(auxiliary.path, auxiliary.unit, { mode: 420 });
9813
10122
  out.ok(`Wrote ${auxiliary.path}`);
9814
10123
  }
9815
10124
  if (options.enrol) {
9816
- if (existsSync2(enrolTokenSourcePath)) {
10125
+ if (existsSync3(enrolTokenSourcePath)) {
9817
10126
  const source = statSync(enrolTokenSourcePath);
9818
- const token = readFileSync2(enrolTokenSourcePath, "utf8").trim();
10127
+ const token = readFileSync3(enrolTokenSourcePath, "utf8").trim();
9819
10128
  if (!source.isFile() || (source.mode & 511) !== 384 || source.uid !== 0) {
9820
10129
  throw new Error("The preloaded enrolment token must be a root-owned 0600 file in /run.");
9821
10130
  }
@@ -9828,7 +10137,7 @@ async function cmdAgent(options, args) {
9828
10137
  if (await prompt.exited !== 0 || !/^fze_[A-Za-z0-9_-]{40,100}$/.test(token)) {
9829
10138
  throw new Error("A valid fze_ enrolment token was not provided.");
9830
10139
  }
9831
- writeFileSync2(enrolTokenSourcePath, `${token}
10140
+ writeFileSync3(enrolTokenSourcePath, `${token}
9832
10141
  `, { mode: 384, flag: "wx" });
9833
10142
  }
9834
10143
  }
@@ -9839,7 +10148,7 @@ async function cmdAgent(options, args) {
9839
10148
  out.line();
9840
10149
  out.line(" Add this machine-specific PUBLIC key as a read-only deploy key:");
9841
10150
  out.line();
9842
- out.line(` ${readFileSync2(gitPublicKeyPath, "utf8").trim()}`);
10151
+ out.line(` ${readFileSync3(gitPublicKeyPath, "utf8").trim()}`);
9843
10152
  out.line();
9844
10153
  return 0;
9845
10154
  } catch (cause) {
@@ -9993,6 +10302,86 @@ function cmdProject(options, args) {
9993
10302
  return 1;
9994
10303
  }
9995
10304
  }
10305
+ function softwareCoordinates(values) {
10306
+ if (values.length === 0)
10307
+ return;
10308
+ return values.map((coordinate) => {
10309
+ const separator = coordinate.lastIndexOf("@");
10310
+ if (separator < 1 || separator === coordinate.length - 1) {
10311
+ throw new Error(`Software must be <key>@<version>, received: ${coordinate || "<empty>"}.`);
10312
+ }
10313
+ return {
10314
+ id: coordinate.slice(0, separator),
10315
+ version: coordinate.slice(separator + 1)
10316
+ };
10317
+ });
10318
+ }
10319
+ function cmdDeploy(options, args) {
10320
+ const operation = args[0] ?? "check";
10321
+ try {
10322
+ if (options.optionError)
10323
+ throw new Error(options.optionError);
10324
+ if (operation === "init") {
10325
+ const created = initializeDeployFile(options.projectRoot, {
10326
+ name: options.projectName,
10327
+ profile: options.deployProfile,
10328
+ software: softwareCoordinates(options.deploySoftware),
10329
+ requireAttestation: options.requireAttestation,
10330
+ channel: options.deployChannel,
10331
+ force: options.force
10332
+ });
10333
+ if (options.json)
10334
+ out.line(JSON.stringify(created.summary, null, 2));
10335
+ else {
10336
+ out.ok(`Initialized .fz/deploy.json (${created.summary.digest}).`);
10337
+ out.warn("Release and health use safe blockers until you replace them with project-specific commands.");
10338
+ out.step("Run `fz deploy check`, commit the file, then push; the verified Git commit is the live sync.");
10339
+ }
10340
+ return 0;
10341
+ }
10342
+ if (operation === "check" || operation === "sync") {
10343
+ const inspected = inspectDeployFile(options.projectRoot, { channel: options.deployChannel });
10344
+ if (options.json)
10345
+ out.line(JSON.stringify(inspected.summary, null, 2));
10346
+ else {
10347
+ out.step(`${inspected.summary.name} \xB7 v${inspected.summary.version} \xB7 ${inspected.summary.digest}`);
10348
+ out.step(`profiles: ${inspected.summary.profiles.join(", ")}`);
10349
+ for (const problem of inspected.summary.problems)
10350
+ out.fail(problem);
10351
+ if (inspected.summary.ready) {
10352
+ out.ok("Deploy definition is typed, catalog-valid and ready to commit.");
10353
+ if (operation === "sync") {
10354
+ out.step("Commit and push this file. ForgeZero deploys the exact webhook commit; no second live file exists.");
10355
+ }
10356
+ }
10357
+ }
10358
+ return inspected.summary.ready ? 0 : 1;
10359
+ }
10360
+ if (operation === "catalog") {
10361
+ const software = SOFTWARE_CATALOG.filter((entry) => entry.status === "active" || options.deployChannel === "development" && entry.status === "testing");
10362
+ const payload = {
10363
+ channel: options.deployChannel,
10364
+ note: "Only active coordinates are selectable in deploy definitions.",
10365
+ os: OS_CATALOG,
10366
+ software
10367
+ };
10368
+ if (options.json)
10369
+ out.line(JSON.stringify(payload, null, 2));
10370
+ else {
10371
+ out.line(`${options.deployChannel} software catalog (only active coordinates are selectable):`);
10372
+ for (const entry of software) {
10373
+ out.step(`${entry.id}@${entry.version} \xB7 ${entry.os} ${entry.osVersion} ${entry.architecture} \xB7 ${entry.status}`);
10374
+ }
10375
+ }
10376
+ return 0;
10377
+ }
10378
+ out.fail("Usage: fz deploy init|check|sync|catalog [--root <path>] [--channel production|development]");
10379
+ return 2;
10380
+ } catch (cause) {
10381
+ out.fail(cause instanceof Error ? cause.message : String(cause));
10382
+ return 1;
10383
+ }
10384
+ }
9996
10385
  function usage() {
9997
10386
  out.line(`
9998
10387
  fz ${VERSION2} \u2014 ForgeZero control surface
@@ -10017,6 +10406,10 @@ function usage() {
10017
10406
  fz project init Create vendor-neutral, Git-persisted AI context
10018
10407
  fz project sync Regenerate Claude/Codex/Gemini/Copilot/Cursor adapters
10019
10408
  fz project check Fail when truth sources or generated adapters drift
10409
+ fz deploy init Create a typed, fail-safe .fz/deploy.json
10410
+ fz deploy check Validate commands, profiles and software coordinates
10411
+ fz deploy sync Prove readiness and print the Git synchronization rule
10412
+ fz deploy catalog List selectable tested OS/software coordinates
10020
10413
 
10021
10414
  CEREMONY OPTIONS
10022
10415
  --key <fp|index> Which agent key to use for custody
@@ -10039,10 +10432,14 @@ function usage() {
10039
10432
  --apply Write the unit rather than printing it (root)
10040
10433
  --enrol Bind this machine with a one-time token prompted
10041
10434
  securely by systemd (tenant-owned compute)
10042
- --root <path> Project root for project init/sync/check
10043
- --name <name> Project name during init
10044
- --purpose <text> Product outcome during init
10045
- --force Init may replace existing AI instruction files
10435
+ --root <path> Project root for project/deploy commands
10436
+ --name <name> Project or deploy name during init
10437
+ --purpose <text> Product outcome during project init
10438
+ --profile <name> Initial deploy profile (default app)
10439
+ --software <key@ver> Initial tested software coordinate; repeatable
10440
+ --channel <name> Catalog view: production or development (shows testing)
10441
+ --attestation Require hardware attestation for every deploy step
10442
+ --force Init may replace an existing generated target
10046
10443
 
10047
10444
  CUSTODY FACTORS
10048
10445
  Every custodian share is sealed TWICE and either envelope alone opens it:
@@ -10074,6 +10471,9 @@ async function runCli() {
10074
10471
  case "project":
10075
10472
  code = cmdProject(options, args);
10076
10473
  break;
10474
+ case "deploy":
10475
+ code = cmdDeploy(options, args);
10476
+ break;
10077
10477
  case "genesis":
10078
10478
  code = await cmdGenesis(options);
10079
10479
  break;
@@ -10097,7 +10497,7 @@ async function runCli() {
10097
10497
  usage();
10098
10498
  code = 1;
10099
10499
  }
10100
- if (args.length > 0 && code === 0 && command !== "agent" && command !== "project") {
10500
+ if (args.length > 0 && code === 0 && command !== "agent" && command !== "project" && command !== "deploy") {
10101
10501
  out.warn(`Ignored: ${args.join(" ")}`);
10102
10502
  }
10103
10503
  process.exit(code);
package/dist/index.d.ts CHANGED
@@ -29,8 +29,8 @@ export type { LifecycleCommandResult, LifecycleExec, LifecycleProfile } from './
29
29
  export { materializeWarpMdm, renderWarpMdm } from './warp-config';
30
30
  export { compareVersions, restoreAgentRelease, selectAgentRelease, stageAgentRelease, validateAgentRelease, DEFAULT_AGENT_RELEASE_ROOT, DEFAULT_AGENT_UPDATE_SOCKET, MAX_AGENT_TARBALL_BYTES } from './agent-update';
31
31
  export type { AgentRelease, StagedAgentRelease, UpdateCommand, UpdateCommandResult } from './agent-update';
32
- export { activateAgentRelease, probeAgentSocket, requestAgentUpdate, startAgentUpdateHelper, AGENT_UPDATE_GROUP, AGENT_UPDATE_HELPER_UNIT_PATH, AGENT_UPDATE_RECEIPT } from './agent-update-helper';
33
- export type { AgentUpdateRequest, AgentUpdateResponse } from './agent-update-helper';
32
+ export { activateAgentRelease, probeAgentSocket, readAgentUpdateReceipt, recoverInterruptedAgentUpdate, requestAgentUpdate, startAgentUpdateHelper, AGENT_UPDATE_GROUP, AGENT_UPDATE_HELPER_UNIT_PATH, AGENT_UPDATE_JOURNAL, AGENT_UPDATE_RECEIPT } from './agent-update-helper';
33
+ export type { AgentUpdateOutcome, AgentUpdateReceipt, AgentUpdateRequest, AgentUpdateResponse } from './agent-update-helper';
34
34
  export { DEFAULT_SOFTWARE_HELPER_SOCKET, requestSoftware, startSoftwareHelper, SOFTWARE_HELPER_GROUP, SOFTWARE_HELPER_UNIT_PATH } from './software-helper';
35
35
  export { ensureSoftwareRequirements, observeSoftwareHost, validateSoftwareRequirements, OS_CATALOG, SOFTWARE_CATALOG } from './software';
36
36
  export type { CatalogStatus, DeploymentChannel, OsCatalogEntry, SoftwareCatalogEntry, SoftwareCommandResult, SoftwareExec, SoftwareId, SoftwareObservation, SoftwareRequirement } from './software';