@forgezero/agent 0.1.30 → 0.1.31

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 CHANGED
@@ -145,6 +145,34 @@ file before executing any command, prepares only the selected profile, and gives
145
145
  step only the vault values it explicitly names. `await` returns the complete
146
146
  pipeline result; no polling service or persistent queue is required.
147
147
 
148
+ Create and validate that file with the same public package that executes it:
149
+
150
+ ```bash
151
+ fz deploy catalog --channel production
152
+ fz deploy init --profile app --software bun@1.3.14
153
+ # Replace the explicit safe blockers with this project's release and health commands.
154
+ fz deploy check
155
+ fz deploy sync
156
+ ```
157
+
158
+ `init` refuses to invent a generic release or health check: both generated steps
159
+ exit non-zero until the project replaces them. `check` validates the published
160
+ v2 schema and active software coordinates, then prints a formatting-independent
161
+ semantic SHA-256 digest. The schema ships at
162
+ `@forgezero/agent/schema/deploy-v2.json` and is served from
163
+ `https://www.forgezero.net/schemas/deploy-v2.json`.
164
+
165
+ Git is the only local-to-live synchronization mechanism. `sync` validates and
166
+ prints that rule; it does not create a second mutable command copy in the API.
167
+ The verified webhook identifies one exact commit, and a successful Agent result
168
+ persists the definition digest so the Applications screen can prove which local
169
+ contract became live.
170
+
171
+ Catalog status is an admission boundary, not a suggestion. `testing` coordinates
172
+ are visible in the development catalog for ForgeZero qualification but cannot be
173
+ selected by any deploy file. A repository cannot promote software by calling
174
+ itself development; only a reviewed catalog change to `active` unlocks it.
175
+
148
176
  The daemon owns source checkout and command execution. Bootstrap explicitly
149
177
  awaits release one because the API does not exist yet, then the Agent consumes a
150
178
  one-use platform enrolment capability. There is no branch watcher. Every normal
@@ -429,7 +429,7 @@ async function postSignedNode(options, path, body) {
429
429
  }
430
430
 
431
431
  // src/version.ts
432
- var VERSION2 = "0.1.30";
432
+ var VERSION2 = "0.1.31";
433
433
 
434
434
  // src/agent-heartbeat.ts
