@forgezero/agent 0.1.29 → 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
@@ -118,20 +118,68 @@ 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
 
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
+
130
176
  The daemon owns source checkout and command execution. Bootstrap explicitly
131
177
  awaits release one because the API does not exist yet, then the Agent consumes a
132
178
  one-use platform enrolment capability. There is no branch watcher. Every normal
133
179
  platform or tenant release is one durable API delivery atomically expanded to
134
- one row for every explicit compute binding:
180
+ one row for every independently attached compute target. Release-scoped steps
181
+ run on one target elected by stable target ordering, never on a user-declared
182
+ coordinator:
135
183
  `pending` is written before dispatch, `running` and a fenced lease before project
136
184
  code, and only an awaited successful pipeline writes `deployed`. An expired
137
185
  claim can be recovered; its stale token cannot renew or finish.
@@ -188,7 +236,7 @@ An operator may also force a deployment and await the complete result over the
188
236
  private control socket:
189
237
 
190
238
  ```bash
191
- fz-agent deploy --revision=<full-40-character-commit> --coordinator
239
+ fz-agent deploy --revision=<full-40-character-commit> --release-executor
192
240
  fz-agent status
193
241
  fz-agent pause
194
242
  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.31";
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,21 @@
1
1
  import type { Pipeline, PipelineStep } from './pipeline';
2
- import { type SoftwareRequirement } from './software';
3
- export declare const PIPELINE_VERSION: 1;
4
- export interface PipelineRole {
5
- name: string;
2
+ import { type DeploymentChannel, type SoftwareRequirement } from './software';
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 declare const DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v2.json";
10
+ export interface PipelineProfile {
6
11
  software: readonly SoftwareRequirement[];
7
12
  }
8
13
  export interface DeployStep extends PipelineStep {
9
14
  phase: 'build' | 'release' | 'migrate' | 'health';
10
- /** The coordinator runs this on one selected node, never on every replica. */
11
- once?: boolean;
15
+ /** Run on this target, or on the one deterministic release executor. */
16
+ scope: 'target' | 'release';
17
+ /** Optional profile filter. An omitted list applies to every profile. */
18
+ profiles?: readonly string[];
12
19
  /** Run only when every named non-secret deployment coordinate has this value. */
13
20
  when?: Readonly<Record<string, string>>;
14
21
  }
@@ -16,12 +23,14 @@ export interface DeployDefinition {
16
23
  version: typeof PIPELINE_VERSION;
17
24
  name: string;
18
25
  requireAttestation?: boolean;
19
- roles: readonly PipelineRole[];
26
+ profiles: Readonly<Record<string, PipelineProfile>>;
20
27
  steps: readonly DeployStep[];
21
28
  }
22
29
  export declare class DefinitionError extends Error {
23
30
  constructor(message: string);
24
31
  }
25
- /** Validate parsed YAML before any command from it is allowed to run. */
26
- export declare function parseDeployDefinition(value: unknown): DeployDefinition;
27
- export declare function phasePipeline(definition: DeployDefinition, phase: DeployStep['phase']): Pipeline;
32
+ /** Validate parsed JSON before any command from it is allowed to run. */
33
+ export declare function parseDeployDefinition(value: unknown, options?: {
34
+ channel?: DeploymentChannel;
35
+ }): DeployDefinition;
36
+ 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 !== "active") {
74
+ throw new Error(`software requirement is not active: ${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,8 @@ async function ensureSoftwareRequirements(requirementsInput, options) {
90
105
  }
91
106
 
92
107
  // src/definition.ts
93
- var PIPELINE_VERSION = 1;
108
+ var PIPELINE_VERSION = 2;
109
+ var DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v2.json";
94
110
 
95
111
  class DefinitionError extends Error {
96
112
  constructor(message) {
@@ -115,6 +131,7 @@ var exactKeys = (value, allowed, where) => {
115
131
  if (unknown.length > 0)
116
132
  throw new DefinitionError(`${where} contains unknown field(s): ${unknown.join(", ")}.`);
117
133
  };
134
+ var NAME = /^[a-z][a-z0-9-]{0,62}$/;
118
135
  var RESERVED_STEP_ENV = new Set([
119
136
  "PATH",
120
137
  "HOME",
@@ -127,46 +144,67 @@ var RESERVED_STEP_ENV = new Set([
127
144
  "GIT_SSH",
128
145
  "GIT_SSH_COMMAND"
129
146
  ]);
130
- function parseDeployDefinition(value) {
147
+ function parseDeployDefinition(value, options = {}) {
131
148
  const root = record(value, "pipeline");
132
- exactKeys(root, ["version", "name", "requireAttestation", "roles", "steps"], "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
+ }
133
153
  if (root.version !== PIPELINE_VERSION) {
134
154
  throw new DefinitionError(`pipeline.version must be ${PIPELINE_VERSION}.`);
135
155
  }
136
- if (!Array.isArray(root.roles) || root.roles.length === 0) {
137
- throw new DefinitionError("pipeline.roles must contain at least one role.");
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.");
138
163
  }
139
164
  if (!Array.isArray(root.steps) || root.steps.length === 0) {
140
165
  throw new DefinitionError("pipeline.steps must contain at least one step.");
141
166
  }
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.`);
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.`);
147
175
  }
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.");
176
+ profiles[name2] = { software: validateSoftwareRequirements(profile.software, options) };
155
177
  }
156
178
  const phases = new Set(["build", "release", "migrate", "health"]);
157
179
  const steps = root.steps.map((raw, index) => {
158
180
  const step = record(raw, `steps[${index}]`);
159
- exactKeys(step, ["name", "run", "phase", "secrets", "once", "always", "timeoutMs", "when"], `steps[${index}]`);
181
+ exactKeys(step, ["name", "run", "phase", "scope", "profiles", "secrets", "always", "timeoutMs", "when"], `steps[${index}]`);
160
182
  const phase = text(step.phase, `steps[${index}].phase`);
161
183
  if (!phases.has(phase))
162
184
  throw new DefinitionError(`steps[${index}].phase is not supported.`);
163
- if (step.secrets !== undefined && (!Array.isArray(step.secrets) || step.secrets.some((name) => typeof name !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(name)))) {
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)))) {
164
202
  throw new DefinitionError(`steps[${index}].secrets must contain names only.`);
165
203
  }
166
204
  if (Array.isArray(step.secrets) && new Set(step.secrets).size !== step.secrets.length) {
167
205
  throw new DefinitionError(`steps[${index}].secrets must not contain duplicates.`);
168
206
  }
169
- 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)))) {
170
208
  throw new DefinitionError(`steps[${index}].secrets may not replace process-control environment variables.`);
171
209
  }
