@forgezero/agent 0.1.29 → 0.1.30

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
@@ -118,12 +118,30 @@ root-only control socket or signed outbound claim path, then the deployment
118
118
  manager validates the checked-in definition and submits it to the common keyed
119
119
  queue.
120
120
 
121
+ ## Project context for any repository
122
+
123
+ Conversation memory is not a project database. Initialize one vendor-neutral,
124
+ Git-owned context and generate the small files each AI product discovers:
125
+
126
+ ```bash
127
+ fz project init
128
+ # edit .forgezero/project.json and create its named truth sources
129
+ fz project sync
130
+ fz project check
131
+ ```
132
+
133
+ `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, Copilot instructions and the Cursor rule
134
+ are generated adapters. They contain no independent architecture or progress.
135
+ `fz project check` fails when a truth source is missing or an adapter was edited
136
+ by hand. Tools and skills remain optional execution aids; accepted decisions and
137
+ status live in the repository and therefore survive switching AI agents.
138
+
121
139
  ## Deployment definitions
122
140
 
123
- A repository may commit `.fz/deploy.yaml` with its own roles, prerequisite
141
+ A repository may commit `.fz/deploy.json` with its own profiles, prerequisite
124
142
  checks, commands, and the exact secret names each step needs. ForgeZero does not
125
143
  choose a tenant's database, framework, or deploy shape. The agent validates the
126
- file before executing any command, prepares only the selected role, and gives a
144
+ file before executing any command, prepares only the selected profile, and gives a
127
145
  step only the vault values it explicitly names. `await` returns the complete
128
146
  pipeline result; no polling service or persistent queue is required.
129
147
 
@@ -131,7 +149,9 @@ The daemon owns source checkout and command execution. Bootstrap explicitly
131
149
  awaits release one because the API does not exist yet, then the Agent consumes a
132
150
  one-use platform enrolment capability. There is no branch watcher. Every normal
133
151
  platform or tenant release is one durable API delivery atomically expanded to
134
- one row for every explicit compute binding:
152
+ one row for every independently attached compute target. Release-scoped steps
153
+ run on one target elected by stable target ordering, never on a user-declared
154
+ coordinator:
135
155
  `pending` is written before dispatch, `running` and a fenced lease before project
136
156
  code, and only an awaited successful pipeline writes `deployed`. An expired
137
157
  claim can be recovered; its stale token cannot renew or finish.
@@ -188,7 +208,7 @@ An operator may also force a deployment and await the complete result over the
188
208
  private control socket:
189
209
 
190
210
  ```bash
191
- fz-agent deploy --revision=<full-40-character-commit> --coordinator
211
+ fz-agent deploy --revision=<full-40-character-commit> --release-executor
192
212
  fz-agent status
193
213
  fz-agent pause
194
214
  fz-agent pause-key --key=project:production
@@ -429,7 +429,7 @@ async function postSignedNode(options, path, body) {
429
429
  }
430
430
 
431
431
  // src/version.ts
432
- var VERSION2 = "0.1.29";
432
+ var VERSION2 = "0.1.30";
433
433
 
434
434
  // src/agent-heartbeat.ts