435
435
  var unquote = (value) => value.replace(/^['"]|['"]$/g, "");
@@ -1,11 +1,12 @@
1
1
  import type { Pipeline, PipelineStep } from './pipeline';
2
- import { type SoftwareRequirement } from './software';
2
+ import { type DeploymentChannel, type SoftwareRequirement } from './software';
3
3
  /**
4
4
  * Version two separates a repository's deploy recipe from the computes that use
5
5
  * it. A target chooses one named profile in the control plane; compute names,
6
6
  * counts and cluster leadership never belong in Git.
7
7
  */
8
8
  export declare const PIPELINE_VERSION: 2;
9
+ export declare const DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v2.json";
9
10
  export interface PipelineProfile {
10
11
  software: readonly SoftwareRequirement[];
11
12
  }
@@ -29,5 +30,7 @@ export declare class DefinitionError extends Error {
29
30
  constructor(message: string);
30
31
  }
31
32
  /** Validate parsed JSON before any command from it is allowed to run. */
32
- export declare function parseDeployDefinition(value: unknown): DeployDefinition;
33
+ export declare function parseDeployDefinition(value: unknown, options?: {
34
+ channel?: DeploymentChannel;
35
+ }): DeployDefinition;
33
36
  export declare function phasePipeline(definition: DeployDefinition, phase: DeployStep['phase'], profile: string, executeRelease?: boolean): Pipeline;
@@ -51,7 +51,7 @@ function observeSoftwareHost(osRelease = readFileSync("/etc/os-release", "utf8")
51
51
  architecture
52
52
  };
53
53
  }
54
- function validateSoftwareRequirements(value, options = {}) {
54
+ function validateSoftwareRequirements(value, _options = {}) {
55
55
  if (!Array.isArray(value) || value.length > 32)
56
56
  throw new Error("software requirements must be an array of at most 32 entries");
57
57
  const seen = new Set;
@@ -70,8 +70,8 @@ function validateSoftwareRequirements(value, options = {}) {
70
70
  throw new Error(`duplicate software requirement: ${requirement.id}`);
71
71
  seen.add(requirement.id);
72
72
  const catalog = SOFTWARE_CATALOG.find((candidate) => candidate.id === requirement.id && candidate.version === requirement.version);
73
- if (!catalog || catalog.status === "retired" || options.channel !== "development" && catalog.status !== "active") {
74
- throw new Error(`software requirement is not available for ${options.channel ?? "production"}: ${requirement.id}@${requirement.version}`);
73
+ if (!catalog || catalog.status !== "active") {
74
+ throw new Error(`software requirement is not active: ${requirement.id}@${requirement.version}`);
75
75
  }
76
76
  return requirement;
77
77
  });
@@ -106,6 +106,7 @@ async function ensureSoftwareRequirements(requirementsInput, options) {
106
106
 
107
107
  // src/definition.ts
108
108
  var PIPELINE_VERSION = 2;
109
+ var DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v2.json";
109
110
 
110
111
  class DefinitionError extends Error {
111
112
  constructor(message) {
@@ -143,15 +144,18 @@ var RESERVED_STEP_ENV = new Set([
143
144
  "GIT_SSH",
144
145
  "GIT_SSH_COMMAND"
145
146
  ]);
146
- function parseDeployDefinition(value) {
147
+ function parseDeployDefinition(value, options = {}) {
147
148
  const root = record(value, "pipeline");
148
149
  exactKeys(root, ["$schema", "version", "name", "requireAttestation", "profiles", "steps"], "pipeline");
149
- if (root.$schema !== undefined && (typeof root.$schema !== "string" || !root.$schema.startsWith("https://"))) {
150
- throw new DefinitionError("pipeline.$schema must be an HTTPS URL.");
150
+ if (root.$schema !== undefined && root.$schema !== DEPLOY_SCHEMA_URL) {
151
+ throw new DefinitionError(`pipeline.$schema must be ${DEPLOY_SCHEMA_URL}.`);
151
152
  }
152
153
  if (root.version !== PIPELINE_VERSION) {
153
154
  throw new DefinitionError(`pipeline.version must be ${PIPELINE_VERSION}.`);
154
155
  }
156
+ if (root.requireAttestation !== undefined && typeof root.requireAttestation !== "boolean") {
157
+ throw new DefinitionError("pipeline.requireAttestation must be a boolean.");
158
+ }
155
159
  const rawProfiles = record(root.profiles, "pipeline.profiles");
156
160
  const profileEntries = Object.entries(rawProfiles);
157
161
  if (profileEntries.length === 0 || profileEntries.length > 32) {
@@ -161,15 +165,15 @@ function parseDeployDefinition(value) {
161
165
  throw new DefinitionError("pipeline.steps must contain at least one step.");
162
166
  }
163
167
  const profiles = {};
164
- for (const [name, raw] of profileEntries) {
165
- if (!NAME.test(name))
166
- throw new DefinitionError(`pipeline profile name is invalid: ${name}.`);
167
- const profile = record(raw, `profiles.${name}`);
168
- exactKeys(profile, ["software"], `profiles.${name}`);
168
+ for (const [name2, raw] of profileEntries) {
169
+ if (!NAME.test(name2))
170
+ throw new DefinitionError(`pipeline profile name is invalid: ${name2}.`);
171
+ const profile = record(raw, `profiles.${name2}`);
172
+ exactKeys(profile, ["software"], `profiles.${name2}`);
169
173
  if (!Array.isArray(profile.software)) {
170
- throw new DefinitionError(`profiles.${name}.software must be an array.`);
174
+ throw new DefinitionError(`profiles.${name2}.software must be an array.`);
171
175
  }
172
- profiles[name] = { software: validateSoftwareRequirements(profile.software) };
176
+ profiles[name2] = { software: validateSoftwareRequirements(profile.software, options) };
173
177
  }
174
178
  const phases = new Set(["build", "release", "migrate", "health"]);
175
179
  const steps = root.steps.map((raw, index) => {
@@ -181,9 +185,12 @@ function parseDeployDefinition(value) {
181
185
  if (step.scope !== "target" && step.scope !== "release") {
182
186
  throw new DefinitionError(`steps[${index}].scope must be target or release.`);
183
187
  }
188
+ if (step.always !== undefined && typeof step.always !== "boolean") {
189
+ throw new DefinitionError(`steps[${index}].always must be a boolean.`);
190
+ }
184
191
  let selectedProfiles;
185
192
  if (step.profiles !== undefined) {
186
- if (!Array.isArray(step.profiles) || step.profiles.length === 0 || step.profiles.some((name) => typeof name !== "string" || !Object.hasOwn(profiles, name))) {
193
+ if (!Array.isArray(step.profiles) || step.profiles.length === 0 || step.profiles.some((name2) => typeof name2 !== "string" || !Object.hasOwn(profiles, name2))) {
187
194
  throw new DefinitionError(`steps[${index}].profiles must name existing profiles.`);
188
195
  }
189
196
  selectedProfiles = [...step.profiles];
@@ -191,13 +198,13 @@ function parseDeployDefinition(value) {
191
198
  throw new DefinitionError(`steps[${index}].profiles must not contain duplicates.`);
192
199
  }
193
200
  }
194
- if (step.secrets !== undefined && (!Array.isArray(step.secrets) || step.secrets.some((name) => typeof name !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(name)))) {
201
+ if (step.secrets !== undefined && (!Array.isArray(step.secrets) || step.secrets.some((name2) => typeof name2 !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(name2)))) {
195
202
  throw new DefinitionError(`steps[${index}].secrets must contain names only.`);
196
203
  }
197
204
  if (Array.isArray(step.secrets) && new Set(step.secrets).size !== step.secrets.length) {
198
205
  throw new DefinitionError(`steps[${index}].secrets must not contain duplicates.`);
199
206
  }
200
- if (Array.isArray(step.secrets) && step.secrets.some((name) => RESERVED_STEP_ENV.has(String(name)))) {
207
+ if (Array.isArray(step.secrets) && step.secrets.some((name2) => RESERVED_STEP_ENV.has(String(name2)))) {
201
208
  throw new DefinitionError(`steps[${index}].secrets may not replace process-control environment variables.`);
202
209
  }
203
210
  const timeoutMs = step.timeoutMs === undefined ? undefined : Number(step.timeoutMs);
@@ -208,11 +215,11 @@ function parseDeployDefinition(value) {
208
215
  if (step.when !== undefined) {
209
216
  const conditions = record(step.when, `steps[${index}].when`);
210
217
  when = {};
211
- for (const [name, expected] of Object.entries(conditions)) {
212
- if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || typeof expected !== "string" || expected.length === 0) {
218
+ for (const [name2, expected] of Object.entries(conditions)) {
219
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(name2) || typeof expected !== "string" || expected.length === 0) {
213
220
  throw new DefinitionError(`steps[${index}].when must map environment names to non-empty strings.`);
214
221
  }
215
- when[name] = expected;
222
+ when[name2] = expected;
216
223
  }
217
224
  if (Object.keys(when).length === 0)
218
225
  throw new DefinitionError(`steps[${index}].when must not be empty.`);
@@ -232,9 +239,12 @@ function parseDeployDefinition(value) {
232
239
  if (new Set(steps.map((step) => step.name)).size !== steps.length) {
233
240
  throw new DefinitionError("pipeline.steps must have unique names.");
234
241
  }
242
+ const name = text(root.name, "pipeline.name");
243
+ if (name.length > 120)
244
+ throw new DefinitionError("pipeline.name must be at most 120 characters.");
235
245
  return {
236
246
  version: PIPELINE_VERSION,
237
- name: text(root.name, "pipeline.name"),
247
+ name,
238
248
  requireAttestation: root.requireAttestation === true,
239
249
  profiles,
240
250
  steps
@@ -254,5 +264,6 @@ export {
254
264
  phasePipeline,
255
265
  parseDeployDefinition,
256
266
  PIPELINE_VERSION,
257
- DefinitionError
267
+ DefinitionError,
268
+ DEPLOY_SCHEMA_URL
258
269
  };
@@ -0,0 +1,40 @@
1
+ import { type DeployDefinition } from './definition';
2
+ import { type DeploymentChannel, type SoftwareRequirement } from './software';
3
+ export declare const DEPLOY_FILE = ".fz/deploy.json";
4
+ export { DEPLOY_SCHEMA_URL } from './definition';
5
+ /**
6
+ * An initialized file must fail safely until its project-specific promotion and
7
+ * health commands are supplied. A generic initializer cannot guess how a
8
+ * tenant starts a service, which systemd unit it owns, or what "healthy" means.
9
+ */
10
+ export declare const DEPLOY_TODO_PREFIX = "ForgeZero pipeline TODO:";
11
+ export interface DeployFileSummary {
12
+ path: string;
13
+ digest: string;
14
+ version: number;
15
+ name: string;
16
+ profiles: readonly string[];
17
+ software: Readonly<Record<string, readonly SoftwareRequirement[]>>;
18
+ ready: boolean;
19
+ problems: readonly string[];
20
+ }
21
+ export interface InitializedDeployFile {
22
+ path: string;
23
+ definition: DeployDefinition;
24
+ summary: DeployFileSummary;
25
+ }
26
+ /** Semantic digest: formatting and object-key order cannot create false drift. */
27
+ export declare function deployDefinitionDigest(definition: DeployDefinition): string;
28
+ export declare function defaultDeployFile(root: string, options?: {
29
+ name?: string;
30
+ profile?: string;
31
+ software?: readonly SoftwareRequirement[];
32
+ requireAttestation?: boolean;
33
+ channel?: DeploymentChannel;
34
+ }): Record<string, unknown>;
35
+ export declare function inspectDeployFile(root: string, options?: {
36
+ channel?: DeploymentChannel;
37
+ }): InitializedDeployFile;
38
+ export declare function initializeDeployFile(root: string, options?: Parameters<typeof defaultDeployFile>[1] & {
39
+ force?: boolean;
40
+ }): InitializedDeployFile;
@@ -0,0 +1,378 @@
1
+ // src/software.ts
2
+ import { readFileSync } from "node:fs";
3
+ var BUN_INSTALLER_SHA256 = "bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd";
4
+ var ARANGO_SHA256 = "b5a9197b4343f2ed554e1ebc1ef8e6529c7c39cde0035cdc311a4747a3355066";
5
+ var CLOUDFLARED_SHA256 = "9d71c677db00134c1bd4144b7783486b654ad281b1ea62b4972098d19f770f17";
6
+ var OS_CATALOG = [
7
+ { id: "ubuntu", version: "26.04", architecture: "x64", status: "active" }
8
+ ];
9
+ var SOFTWARE_CATALOG = [
10
+ { id: "bun", version: "1.3.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
11
+ { id: "nginx", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
12
+ { id: "arangodb", version: "3.11.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
13
+ { id: "cloudflared", version: "2026.7.3", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
14
+ { id: "ufw", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" }
15
+ ];
16
+ var UBUNTU_2604_X64 = [
17
+ {
18
+ requirement: { id: "bun", version: "1.3.14" },
19
+ check: 'test "$(/usr/local/bin/bun --version 2>/dev/null)" = 1.3.14',
20
+ install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL https://bun.sh/install -o "$tmp/install"; ` + `echo "${BUN_INSTALLER_SHA256} $tmp/install" | sha256sum -c -; ` + `BUN_INSTALL="$tmp/bun" BUN_VERSION=1.3.14 bash "$tmp/install" >/dev/null; ` + `install -d -m 0755 /usr/local/lib/forgezero/runtime; ` + `install -m 0755 "$tmp/bun/bin/bun" /usr/local/lib/forgezero/runtime/bun.next; ` + `mv -Tf /usr/local/lib/forgezero/runtime/bun.next /usr/local/lib/forgezero/runtime/bun; ` + `ln -sfn /usr/local/lib/forgezero/runtime/bun /usr/local/bin/bun`
21
+ },
22
+ {
23
+ requirement: { id: "nginx", version: "ubuntu-26.04" },
24
+ check: "command -v nginx >/dev/null && systemctl is-active --quiet nginx",
25
+ install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y nginx && systemctl enable --now nginx"
26
+ },
27
+ {
28
+ requirement: { id: "arangodb", version: "3.11.14" },
29
+ check: `arangod --version 2>/dev/null | head -1 | grep -q '3.11.14'`,
30
+ install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL 'https://download.arangodb.com/arangodb311/DEBIAN/amd64/arangodb3_3.11.14-1_amd64.deb' -o "$tmp/arangodb.deb"; ` + `echo "${ARANGO_SHA256} $tmp/arangodb.deb" | sha256sum -c -; ` + `DEBIAN_FRONTEND=noninteractive dpkg -i "$tmp/arangodb.deb" >/dev/null 2>&1 || ` + `DEBIAN_FRONTEND=noninteractive apt-get -y -f install`
31
+ },
32
+ {
33
+ requirement: { id: "cloudflared", version: "2026.7.3" },
34
+ check: `cloudflared --version 2>/dev/null | grep -q '2026.7.3'`,
35
+ install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL 'https://github.com/cloudflare/cloudflared/releases/download/2026.7.3/cloudflared-linux-amd64' -o "$tmp/cloudflared"; ` + `echo "${CLOUDFLARED_SHA256} $tmp/cloudflared" | sha256sum -c -; ` + `install -m 0755 "$tmp/cloudflared" /usr/local/bin/cloudflared`
36
+ },
37
+ {
38
+ requirement: { id: "ufw", version: "ubuntu-26.04" },
39
+ check: "command -v ufw >/dev/null",
40
+ install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y ufw"
41
+ }
42
+ ];
43
+ function observeSoftwareHost(osRelease = readFileSync("/etc/os-release", "utf8"), architecture = process.arch) {
44
+ const values = Object.fromEntries(osRelease.split(`
45
+ `).flatMap((line) => {
46
+ const separator = line.indexOf("=");
47
+ return separator > 0 ? [[line.slice(0, separator), line.slice(separator + 1).replace(/^['"]|['"]$/g, "")]] : [];
48
+ }));
49
+ return {
50
+ os: { id: (values.ID ?? "unknown").toLowerCase(), versionId: values.VERSION_ID ?? "unknown" },
51
+ architecture
52
+ };
53
+ }
54
+ function validateSoftwareRequirements(value, _options = {}) {
55
+ if (!Array.isArray(value) || value.length > 32)
56
+ throw new Error("software requirements must be an array of at most 32 entries");
57
+ const seen = new Set;
58
+ return value.map((item) => {
59
+ if (!item || typeof item !== "object" || Array.isArray(item))
60
+ throw new Error("software requirement must be an object");
61
+ const row = item;
62
+ if (Object.keys(row).some((key) => key !== "id" && key !== "version")) {
63
+ throw new Error("software requirement contains an unknown field");
64
+ }
65
+ 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)) {
66
+ throw new Error("software requirement coordinate is invalid");
67
+ }
68
+ const requirement = { id: row.id, version: row.version };
69
+ if (seen.has(requirement.id))
70
+ throw new Error(`duplicate software requirement: ${requirement.id}`);
71
+ seen.add(requirement.id);
72
+ const catalog = SOFTWARE_CATALOG.find((candidate) => candidate.id === requirement.id && candidate.version === requirement.version);
73
+ if (!catalog || catalog.status !== "active") {
74
+ throw new Error(`software requirement is not active: ${requirement.id}@${requirement.version}`);
75
+ }
76
+ return requirement;
77
+ });
78
+ }
79
+ async function ensureSoftwareRequirements(requirementsInput, options) {
80
+ const requirements = validateSoftwareRequirements(requirementsInput);
81
+ const observation = options.observation ?? observeSoftwareHost();
82
+ const os = OS_CATALOG.find((candidate) => candidate.id === observation.os.id && candidate.version === observation.os.versionId && candidate.architecture === observation.architecture);
83
+ if (!os || os.status !== "active") {
84
+ throw new Error(`unsupported software strategy: ${observation.os.id} ${observation.os.versionId} ${observation.architecture}`);
85
+ }
86
+ const results = [];
87
+ for (const requirement of requirements) {
88
+ const strategy = UBUNTU_2604_X64.find(({ requirement: candidate }) => candidate.id === requirement.id && candidate.version === requirement.version);
89
+ if (!strategy)
90
+ throw new Error(`unsupported software requirement: ${requirement.id}@${requirement.version}`);
91
+ const before = await options.exec(strategy.check);
92
+ if (before.exitCode === 0) {
93
+ results.push({ ...requirement, changed: false });
94
+ continue;
95
+ }
96
+ const installed = await options.exec(strategy.install);
97
+ if (installed.exitCode !== 0)
98
+ throw new Error(`could not install ${requirement.id}@${requirement.version}: ${installed.output.trim()}`);
99
+ const after = await options.exec(strategy.check);
100
+ if (after.exitCode !== 0)
101
+ throw new Error(`${requirement.id}@${requirement.version} did not pass its post-install check`);
102
+ results.push({ ...requirement, changed: true });
103
+ }
104
+ return results;
105
+ }
106
+
107
+ // src/definition.ts
108
+ var PIPELINE_VERSION = 2;
109
+ var DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v2.json";
110
+
111
+ class DefinitionError extends Error {
112
+ constructor(message) {
113
+ super(message);
114
+ this.name = "DefinitionError";
115
+ }
116
+ }
117
+ var record = (value, where) => {
118
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
119
+ throw new DefinitionError(`${where} must be an object.`);
120
+ }
121
+ return value;
122
+ };
123
+ var text = (value, where) => {
124
+ if (typeof value !== "string" || value.trim() === "") {
125
+ throw new DefinitionError(`${where} must be a non-empty string.`);
126
+ }
127
+ return value;
128
+ };
129
+ var exactKeys = (value, allowed, where) => {
130
+ const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
131
+ if (unknown.length > 0)
132
+ throw new DefinitionError(`${where} contains unknown field(s): ${unknown.join(", ")}.`);
133
+ };
134
+ var NAME = /^[a-z][a-z0-9-]{0,62}$/;
135
+ var RESERVED_STEP_ENV = new Set([
136
+ "PATH",
137
+ "HOME",
138
+ "SHELL",
139
+ "PWD",
140
+ "BUN_INSTALL",
141
+ "NODE_OPTIONS",
142
+ "LD_PRELOAD",
143
+ "LD_LIBRARY_PATH",
144
+ "GIT_SSH",
145
+ "GIT_SSH_COMMAND"
146
+ ]);
147
+ function parseDeployDefinition(value, options = {}) {
148
+ const root = record(value, "pipeline");
149
+ exactKeys(root, ["$schema", "version", "name", "requireAttestation", "profiles", "steps"], "pipeline");
150
+ if (root.$schema !== undefined && root.$schema !== DEPLOY_SCHEMA_URL) {
151
+ throw new DefinitionError(`pipeline.$schema must be ${DEPLOY_SCHEMA_URL}.`);
152
+ }
153
+ if (root.version !== PIPELINE_VERSION) {
154
+ throw new DefinitionError(`pipeline.version must be ${PIPELINE_VERSION}.`);
155
+ }
156
+ if (root.requireAttestation !== undefined && typeof root.requireAttestation !== "boolean") {
157
+ throw new DefinitionError("pipeline.requireAttestation must be a boolean.");
158
+ }
159
+ const rawProfiles = record(root.profiles, "pipeline.profiles");
160
+ const profileEntries = Object.entries(rawProfiles);
161
+ if (profileEntries.length === 0 || profileEntries.length > 32) {
162
+ throw new DefinitionError("pipeline.profiles must contain from 1 to 32 named profiles.");
163
+ }
164
+ if (!Array.isArray(root.steps) || root.steps.length === 0) {
165
+ throw new DefinitionError("pipeline.steps must contain at least one step.");
166
+ }
167
+ const profiles = {};
168
+ for (const [name2, raw] of profileEntries) {
169
+ if (!NAME.test(name2))
170
+ throw new DefinitionError(`pipeline profile name is invalid: ${name2}.`);
171
+ const profile = record(raw, `profiles.${name2}`);
172
+ exactKeys(profile, ["software"], `profiles.${name2}`);
173
+ if (!Array.isArray(profile.software)) {
174
+ throw new DefinitionError(`profiles.${name2}.software must be an array.`);
175
+ }
176
+ profiles[name2] = { software: validateSoftwareRequirements(profile.software, options) };
177
+ }
178
+ const phases = new Set(["build", "release", "migrate", "health"]);
179
+ const steps = root.steps.map((raw, index) => {
180
+ const step = record(raw, `steps[${index}]`);
181
+ exactKeys(step, ["name", "run", "phase", "scope", "profiles", "secrets", "always", "timeoutMs", "when"], `steps[${index}]`);
182
+ const phase = text(step.phase, `steps[${index}].phase`);
183
+ if (!phases.has(phase))
184
+ throw new DefinitionError(`steps[${index}].phase is not supported.`);
185
+ if (step.scope !== "target" && step.scope !== "release") {
186
+ throw new DefinitionError(`steps[${index}].scope must be target or release.`);
187
+ }
188
+ if (step.always !== undefined && typeof step.always !== "boolean") {
189
+ throw new DefinitionError(`steps[${index}].always must be a boolean.`);
190
+ }
191
+ let selectedProfiles;
192
+ if (step.profiles !== undefined) {
193
+ if (!Array.isArray(step.profiles) || step.profiles.length === 0 || step.profiles.some((name2) => typeof name2 !== "string" || !Object.hasOwn(profiles, name2))) {
194
+ throw new DefinitionError(`steps[${index}].profiles must name existing profiles.`);
195
+ }
196
+ selectedProfiles = [...step.profiles];
197
+ if (new Set(selectedProfiles).size !== selectedProfiles.length) {
198
+ throw new DefinitionError(`steps[${index}].profiles must not contain duplicates.`);
199
+ }
200
+ }
201
+ if (step.secrets !== undefined && (!Array.isArray(step.secrets) || step.secrets.some((name2) => typeof name2 !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(name2)))) {
202
+ throw new DefinitionError(`steps[${index}].secrets must contain names only.`);
203
+ }
204
+ if (Array.isArray(step.secrets) && new Set(step.secrets).size !== step.secrets.length) {
205
+ throw new DefinitionError(`steps[${index}].secrets must not contain duplicates.`);
206
+ }
207
+ if (Array.isArray(step.secrets) && step.secrets.some((name2) => RESERVED_STEP_ENV.has(String(name2)))) {
208
+ throw new DefinitionError(`steps[${index}].secrets may not replace process-control environment variables.`);
209
+ }
210
+ const timeoutMs = step.timeoutMs === undefined ? undefined : Number(step.timeoutMs);
211
+ if (timeoutMs !== undefined && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 86400000)) {
212
+ throw new DefinitionError(`steps[${index}].timeoutMs must be an integer from 1 to 86400000.`);
213
+ }
214
+ let when;
215
+ if (step.when !== undefined) {
216
+ const conditions = record(step.when, `steps[${index}].when`);
217
+ when = {};
218
+ for (const [name2, expected] of Object.entries(conditions)) {
219
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(name2) || typeof expected !== "string" || expected.length === 0) {
220
+ throw new DefinitionError(`steps[${index}].when must map environment names to non-empty strings.`);
221
+ }
222
+ when[name2] = expected;
223
+ }
224
+ if (Object.keys(when).length === 0)
225
+ throw new DefinitionError(`steps[${index}].when must not be empty.`);
226
+ }
227
+ return {
228
+ name: text(step.name, `steps[${index}].name`),
229
+ run: text(step.run, `steps[${index}].run`),
230
+ phase,
231
+ scope: step.scope,
232
+ profiles: selectedProfiles,
233
+ secrets: step.secrets,
234
+ always: step.always === true,
235
+ timeoutMs,
236
+ when
237
+ };
238
+ });
239
+ if (new Set(steps.map((step) => step.name)).size !== steps.length) {
240
+ throw new DefinitionError("pipeline.steps must have unique names.");
241
+ }
242
+ const name = text(root.name, "pipeline.name");
243
+ if (name.length > 120)
244
+ throw new DefinitionError("pipeline.name must be at most 120 characters.");
245
+ return {
246
+ version: PIPELINE_VERSION,
247
+ name,
248
+ requireAttestation: root.requireAttestation === true,
249
+ profiles,
250
+ steps
251
+ };
252
+ }
253
+ function phasePipeline(definition, phase, profile, executeRelease = false) {
254
+ if (!Object.hasOwn(definition.profiles, profile)) {
255
+ throw new DefinitionError(`pipeline profile does not exist: ${profile}.`);
256
+ }
257
+ return {
258
+ name: `${definition.name}:${phase}`,
259
+ requireAttestation: definition.requireAttestation,
260
+ steps: definition.steps.filter((step) => step.phase === phase && (!step.profiles || step.profiles.includes(profile)) && (step.scope === "target" || executeRelease))
261
+ };
262
+ }
263
+
264
+ // src/deploy-file.ts
265
+ import { createHash } from "node:crypto";
266
+ import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
267
+ import { basename, join } from "node:path";
268
+ var DEPLOY_FILE = ".fz/deploy.json";
269
+ var DEPLOY_TODO_PREFIX = "ForgeZero pipeline TODO:";
270
+ var stable = (value) => {
271
+ if (Array.isArray(value))
272
+ return `[${value.map(stable).join(",")}]`;
273
+ if (value && typeof value === "object") {
274
+ return `{${Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => `${JSON.stringify(key)}:${stable(entry)}`).join(",")}}`;
275
+ }
276
+ return JSON.stringify(value);
277
+ };
278
+ function deployDefinitionDigest(definition) {
279
+ return `sha256:${createHash("sha256").update(stable(definition)).digest("hex")}`;
280
+ }
281
+ var safeName = (value) => {
282
+ const normalized = value.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
283
+ return /^[a-z]/.test(normalized) ? normalized : `app-${normalized || "service"}`.slice(0, 63);
284
+ };
285
+ function packageHints(root) {
286
+ const packagePath = join(root, "package.json");
287
+ if (!existsSync(packagePath))
288
+ return { bun: existsSync(join(root, "bun.lock")) };
289
+ try {
290
+ const manifest = JSON.parse(readFileSync2(packagePath, "utf8"));
291
+ return {
292
+ name: typeof manifest.name === "string" ? manifest.name : undefined,
293
+ build: typeof manifest.scripts?.build === "string" ? "bun run build" : undefined,
294
+ bun: existsSync(join(root, "bun.lock")) || existsSync(join(root, "bun.lockb"))
295
+ };
296
+ } catch {
297
+ return { bun: existsSync(join(root, "bun.lock")) };
298
+ }
299
+ }
300
+ var blocker = (instruction) => `printf '%s\\n' '${DEPLOY_TODO_PREFIX} ${instruction}' >&2; exit 78`;
301
+ function defaultDeployFile(root, options = {}) {
302
+ const hints = packageHints(root);
303
+ const software = options.software ?? (hints.bun ? SOFTWARE_CATALOG.filter((entry) => entry.id === "bun" && entry.status === "active").map(({ id, version }) => ({ id, version })) : []);
304
+ validateSoftwareRequirements(software, { channel: options.channel });
305
+ const profile = options.profile ?? "app";
306
+ const build = hints.build ?? blocker("replace the build step with the project build command");
307
+ return {
308
+ $schema: DEPLOY_SCHEMA_URL,
309
+ version: 2,
310
+ name: safeName(options.name ?? hints.name ?? basename(root)),
311
+ ...options.requireAttestation ? { requireAttestation: true } : {},
312
+ profiles: { [profile]: { software } },
313
+ steps: [
314
+ { name: "build", phase: "build", scope: "target", run: build, timeoutMs: 600000 },
315
+ {
316
+ name: "promote release",
317
+ phase: "release",
318
+ scope: "target",
319
+ run: blocker("replace the release step with an atomic promotion command"),
320
+ timeoutMs: 120000
321
+ },
322
+ {
323
+ name: "health check",
324
+ phase: "health",
325
+ scope: "target",
326
+ run: blocker("replace the health step with a bounded local health check"),
327
+ timeoutMs: 30000
328
+ }
329
+ ]
330
+ };
331
+ }
332
+ function inspectDeployFile(root, options = {}) {
333
+ const path = join(root, DEPLOY_FILE);
334
+ if (!existsSync(path))
335
+ throw new Error(`${DEPLOY_FILE} does not exist; run \`fz deploy init\`.`);
336
+ const raw = JSON.parse(readFileSync2(path, "utf8"));
337
+ const definition = parseDeployDefinition(raw, options);
338
+ const problems = definition.steps.filter((step) => step.run.includes(DEPLOY_TODO_PREFIX)).map((step) => `${step.name} still contains the safe initialization blocker`);
339
+ const profiles = Object.keys(definition.profiles).sort();
340
+ return {
341
+ path,
342
+ definition,
343
+ summary: {
344
+ path,
345
+ digest: deployDefinitionDigest(definition),
346
+ version: definition.version,
347
+ name: definition.name,
348
+ profiles,
349
+ software: Object.fromEntries(profiles.map((profile) => [
350
+ profile,
351
+ definition.profiles[profile].software
352
+ ])),
353
+ ready: problems.length === 0,
354
+ problems
355
+ }
356
+ };
357
+ }
358
+ function initializeDeployFile(root, options = {}) {
359
+ const path = join(root, DEPLOY_FILE);
360
+ if (existsSync(path) && !options.force) {
361
+ throw new Error(`${DEPLOY_FILE} already exists; use --force only when replacing it deliberately.`);
362
+ }
363
+ const raw = defaultDeployFile(root, options);
364
+ parseDeployDefinition(raw, { channel: options.channel });
365
+ mkdirSync(join(root, ".fz"), { recursive: true });
366
+ writeFileSync(path, `${JSON.stringify(raw, null, 2)}
367
+ `, { mode: 420 });
368
+ return inspectDeployFile(root, { channel: options.channel });
369
+ }
370
+ export {
371
+ inspectDeployFile,
372
+ initializeDeployFile,
373
+ deployDefinitionDigest,
374
+ defaultDeployFile,
375
+ DEPLOY_TODO_PREFIX,
376
+ DEPLOY_SCHEMA_URL,
377
+ DEPLOY_FILE
378
+ };
@@ -24,6 +24,10 @@ export interface DeploymentResult {
24
24
  repository: string;
25
25
  branch: string;
26
26
  revision: string;
27
+ /** Semantic digest of the validated definition from this exact commit. */
28
+ definitionDigest: string;
29
+ definitionVersion: number;
30
+ profile: string;
27
31
  release: string;
28
32
  ok: boolean;
29
33
  phases: readonly RunResult[];