@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 +52 -4
- package/dist/agent-heartbeat.js +1 -1
- package/dist/cli/agent-install.d.ts +1 -1
- package/dist/definition.d.ts +19 -10
- package/dist/definition.js +77 -31
- package/dist/deploy-file.d.ts +40 -0
- package/dist/deploy-file.js +378 -0
- package/dist/deployment-pull.d.ts +3 -3
- package/dist/deployment.d.ts +9 -5
- package/dist/fz-agent.js +123 -58
- package/dist/fz.js +729 -19
- package/dist/index.d.ts +2 -2
- package/dist/project-context.d.ts +37 -0
- package/dist/project-context.js +266 -0
- package/dist/provision.d.ts +1 -1
- package/dist/provision.js +19 -4
- package/dist/software-helper.js +17 -2
- package/dist/software.d.ts +24 -2
- package/dist/software.js +20 -3
- package/dist/version.d.ts +1 -1
- package/package.json +12 -2
- package/schema/deploy-v2.json +70 -0
package/dist/fz.js
CHANGED
|
@@ -4871,8 +4871,8 @@ async function spawnWith(command, env, report = () => {}) {
|
|
|
4871
4871
|
|
|
4872
4872
|
// src/cli/index.ts
|
|
4873
4873
|
init_dist();
|
|
4874
|
-
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
4875
|
-
import { dirname } from "path";
|
|
4874
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, statSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
|
|
4875
|
+
import { dirname as dirname2 } from "path";
|
|
4876
4876
|
import { fileURLToPath } from "url";
|
|
4877
4877
|
|
|
4878
4878
|
// src/agent-update.ts
|
|
@@ -4886,12 +4886,22 @@ var AGENT_UPDATE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-agent-update-
|
|
|
4886
4886
|
var MAX_REQUEST_BYTES = 8 * 1024;
|
|
4887
4887
|
|
|
4888
4888
|
// src/version.ts
|
|
4889
|
-
var VERSION2 = "0.1.
|
|
4889
|
+
var VERSION2 = "0.1.31";
|
|
4890
4890
|
|
|
4891
4891
|
// src/software.ts
|
|
4892
4892
|
var BUN_INSTALLER_SHA256 = "bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd";
|
|
4893
4893
|
var ARANGO_SHA256 = "b5a9197b4343f2ed554e1ebc1ef8e6529c7c39cde0035cdc311a4747a3355066";
|
|
4894
4894
|
var CLOUDFLARED_SHA256 = "9d71c677db00134c1bd4144b7783486b654ad281b1ea62b4972098d19f770f17";
|
|
4895
|
+
var OS_CATALOG = [
|
|
4896
|
+
{ id: "ubuntu", version: "26.04", architecture: "x64", status: "active" }
|
|
4897
|
+
];
|
|
4898
|
+
var SOFTWARE_CATALOG = [
|
|
4899
|
+
{ id: "bun", version: "1.3.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
4900
|
+
{ id: "nginx", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
4901
|
+
{ id: "arangodb", version: "3.11.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
4902
|
+
{ id: "cloudflared", version: "2026.7.3", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
4903
|
+
{ id: "ufw", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" }
|
|
4904
|
+
];
|
|
4895
4905
|
var UBUNTU_2604_X64 = [
|
|
4896
4906
|
{
|
|
4897
4907
|
requirement: { id: "bun", version: "1.3.14" },
|
|
@@ -4919,6 +4929,31 @@ var UBUNTU_2604_X64 = [
|
|
|
4919
4929
|
install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y ufw"
|
|
4920
4930
|
}
|
|
4921
4931
|
];
|
|
4932
|
+
function validateSoftwareRequirements(value, _options = {}) {
|
|
4933
|
+
if (!Array.isArray(value) || value.length > 32)
|
|
4934
|
+
throw new Error("software requirements must be an array of at most 32 entries");
|
|
4935
|
+
const seen = new Set;
|
|
4936
|
+
return value.map((item) => {
|
|
4937
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
4938
|
+
throw new Error("software requirement must be an object");
|
|
4939
|
+
const row = item;
|
|
4940
|
+
if (Object.keys(row).some((key) => key !== "id" && key !== "version")) {
|
|
4941
|
+
throw new Error("software requirement contains an unknown field");
|
|
4942
|
+
}
|
|
4943
|
+
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)) {
|
|
4944
|
+
throw new Error("software requirement coordinate is invalid");
|
|
4945
|
+
}
|
|
4946
|
+
const requirement = { id: row.id, version: row.version };
|
|
4947
|
+
if (seen.has(requirement.id))
|
|
4948
|
+
throw new Error(`duplicate software requirement: ${requirement.id}`);
|
|
4949
|
+
seen.add(requirement.id);
|
|
4950
|
+
const catalog = SOFTWARE_CATALOG.find((candidate) => candidate.id === requirement.id && candidate.version === requirement.version);
|
|
4951
|
+
if (!catalog || catalog.status !== "active") {
|
|
4952
|
+
throw new Error(`software requirement is not active: ${requirement.id}@${requirement.version}`);
|
|
4953
|
+
}
|
|
4954
|
+
return requirement;
|
|
4955
|
+
});
|
|
4956
|
+
}
|
|
4922
4957
|
|
|
4923
4958
|
// src/software-helper.ts
|
|
4924
4959
|
var DEFAULT_SOFTWARE_HELPER_SOCKET = "/run/forgezero-software/helper.sock";
|
|
@@ -5363,7 +5398,7 @@ function agentUnit(options) {
|
|
|
5363
5398
|
options.gitPublicKeyPath ? `FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}` : null,
|
|
5364
5399
|
options.repository ? `FZ_DEPLOY_REPO=${options.repository}` : null,
|
|
5365
5400
|
options.branch ? `FZ_DEPLOY_BRANCH=${options.branch}` : null,
|
|
5366
|
-
options.
|
|
5401
|
+
options.profile ? `FZ_DEPLOY_PROFILE=${options.profile}` : null,
|
|
5367
5402
|
options.repository && options.branch ? `FZ_DEPLOY_KEY=${options.project ?? "platform"}:${options.environment ?? "production"}` : null,
|
|
5368
5403
|
deploymentEnabled ? `FZ_DEPLOY_ROOT=${deployRoot}` : null,
|
|
5369
5404
|
deploymentEnabled ? `FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}` : null,
|
|
@@ -9275,6 +9310,517 @@ async function resolveIdentity(selector, socketPath) {
|
|
|
9275
9310
|
return chosen;
|
|
9276
9311
|
}
|
|
9277
9312
|
|
|
9313
|
+
// src/project-context.ts
|
|
9314
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
9315
|
+
import { dirname, join as join2, resolve } from "path";
|
|
9316
|
+
var PROJECT_CONTEXT_VERSION = 1;
|
|
9317
|
+
var GENERATED = "<!-- Generated by @forgezero/agent project context. Edit .forgezero/project.json, then run `fz project sync`. -->";
|
|
9318
|
+
|
|
9319
|
+
class ProjectContextError extends Error {
|
|
9320
|
+
constructor(message) {
|
|
9321
|
+
super(message);
|
|
9322
|
+
this.name = "ProjectContextError";
|
|
9323
|
+
}
|
|
9324
|
+
}
|
|
9325
|
+
var text = (value, where) => {
|
|
9326
|
+
if (typeof value !== "string" || !value.trim() || /[\r\0]/.test(value)) {
|
|
9327
|
+
throw new ProjectContextError(`${where} must be non-empty text.`);
|
|
9328
|
+
}
|
|
9329
|
+
return value.trim();
|
|
9330
|
+
};
|
|
9331
|
+
var relativePath = (value, where) => {
|
|
9332
|
+
const path = text(value, where);
|
|
9333
|
+
if (path.startsWith("/") || path.split("/").includes("..")) {
|
|
9334
|
+
throw new ProjectContextError(`${where} must stay inside the repository.`);
|
|
9335
|
+
}
|
|
9336
|
+
return path.replace(/^\.\//, "");
|
|
9337
|
+
};
|
|
9338
|
+
var stringList = (value, where, paths = false) => {
|
|
9339
|
+
if (!Array.isArray(value) || value.length > 128) {
|
|
9340
|
+
throw new ProjectContextError(`${where} must be an array of at most 128 entries.`);
|
|
9341
|
+
}
|
|
9342
|
+
const items = value.map((item, index) => paths ? relativePath(item, `${where}[${index}]`) : text(item, `${where}[${index}]`));
|
|
9343
|
+
if (new Set(items).size !== items.length)
|
|
9344
|
+
throw new ProjectContextError(`${where} must not contain duplicates.`);
|
|
9345
|
+
return items;
|
|
9346
|
+
};
|
|
9347
|
+
function parseProjectContext(value) {
|
|
9348
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
9349
|
+
throw new ProjectContextError("project context must be an object.");
|
|
9350
|
+
}
|
|
9351
|
+
const row = value;
|
|
9352
|
+
const allowed = ["schemaVersion", "name", "purpose", "truth", "readFirst", "verify", "rules", "nonAuthoritative"];
|
|
9353
|
+
const unknown = Object.keys(row).filter((key) => !allowed.includes(key));
|
|
9354
|
+
if (unknown.length)
|
|
9355
|
+
throw new ProjectContextError(`project context contains unknown field(s): ${unknown.join(", ")}.`);
|
|
9356
|
+
if (row.schemaVersion !== PROJECT_CONTEXT_VERSION) {
|
|
9357
|
+
throw new ProjectContextError(`project context schemaVersion must be ${PROJECT_CONTEXT_VERSION}.`);
|
|
9358
|
+
}
|
|
9359
|
+
if (!Array.isArray(row.truth) || row.truth.length === 0 || row.truth.length > 64) {
|
|
9360
|
+
throw new ProjectContextError("project context truth must contain from 1 to 64 sources.");
|
|
9361
|
+
}
|
|
9362
|
+
const truth = row.truth.map((item, index) => {
|
|
9363
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
9364
|
+
throw new ProjectContextError(`truth[${index}] must be an object.`);
|
|
9365
|
+
}
|
|
9366
|
+
const source = item;
|
|
9367
|
+
if (Object.keys(source).some((key) => !["area", "path", "description"].includes(key))) {
|
|
9368
|
+
throw new ProjectContextError(`truth[${index}] contains an unknown field.`);
|
|
9369
|
+
}
|
|
9370
|
+
return {
|
|
9371
|
+
area: text(source.area, `truth[${index}].area`),
|
|
9372
|
+
path: relativePath(source.path, `truth[${index}].path`),
|
|
9373
|
+
description: text(source.description, `truth[${index}].description`)
|
|
9374
|
+
};
|
|
9375
|
+
});
|
|
9376
|
+
const areas = truth.map((source) => source.area);
|
|
9377
|
+
if (new Set(areas).size !== areas.length)
|
|
9378
|
+
throw new ProjectContextError("project context truth areas must be unique.");
|
|
9379
|
+
return {
|
|
9380
|
+
schemaVersion: PROJECT_CONTEXT_VERSION,
|
|
9381
|
+
name: text(row.name, "project context name"),
|
|
9382
|
+
purpose: text(row.purpose, "project context purpose"),
|
|
9383
|
+
truth,
|
|
9384
|
+
readFirst: stringList(row.readFirst, "project context readFirst", true),
|
|
9385
|
+
verify: stringList(row.verify, "project context verify"),
|
|
9386
|
+
rules: stringList(row.rules, "project context rules"),
|
|
9387
|
+
nonAuthoritative: stringList(row.nonAuthoritative, "project context nonAuthoritative", true)
|
|
9388
|
+
};
|
|
9389
|
+
}
|
|
9390
|
+
function defaultProjectContext(root = process.cwd()) {
|
|
9391
|
+
let name = root.split("/").filter(Boolean).at(-1) ?? "project";
|
|
9392
|
+
let verify = ["npm test"];
|
|
9393
|
+
const manifestPath = join2(root, "package.json");
|
|
9394
|
+
if (existsSync(manifestPath)) {
|
|
9395
|
+
try {
|
|
9396
|
+
const pkg = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
9397
|
+
name = pkg.name ?? name;
|
|
9398
|
+
const runner = existsSync(join2(root, "bun.lock")) ? "bun run" : "npm run";
|
|
9399
|
+
verify = ["check", "test", "build"].filter((script) => pkg.scripts?.[script]).map((script) => `${runner} ${script}`);
|
|
9400
|
+
if (verify.length === 0)
|
|
9401
|
+
verify = [existsSync(join2(root, "bun.lock")) ? "bun test" : "npm test"];
|
|
9402
|
+
} catch {}
|
|
9403
|
+
}
|
|
9404
|
+
return {
|
|
9405
|
+
schemaVersion: PROJECT_CONTEXT_VERSION,
|
|
9406
|
+
name,
|
|
9407
|
+
purpose: "Describe the product outcome here; implementation details belong in the truth sources below.",
|
|
9408
|
+
truth: [
|
|
9409
|
+
{ area: "architecture", path: "docs/architecture.md", description: "Current system boundaries and decisions." },
|
|
9410
|
+
{ area: "progress", path: "docs/progress.md", description: "Evidence-backed delivery state and next work." }
|
|
9411
|
+
],
|
|
9412
|
+
readFirst: [".forgezero/PROJECT.md"],
|
|
9413
|
+
verify,
|
|
9414
|
+
rules: [
|
|
9415
|
+
"Inspect the current worktree before editing and preserve unrelated changes.",
|
|
9416
|
+
"Update a truth source instead of copying architecture or progress into another document.",
|
|
9417
|
+
"Never report a feature as complete without running its declared verification."
|
|
9418
|
+
],
|
|
9419
|
+
nonAuthoritative: ["audit/"]
|
|
9420
|
+
};
|
|
9421
|
+
}
|
|
9422
|
+
function renderProjectContext(manifest) {
|
|
9423
|
+
const truth = manifest.truth.map((source) => `| ${source.area} | \`${source.path}\` | ${source.description} |`).join(`
|
|
9424
|
+
`);
|
|
9425
|
+
return `${GENERATED}
|
|
9426
|
+
# ${manifest.name} \u2014 project context
|
|
9427
|
+
|
|
9428
|
+
${manifest.purpose}
|
|
9429
|
+
|
|
9430
|
+
## Read first
|
|
9431
|
+
|
|
9432
|
+
${manifest.readFirst.map((path) => `- \`${path}\``).join(`
|
|
9433
|
+
`) || "- No additional entry points."}
|
|
9434
|
+
|
|
9435
|
+
## Sources of truth
|
|
9436
|
+
|
|
9437
|
+
| Area | Path | Authority |
|
|
9438
|
+
|---|---|---|
|
|
9439
|
+
${truth}
|
|
9440
|
+
|
|
9441
|
+
If two files disagree, the file named in this table wins. Fix or regenerate the
|
|
9442
|
+
other file in the same change. Conversation memory, audit snapshots and generated
|
|
9443
|
+
output never override repository truth.
|
|
9444
|
+
|
|
9445
|
+
## Project rules
|
|
9446
|
+
|
|
9447
|
+
${manifest.rules.map((rule) => `- ${rule}`).join(`
|
|
9448
|
+
`) || "- No additional project rules."}
|
|
9449
|
+
|
|
9450
|
+
## Verification
|
|
9451
|
+
|
|
9452
|
+
${manifest.verify.map((command) => `- \`${command}\``).join(`
|
|
9453
|
+
`) || "- No verification command declared."}
|
|
9454
|
+
|
|
9455
|
+
## Non-authoritative material
|
|
9456
|
+
|
|
9457
|
+
${manifest.nonAuthoritative.map((path) => `- \`${path}\``).join(`
|
|
9458
|
+
`) || "- None declared."}
|
|
9459
|
+
|
|
9460
|
+
Tools, skills and AI vendors may change. They are execution aids, not memory.
|
|
9461
|
+
Persist every accepted decision and status change in the source of truth that
|
|
9462
|
+
owns it, then run \`fz project check\` before handoff.
|
|
9463
|
+
`;
|
|
9464
|
+
}
|
|
9465
|
+
var adapter = (name) => `${GENERATED}
|
|
9466
|
+
# ${name} project instructions
|
|
9467
|
+
|
|
9468
|
+
Read \`.forgezero/PROJECT.md\` completely before acting. It is generated from
|
|
9469
|
+
\`.forgezero/project.json\`, the vendor-neutral project context. Follow every
|
|
9470
|
+
source of truth and verification command it names.
|
|
9471
|
+
|
|
9472
|
+
Do not treat this adapter, conversation memory, an audit report, generated
|
|
9473
|
+
output, a tool, or a skill as architectural authority. When work changes an
|
|
9474
|
+
accepted decision or delivery state, update the named Git source in the same
|
|
9475
|
+
change and run \`fz project check\`.
|
|
9476
|
+
`;
|
|
9477
|
+
function projectContextFiles(manifestInput) {
|
|
9478
|
+
const manifest = parseProjectContext(manifestInput);
|
|
9479
|
+
return [
|
|
9480
|
+
{ path: ".forgezero/PROJECT.md", content: renderProjectContext(manifest) },
|
|
9481
|
+
{ path: "AGENTS.md", content: adapter("AI agent") },
|
|
9482
|
+
{ path: "CLAUDE.md", content: adapter("Claude") },
|
|
9483
|
+
{ path: "GEMINI.md", content: adapter("Gemini") },
|
|
9484
|
+
{ path: ".github/copilot-instructions.md", content: adapter("GitHub Copilot") },
|
|
9485
|
+
{ path: ".cursor/rules/project-context.mdc", content: `${GENERATED}
|
|
9486
|
+
---
|
|
9487
|
+
description: Repository source-of-truth contract
|
|
9488
|
+
alwaysApply: true
|
|
9489
|
+
---
|
|
9490
|
+
|
|
9491
|
+
${adapter("Cursor").replace(`${GENERATED}
|
|
9492
|
+
`, "")}` }
|
|
9493
|
+
];
|
|
9494
|
+
}
|
|
9495
|
+
var atomicWrite = (path, content) => {
|
|
9496
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
9497
|
+
const next = `${path}.${process.pid}.next`;
|
|
9498
|
+
writeFileSync(next, content, { mode: 420 });
|
|
9499
|
+
renameSync(next, path);
|
|
9500
|
+
};
|
|
9501
|
+
function initializeProjectContext(rootInput, manifestInput = defaultProjectContext(rootInput), options = {}) {
|
|
9502
|
+
const root = resolve(rootInput);
|
|
9503
|
+
const manifest = parseProjectContext(manifestInput);
|
|
9504
|
+
const manifestPath = join2(root, ".forgezero", "project.json");
|
|
9505
|
+
const files = projectContextFiles(manifest);
|
|
9506
|
+
const collisions = [manifestPath, ...files.map((file) => join2(root, file.path))].filter((path) => {
|
|
9507
|
+
if (!existsSync(path))
|
|
9508
|
+
return false;
|
|
9509
|
+
if (path === manifestPath)
|
|
9510
|
+
return true;
|
|
9511
|
+
return !readFileSync(path, "utf8").startsWith(GENERATED);
|
|
9512
|
+
});
|
|
9513
|
+
if (collisions.length && !options.force) {
|
|
9514
|
+
throw new ProjectContextError(`refusing to replace existing project context: ${collisions.join(", ")}`);
|
|
9515
|
+
}
|
|
9516
|
+
atomicWrite(manifestPath, `${JSON.stringify(manifest, null, 2)}
|
|
9517
|
+
`);
|
|
9518
|
+
for (const file of files)
|
|
9519
|
+
atomicWrite(join2(root, file.path), file.content);
|
|
9520
|
+
return files;
|
|
9521
|
+
}
|
|
9522
|
+
function syncProjectContext(rootInput) {
|
|
9523
|
+
const root = resolve(rootInput);
|
|
9524
|
+
const manifestPath = join2(root, ".forgezero", "project.json");
|
|
9525
|
+
if (!existsSync(manifestPath))
|
|
9526
|
+
throw new ProjectContextError("No .forgezero/project.json. Run `fz project init`.");
|
|
9527
|
+
const manifest = parseProjectContext(JSON.parse(readFileSync(manifestPath, "utf8")));
|
|
9528
|
+
const files = projectContextFiles(manifest);
|
|
9529
|
+
for (const file of files) {
|
|
9530
|
+
const path = join2(root, file.path);
|
|
9531
|
+
if (existsSync(path) && !readFileSync(path, "utf8").startsWith(GENERATED)) {
|
|
9532
|
+
throw new ProjectContextError(`refusing to replace non-generated adapter: ${file.path}`);
|
|
9533
|
+
}
|
|
9534
|
+
atomicWrite(path, file.content);
|
|
9535
|
+
}
|
|
9536
|
+
return files;
|
|
9537
|
+
}
|
|
9538
|
+
function checkProjectContext(rootInput) {
|
|
9539
|
+
const root = resolve(rootInput);
|
|
9540
|
+
const manifestPath = join2(root, ".forgezero", "project.json");
|
|
9541
|
+
if (!existsSync(manifestPath))
|
|
9542
|
+
return { ok: false, problems: ["missing .forgezero/project.json"] };
|
|
9543
|
+
let manifest;
|
|
9544
|
+
try {
|
|
9545
|
+
manifest = parseProjectContext(JSON.parse(readFileSync(manifestPath, "utf8")));
|
|
9546
|
+
} catch (cause) {
|
|
9547
|
+
return { ok: false, problems: [cause instanceof Error ? cause.message : String(cause)] };
|
|
9548
|
+
}
|
|
9549
|
+
const problems = [];
|
|
9550
|
+
for (const source of manifest.truth) {
|
|
9551
|
+
if (!existsSync(join2(root, source.path)))
|
|
9552
|
+
problems.push(`missing truth source: ${source.path}`);
|
|
9553
|
+
}
|
|
9554
|
+
for (const path of manifest.readFirst) {
|
|
9555
|
+
if (!existsSync(join2(root, path)))
|
|
9556
|
+
problems.push(`missing read-first file: ${path}`);
|
|
9557
|
+
}
|
|
9558
|
+
for (const file of projectContextFiles(manifest)) {
|
|
9559
|
+
const path = join2(root, file.path);
|
|
9560
|
+
if (!existsSync(path))
|
|
9561
|
+
problems.push(`missing generated adapter: ${file.path}`);
|
|
9562
|
+
else if (readFileSync(path, "utf8") !== file.content)
|
|
9563
|
+
problems.push(`drifted generated adapter: ${file.path}`);
|
|
9564
|
+
}
|
|
9565
|
+
return { ok: problems.length === 0, problems };
|
|
9566
|
+
}
|
|
9567
|
+
|
|
9568
|
+
// src/deploy-file.ts
|
|
9569
|
+
import { createHash as createHash2 } from "crypto";
|
|
9570
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
9571
|
+
import { basename, join as join3 } from "path";
|
|
9572
|
+
|
|
9573
|
+
// src/definition.ts
|
|
9574
|
+
var PIPELINE_VERSION = 2;
|
|
9575
|
+
var DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v2.json";
|
|
9576
|
+
|
|
9577
|
+
class DefinitionError extends Error {
|
|
9578
|
+
constructor(message) {
|
|
9579
|
+
super(message);
|
|
9580
|
+
this.name = "DefinitionError";
|
|
9581
|
+
}
|
|
9582
|
+
}
|
|
9583
|
+
var record = (value, where) => {
|
|
9584
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
9585
|
+
throw new DefinitionError(`${where} must be an object.`);
|
|
9586
|
+
}
|
|
9587
|
+
return value;
|
|
9588
|
+
};
|
|
9589
|
+
var text2 = (value, where) => {
|
|
9590
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
9591
|
+
throw new DefinitionError(`${where} must be a non-empty string.`);
|
|
9592
|
+
}
|
|
9593
|
+
return value;
|
|
9594
|
+
};
|
|
9595
|
+
var exactKeys = (value, allowed, where) => {
|
|
9596
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
9597
|
+
if (unknown.length > 0)
|
|
9598
|
+
throw new DefinitionError(`${where} contains unknown field(s): ${unknown.join(", ")}.`);
|
|
9599
|
+
};
|
|
9600
|
+
var NAME2 = /^[a-z][a-z0-9-]{0,62}$/;
|
|
9601
|
+
var RESERVED_STEP_ENV = new Set([
|
|
9602
|
+
"PATH",
|
|
9603
|
+
"HOME",
|
|
9604
|
+
"SHELL",
|
|
9605
|
+
"PWD",
|
|
9606
|
+
"BUN_INSTALL",
|
|
9607
|
+
"NODE_OPTIONS",
|
|
9608
|
+
"LD_PRELOAD",
|
|
9609
|
+
"LD_LIBRARY_PATH",
|
|
9610
|
+
"GIT_SSH",
|
|
9611
|
+
"GIT_SSH_COMMAND"
|
|
9612
|
+
]);
|
|
9613
|
+
function parseDeployDefinition(value, options = {}) {
|
|
9614
|
+
const root = record(value, "pipeline");
|
|
9615
|
+
exactKeys(root, ["$schema", "version", "name", "requireAttestation", "profiles", "steps"], "pipeline");
|
|
9616
|
+
if (root.$schema !== undefined && root.$schema !== DEPLOY_SCHEMA_URL) {
|
|
9617
|
+
throw new DefinitionError(`pipeline.$schema must be ${DEPLOY_SCHEMA_URL}.`);
|
|
9618
|
+
}
|
|
9619
|
+
if (root.version !== PIPELINE_VERSION) {
|
|
9620
|
+
throw new DefinitionError(`pipeline.version must be ${PIPELINE_VERSION}.`);
|
|
9621
|
+
}
|
|
9622
|
+
if (root.requireAttestation !== undefined && typeof root.requireAttestation !== "boolean") {
|
|
9623
|
+
throw new DefinitionError("pipeline.requireAttestation must be a boolean.");
|
|
9624
|
+
}
|
|
9625
|
+
const rawProfiles = record(root.profiles, "pipeline.profiles");
|
|
9626
|
+
const profileEntries = Object.entries(rawProfiles);
|
|
9627
|
+
if (profileEntries.length === 0 || profileEntries.length > 32) {
|
|
9628
|
+
throw new DefinitionError("pipeline.profiles must contain from 1 to 32 named profiles.");
|
|
9629
|
+
}
|
|
9630
|
+
if (!Array.isArray(root.steps) || root.steps.length === 0) {
|
|
9631
|
+
throw new DefinitionError("pipeline.steps must contain at least one step.");
|
|
9632
|
+
}
|
|
9633
|
+
const profiles = {};
|
|
9634
|
+
for (const [name2, raw] of profileEntries) {
|
|
9635
|
+
if (!NAME2.test(name2))
|
|
9636
|
+
throw new DefinitionError(`pipeline profile name is invalid: ${name2}.`);
|
|
9637
|
+
const profile = record(raw, `profiles.${name2}`);
|
|
9638
|
+
exactKeys(profile, ["software"], `profiles.${name2}`);
|
|
9639
|
+
if (!Array.isArray(profile.software)) {
|
|
9640
|
+
throw new DefinitionError(`profiles.${name2}.software must be an array.`);
|
|
9641
|
+
}
|
|
9642
|
+
profiles[name2] = { software: validateSoftwareRequirements(profile.software, options) };
|
|
9643
|
+
}
|
|
9644
|
+
const phases = new Set(["build", "release", "migrate", "health"]);
|
|
9645
|
+
const steps = root.steps.map((raw, index) => {
|
|
9646
|
+
const step = record(raw, `steps[${index}]`);
|
|
9647
|
+
exactKeys(step, ["name", "run", "phase", "scope", "profiles", "secrets", "always", "timeoutMs", "when"], `steps[${index}]`);
|
|
9648
|
+
const phase = text2(step.phase, `steps[${index}].phase`);
|
|
9649
|
+
if (!phases.has(phase))
|
|
9650
|
+
throw new DefinitionError(`steps[${index}].phase is not supported.`);
|
|
9651
|
+
if (step.scope !== "target" && step.scope !== "release") {
|
|
9652
|
+
throw new DefinitionError(`steps[${index}].scope must be target or release.`);
|
|
9653
|
+
}
|
|
9654
|
+
if (step.always !== undefined && typeof step.always !== "boolean") {
|
|
9655
|
+
throw new DefinitionError(`steps[${index}].always must be a boolean.`);
|
|
9656
|
+
}
|
|
9657
|
+
let selectedProfiles;
|
|
9658
|
+
if (step.profiles !== undefined) {
|
|
9659
|
+
if (!Array.isArray(step.profiles) || step.profiles.length === 0 || step.profiles.some((name2) => typeof name2 !== "string" || !Object.hasOwn(profiles, name2))) {
|
|
9660
|
+
throw new DefinitionError(`steps[${index}].profiles must name existing profiles.`);
|
|
9661
|
+
}
|
|
9662
|
+
selectedProfiles = [...step.profiles];
|
|
9663
|
+
if (new Set(selectedProfiles).size !== selectedProfiles.length) {
|
|
9664
|
+
throw new DefinitionError(`steps[${index}].profiles must not contain duplicates.`);
|
|
9665
|
+
}
|
|
9666
|
+
}
|
|
9667
|
+
if (step.secrets !== undefined && (!Array.isArray(step.secrets) || step.secrets.some((name2) => typeof name2 !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(name2)))) {
|
|
9668
|
+
throw new DefinitionError(`steps[${index}].secrets must contain names only.`);
|
|
9669
|
+
}
|
|
9670
|
+
if (Array.isArray(step.secrets) && new Set(step.secrets).size !== step.secrets.length) {
|
|
9671
|
+
throw new DefinitionError(`steps[${index}].secrets must not contain duplicates.`);
|
|
9672
|
+
}
|
|
9673
|
+
if (Array.isArray(step.secrets) && step.secrets.some((name2) => RESERVED_STEP_ENV.has(String(name2)))) {
|
|
9674
|
+
throw new DefinitionError(`steps[${index}].secrets may not replace process-control environment variables.`);
|
|
9675
|
+
}
|
|
9676
|
+
const timeoutMs = step.timeoutMs === undefined ? undefined : Number(step.timeoutMs);
|
|
9677
|
+
if (timeoutMs !== undefined && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 86400000)) {
|
|
9678
|
+
throw new DefinitionError(`steps[${index}].timeoutMs must be an integer from 1 to 86400000.`);
|
|
9679
|
+
}
|
|
9680
|
+
let when;
|
|
9681
|
+
if (step.when !== undefined) {
|
|
9682
|
+
const conditions = record(step.when, `steps[${index}].when`);
|
|
9683
|
+
when = {};
|
|
9684
|
+
for (const [name2, expected] of Object.entries(conditions)) {
|
|
9685
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name2) || typeof expected !== "string" || expected.length === 0) {
|
|
9686
|
+
throw new DefinitionError(`steps[${index}].when must map environment names to non-empty strings.`);
|
|
9687
|
+
}
|
|
9688
|
+
when[name2] = expected;
|
|
9689
|
+
}
|
|
9690
|
+
if (Object.keys(when).length === 0)
|
|
9691
|
+
throw new DefinitionError(`steps[${index}].when must not be empty.`);
|
|
9692
|
+
}
|
|
9693
|
+
return {
|
|
9694
|
+
name: text2(step.name, `steps[${index}].name`),
|
|
9695
|
+
run: text2(step.run, `steps[${index}].run`),
|
|
9696
|
+
phase,
|
|
9697
|
+
scope: step.scope,
|
|
9698
|
+
profiles: selectedProfiles,
|
|
9699
|
+
secrets: step.secrets,
|
|
9700
|
+
always: step.always === true,
|
|
9701
|
+
timeoutMs,
|
|
9702
|
+
when
|
|
9703
|
+
};
|
|
9704
|
+
});
|
|
9705
|
+
if (new Set(steps.map((step) => step.name)).size !== steps.length) {
|
|
9706
|
+
throw new DefinitionError("pipeline.steps must have unique names.");
|
|
9707
|
+
}
|
|
9708
|
+
const name = text2(root.name, "pipeline.name");
|
|
9709
|
+
if (name.length > 120)
|
|
9710
|
+
throw new DefinitionError("pipeline.name must be at most 120 characters.");
|
|
9711
|
+
return {
|
|
9712
|
+
version: PIPELINE_VERSION,
|
|
9713
|
+
name,
|
|
9714
|
+
requireAttestation: root.requireAttestation === true,
|
|
9715
|
+
profiles,
|
|
9716
|
+
steps
|
|
9717
|
+
};
|
|
9718
|
+
}
|
|
9719
|
+
|
|
9720
|
+
// src/deploy-file.ts
|
|
9721
|
+
var DEPLOY_FILE = ".fz/deploy.json";
|
|
9722
|
+
var DEPLOY_TODO_PREFIX = "ForgeZero pipeline TODO:";
|
|
9723
|
+
var stable = (value) => {
|
|
9724
|
+
if (Array.isArray(value))
|
|
9725
|
+
return `[${value.map(stable).join(",")}]`;
|
|
9726
|
+
if (value && typeof value === "object") {
|
|
9727
|
+
return `{${Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => `${JSON.stringify(key)}:${stable(entry)}`).join(",")}}`;
|
|
9728
|
+
}
|
|
9729
|
+
return JSON.stringify(value);
|
|
9730
|
+
};
|
|
9731
|
+
function deployDefinitionDigest(definition) {
|
|
9732
|
+
return `sha256:${createHash2("sha256").update(stable(definition)).digest("hex")}`;
|
|
9733
|
+
}
|
|
9734
|
+
var safeName = (value) => {
|
|
9735
|
+
const normalized = value.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
|
|
9736
|
+
return /^[a-z]/.test(normalized) ? normalized : `app-${normalized || "service"}`.slice(0, 63);
|
|
9737
|
+
};
|
|
9738
|
+
function packageHints(root) {
|
|
9739
|
+
const packagePath = join3(root, "package.json");
|
|
9740
|
+
if (!existsSync2(packagePath))
|
|
9741
|
+
return { bun: existsSync2(join3(root, "bun.lock")) };
|
|
9742
|
+
try {
|
|
9743
|
+
const manifest = JSON.parse(readFileSync2(packagePath, "utf8"));
|
|
9744
|
+
return {
|
|
9745
|
+
name: typeof manifest.name === "string" ? manifest.name : undefined,
|
|
9746
|
+
build: typeof manifest.scripts?.build === "string" ? "bun run build" : undefined,
|
|
9747
|
+
bun: existsSync2(join3(root, "bun.lock")) || existsSync2(join3(root, "bun.lockb"))
|
|
9748
|
+
};
|
|
9749
|
+
} catch {
|
|
9750
|
+
return { bun: existsSync2(join3(root, "bun.lock")) };
|
|
9751
|
+
}
|
|
9752
|
+
}
|
|
9753
|
+
var blocker = (instruction) => `printf '%s\\n' '${DEPLOY_TODO_PREFIX} ${instruction}' >&2; exit 78`;
|
|
9754
|
+
function defaultDeployFile(root, options = {}) {
|
|
9755
|
+
const hints = packageHints(root);
|
|
9756
|
+
const software = options.software ?? (hints.bun ? SOFTWARE_CATALOG.filter((entry) => entry.id === "bun" && entry.status === "active").map(({ id: id2, version }) => ({ id: id2, version })) : []);
|
|
9757
|
+
validateSoftwareRequirements(software, { channel: options.channel });
|
|
9758
|
+
const profile = options.profile ?? "app";
|
|
9759
|
+
const build = hints.build ?? blocker("replace the build step with the project build command");
|
|
9760
|
+
return {
|
|
9761
|
+
$schema: DEPLOY_SCHEMA_URL,
|
|
9762
|
+
version: 2,
|
|
9763
|
+
name: safeName(options.name ?? hints.name ?? basename(root)),
|
|
9764
|
+
...options.requireAttestation ? { requireAttestation: true } : {},
|
|
9765
|
+
profiles: { [profile]: { software } },
|
|
9766
|
+
steps: [
|
|
9767
|
+
{ name: "build", phase: "build", scope: "target", run: build, timeoutMs: 600000 },
|
|
9768
|
+
{
|
|
9769
|
+
name: "promote release",
|
|
9770
|
+
phase: "release",
|
|
9771
|
+
scope: "target",
|
|
9772
|
+
run: blocker("replace the release step with an atomic promotion command"),
|
|
9773
|
+
timeoutMs: 120000
|
|
9774
|
+
},
|
|
9775
|
+
{
|
|
9776
|
+
name: "health check",
|
|
9777
|
+
phase: "health",
|
|
9778
|
+
scope: "target",
|
|
9779
|
+
run: blocker("replace the health step with a bounded local health check"),
|
|
9780
|
+
timeoutMs: 30000
|
|
9781
|
+
}
|
|
9782
|
+
]
|
|
9783
|
+
};
|
|
9784
|
+
}
|
|
9785
|
+
function inspectDeployFile(root, options = {}) {
|
|
9786
|
+
const path = join3(root, DEPLOY_FILE);
|
|
9787
|
+
if (!existsSync2(path))
|
|
9788
|
+
throw new Error(`${DEPLOY_FILE} does not exist; run \`fz deploy init\`.`);
|
|
9789
|
+
const raw = JSON.parse(readFileSync2(path, "utf8"));
|
|
9790
|
+
const definition = parseDeployDefinition(raw, options);
|
|
9791
|
+
const problems = definition.steps.filter((step) => step.run.includes(DEPLOY_TODO_PREFIX)).map((step) => `${step.name} still contains the safe initialization blocker`);
|
|
9792
|
+
const profiles = Object.keys(definition.profiles).sort();
|
|
9793
|
+
return {
|
|
9794
|
+
path,
|
|
9795
|
+
definition,
|
|
9796
|
+
summary: {
|
|
9797
|
+
path,
|
|
9798
|
+
digest: deployDefinitionDigest(definition),
|
|
9799
|
+
version: definition.version,
|
|
9800
|
+
name: definition.name,
|
|
9801
|
+
profiles,
|
|
9802
|
+
software: Object.fromEntries(profiles.map((profile) => [
|
|
9803
|
+
profile,
|
|
9804
|
+
definition.profiles[profile].software
|
|
9805
|
+
])),
|
|
9806
|
+
ready: problems.length === 0,
|
|
9807
|
+
problems
|
|
9808
|
+
}
|
|
9809
|
+
};
|
|
9810
|
+
}
|
|
9811
|
+
function initializeDeployFile(root, options = {}) {
|
|
9812
|
+
const path = join3(root, DEPLOY_FILE);
|
|
9813
|
+
if (existsSync2(path) && !options.force) {
|
|
9814
|
+
throw new Error(`${DEPLOY_FILE} already exists; use --force only when replacing it deliberately.`);
|
|
9815
|
+
}
|
|
9816
|
+
const raw = defaultDeployFile(root, options);
|
|
9817
|
+
parseDeployDefinition(raw, { channel: options.channel });
|
|
9818
|
+
mkdirSync2(join3(root, ".fz"), { recursive: true });
|
|
9819
|
+
writeFileSync2(path, `${JSON.stringify(raw, null, 2)}
|
|
9820
|
+
`, { mode: 420 });
|
|
9821
|
+
return inspectDeployFile(root, { channel: options.channel });
|
|
9822
|
+
}
|
|
9823
|
+
|
|
9278
9824
|
// src/cli/index.ts
|
|
9279
9825
|
var DEFAULT_MODE = THRESHOLD_MODES[0].id;
|
|
9280
9826
|
var RECOMMENDED_MODE = (THRESHOLD_MODES.find((mode) => mode.recommended) ?? THRESHOLD_MODES[0]).id;
|
|
@@ -9290,7 +9836,13 @@ function parseOptions(argv) {
|
|
|
9290
9836
|
mode: DEFAULT_MODE,
|
|
9291
9837
|
user: process.env.FZ_USER ?? "operator",
|
|
9292
9838
|
email: process.env.FZ_EMAIL ?? "operator@localhost",
|
|
9293
|
-
preserveEnv: false
|
|
9839
|
+
preserveEnv: false,
|
|
9840
|
+
projectRoot: process.cwd(),
|
|
9841
|
+
deployProfile: "app",
|
|
9842
|
+
deploySoftware: [],
|
|
9843
|
+
deployChannel: "production",
|
|
9844
|
+
requireAttestation: false,
|
|
9845
|
+
force: false
|
|
9294
9846
|
};
|
|
9295
9847
|
const positional = [];
|
|
9296
9848
|
for (let index = 0;index < argv.length; index += 1) {
|
|
@@ -9309,6 +9861,26 @@ function parseOptions(argv) {
|
|
|
9309
9861
|
options.enrol = true;
|
|
9310
9862
|
else if (token === "--preserve-env")
|
|
9311
9863
|
options.preserveEnv = true;
|
|
9864
|
+
else if (token === "--root")
|
|
9865
|
+
options.projectRoot = argv[++index] ?? options.projectRoot;
|
|
9866
|
+
else if (token === "--name")
|
|
9867
|
+
options.projectName = argv[++index];
|
|
9868
|
+
else if (token === "--purpose")
|
|
9869
|
+
options.projectPurpose = argv[++index];
|
|
9870
|
+
else if (token === "--profile")
|
|
9871
|
+
options.deployProfile = argv[++index] ?? options.deployProfile;
|
|
9872
|
+
else if (token === "--software")
|
|
9873
|
+
options.deploySoftware.push(argv[++index] ?? "");
|
|
9874
|
+
else if (token === "--channel") {
|
|
9875
|
+
const channel = argv[++index];
|
|
9876
|
+
if (channel === "development" || channel === "production")
|
|
9877
|
+
options.deployChannel = channel;
|
|
9878
|
+
else
|
|
9879
|
+
options.optionError = "--channel must be production or development.";
|
|
9880
|
+
} else if (token === "--attestation")
|
|
9881
|
+
options.requireAttestation = true;
|
|
9882
|
+
else if (token === "--force")
|
|
9883
|
+
options.force = true;
|
|
9312
9884
|
else if (token === "--key")
|
|
9313
9885
|
options.key = argv[++index];
|
|
9314
9886
|
else if (token === "--mode")
|
|
@@ -9327,15 +9899,15 @@ function parseOptions(argv) {
|
|
|
9327
9899
|
return { command: positional[0] ?? "help", args: positional.slice(1), options };
|
|
9328
9900
|
}
|
|
9329
9901
|
var out = {
|
|
9330
|
-
line: (
|
|
9902
|
+
line: (text3 = "") => process.stdout.write(`${text3}
|
|
9331
9903
|
`),
|
|
9332
|
-
step: (
|
|
9904
|
+
step: (text3) => process.stdout.write(` ${text3}
|
|
9333
9905
|
`),
|
|
9334
|
-
warn: (
|
|
9906
|
+
warn: (text3) => process.stderr.write(` ! ${text3}
|
|
9335
9907
|
`),
|
|
9336
|
-
fail: (
|
|
9908
|
+
fail: (text3) => process.stderr.write(` \u2717 ${text3}
|
|
9337
9909
|
`),
|
|
9338
|
-
ok: (
|
|
9910
|
+
ok: (text3) => process.stdout.write(` \u2713 ${text3}
|
|
9339
9911
|
`)
|
|
9340
9912
|
};
|
|
9341
9913
|
var sessionCookie = null;
|
|
@@ -9490,7 +10062,7 @@ async function cmdAgent(options, args) {
|
|
|
9490
10062
|
controlSocketPath: process.env.FZ_CONTROL_SOCKET,
|
|
9491
10063
|
repository: process.env.FZ_DEPLOY_REPO,
|
|
9492
10064
|
branch: process.env.FZ_DEPLOY_BRANCH,
|
|
9493
|
-
|
|
10065
|
+
profile: process.env.FZ_DEPLOY_PROFILE,
|
|
9494
10066
|
deployRoot: process.env.FZ_DEPLOY_ROOT,
|
|
9495
10067
|
publicApiUrl: process.env.FZ_PUBLIC_API_URL,
|
|
9496
10068
|
deploymentEnvironment: parseAssignments(process.env.FZ_DEPLOY_ENV),
|
|
@@ -9540,17 +10112,17 @@ async function cmdAgent(options, args) {
|
|
|
9540
10112
|
return 0;
|
|
9541
10113
|
}
|
|
9542
10114
|
try {
|
|
9543
|
-
|
|
10115
|
+
writeFileSync3(plan.unitPath, plan.unit, { mode: 420 });
|
|
9544
10116
|
out.ok(`Wrote ${plan.unitPath}`);
|
|
9545
10117
|
for (const auxiliary of plan.auxiliaryUnits) {
|
|
9546
|
-
|
|
9547
|
-
|
|
10118
|
+
mkdirSync3(dirname2(auxiliary.path), { recursive: true, mode: 493 });
|
|
10119
|
+
writeFileSync3(auxiliary.path, auxiliary.unit, { mode: 420 });
|
|
9548
10120
|
out.ok(`Wrote ${auxiliary.path}`);
|
|
9549
10121
|
}
|
|
9550
10122
|
if (options.enrol) {
|
|
9551
|
-
if (
|
|
10123
|
+
if (existsSync3(enrolTokenSourcePath)) {
|
|
9552
10124
|
const source = statSync(enrolTokenSourcePath);
|
|
9553
|
-
const token =
|
|
10125
|
+
const token = readFileSync3(enrolTokenSourcePath, "utf8").trim();
|
|
9554
10126
|
if (!source.isFile() || (source.mode & 511) !== 384 || source.uid !== 0) {
|
|
9555
10127
|
throw new Error("The preloaded enrolment token must be a root-owned 0600 file in /run.");
|
|
9556
10128
|
}
|
|
@@ -9563,7 +10135,7 @@ async function cmdAgent(options, args) {
|
|
|
9563
10135
|
if (await prompt.exited !== 0 || !/^fze_[A-Za-z0-9_-]{40,100}$/.test(token)) {
|
|
9564
10136
|
throw new Error("A valid fze_ enrolment token was not provided.");
|
|
9565
10137
|
}
|
|
9566
|
-
|
|
10138
|
+
writeFileSync3(enrolTokenSourcePath, `${token}
|
|
9567
10139
|
`, { mode: 384, flag: "wx" });
|
|
9568
10140
|
}
|
|
9569
10141
|
}
|
|
@@ -9574,7 +10146,7 @@ async function cmdAgent(options, args) {
|
|
|
9574
10146
|
out.line();
|
|
9575
10147
|
out.line(" Add this machine-specific PUBLIC key as a read-only deploy key:");
|
|
9576
10148
|
out.line();
|
|
9577
|
-
out.line(` ${
|
|
10149
|
+
out.line(` ${readFileSync3(gitPublicKeyPath, "utf8").trim()}`);
|
|
9578
10150
|
out.line();
|
|
9579
10151
|
return 0;
|
|
9580
10152
|
} catch (cause) {
|
|
@@ -9691,6 +10263,123 @@ Is fz-agent running on this machine? \`fz agent install --apply\`.`);
|
|
|
9691
10263
|
out.line(describeInjection(merged, Object.keys(secrets).length));
|
|
9692
10264
|
return spawnWith(command, merged.env, (line) => out.line(line));
|
|
9693
10265
|
}
|
|
10266
|
+
function cmdProject(options, args) {
|
|
10267
|
+
const operation = args[0] ?? "check";
|
|
10268
|
+
try {
|
|
10269
|
+
if (operation === "init") {
|
|
10270
|
+
const manifest = defaultProjectContext(options.projectRoot);
|
|
10271
|
+
if (options.projectName)
|
|
10272
|
+
manifest.name = options.projectName;
|
|
10273
|
+
if (options.projectPurpose)
|
|
10274
|
+
manifest.purpose = options.projectPurpose;
|
|
10275
|
+
const files = initializeProjectContext(options.projectRoot, manifest, { force: options.force });
|
|
10276
|
+
out.ok(`Project context initialized; ${files.length} generated adapters now share one Git source.`);
|
|
10277
|
+
out.step("Edit .forgezero/project.json, create every named truth source, then run `fz project sync`.");
|
|
10278
|
+
return 0;
|
|
10279
|
+
}
|
|
10280
|
+
if (operation === "sync") {
|
|
10281
|
+
const files = syncProjectContext(options.projectRoot);
|
|
10282
|
+
out.ok(`Synchronized ${files.length} AI adapters from .forgezero/project.json.`);
|
|
10283
|
+
return 0;
|
|
10284
|
+
}
|
|
10285
|
+
if (operation === "check") {
|
|
10286
|
+
const result = checkProjectContext(options.projectRoot);
|
|
10287
|
+
if (options.json)
|
|
10288
|
+
out.line(JSON.stringify(result, null, 2));
|
|
10289
|
+
else if (result.ok)
|
|
10290
|
+
out.ok("Project context and every AI adapter are in sync.");
|
|
10291
|
+
else
|
|
10292
|
+
for (const problem of result.problems)
|
|
10293
|
+
out.fail(problem);
|
|
10294
|
+
return result.ok ? 0 : 1;
|
|
10295
|
+
}
|
|
10296
|
+
out.fail("Usage: fz project init|sync|check [--root <path>] [--force]");
|
|
10297
|
+
return 2;
|
|
10298
|
+
} catch (cause) {
|
|
10299
|
+
out.fail(cause instanceof Error ? cause.message : String(cause));
|
|
10300
|
+
return 1;
|
|
10301
|
+
}
|
|
10302
|
+
}
|
|
10303
|
+
function softwareCoordinates(values) {
|
|
10304
|
+
if (values.length === 0)
|
|
10305
|
+
return;
|
|
10306
|
+
return values.map((coordinate) => {
|
|
10307
|
+
const separator = coordinate.lastIndexOf("@");
|
|
10308
|
+
if (separator < 1 || separator === coordinate.length - 1) {
|
|
10309
|
+
throw new Error(`Software must be <key>@<version>, received: ${coordinate || "<empty>"}.`);
|
|
10310
|
+
}
|
|
10311
|
+
return {
|
|
10312
|
+
id: coordinate.slice(0, separator),
|
|
10313
|
+
version: coordinate.slice(separator + 1)
|
|
10314
|
+
};
|
|
10315
|
+
});
|
|
10316
|
+
}
|
|
10317
|
+
function cmdDeploy(options, args) {
|
|
10318
|
+
const operation = args[0] ?? "check";
|
|
10319
|
+
try {
|
|
10320
|
+
if (options.optionError)
|
|
10321
|
+
throw new Error(options.optionError);
|
|
10322
|
+
if (operation === "init") {
|
|
10323
|
+
const created = initializeDeployFile(options.projectRoot, {
|
|
10324
|
+
name: options.projectName,
|
|
10325
|
+
profile: options.deployProfile,
|
|
10326
|
+
software: softwareCoordinates(options.deploySoftware),
|
|
10327
|
+
requireAttestation: options.requireAttestation,
|
|
10328
|
+
channel: options.deployChannel,
|
|
10329
|
+
force: options.force
|
|
10330
|
+
});
|
|
10331
|
+
if (options.json)
|
|
10332
|
+
out.line(JSON.stringify(created.summary, null, 2));
|
|
10333
|
+
else {
|
|
10334
|
+
out.ok(`Initialized .fz/deploy.json (${created.summary.digest}).`);
|
|
10335
|
+
out.warn("Release and health use safe blockers until you replace them with project-specific commands.");
|
|
10336
|
+
out.step("Run `fz deploy check`, commit the file, then push; the verified Git commit is the live sync.");
|
|
10337
|
+
}
|
|
10338
|
+
return 0;
|
|
10339
|
+
}
|
|
10340
|
+
if (operation === "check" || operation === "sync") {
|
|
10341
|
+
const inspected = inspectDeployFile(options.projectRoot, { channel: options.deployChannel });
|
|
10342
|
+
if (options.json)
|
|
10343
|
+
out.line(JSON.stringify(inspected.summary, null, 2));
|
|
10344
|
+
else {
|
|
10345
|
+
out.step(`${inspected.summary.name} \xB7 v${inspected.summary.version} \xB7 ${inspected.summary.digest}`);
|
|
10346
|
+
out.step(`profiles: ${inspected.summary.profiles.join(", ")}`);
|
|
10347
|
+
for (const problem of inspected.summary.problems)
|
|
10348
|
+
out.fail(problem);
|
|
10349
|
+
if (inspected.summary.ready) {
|
|
10350
|
+
out.ok("Deploy definition is typed, catalog-valid and ready to commit.");
|
|
10351
|
+
if (operation === "sync") {
|
|
10352
|
+
out.step("Commit and push this file. ForgeZero deploys the exact webhook commit; no second live file exists.");
|
|
10353
|
+
}
|
|
10354
|
+
}
|
|
10355
|
+
}
|
|
10356
|
+
return inspected.summary.ready ? 0 : 1;
|
|
10357
|
+
}
|
|
10358
|
+
if (operation === "catalog") {
|
|
10359
|
+
const software = SOFTWARE_CATALOG.filter((entry) => entry.status === "active" || options.deployChannel === "development" && entry.status === "testing");
|
|
10360
|
+
const payload = {
|
|
10361
|
+
channel: options.deployChannel,
|
|
10362
|
+
note: "Only active coordinates are selectable in deploy definitions.",
|
|
10363
|
+
os: OS_CATALOG,
|
|
10364
|
+
software
|
|
10365
|
+
};
|
|
10366
|
+
if (options.json)
|
|
10367
|
+
out.line(JSON.stringify(payload, null, 2));
|
|
10368
|
+
else {
|
|
10369
|
+
out.line(`${options.deployChannel} software catalog (only active coordinates are selectable):`);
|
|
10370
|
+
for (const entry of software) {
|
|
10371
|
+
out.step(`${entry.id}@${entry.version} \xB7 ${entry.os} ${entry.osVersion} ${entry.architecture} \xB7 ${entry.status}`);
|
|
10372
|
+
}
|
|
10373
|
+
}
|
|
10374
|
+
return 0;
|
|
10375
|
+
}
|
|
10376
|
+
out.fail("Usage: fz deploy init|check|sync|catalog [--root <path>] [--channel production|development]");
|
|
10377
|
+
return 2;
|
|
10378
|
+
} catch (cause) {
|
|
10379
|
+
out.fail(cause instanceof Error ? cause.message : String(cause));
|
|
10380
|
+
return 1;
|
|
10381
|
+
}
|
|
10382
|
+
}
|
|
9694
10383
|
function usage() {
|
|
9695
10384
|
out.line(`
|
|
9696
10385
|
fz ${VERSION2} \u2014 ForgeZero control surface
|
|
@@ -9712,6 +10401,13 @@ function usage() {
|
|
|
9712
10401
|
fz agent install Install the node agent as a systemd service, so
|
|
9713
10402
|
applications on this box read secrets through a
|
|
9714
10403
|
local socket instead of holding an API key
|
|
10404
|
+
fz project init Create vendor-neutral, Git-persisted AI context
|
|
10405
|
+
fz project sync Regenerate Claude/Codex/Gemini/Copilot/Cursor adapters
|
|
10406
|
+
fz project check Fail when truth sources or generated adapters drift
|
|
10407
|
+
fz deploy init Create a typed, fail-safe .fz/deploy.json
|
|
10408
|
+
fz deploy check Validate commands, profiles and software coordinates
|
|
10409
|
+
fz deploy sync Prove readiness and print the Git synchronization rule
|
|
10410
|
+
fz deploy catalog List selectable tested OS/software coordinates
|
|
9715
10411
|
|
|
9716
10412
|
CEREMONY OPTIONS
|
|
9717
10413
|
--key <fp|index> Which agent key to use for custody
|
|
@@ -9734,6 +10430,14 @@ function usage() {
|
|
|
9734
10430
|
--apply Write the unit rather than printing it (root)
|
|
9735
10431
|
--enrol Bind this machine with a one-time token prompted
|
|
9736
10432
|
securely by systemd (tenant-owned compute)
|
|
10433
|
+
--root <path> Project root for project/deploy commands
|
|
10434
|
+
--name <name> Project or deploy name during init
|
|
10435
|
+
--purpose <text> Product outcome during project init
|
|
10436
|
+
--profile <name> Initial deploy profile (default app)
|
|
10437
|
+
--software <key@ver> Initial tested software coordinate; repeatable
|
|
10438
|
+
--channel <name> Catalog view: production or development (shows testing)
|
|
10439
|
+
--attestation Require hardware attestation for every deploy step
|
|
10440
|
+
--force Init may replace an existing generated target
|
|
9737
10441
|
|
|
9738
10442
|
CUSTODY FACTORS
|
|
9739
10443
|
Every custodian share is sealed TWICE and either envelope alone opens it:
|
|
@@ -9762,6 +10466,12 @@ async function runCli() {
|
|
|
9762
10466
|
case "agent":
|
|
9763
10467
|
code = await cmdAgent(options, args);
|
|
9764
10468
|
break;
|
|
10469
|
+
case "project":
|
|
10470
|
+
code = cmdProject(options, args);
|
|
10471
|
+
break;
|
|
10472
|
+
case "deploy":
|
|
10473
|
+
code = cmdDeploy(options, args);
|
|
10474
|
+
break;
|
|
9765
10475
|
case "genesis":
|
|
9766
10476
|
code = await cmdGenesis(options);
|
|
9767
10477
|
break;
|
|
@@ -9785,7 +10495,7 @@ async function runCli() {
|
|
|
9785
10495
|
usage();
|
|
9786
10496
|
code = 1;
|
|
9787
10497
|
}
|
|
9788
|
-
if (args.length > 0 && code === 0 && command !== "agent") {
|
|
10498
|
+
if (args.length > 0 && code === 0 && command !== "agent" && command !== "project" && command !== "deploy") {
|
|
9789
10499
|
out.warn(`Ignored: ${args.join(" ")}`);
|
|
9790
10500
|
}
|
|
9791
10501
|
process.exit(code);
|