435
435
  var unquote = (value) => value.replace(/^['"]|['"]$/g, "");
@@ -44,7 +44,7 @@ export interface InstallOptions {
44
44
  controlSocketPath?: string;
45
45
  repository?: string;
46
46
  branch?: string;
47
- role?: string;
47
+ profile?: string;
48
48
  deployRoot?: string;
49
49
  publicApiUrl?: string;
50
50
  deploymentEnvironment?: Record<string, string>;
@@ -1,14 +1,20 @@
1
1
  import type { Pipeline, PipelineStep } from './pipeline';
2
2
  import { type SoftwareRequirement } from './software';
3
- export declare const PIPELINE_VERSION: 1;
4
- export interface PipelineRole {
5
- name: string;
3
+ /**
4
+ * Version two separates a repository's deploy recipe from the computes that use
5
+ * it. A target chooses one named profile in the control plane; compute names,
6
+ * counts and cluster leadership never belong in Git.
7
+ */
8
+ export declare const PIPELINE_VERSION: 2;
9
+ export interface PipelineProfile {
6
10
  software: readonly SoftwareRequirement[];
7
11
  }
8
12
  export interface DeployStep extends PipelineStep {
9
13
  phase: 'build' | 'release' | 'migrate' | 'health';
10
- /** The coordinator runs this on one selected node, never on every replica. */
11
- once?: boolean;
14
+ /** Run on this target, or on the one deterministic release executor. */
15
+ scope: 'target' | 'release';
16
+ /** Optional profile filter. An omitted list applies to every profile. */
17
+ profiles?: readonly string[];
12
18
  /** Run only when every named non-secret deployment coordinate has this value. */
13
19
  when?: Readonly<Record<string, string>>;
14
20
  }
@@ -16,12 +22,12 @@ export interface DeployDefinition {
16
22
  version: typeof PIPELINE_VERSION;
17
23
  name: string;
18
24
  requireAttestation?: boolean;
19
- roles: readonly PipelineRole[];
25
+ profiles: Readonly<Record<string, PipelineProfile>>;
20
26
  steps: readonly DeployStep[];
21
27
  }
22
28
  export declare class DefinitionError extends Error {
23
29
  constructor(message: string);
24
30
  }
25
- /** Validate parsed YAML before any command from it is allowed to run. */
31
+ /** Validate parsed JSON before any command from it is allowed to run. */
26
32
  export declare function parseDeployDefinition(value: unknown): DeployDefinition;
27
- export declare function phasePipeline(definition: DeployDefinition, phase: DeployStep['phase']): Pipeline;
33
+ export declare function phasePipeline(definition: DeployDefinition, phase: DeployStep['phase'], profile: string, executeRelease?: boolean): Pipeline;
@@ -3,6 +3,16 @@ import { readFileSync } from "node:fs";
3
3
  var BUN_INSTALLER_SHA256 = "bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd";
4
4
  var ARANGO_SHA256 = "b5a9197b4343f2ed554e1ebc1ef8e6529c7c39cde0035cdc311a4747a3355066";
5
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
+ ];
6
16
  var UBUNTU_2604_X64 = [
7
17
  {
8
18
  requirement: { id: "bun", version: "1.3.14" },
@@ -41,7 +51,7 @@ function observeSoftwareHost(osRelease = readFileSync("/etc/os-release", "utf8")
41
51
  architecture
42
52
  };
43
53
  }
44
- function validateSoftwareRequirements(value) {
54
+ function validateSoftwareRequirements(value, options = {}) {
45
55
  if (!Array.isArray(value) || value.length > 32)
46
56
  throw new Error("software requirements must be an array of at most 32 entries");
47
57
  const seen = new Set;
@@ -59,13 +69,18 @@ function validateSoftwareRequirements(value) {
59
69
  if (seen.has(requirement.id))
60
70
  throw new Error(`duplicate software requirement: ${requirement.id}`);
61
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 === "retired" || options.channel !== "development" && catalog.status !== "active") {
74
+ throw new Error(`software requirement is not available for ${options.channel ?? "production"}: ${requirement.id}@${requirement.version}`);
75
+ }
62
76
  return requirement;
63
77
  });
64
78
  }
65
79
  async function ensureSoftwareRequirements(requirementsInput, options) {
66
80
  const requirements = validateSoftwareRequirements(requirementsInput);
67
81
  const observation = options.observation ?? observeSoftwareHost();
68
- if (observation.os.id !== "ubuntu" || observation.os.versionId !== "26.04" || observation.architecture !== "x64") {
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") {
69
84
  throw new Error(`unsupported software strategy: ${observation.os.id} ${observation.os.versionId} ${observation.architecture}`);
70
85
  }
71
86
  const results = [];
@@ -90,7 +105,7 @@ async function ensureSoftwareRequirements(requirementsInput, options) {
90
105
  }
91
106
 
92
107
  // src/definition.ts
93
- var PIPELINE_VERSION = 1;
108
+ var PIPELINE_VERSION = 2;
94
109
 
95
110
  class DefinitionError extends Error {
96
111
  constructor(message) {
@@ -115,6 +130,7 @@ var exactKeys = (value, allowed, where) => {
115
130
  if (unknown.length > 0)
116
131
  throw new DefinitionError(`${where} contains unknown field(s): ${unknown.join(", ")}.`);
117
132
  };
133
+ var NAME = /^[a-z][a-z0-9-]{0,62}$/;
118
134
  var RESERVED_STEP_ENV = new Set([
119
135
  "PATH",
120
136
  "HOME",
@@ -129,37 +145,52 @@ var RESERVED_STEP_ENV = new Set([
129
145
  ]);
130
146
  function parseDeployDefinition(value) {
131
147
  const root = record(value, "pipeline");
132
- exactKeys(root, ["version", "name", "requireAttestation", "roles", "steps"], "pipeline");
148
+ 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.");
151
+ }
133
152
  if (root.version !== PIPELINE_VERSION) {
134
153
  throw new DefinitionError(`pipeline.version must be ${PIPELINE_VERSION}.`);
135
154
  }
136
- if (!Array.isArray(root.roles) || root.roles.length === 0) {
137
- throw new DefinitionError("pipeline.roles must contain at least one role.");
155
+ const rawProfiles = record(root.profiles, "pipeline.profiles");
156
+ const profileEntries = Object.entries(rawProfiles);
157
+ if (profileEntries.length === 0 || profileEntries.length > 32) {
158
+ throw new DefinitionError("pipeline.profiles must contain from 1 to 32 named profiles.");
138
159
  }
139
160
  if (!Array.isArray(root.steps) || root.steps.length === 0) {
140
161
  throw new DefinitionError("pipeline.steps must contain at least one step.");
141
162
  }
142
- const roles = root.roles.map((raw, index) => {
143
- const role = record(raw, `roles[${index}]`);
144
- exactKeys(role, ["name", "software"], `roles[${index}]`);
145
- if (!Array.isArray(role.software)) {
146
- throw new DefinitionError(`roles[${index}].software must be an array.`);
163
+ 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}`);
169
+ if (!Array.isArray(profile.software)) {
170
+ throw new DefinitionError(`profiles.${name}.software must be an array.`);
147
171
  }
148
- return {
149
- name: text(role.name, `roles[${index}].name`),
150
- software: validateSoftwareRequirements(role.software)
151
- };
152
- });
153
- if (new Set(roles.map((role) => role.name)).size !== roles.length) {
154
- throw new DefinitionError("pipeline.roles must have unique names.");
172
+ profiles[name] = { software: validateSoftwareRequirements(profile.software) };
155
173
  }
156
174
  const phases = new Set(["build", "release", "migrate", "health"]);
157
175
  const steps = root.steps.map((raw, index) => {
158
176
  const step = record(raw, `steps[${index}]`);
159
- exactKeys(step, ["name", "run", "phase", "secrets", "once", "always", "timeoutMs", "when"], `steps[${index}]`);
177
+ exactKeys(step, ["name", "run", "phase", "scope", "profiles", "secrets", "always", "timeoutMs", "when"], `steps[${index}]`);
160
178
  const phase = text(step.phase, `steps[${index}].phase`);
161
179
  if (!phases.has(phase))
162
180
  throw new DefinitionError(`steps[${index}].phase is not supported.`);
181
+ if (step.scope !== "target" && step.scope !== "release") {
182
+ throw new DefinitionError(`steps[${index}].scope must be target or release.`);
183
+ }
184
+ let selectedProfiles;
185
+ 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))) {
187
+ throw new DefinitionError(`steps[${index}].profiles must name existing profiles.`);
188
+ }
189
+ selectedProfiles = [...step.profiles];
190
+ if (new Set(selectedProfiles).size !== selectedProfiles.length) {
191
+ throw new DefinitionError(`steps[${index}].profiles must not contain duplicates.`);
192
+ }
193
+ }
163
194
  if (step.secrets !== undefined && (!Array.isArray(step.secrets) || step.secrets.some((name) => typeof name !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(name)))) {
164
195
  throw new DefinitionError(`steps[${index}].secrets must contain names only.`);
165
196
  }
@@ -190,8 +221,9 @@ function parseDeployDefinition(value) {
190
221
  name: text(step.name, `steps[${index}].name`),
191
222
  run: text(step.run, `steps[${index}].run`),
192
223
  phase,
224
+ scope: step.scope,
225
+ profiles: selectedProfiles,
193
226
  secrets: step.secrets,
194
- once: step.once === true,
195
227
  always: step.always === true,
196
228
  timeoutMs,
197
229
  when
@@ -204,15 +236,18 @@ function parseDeployDefinition(value) {
204
236
  version: PIPELINE_VERSION,
205
237
  name: text(root.name, "pipeline.name"),
206
238
  requireAttestation: root.requireAttestation === true,
207
- roles,
239
+ profiles,
208
240
  steps
209
241
  };
210
242
  }
211
- function phasePipeline(definition, phase) {
243
+ function phasePipeline(definition, phase, profile, executeRelease = false) {
244
+ if (!Object.hasOwn(definition.profiles, profile)) {
245
+ throw new DefinitionError(`pipeline profile does not exist: ${profile}.`);
246
+ }
212
247
  return {
213
248
  name: `${definition.name}:${phase}`,
214
249
  requireAttestation: definition.requireAttestation,
215
- steps: definition.steps.filter((step) => step.phase === phase)
250
+ steps: definition.steps.filter((step) => step.phase === phase && (!step.profiles || step.profiles.includes(profile)) && (step.scope === "target" || executeRelease))
216
251
  };
217
252
  }
218
253
  export {
@@ -7,12 +7,12 @@ export interface RemoteDeploymentClaim {
7
7
  claimToken: string;
8
8
  claimExpiresAtTs: number;
9
9
  attempt: number;
10
- /** True on exactly one compute in a clustered delivery. */
11
- coordinator: boolean;
10
+ /** True on the one target deterministically elected for this release. */
11
+ releaseExecutor: boolean;
12
12
  source: {
13
13
  repository: string;
14
14
  branch: string;
15
- role: string;
15
+ profile: string;
16
16
  knownHosts?: string;
17
17
  auth?: GitSourceAuth;
18
18
  };
@@ -16,8 +16,8 @@ export interface CommandResult {
16
16
  export interface DeploymentRequest {
17
17
  /** Optional exact commit from a verified webhook. Never a branch name. */
18
18
  revision?: string;
19
- /** Whether this node is the coordinator allowed to execute `once` steps. */
20
- coordinator?: boolean;
19
+ /** True only for the target deterministically elected for release-scoped steps. */
20
+ releaseExecutor?: boolean;
21
21
  }
22
22
  export interface DeploymentResult {
23
23
  key: string;
@@ -46,7 +46,7 @@ export interface DeploymentOptions {
46
46
  key: string;
47
47
  repository: string;
48
48
  branch: string;
49
- role: string;
49
+ profile: string;
50
50
  root: string;
51
51
  publicApiUrl?: string;
52
52
  sourceAuth?: GitSourceAuth;
@@ -74,8 +74,8 @@ export declare class DeploymentError extends Error {
74
74
  /**
75
75
  * One source and one pipeline owner.
76
76
  *
77
- * A request may choose an exact commit and whether this assigned node is the
78
- * coordinator. It cannot choose a repository, branch, working directory or
77
+ * A request may choose an exact commit and carry the control plane's
78
+ * deterministic release-executor decision. It cannot choose a repository, branch, working directory or
79
79
  * command: those are sealed into the agent unit and the checked-out definition.
80
80
  */
81
81
  export declare function createDeploymentManager(options: DeploymentOptions): {
package/dist/fz-agent.js CHANGED
@@ -4886,6 +4886,16 @@ import { readFileSync } from "fs";
4886
4886
  var BUN_INSTALLER_SHA256 = "bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd";
4887
4887
  var ARANGO_SHA256 = "b5a9197b4343f2ed554e1ebc1ef8e6529c7c39cde0035cdc311a4747a3355066";
4888
4888
  var CLOUDFLARED_SHA256 = "9d71c677db00134c1bd4144b7783486b654ad281b1ea62b4972098d19f770f17";
4889
+ var OS_CATALOG = [
4890
+ { id: "ubuntu", version: "26.04", architecture: "x64", status: "active" }
4891
+ ];
4892
+ var SOFTWARE_CATALOG = [
4893
+ { id: "bun", version: "1.3.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
4894
+ { id: "nginx", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
4895
+ { id: "arangodb", version: "3.11.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
4896
+ { id: "cloudflared", version: "2026.7.3", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
4897
+ { id: "ufw", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" }
4898
+ ];
4889
4899
  var UBUNTU_2604_X64 = [
4890
4900
  {
4891
4901
  requirement: { id: "bun", version: "1.3.14" },
@@ -4924,7 +4934,7 @@ function observeSoftwareHost(osRelease = readFileSync("/etc/os-release", "utf8")
4924
4934
  architecture
4925
4935
  };
4926
4936
  }
4927
- function validateSoftwareRequirements(value) {
4937
+ function validateSoftwareRequirements(value, options = {}) {
4928
4938
  if (!Array.isArray(value) || value.length > 32)
4929
4939
  throw new Error("software requirements must be an array of at most 32 entries");
4930
4940
  const seen = new Set;
@@ -4942,13 +4952,18 @@ function validateSoftwareRequirements(value) {
4942
4952
  if (seen.has(requirement.id))
4943
4953
  throw new Error(`duplicate software requirement: ${requirement.id}`);
4944
4954
  seen.add(requirement.id);
4955
+ const catalog = SOFTWARE_CATALOG.find((candidate) => candidate.id === requirement.id && candidate.version === requirement.version);
4956
+ if (!catalog || catalog.status === "retired" || options.channel !== "development" && catalog.status !== "active") {
4957
+ throw new Error(`software requirement is not available for ${options.channel ?? "production"}: ${requirement.id}@${requirement.version}`);
4958
+ }
4945
4959
  return requirement;
4946
4960
  });
4947
4961
  }
4948
4962
  async function ensureSoftwareRequirements(requirementsInput, options) {
4949
4963
  const requirements = validateSoftwareRequirements(requirementsInput);
4950
4964
  const observation = options.observation ?? observeSoftwareHost();
4951
- if (observation.os.id !== "ubuntu" || observation.os.versionId !== "26.04" || observation.architecture !== "x64") {
4965
+ const os = OS_CATALOG.find((candidate) => candidate.id === observation.os.id && candidate.version === observation.os.versionId && candidate.architecture === observation.architecture);
4966
+ if (!os || os.status !== "active") {
4952
4967
  throw new Error(`unsupported software strategy: ${observation.os.id} ${observation.os.versionId} ${observation.architecture}`);
4953
4968
  }
4954
4969
  const results = [];
@@ -4973,7 +4988,7 @@ async function ensureSoftwareRequirements(requirementsInput, options) {
4973
4988
  }
4974
4989
 
4975
4990
  // src/definition.ts
4976
- var PIPELINE_VERSION = 1;
4991
+ var PIPELINE_VERSION = 2;
4977
4992
 
4978
4993
  class DefinitionError extends Error {
4979
4994
  constructor(message) {
@@ -4998,6 +5013,7 @@ var exactKeys = (value, allowed, where) => {
4998
5013
  if (unknown.length > 0)
4999
5014
  throw new DefinitionError(`${where} contains unknown field(s): ${unknown.join(", ")}.`);
5000
5015
  };
5016
+ var NAME = /^[a-z][a-z0-9-]{0,62}$/;
5001
5017
  var RESERVED_STEP_ENV = new Set([
5002
5018
  "PATH",
5003
5019
  "HOME",
@@ -5012,37 +5028,52 @@ var RESERVED_STEP_ENV = new Set([
5012
5028
  ]);
5013
5029
  function parseDeployDefinition(value) {
5014
5030
  const root = record(value, "pipeline");
5015
- exactKeys(root, ["version", "name", "requireAttestation", "roles", "steps"], "pipeline");
5031
+ exactKeys(root, ["$schema", "version", "name", "requireAttestation", "profiles", "steps"], "pipeline");
5032
+ if (root.$schema !== undefined && (typeof root.$schema !== "string" || !root.$schema.startsWith("https://"))) {
5033
+ throw new DefinitionError("pipeline.$schema must be an HTTPS URL.");
5034
+ }
5016
5035
  if (root.version !== PIPELINE_VERSION) {
5017
5036
  throw new DefinitionError(`pipeline.version must be ${PIPELINE_VERSION}.`);
5018
5037
  }
5019
- if (!Array.isArray(root.roles) || root.roles.length === 0) {
5020
- throw new DefinitionError("pipeline.roles must contain at least one role.");
5038
+ const rawProfiles = record(root.profiles, "pipeline.profiles");
5039
+ const profileEntries = Object.entries(rawProfiles);
5040
+ if (profileEntries.length === 0 || profileEntries.length > 32) {
5041
+ throw new DefinitionError("pipeline.profiles must contain from 1 to 32 named profiles.");
5021
5042
  }
5022
5043
  if (!Array.isArray(root.steps) || root.steps.length === 0) {
5023
5044
  throw new DefinitionError("pipeline.steps must contain at least one step.");
5024
5045
  }
5025
- const roles = root.roles.map((raw, index) => {
5026
- const role = record(raw, `roles[${index}]`);
5027
- exactKeys(role, ["name", "software"], `roles[${index}]`);
5028
- if (!Array.isArray(role.software)) {
5029
- throw new DefinitionError(`roles[${index}].software must be an array.`);
5046
+ const profiles = {};
5047
+ for (const [name, raw] of profileEntries) {
5048
+ if (!NAME.test(name))
5049
+ throw new DefinitionError(`pipeline profile name is invalid: ${name}.`);
5050
+ const profile = record(raw, `profiles.${name}`);
5051
+ exactKeys(profile, ["software"], `profiles.${name}`);
5052
+ if (!Array.isArray(profile.software)) {
5053
+ throw new DefinitionError(`profiles.${name}.software must be an array.`);
5030
5054
  }
5031
- return {
5032
- name: text(role.name, `roles[${index}].name`),
5033
- software: validateSoftwareRequirements(role.software)
5034
- };
5035
- });
5036
- if (new Set(roles.map((role) => role.name)).size !== roles.length) {
5037
- throw new DefinitionError("pipeline.roles must have unique names.");
5055
+ profiles[name] = { software: validateSoftwareRequirements(profile.software) };
5038
5056
  }
5039
5057
  const phases = new Set(["build", "release", "migrate", "health"]);
5040
5058
  const steps = root.steps.map((raw, index) => {
5041
5059
  const step = record(raw, `steps[${index}]`);
5042
- exactKeys(step, ["name", "run", "phase", "secrets", "once", "always", "timeoutMs", "when"], `steps[${index}]`);
5060
+ exactKeys(step, ["name", "run", "phase", "scope", "profiles", "secrets", "always", "timeoutMs", "when"], `steps[${index}]`);
5043
5061
  const phase = text(step.phase, `steps[${index}].phase`);
5044
5062
  if (!phases.has(phase))
5045
5063
  throw new DefinitionError(`steps[${index}].phase is not supported.`);
5064
+ if (step.scope !== "target" && step.scope !== "release") {
5065
+ throw new DefinitionError(`steps[${index}].scope must be target or release.`);
5066
+ }
5067
+ let selectedProfiles;
5068
+ if (step.profiles !== undefined) {
5069
+ if (!Array.isArray(step.profiles) || step.profiles.length === 0 || step.profiles.some((name) => typeof name !== "string" || !Object.hasOwn(profiles, name))) {
5070
+ throw new DefinitionError(`steps[${index}].profiles must name existing profiles.`);
5071
+ }
5072
+ selectedProfiles = [...step.profiles];
5073
+ if (new Set(selectedProfiles).size !== selectedProfiles.length) {
5074
+ throw new DefinitionError(`steps[${index}].profiles must not contain duplicates.`);
5075
+ }
5076
+ }
5046
5077
  if (step.secrets !== undefined && (!Array.isArray(step.secrets) || step.secrets.some((name) => typeof name !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(name)))) {
5047
5078
  throw new DefinitionError(`steps[${index}].secrets must contain names only.`);
5048
5079
  }
@@ -5073,8 +5104,9 @@ function parseDeployDefinition(value) {
5073
5104
  name: text(step.name, `steps[${index}].name`),
5074
5105
  run: text(step.run, `steps[${index}].run`),
5075
5106
  phase,
5107
+ scope: step.scope,
5108
+ profiles: selectedProfiles,
5076
5109
  secrets: step.secrets,
5077
- once: step.once === true,
5078
5110
  always: step.always === true,
5079
5111
  timeoutMs,
5080
5112
  when
@@ -5087,7 +5119,7 @@ function parseDeployDefinition(value) {
5087
5119
  version: PIPELINE_VERSION,
5088
5120
  name: text(root.name, "pipeline.name"),
5089
5121
  requireAttestation: root.requireAttestation === true,
5090
- roles,
5122
+ profiles,
5091
5123
  steps
5092
5124
  };
5093
5125
  }
@@ -5217,7 +5249,7 @@ function createDeploymentManager(options) {
5217
5249
  const exec = options.exec ?? shell;
5218
5250
  const projectExec = options.projectExec ?? exec;
5219
5251
  const now = options.now ?? Date.now;
5220
- const readDefinition = options.readDefinition ?? ((path) => Bun.YAML.parse(readFileSync2(path, "utf8")));
5252
+ const readDefinition = options.readDefinition ?? ((path) => JSON.parse(readFileSync2(path, "utf8")));
5221
5253
  const credentialsDirectory = process.env.CREDENTIALS_DIRECTORY;
5222
5254
  const gitCredentialPath = options.gitCredentialPath ?? (credentialsDirectory ? join(credentialsDirectory, "git-deploy-key") : undefined);
5223
5255
  const knownHostsPath = options.knownHostsPath ?? "/etc/forgezero/git/known_hosts";
@@ -5332,22 +5364,22 @@ function createDeploymentManager(options) {
5332
5364
  if (request.revision && head !== request.revision) {
5333
5365
  throw new DeploymentError("SOURCE_FAILED", `Git checked out ${head}, not requested ${request.revision}.`);
5334
5366
  }
5335
- const definition = parseDeployDefinition(readDefinition(join(release, ".fz", "deploy.yaml")));
5336
- const selectedRole = definition.roles.find((candidate) => candidate.name === options.role);
5337
- if (!selectedRole)
5338
- throw new DeploymentError("PIPELINE_FAILED", `pipeline role does not exist: ${options.role}.`);
5339
- if (selectedRole.software.length > 0) {
5367
+ const definition = parseDeployDefinition(readDefinition(join(release, ".fz", "deploy.json")));
5368
+ const selectedProfile = definition.profiles[options.profile];
5369
+ if (!selectedProfile)
5370
+ throw new DeploymentError("PIPELINE_FAILED", `pipeline profile does not exist: ${options.profile}.`);
5371
+ if (selectedProfile.software.length > 0) {
5340
5372
  if (!options.ensureSoftware) {
5341
5373
  throw new DeploymentError("PIPELINE_FAILED", "the supervised software helper is unavailable");
5342
5374
  }
5343
- await options.ensureSoftware(selectedRole.software);
5375
+ await options.ensureSoftware(selectedProfile.software);
5344
5376
  }
5345
5377
  const phaseEnvironment = {
5346
5378
  ...options.environment ?? {},
5347
5379
  FZ_RELEASE: release,
5348
5380
  FZ_DEPLOY_REVISION: head,
5349
5381
  FZ_DEPLOY_BRANCH: options.branch,
5350
- FZ_DEPLOY_ROLE: options.role,
5382
+ FZ_DEPLOY_PROFILE: options.profile,
5351
5383
  ...options.publicApiUrl ? { PUBLIC_API_URL: options.publicApiUrl } : {}
5352
5384
  };
5353
5385
  const phaseExec = ({ command, env: secrets, timeoutMs }) => projectExec({ command, cwd: release, env: { ...phaseEnvironment, ...secrets }, timeoutMs });
@@ -5363,7 +5395,7 @@ function createDeploymentManager(options) {
5363
5395
  return {
5364
5396
  name: `${definition.name}:${phase}`,
5365
5397
  requireAttestation: definition.requireAttestation,
5366
- steps: definition.steps.filter((step) => step.phase === phase && (!step.once || request.coordinator === true) && (!step.when || Object.entries(step.when).every(([name, expected]) => phaseEnvironment[name] === expected)))
5398
+ steps: definition.steps.filter((step) => step.phase === phase && (!step.profiles || step.profiles.includes(options.profile)) && (step.scope === "target" || request.releaseExecutor === true) && (!step.when || Object.entries(step.when).every(([name, expected]) => phaseEnvironment[name] === expected)))
5367
5399
  };
5368
5400
  })
5369
5401
  ].filter((pipeline) => pipeline.steps.length > 0);
@@ -5400,7 +5432,7 @@ function createDeploymentManager(options) {
5400
5432
  return revision.toLowerCase();
5401
5433
  },
5402
5434
  deploy(request = {}) {
5403
- const revisionKey = request.revision ? `${request.revision.toLowerCase()}:${request.coordinator === true ? "coordinator" : "node"}` : undefined;
5435
+ const revisionKey = request.revision ? `${request.revision.toLowerCase()}:${request.releaseExecutor === true ? "release" : "target"}` : undefined;
5404
5436
  if (revisionKey) {
5405
5437
  const existing = activeRevisions.get(revisionKey);
5406
5438
  if (existing)
@@ -5592,7 +5624,7 @@ async function deployClaim(options, claim) {
5592
5624
  schedule();
5593
5625
  let result;
5594
5626
  try {
5595
- result = await manager.deploy({ revision: claim.revision, coordinator: claim.coordinator }).result;
5627
+ result = await manager.deploy({ revision: claim.revision, releaseExecutor: claim.releaseExecutor }).result;
5596
5628
  } finally {
5597
5629
  stopped = true;
5598
5630
  clearTimer(timer);
@@ -7875,7 +7907,7 @@ function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, t
7875
7907
  import { readFileSync as readFileSync7 } from "fs";
7876
7908
 
7877
7909
  // src/version.ts
7878
- var VERSION2 = "0.1.29";
7910
+ var VERSION2 = "0.1.30";
7879
7911
 
7880
7912
  // src/agent-heartbeat.ts
7881
7913
  var unquote = (value) => value.replace(/^['"]|['"]$/g, "");
@@ -8164,7 +8196,7 @@ if (import.meta.main) {
8164
8196
  " FZ_SEED_PATH legacy/dev seed file (default: " + DEFAULT_SEED_PATH + ")",
8165
8197
  " FZ_NODE_KEY override the node key (default: derived from the seed)",
8166
8198
  "",
8167
- " deploy [--revision=<full-sha>] [--coordinator]",
8199
+ " deploy [--revision=<full-sha>] [--release-executor]",
8168
8200
  " identity print this sealed seed's public identity",
8169
8201
  " enrol consume a systemd-loaded compute capability",
8170
8202
  " metal-helper --profile=/etc/forgezero/metal.json",
@@ -8221,9 +8253,9 @@ if (import.meta.main) {
8221
8253
  const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
8222
8254
  if (!profilePath)
8223
8255
  throw new Error("metal-helper requires --profile=/absolute/path.json");
8224
- const profile = JSON.parse(readFileSync8(profilePath, "utf8"));
8256
+ const profile2 = JSON.parse(readFileSync8(profilePath, "utf8"));
8225
8257
  const helper = startMetalHelper({
8226
- profile,
8258
+ profile: profile2,
8227
8259
  socketPath: process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET
8228
8260
  });
8229
8261
  console.log(`[metal-helper] listening on ${process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET}`);
@@ -8339,8 +8371,8 @@ if (import.meta.main) {
8339
8371
  const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
8340
8372
  if (!profilePath)
8341
8373
  throw new Error("metal-isolation requires --profile=/absolute/path.json");
8342
- const profile = JSON.parse(readFileSync8(profilePath, "utf8"));
8343
- await applyMetalIsolation(profile);
8374
+ const profile2 = JSON.parse(readFileSync8(profilePath, "utf8"));
8375
+ await applyMetalIsolation(profile2);
8344
8376
  console.log("[metal-isolation] host and guest cgroup boundaries active");
8345
8377
  process.exit(0);
8346
8378
  }
@@ -8468,7 +8500,7 @@ if (import.meta.main) {
8468
8500
  op: "deploy",
8469
8501
  request: {
8470
8502
  revision: revisionArg?.slice("--revision=".length),
8471
- coordinator: args.includes("--coordinator")
8503
+ releaseExecutor: args.includes("--release-executor")
8472
8504
  }
8473
8505
  } : command2 === "cancel" ? { op: "cancel", id: idArg?.slice("--id=".length) ?? "" } : ["pause-key", "resume-key", "stop-key", "start-key"].includes(command2) ? {
8474
8506
  op: command2,
@@ -8575,19 +8607,19 @@ if (import.meta.main) {
8575
8607
  } : systemdDeploymentSecrets;
8576
8608
  const repository = process.env.FZ_DEPLOY_REPO;
8577
8609
  const branch = process.env.FZ_DEPLOY_BRANCH;
8578
- const role = process.env.FZ_DEPLOY_ROLE;
8610
+ const profile = process.env.FZ_DEPLOY_PROFILE;
8579
8611
  const root = process.env.FZ_DEPLOY_ROOT;
8580
8612
  const pullEnabled = process.env.FZ_DEPLOY_PULL === "true" && Boolean(process.env.FZ_API);
8581
8613
  if (pullEnabled && !binding) {
8582
8614
  throw new Error("agent: outbound guest deployment requires a persisted tenant enrolment binding");
8583
8615
  }
8584
- if (root && (repository && branch && role || pullEnabled)) {
8616
+ if (root && (repository && branch && profile || pullEnabled)) {
8585
8617
  const managers = new Map;
8586
8618
  const managerOptions = (source, key) => ({
8587
8619
  key,
8588
8620
  repository: source.repository,
8589
8621
  branch: source.branch,
8590
- role: source.role,
8622
+ profile: source.profile,
8591
8623
  sourceAuth: source.auth,
8592
8624
  root,
8593
8625
  publicApiUrl: process.env.FZ_PUBLIC_API_URL,
@@ -8604,7 +8636,7 @@ if (import.meta.main) {
8604
8636
  ensureSoftware: (requirements) => requestSoftware(requirements, process.env.FZ_SOFTWARE_HELPER_SOCKET ?? DEFAULT_SOFTWARE_HELPER_SOCKET),
8605
8637
  projectExec: process.env.FZ_DEPLOY_RUNNER_SOCKET ? (input) => requestDeploymentCommand(input, process.env.FZ_DEPLOY_RUNNER_SOCKET) : undefined
8606
8638
  });
8607
- const staticManager = repository && branch && role ? createDeploymentManager(managerOptions({ repository, branch, role }, process.env.FZ_DEPLOY_KEY ?? `${repository}:${branch}:${role}`)) : undefined;
8639
+ const staticManager = repository && branch && profile ? createDeploymentManager(managerOptions({ repository, branch, profile }, process.env.FZ_DEPLOY_KEY ?? `${repository}:${branch}:${profile}`)) : undefined;
8608
8640
  if (staticManager)
8609
8641
  managers.set("__static__", staticManager);
8610
8642
  const control = staticManager ? startControlServer(staticManager, process.env.FZ_CONTROL_SOCKET ?? DEFAULT_CONTROL_SOCKET) : undefined;
@@ -8620,7 +8652,7 @@ if (import.meta.main) {
8620
8652
  claim.pipelineKey,
8621
8653
  claim.source.repository,
8622
8654
  claim.source.branch,
8623
- claim.source.role,
8655
+ claim.source.profile,
8624
8656
  JSON.stringify(claim.source.auth ?? null)
8625
8657
  ].join("\x00");
8626
8658
  const existing = managers.get(cacheKey);
@@ -8818,6 +8850,8 @@ export {
8818
8850
  SUPPORTED_GUEST_IMAGE,
8819
8851
  SOFTWARE_HELPER_UNIT_PATH,
8820
8852
  SOFTWARE_HELPER_GROUP,
8853
+ SOFTWARE_CATALOG,
8854
+ OS_CATALOG,
8821
8855
  MAX_AGENT_TARBALL_BYTES,
8822
8856
  DeploymentError,
8823
8857
  DEFAULT_SOFTWARE_HELPER_SOCKET,