172
210
  const timeoutMs = step.timeoutMs === undefined ? undefined : Number(step.timeoutMs);
@@ -177,11 +215,11 @@ function parseDeployDefinition(value) {
177
215
  if (step.when !== undefined) {
178
216
  const conditions = record(step.when, `steps[${index}].when`);
179
217
  when = {};
180
- for (const [name, expected] of Object.entries(conditions)) {
181
- 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) {
182
220
  throw new DefinitionError(`steps[${index}].when must map environment names to non-empty strings.`);
183
221
  }
184
- when[name] = expected;
222
+ when[name2] = expected;
185
223
  }
186
224
  if (Object.keys(when).length === 0)
187
225
  throw new DefinitionError(`steps[${index}].when must not be empty.`);
@@ -190,8 +228,9 @@ function parseDeployDefinition(value) {
190
228
  name: text(step.name, `steps[${index}].name`),
191
229
  run: text(step.run, `steps[${index}].run`),
192
230
  phase,
231
+ scope: step.scope,
232
+ profiles: selectedProfiles,
193
233
  secrets: step.secrets,
194
- once: step.once === true,
195
234
  always: step.always === true,
196
235
  timeoutMs,
197
236
  when
@@ -200,24 +239,31 @@ function parseDeployDefinition(value) {
200
239
  if (new Set(steps.map((step) => step.name)).size !== steps.length) {
201
240
  throw new DefinitionError("pipeline.steps must have unique names.");
202
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.");
203
245
  return {
204
246
  version: PIPELINE_VERSION,
205
- name: text(root.name, "pipeline.name"),
247
+ name,
206
248
  requireAttestation: root.requireAttestation === true,
207
- roles,
249
+ profiles,
208
250
  steps
209
251
  };
210
252
  }
211
- function phasePipeline(definition, phase) {
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
+ }
212
257
  return {
213
258
  name: `${definition.name}:${phase}`,
214
259
  requireAttestation: definition.requireAttestation,
215
- steps: definition.steps.filter((step) => step.phase === phase)
260
+ steps: definition.steps.filter((step) => step.phase === phase && (!step.profiles || step.profiles.includes(profile)) && (step.scope === "target" || executeRelease))
216
261
  };
217
262
  }
218
263
  export {
219
264
  phasePipeline,
220
265
  parseDeployDefinition,
221
266
  PIPELINE_VERSION,
222
- DefinitionError
267
+ DefinitionError,
268
+ DEPLOY_SCHEMA_URL
223
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;