@namewta/speculo 1.0.19 → 1.0.21
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 +8 -7
- package/THIRD-PARTY-NOTICES.md +33 -0
- package/dist/src/agent-files.js +2 -2
- package/dist/src/agent-files.js.map +1 -1
- package/dist/src/agent-skills.d.ts +81 -0
- package/dist/src/agent-skills.js +530 -0
- package/dist/src/agent-skills.js.map +1 -0
- package/dist/src/cli.js +11 -3
- package/dist/src/cli.js.map +1 -1
- package/dist/src/config.js +5 -3
- package/dist/src/config.js.map +1 -1
- package/dist/src/doctor.js +7 -2
- package/dist/src/doctor.js.map +1 -1
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +23 -3
- package/dist/src/index.js.map +1 -1
- package/dist/src/manifest.d.ts +9 -0
- package/dist/src/manifest.js +45 -7
- package/dist/src/manifest.js.map +1 -1
- package/dist/src/refresh.js +40 -0
- package/dist/src/refresh.js.map +1 -1
- package/dist/src/structured.js +15 -4
- package/dist/src/structured.js.map +1 -1
- package/dist/src/transaction.d.ts +24 -3
- package/dist/src/transaction.js +191 -12
- package/dist/src/transaction.js.map +1 -1
- package/package.json +7 -4
- package/scripts/validate-skills.mjs +56 -0
- package/template/AGENTS.md +1 -1
- package/template/CLAUDE.md +1 -1
- package/template/canonical/canonical-specdev-goal-plan.md +156 -28
- package/template/canonical/canonical-specdev-grill-with-docs.md +153 -31
- package/template/canonical/canonical-specdev-spec.md +153 -27
- package/template/canonical/canonical-specdev-tickets.md +153 -27
- package/template/canonical/canonical-specdev-wayfinder.md +142 -27
- package/template/commands/retro.md +11 -9
- package/template/skills/archive-and-consolidate/SKILL.md +163 -7
- package/template/skills/archive-and-consolidate/references/cleanup-rules.md +1 -1
- package/template/skills/docs-sync/references/agents/claude-redirect.md +8 -8
- package/template/skills/docs-sync/references/agents/content-contract.md +1 -1
- package/template/skills/docs-sync/references/agents/manifest-discovery.md +7 -7
- package/template/skills/docs-sync/references/agents/role-classification.md +4 -4
- package/template/skills/docs-sync/references/agents-contract.md +1 -1
- package/template/skills/engineering-standards-builder/SKILL.md +149 -9
- package/template/skills/engineering-standards-builder/agents/openai.yaml +2 -0
- package/template/skills/engineering-standards-builder/manifest.txt +2 -1
- package/template/skills/engineering-standards-builder/references/rules/09-mechanical-guardrails.md +9 -0
- package/template/skills/engineering-standards-builder/scripts/validate-builder.mjs +4 -7
- package/template/skills/git-history-squash/SKILL.md +87 -11
- package/template/skills/git-history-squash/agents/openai.yaml +2 -0
- package/template/skills/optimize-codex-config/SKILL.md +71 -11
- package/template/skills/{speculo-retro/references/entry-procedure.md → retrospective/SKILL.md} +12 -8
- package/template/skills/{speculo-retro → retrospective}/references/friction-taxonomy.md +22 -13
- package/template/skills/{speculo-retro → retrospective}/references/issue-drafting-sop.md +10 -20
- package/template/skills/source-code-zip/SKILL.md +44 -8
- package/template/skills/source-code-zip/references/{entry-procedure.md → cli-reference.md} +0 -43
- package/template/skills/upstream-fork-sync/SKILL.md +71 -8
- package/template/skills/{writing-great-skills → writing-for-agents}/GLOSSARY.md +1 -1
- package/template/skills/writing-for-agents/SKILL.md +22 -0
- package/template/skills/writing-for-agents/agents/openai.yaml +2 -0
- package/template/skills/{writing-great-skills/references/entry-procedure.md → writing-for-agents/references/authoring-principles.md} +2 -9
- package/template/workflows/ops/D-project-deploy/D-project-deploy.md +1 -1
- package/template/workflows/ops/H-host-manage/H-host-manage.md +4 -0
- package/template/workflows/ops/H-host-manage/references/host-profiles.md +4 -0
- package/template/workflows/ops/I-initialize/I-initialize.md +1 -1
- package/template/workflows/ops/INDEX.md +2 -0
- package/template/workflows/ops/README.md +7 -3
- package/template/workflows/ops/S-server-connect/S-server-connect.md +5 -5
- package/template/workflows/ops/V-inventory-view/V-inventory-view.md +2 -0
- package/template/workflows/ops/common/CAPABILITIES.md +2 -0
- package/template/workflows/ops/common/USAGE.md +5 -5
- package/template/workflows/ops/common/WORKSPACE-USAGE.md +29 -2
- package/template/workflows/ops/common/examples/README.md +1 -1
- package/template/workflows/ops/common/examples/register.example.json +7 -3
- package/template/workflows/ops/common/examples/server-connection.example.json +11 -0
- package/template/workflows/ops/common/rules/deployment-layout.md +1 -1
- package/template/workflows/ops/common/rules/host-root-and-onboarding.md +47 -0
- package/template/workflows/ops/common/rules/persistence-and-secrets.md +1 -1
- package/template/workflows/ops/common/rules/research-and-decisions.md +8 -0
- package/template/workflows/ops/common/rules/server-onboarding.md +3 -3
- package/template/workflows/ops/common/rules/workspace-and-authorization.md +2 -2
- package/template/workflows/ops/common/schemas/fleet-view.schema.json +64 -0
- package/template/workflows/ops/common/schemas/host.schema.json +19 -0
- package/template/workflows/ops/common/schemas/plan.schema.json +57 -0
- package/template/workflows/ops/common/schemas/root-confirmation.schema.json +118 -0
- package/template/workflows/ops/common/schemas/server-connection.schema.json +67 -0
- package/template/workflows/ops/common/schemas/server-discovery.schema.json +171 -0
- package/template/workflows/ops/common/schemas/spec.schema.json +19 -0
- package/template/workflows/ops/common/schemas/status.schema.json +19 -0
- package/template/workflows/ops/common/templates/FLEET.html +1 -1
- package/template/workflows/ops/common/templates/HOST-README.md +3 -1
- package/template/workflows/ops/common/tests/test_ops.mjs +8 -11
- package/template/workflows/ops/common/tests/test_ops_bootstrap.mjs +101 -16
- package/template/workflows/ops/common/tests/test_ops_onboarding.mjs +225 -0
- package/template/workflows/ops/common/tests/test_ops_workspace.mjs +17 -8
- package/template/workflows/ops/common/tools/demo-local.mjs +4 -0
- package/template/workflows/ops/common/tools/opslib/agent.mjs +26 -0
- package/template/workflows/ops/common/tools/opslib/bootstrap.mjs +52 -26
- package/template/workflows/ops/common/tools/opslib/cli.mjs +1 -0
- package/template/workflows/ops/common/tools/opslib/core.mjs +1 -1
- package/template/workflows/ops/common/tools/opslib/docs.mjs +1 -0
- package/template/workflows/ops/common/tools/opslib/execution.mjs +4 -0
- package/template/workflows/ops/common/tools/opslib/fleet.mjs +26 -4
- package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +34 -0
- package/template/workflows/ops/common/tools/opslib/model.mjs +6 -0
- package/template/workflows/ops/common/tools/opslib/onboarding.mjs +215 -0
- package/template/workflows/ops/common/tools/opslib/planner.mjs +3 -0
- package/template/workflows/ops/common/tools/opslib/transport.mjs +10 -6
- package/template/workflows/ops/common/tools/opslib/workspace_cli.mjs +20 -1
- package/template/workflows/ops/common/tools/server-discover.sh +65 -0
- package/template/workflows/ops/common/tools/validate-ops.mjs +1 -1
- package/template/workflows/ops/manifest.json +12 -5
- package/template/workflows/specdev/A-archive-and-consolidate/consolidation-interview.md +3 -3
- package/template/workflows/specdev/C-code-review/C-code-review.md +1 -1
- package/template/workflows/specdev/D-diagnose-bugs/D-diagnose-bugs.md +6 -0
- package/template/workflows/specdev/G-grill-with-docs/references/interview-procedure.md +6 -6
- package/template/workflows/specdev/I-implement/references/implementation-procedure.md +1 -1
- package/template/workflows/specdev/I-implement/tdd-rules.md +4 -0
- package/template/workflows/specdev/I-init-setup/config-template.json +5 -1
- package/template/workflows/specdev/INDEX.md +2 -2
- package/template/workflows/specdev/P-goal-plan/planning-modes.md +1 -1
- package/template/workflows/specdev/P-prototype/P-prototype.md +7 -3
- package/template/workflows/specdev/P-prototype/logic-protocol.md +11 -0
- package/template/workflows/specdev/P-prototype/logic-template.md +27 -0
- package/template/workflows/specdev/P-prototype/project-preview-protocol.md +9 -0
- package/template/workflows/specdev/R-retro/R-retro.md +26 -0
- package/template/workflows/specdev/R-retro/retro-template.md +27 -0
- package/template/workflows/specdev/R-review-architecture/references/selected-design.md +1 -1
- package/template/workflows/specdev/README.md +13 -2
- package/template/workflows/specdev/S-spec/S-spec.md +4 -0
- package/template/workflows/specdev/T-tickets/T-tickets.md +4 -0
- package/template/workflows/specdev/T-triage/T-triage.md +31 -25
- package/template/workflows/specdev/T-triage/capture-protocol.md +1 -1
- package/template/workflows/specdev/T-triage/ci-security-protocol.md +11 -0
- package/template/workflows/specdev/T-triage/intake-protocol.md +10 -4
- package/template/workflows/specdev/T-triage/pr-delivery-protocol.md +13 -0
- package/template/workflows/specdev/T-triage/pr-record-template.md +39 -0
- package/template/workflows/specdev/T-triage/publish-protocol.md +1 -1
- package/template/workflows/specdev/T-triage/queue-protocol.md +7 -0
- package/template/workflows/specdev/T-triage/reconcile-protocol.md +1 -1
- package/template/workflows/specdev/T-triage/references/issue-pr-policy.md +23 -0
- package/template/workflows/specdev/T-triage/references/release-authentication.md +9 -0
- package/template/workflows/specdev/T-triage/references/release-notes-injection.md +21 -0
- package/template/workflows/specdev/T-triage/references/workflow-yaml-reference.md +37 -0
- package/template/workflows/specdev/T-triage/release-preflight.md +13 -0
- package/template/workflows/specdev/T-triage/release-protocol.md +12 -0
- package/template/workflows/specdev/T-triage/release-recovery.md +18 -0
- package/template/workflows/specdev/T-triage/remote-operations.md +25 -0
- package/template/workflows/specdev/T-triage/scripts/github-transport.mjs +208 -0
- package/template/workflows/specdev/T-triage/source-template.md +7 -0
- package/template/workflows/specdev/T-triage/triage-run-template.md +34 -0
- package/template/workflows/specdev/T-triage/triage-template.md +4 -1
- package/template/workflows/specdev/common/rules/artifact-contract.md +7 -0
- package/template/workflows/specdev/common/rules/change-completion.md +6 -0
- package/template/workflows/specdev/common/rules/workflow-routing.md +6 -0
- package/template/workflows/specdev/common/rules/workflow-state-and-lifecycle.md +1 -1
- package/template/workflows/specdev/common/schemas/config.schema.json +137 -26
- package/template/workflows/specdev/common/schemas/logic-prototype.schema.json +62 -0
- package/template/workflows/specdev/common/schemas/pull-request.schema.json +90 -0
- package/template/workflows/specdev/common/schemas/retrospective.schema.json +58 -0
- package/template/workflows/specdev/common/schemas/triage-run.schema.json +149 -0
- package/template/workflows/specdev/common/schemas/triage.schema.json +117 -15
- package/template/workflows/specdev/common/tools/delivery-artifacts.mjs +95 -0
- package/template/workflows/specdev/common/tools/validate-specdev.mjs +60 -19
- package/template/workflows/specdev/runtime-contract.json +4 -2
- package/template/skills/archive-and-consolidate/references/entry-procedure.md +0 -178
- package/template/skills/docs-sync/references/entry-procedure.md +0 -3
- package/template/skills/engineering-standards-builder/references/entry-procedure.md +0 -154
- package/template/skills/git-history-squash/references/entry-procedure.md +0 -94
- package/template/skills/github-npm-ops/SKILL.md +0 -19
- package/template/skills/github-npm-ops/references/ci-and-security-ops.md +0 -178
- package/template/skills/github-npm-ops/references/entry-procedure.md +0 -25
- package/template/skills/github-npm-ops/references/failure-recovery.md +0 -123
- package/template/skills/github-npm-ops/references/issue-pr-triage.md +0 -227
- package/template/skills/github-npm-ops/references/issue-transport.md +0 -26
- package/template/skills/github-npm-ops/references/package-json-checklist.md +0 -171
- package/template/skills/github-npm-ops/references/preflight-checklist.md +0 -39
- package/template/skills/github-npm-ops/references/publish-detection.md +0 -68
- package/template/skills/github-npm-ops/references/release-notes-injection.md +0 -238
- package/template/skills/github-npm-ops/references/release-pipeline.md +0 -108
- package/template/skills/github-npm-ops/references/setup-npm-token.md +0 -63
- package/template/skills/github-npm-ops/references/troubleshooting-playbook.md +0 -293
- package/template/skills/github-npm-ops/references/version-bump-flow.md +0 -217
- package/template/skills/github-npm-ops/references/workflow-yaml-reference.md +0 -268
- package/template/skills/github-npm-ops/scripts/issue-transport.mjs +0 -227
- package/template/skills/optimize-codex-config/references/entry-procedure.md +0 -78
- package/template/skills/speculo-retro/SKILL.md +0 -19
- package/template/skills/upstream-fork-sync/references/entry-procedure.md +0 -78
- package/template/skills/writing-great-skills/SKILL.md +0 -23
- /package/template/skills/{writing-great-skills → writing-for-agents}/references/document-contract.md +0 -0
- /package/template/workflows/specdev/{G-grill-with-docs → common/rules}/adr-format.md +0 -0
- /package/template/workflows/specdev/{G-grill-with-docs → common/rules}/context-format.md +0 -0
- /package/template/workflows/specdev/{I-implement → common/rules}/design-it-twice.md +0 -0
- /package/template/workflows/specdev/{G-grill-with-docs/domain-modeling-rules.md → common/rules/domain-modeling.md} +0 -0
|
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, statSync, symlinkSync, rmSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
|
-
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { dirname, join, resolve, posix } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
9
|
import { digest, emptyStatus, now, writeJson } from "../tools/opslib/core.mjs";
|
|
@@ -18,7 +18,7 @@ function fixture(t) {
|
|
|
18
18
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
19
19
|
const state = join(root, "state"); mkdirSync(state);
|
|
20
20
|
const s = emptyStatus(); s.controller = { controller_id: "controller-a", state_root: state, created_at: now() };
|
|
21
|
-
for (const id of ["node-a", "node-b"]) s.hosts[id] = { host_id: id, display_name: id, platform: "linux", transport: "local", root: join(root, id), identity: "a".repeat(64), connection: {} };
|
|
21
|
+
for (const id of ["node-a", "node-b"]) s.hosts[id] = { host_id: id, display_name: id, platform: process.platform === "win32" ? "windows" : "linux", transport: "local", root: join(root, id), identity: "a".repeat(64), connection: {} };
|
|
22
22
|
for (const id of ["app-a", "app-b"]) s.projects[id] = { project_id: id, display_name: id, kind: "app", service_type: null, source: { type: "local", location: join(root, "source"), revision: "v1" } };
|
|
23
23
|
writeJson(join(state, "status.json"), s);
|
|
24
24
|
const save = () => writeJson(join(state, "status.json"), s);
|
|
@@ -32,7 +32,13 @@ function request(id = "task-one") {
|
|
|
32
32
|
function inventory(h) {
|
|
33
33
|
return { identity: h.identity, platform: "linux", tools: { node: { version: "v22.22.3" } },
|
|
34
34
|
snapshots: { [h.root]: { kind: "directory" } }, diagnostics: { disks: { [h.root]: { free: 4 * 1024 ** 3 } }, memory: { MemAvailable: 1024 ** 3 } },
|
|
35
|
-
docker_control: { status: "observed", endpoint: "unix:///var/run/docker.sock", data_root: join(h.root, "_runtime/docker"), compose_version: "2.30.0" } };
|
|
35
|
+
docker_control: { status: "observed", endpoint: "unix:///var/run/docker.sock", data_root: posix.join(h.root, "_runtime/docker"), compose_version: "2.30.0" } };
|
|
36
|
+
}
|
|
37
|
+
function linuxServer(f) {
|
|
38
|
+
return Object.assign(f.s.hosts["node-a"], { platform: "linux", root: "/srv/speculo-test/node-a" });
|
|
39
|
+
}
|
|
40
|
+
function directoryLink(target, path) {
|
|
41
|
+
symlinkSync(target, path, process.platform === "win32" ? "junction" : "dir");
|
|
36
42
|
}
|
|
37
43
|
function adapterFor(f, options = {}) {
|
|
38
44
|
const calls = { compile: 0, approve: 0, apply: 0 };
|
|
@@ -76,7 +82,7 @@ test("explicit layer routing distinguishes controller, access, server, project a
|
|
|
76
82
|
test("workspace rejects guessed roots, traversal, symlinks, duplicate JSON keys and orphan deployments", (t) => {
|
|
77
83
|
const f = fixture(t);
|
|
78
84
|
for (const path of [".", "/", f.state + "/../state"]) assert.throws(() => statePath(path));
|
|
79
|
-
const link = join(f.root, "linked");
|
|
85
|
+
const link = join(f.root, "linked"); directoryLink(f.state, link); assert.throws(() => statePath(link), /symlink/);
|
|
80
86
|
writeFileSync(join(f.state, "status.json"), '{"schema_version":3,"schema_version":3}'); assert.throws(() => readRegistry(f.state), /duplicate JSON/);
|
|
81
87
|
f.s.deployments.dangling = { deployment_id: "dangling", host_id: "missing", project_id: "app-a" }; f.save(); assert.throws(() => readRegistry(f.state), /orphan/);
|
|
82
88
|
});
|
|
@@ -90,7 +96,7 @@ test("CLI help works without network, server dependencies or state", () => {
|
|
|
90
96
|
const r = spawnSync(process.execPath, [path, "workspace-help"], { encoding: "utf8" }); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, /server-check/);
|
|
91
97
|
});
|
|
92
98
|
test("Linux checklist distinguishes missing, failed, profile-ready and stale evidence", (t) => {
|
|
93
|
-
const f = fixture(t), h = f
|
|
99
|
+
const f = fixture(t), h = linuxServer(f), inv = inventory(h), at = new Date("2026-01-01T00:00:00Z");
|
|
94
100
|
assert.equal(readiness(assessServer(h), at.toISOString(), { at }), "unknown");
|
|
95
101
|
assert.equal(readiness(assessServer(h, { profile: "compose", inventory: inv }), at.toISOString(), { at }), "ready");
|
|
96
102
|
assert.equal(readiness(assessServer(h, { profile: "compose", inventory: inv }), at.toISOString(), { at: new Date("2026-01-03T00:00:00Z") }), "stale");
|
|
@@ -100,7 +106,7 @@ test("Linux checklist distinguishes missing, failed, profile-ready and stale evi
|
|
|
100
106
|
}
|
|
101
107
|
});
|
|
102
108
|
test("server check uses pinned public-key-only SSH and records restricted evidence", async (t) => {
|
|
103
|
-
const f = fixture(t), h = f
|
|
109
|
+
const f = fixture(t), h = linuxServer(f), kh = join(f.root, "known_hosts"); writeFileSync(kh, "fixture verified key");
|
|
104
110
|
Object.assign(h, { transport: "ssh", connection: { hostname: "example.invalid", username: "deploy", known_hosts: kh } }); f.save();
|
|
105
111
|
let spawned = 0, called = 0;
|
|
106
112
|
const a = { sshArgv: () => ["ssh", "-o", "StrictHostKeyChecking=yes"], spawn: (_cmd, args) => { spawned++; assert.ok(args.includes("PasswordAuthentication=no")); assert.ok(args.includes("KbdInteractiveAuthentication=no")); return { status: 0 }; }, call: () => { called++; return inventory(h); } };
|
|
@@ -111,7 +117,7 @@ test("server check uses pinned public-key-only SSH and records restricted eviden
|
|
|
111
117
|
writeFileSync(kh, "different key"); assert.notEqual(check.host_digest, targetDigest(h));
|
|
112
118
|
});
|
|
113
119
|
test("failed SSH login never triggers Node probe and is not reported ready", async (t) => {
|
|
114
|
-
const f = fixture(t), h = f
|
|
120
|
+
const f = fixture(t), h = linuxServer(f), kh = join(f.root, "known_hosts"); writeFileSync(kh, "fixture"); Object.assign(h, { transport: "ssh", connection: { hostname: "invalid", username: "deploy", known_hosts: kh } }); f.save();
|
|
115
121
|
const r = await checkServer(f.state, "node-a", {}, { sshArgv: () => ["ssh"], spawn: () => ({ status: 255 }), call: () => { throw new Error("must not probe"); } });
|
|
116
122
|
assert.equal(r.status, "blocked"); assert.equal(r.checks.find(c => c.id === "machine-identity").status, "unknown");
|
|
117
123
|
});
|
|
@@ -210,7 +216,10 @@ test("fleet writes immutable generations, verifies readback and retains one cons
|
|
|
210
216
|
const before = readFileSync(first.markdown, "utf8"); writeFileSync(first.html, "tampered"); assert.throws(() => generateFleet(f.state, { at }), /readback mismatch/); assert.equal(readFileSync(first.markdown, "utf8"), before);
|
|
211
217
|
});
|
|
212
218
|
test("fleet refuses symlink outputs and catalog locks without overwriting old entry", (t) => {
|
|
213
|
-
const f = fixture(t),
|
|
219
|
+
const f = fixture(t), outsideDir = join(f.root, "outside"), outside = join(outsideDir, "sentinel.txt");
|
|
220
|
+
mkdirSync(outsideDir); writeFileSync(outside, "untouched");
|
|
221
|
+
if (process.platform === "win32") directoryLink(outsideDir, join(f.state, "FLEET.md"));
|
|
222
|
+
else symlinkSync(outside, join(f.state, "FLEET.md"));
|
|
214
223
|
assert.throws(() => generateFleet(f.state), /symlink/); assert.equal(readFileSync(outside, "utf8"), "untouched");
|
|
215
224
|
rmSync(join(f.state, "FLEET.md")); mkdirSync(join(f.state, ".locks/catalog"), { recursive: true }); assert.throws(() => generateFleet(f.state), /catalog lock/);
|
|
216
225
|
});
|
|
@@ -9,6 +9,7 @@ import { register, putCredential } from "./opslib/model.mjs";
|
|
|
9
9
|
import { probeLocal } from "./opslib/transport.mjs";
|
|
10
10
|
import { compilePlan } from "./opslib/planner.mjs";
|
|
11
11
|
import { approval, apply } from "./opslib/execution.mjs";
|
|
12
|
+
import { discoverServer, confirmServerRoot } from "./opslib/onboarding.mjs";
|
|
12
13
|
|
|
13
14
|
export function run(output) {
|
|
14
15
|
output = resolve(output);
|
|
@@ -22,10 +23,13 @@ export function run(output) {
|
|
|
22
23
|
const inventory = probeLocal();
|
|
23
24
|
const platform = inventory.platform;
|
|
24
25
|
if (!["linux", "darwin", "windows"].includes(platform)) throw new OpsError("unsupported platform");
|
|
26
|
+
const discovered = discoverServer(state, "demo-local", { platform, transport: "local", connection: {} });
|
|
27
|
+
const confirmed = confirmServerRoot(state, "demo-local", discovered.discovery.receipt_id, target, "demo-user", "Run this explicitly requested disposable demo only inside its new output/server directory.");
|
|
25
28
|
register(state, {
|
|
26
29
|
hosts: [{
|
|
27
30
|
host_id: "demo-local", display_name: "Disposable local demo", platform, transport: "local",
|
|
28
31
|
connection: {}, root: target, identity: inventory.identity,
|
|
32
|
+
root_confirmation: confirmed.root_confirmation,
|
|
29
33
|
}],
|
|
30
34
|
projects: [{
|
|
31
35
|
project_id: "app-a", display_name: "Demo APP A", kind: "app", service_type: null,
|
|
@@ -775,10 +775,36 @@ export function benchmarkMirrors(req) {
|
|
|
775
775
|
};
|
|
776
776
|
}
|
|
777
777
|
|
|
778
|
+
export function onboardingObservation(root = null) {
|
|
779
|
+
const account = userInfo();
|
|
780
|
+
const result = { identity: fingerprint(), platform: process.platform === "win32" ? "windows" : process.platform,
|
|
781
|
+
username: account.username, uid: String(account.uid), home: account.homedir, node: process.execPath, node_version: process.version };
|
|
782
|
+
if (root) {
|
|
783
|
+
if (!isAbsolute(root) || root.split(/[\\/]/).includes("..")) throw new Failure("unsafe discovery root");
|
|
784
|
+
noLinks(root);
|
|
785
|
+
let parent = root;
|
|
786
|
+
while (!existsSync(parent)) parent = dirname(parent);
|
|
787
|
+
if (!statSync(parent).isDirectory()) throw new Failure("root ancestor is not a directory");
|
|
788
|
+
let writable = true;
|
|
789
|
+
try { accessSync(parent, fsConstants.W_OK | fsConstants.X_OK); } catch { writable = false; }
|
|
790
|
+
const present = existsSync(root);
|
|
791
|
+
for (const rel of [".ops-host.json", "_host", "_runtime", "docs", "knowledge", "README.md", "DEPLOYMENTS.md"]) noLinks(join(root, rel));
|
|
792
|
+
const marker = join(root, ".ops-host.json");
|
|
793
|
+
let owner = null;
|
|
794
|
+
if (existsSync(marker)) {
|
|
795
|
+
if (!statSync(marker).isFile() || statSync(marker).size > 16384) throw new Failure("invalid root owner marker");
|
|
796
|
+
owner = rj(marker);
|
|
797
|
+
}
|
|
798
|
+
result.root_check = { exists: present, writable, nonempty: present && readdirSync(root).length > 0, owner };
|
|
799
|
+
}
|
|
800
|
+
return result;
|
|
801
|
+
}
|
|
802
|
+
|
|
778
803
|
export function main(req) {
|
|
779
804
|
const identity = fingerprint();
|
|
780
805
|
if (req.identity && identity !== req.identity) throw new Failure("target identity drift");
|
|
781
806
|
const action = req.action;
|
|
807
|
+
if (action === "onboarding") return onboardingObservation(req.root);
|
|
782
808
|
if (action === "probe") return inventory(req);
|
|
783
809
|
if (action === "mirror-probe") return benchmarkMirrors(req);
|
|
784
810
|
if (action === "snapshot") return { identity, paths: Object.fromEntries((req.paths || []).map((p) => [p, fileState(p)])), at: stamp() };
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/** Node-less Linux SSH bootstrap: pinned Volta/Node via POSIX + scp, then enroll. */
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
-
import { dirname, basename,
|
|
4
|
+
import { dirname, basename, join, posix as posixPath } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { digest, exact, identifier, newId, noSymlinks, NodeMissing, now, OpsError, privateDir, rootPath, targetJoin, writeJson } from "./core.mjs";
|
|
6
|
+
import { digest, exact, identifier, newId, noSymlinks, NodeMissing, now, OpsError, privateDir, readJson, rootPath, targetJoin, withLock, writeJson } from "./core.mjs";
|
|
7
7
|
import { register, validateHost } from "./model.mjs";
|
|
8
8
|
import { call, posixCall, posixSend, validateSshEndpoint } from "./transport.mjs";
|
|
9
|
+
import { confirmedBootstrapHost, validateRootConfirmation } from "./onboarding.mjs";
|
|
9
10
|
|
|
10
11
|
const ACK = "I-APPROVE-THIS-BOOTSTRAP";
|
|
11
12
|
const TOOLS = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
-
const SCRIPT = readFileSync(join(TOOLS, "bootstrap-volta.sh"), "utf8");
|
|
13
|
+
const SCRIPT = readFileSync(join(TOOLS, "bootstrap-volta.sh"), "utf8").replace(/\r\n?/g, "\n");
|
|
13
14
|
const PIN_PATH = join(TOOLS, "..", "toolchains", "volta-linux.json");
|
|
14
15
|
|
|
15
16
|
export function loadVoltaPin() {
|
|
@@ -47,7 +48,7 @@ function parseJsonLine(stdout, label) {
|
|
|
47
48
|
export function posixProbe(endpoint, nodePath) {
|
|
48
49
|
const args = ["--probe"];
|
|
49
50
|
if (nodePath) args.push(String(nodePath));
|
|
50
|
-
const r = posixCall(endpoint, SCRIPT, { args });
|
|
51
|
+
const r = posixCall(endpoint, SCRIPT, { args, sudo: false });
|
|
51
52
|
return parseJsonLine(r.stdout, "posix probe");
|
|
52
53
|
}
|
|
53
54
|
|
|
@@ -124,7 +125,7 @@ export function bootstrapNode(args) {
|
|
|
124
125
|
if (args.ack !== ACK) throw new OpsError("explicit acknowledgement I-APPROVE-THIS-BOOTSTRAP is required");
|
|
125
126
|
if (!args.account) throw new OpsError("--account is required");
|
|
126
127
|
if (!args.host_root) throw new OpsError("--host-root is required");
|
|
127
|
-
|
|
128
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,79}$/.test(args.account)) throw new OpsError("unsafe toolchain account");
|
|
128
129
|
const hostRoot = rootPath(args.host_root, "linux");
|
|
129
130
|
const nodeVersion = rejectUnpinned("node version", args.node_version || pin.node_version);
|
|
130
131
|
if (nodeVersion !== pin.node_version) throw new OpsError("node version must match the reviewed pin " + pin.node_version);
|
|
@@ -139,29 +140,53 @@ export function bootstrapNode(args) {
|
|
|
139
140
|
allowNetwork: Boolean(args.allow_network), cacheDir, label: "Node",
|
|
140
141
|
});
|
|
141
142
|
|
|
143
|
+
// Only read operations precede confirmation. The remote write gate is shared with H/D.
|
|
144
|
+
const confirmed = confirmedBootstrapHost(args.state, host, args.host_id, hostRoot);
|
|
145
|
+
if (args.account !== endpoint.username) throw new OpsError("bootstrap account must be the observed SSH login account");
|
|
146
|
+
if (/[\s\\\0]/.test(hostRoot)) throw new OpsError("Node bootstrap scp requires a root without whitespace or backslashes; use an existing Node or a supported dedicated path");
|
|
147
|
+
|
|
142
148
|
const installers = installerDir(hostRoot);
|
|
143
149
|
const voltaHome = managedVoltaHome(hostRoot, args.account);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
return withLock(join(args.state, ".locks", "catalog"), { operation: "bootstrap-node", host_id: args.host_id }, () => {
|
|
151
|
+
validateRootConfirmation(args.state, confirmed, { fresh: true, freshLegacy: true, checkEndpoint: true });
|
|
152
|
+
const rootConfirmation = confirmed.root_confirmation ?? null;
|
|
153
|
+
const attempt = join(args.state, "hosts", args.host_id, "bootstrap", "attempt-" + digest({ identity: confirmed.identity, root: hostRoot }).slice(0, 24) + ".json");
|
|
154
|
+
const inputDigest = digest({ identity: confirmed.identity, root: hostRoot, account: args.account, node_version: nodeVersion, volta_sha256: volta.sha256, node_sha256: node.sha256 });
|
|
155
|
+
noSymlinks(attempt);
|
|
156
|
+
if (existsSync(attempt)) {
|
|
157
|
+
const previous = readJson(attempt);
|
|
158
|
+
if (args.recover_ack !== "I-VERIFIED-BOOTSTRAP-IS-STOPPED" || !args.recovery_statement?.trim()) throw new OpsError("bootstrap already started; inspect " + attempt + " and remote state, then explicitly supply --recover-ack I-VERIFIED-BOOTSTRAP-IS-STOPPED --recovery-statement with the actual recovery authorization; never replay blindly");
|
|
159
|
+
if (previous.input_digest !== inputDigest) throw new OpsError("bootstrap recovery inputs differ from the original attempt; preserve evidence and prepare a separate repair plan");
|
|
160
|
+
writeJson(join(dirname(attempt), newId("recovery") + ".json"), { previous_attempt: previous, ack: args.recover_ack, statement: args.recovery_statement, at: now() }, { exclusive: true });
|
|
161
|
+
}
|
|
162
|
+
writeJson(attempt, { status: "started", host_id: args.host_id, root: hostRoot, root_confirmation: rootConfirmation, input_digest: inputDigest, at: now() });
|
|
163
|
+
const owner = JSON.stringify({ host_id: args.host_id, identity: confirmed.identity });
|
|
164
|
+
// Match the existing agent-owned marker. Run as the login account so scp can write.
|
|
165
|
+
// Recheck links immediately before mkdir; never chown/chmod an existing parent.
|
|
166
|
+
posixCall(endpoint, "#!/bin/sh\nset -eu\numask 077\n[ ! -e \"$3/_host/execution.lock\" ] && [ ! -L \"$3/_host/execution.lock\" ] || { echo 'target execution lock exists; inspect before bootstrap' >&2; exit 2; }\nfor dest in \"$1\" \"$2\"; do p=$dest; while [ \"$p\" != / ]; do [ ! -L \"$p\" ] || exit 2; p=$(dirname -- \"$p\"); done; done\nmkdir -p -- \"$1\" \"$2\"\nif [ ! -e \"$3/.ops-host.json\" ]; then (set -C; printf '%s\\n' \"$4\" > \"$3/.ops-host.json\"); fi\n", { sudo: false, args: [installers, voltaHome, hostRoot, owner] });
|
|
167
|
+
const remoteVolta = posixPath.join(installers, volta.filename);
|
|
168
|
+
const remoteNode = posixPath.join(installers, node.filename);
|
|
169
|
+
posixSend(endpoint, volta.file, remoteVolta);
|
|
170
|
+
posixSend(endpoint, node.file, remoteNode);
|
|
171
|
+
const applied = posixCall(endpoint, SCRIPT, {
|
|
172
|
+
args: ["--apply", remoteVolta, volta.sha256, remoteNode, node.sha256, voltaHome, nodeVersion, ACK],
|
|
173
|
+
timeout: 600,
|
|
174
|
+
sudo: false,
|
|
175
|
+
});
|
|
176
|
+
const result = parseJsonLine(applied.stdout, "posix apply");
|
|
177
|
+
result.status = result.status || "installed";
|
|
178
|
+
result.posix_probe = posix;
|
|
179
|
+
result.arch = arch;
|
|
180
|
+
|
|
181
|
+
if (args.state && args.host_id) {
|
|
182
|
+
identifier(args.host_id);
|
|
183
|
+
const receipt = join(args.state, "hosts", args.host_id, "bootstrap", newId("receipt") + ".json");
|
|
184
|
+
writeJson(receipt, { ...result, host_id: args.host_id, at: now() });
|
|
185
|
+
writeJson(attempt, { status: "installed", host_id: args.host_id, root: hostRoot, root_confirmation: rootConfirmation, input_digest: inputDigest, receipt, at: now() });
|
|
186
|
+
result.receipt = receipt;
|
|
187
|
+
}
|
|
188
|
+
return result;
|
|
152
189
|
});
|
|
153
|
-
const result = parseJsonLine(applied.stdout, "posix apply");
|
|
154
|
-
result.status = result.status || "installed";
|
|
155
|
-
result.posix_probe = posix;
|
|
156
|
-
result.arch = arch;
|
|
157
|
-
|
|
158
|
-
if (args.state && args.host_id) {
|
|
159
|
-
identifier(args.host_id);
|
|
160
|
-
const receipt = join(args.state, "hosts", args.host_id, "bootstrap", newId("receipt") + ".json");
|
|
161
|
-
writeJson(receipt, { ...result, host_id: args.host_id, at: now() });
|
|
162
|
-
result.receipt = receipt;
|
|
163
|
-
}
|
|
164
|
-
return result;
|
|
165
190
|
}
|
|
166
191
|
|
|
167
192
|
export function enroll(state, request) {
|
|
@@ -181,13 +206,14 @@ export function enroll(state, request) {
|
|
|
181
206
|
if (posix.tools?.node === "missing" || posix.node_usable === false) throw new NodeMissing(posix);
|
|
182
207
|
const discovery = host.identity === "discover" || host.identity == null;
|
|
183
208
|
if (discovery) host.identity = "0".repeat(64);
|
|
184
|
-
if (!isAbsolute(host.connection.node)) {
|
|
209
|
+
if (!posixPath.isAbsolute(host.connection.node) || host.connection.node.includes("\\")) {
|
|
185
210
|
throw new OpsError("connection.node must be the absolute path returned by bootstrap-node");
|
|
186
211
|
}
|
|
187
212
|
validateHost(host);
|
|
188
213
|
const observed = call(host, { action: "probe", ...(discovery ? { identity: null } : {}) }, { timeout: 180 });
|
|
189
214
|
host.identity = observed.identity;
|
|
190
215
|
validateHost(host);
|
|
216
|
+
validateRootConfirmation(state, host, { checkEndpoint: true });
|
|
191
217
|
register(state, { hosts: [host], projects: request.projects });
|
|
192
218
|
const inventory = call(host, { action: "probe" }, { timeout: 180 });
|
|
193
219
|
const snap = join(state, "hosts", host.host_id, "inventory", newId("snapshot") + ".json");
|
|
@@ -223,6 +223,7 @@ function parseArgs(argv) {
|
|
|
223
223
|
grab("--connection-file"); grab("--host-id"); grab("--account"); grab("--host-root");
|
|
224
224
|
grab("--volta-archive"); grab("--volta-sha256"); grab("--node-archive"); grab("--node-sha256");
|
|
225
225
|
grab("--node-version"); grab("--ack");
|
|
226
|
+
grab("--recover-ack"); grab("--recovery-statement");
|
|
226
227
|
out.probe = flag("--probe");
|
|
227
228
|
out.apply = flag("--apply");
|
|
228
229
|
out.allow_network = flag("--allow-network");
|
|
@@ -8,7 +8,7 @@ import { hostname } from "node:os";
|
|
|
8
8
|
import { dirname, join, posix, win32 } from "node:path";
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
10
|
|
|
11
|
-
export const VERSION = "2.
|
|
11
|
+
export const VERSION = "2.3.0";
|
|
12
12
|
const ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
13
13
|
export const SECRET_RE = /\{\{credential:([a-z0-9-]+)@([1-9][0-9]*):([A-Za-z_][A-Za-z0-9_]*)\}\}/g;
|
|
14
14
|
|
|
@@ -285,6 +285,7 @@ export function hostReadme(status, hid, runId, at, { ledger = null, full = false
|
|
|
285
285
|
const out = [
|
|
286
286
|
`# 主机 ${host.display_name} / ${hid}\n`,
|
|
287
287
|
`主机持久化根:${code(host.root)};更新:${at};运行:${runId}。\n`,
|
|
288
|
+
`连接账号:${code(host.connection.username ?? "显式本机账号")};根目录确认:${host.root_confirmation ? code(host.root_confirmation.receipt_id) : "既有登记(保留原根,不补造历史确认)"}。更换连接账号不改变该根。\n`,
|
|
288
289
|
"APP 与公共服务同级。主机级入口(WireGuard/Nginx/探测)登记为 host_services,不是假的 APP 目录。通用规范见 docs/standards/DEPLOYMENT-STANDARD.md。\n",
|
|
289
290
|
"## 服务一览\n",
|
|
290
291
|
renderOverviewTable(rows, { includeCredentials }),
|
|
@@ -11,6 +11,7 @@ import { load, save, ledgerLoad, validate, validateStatus } from "./model.mjs";
|
|
|
11
11
|
import { locatePlan, envFile, sliceDigest } from "./planner.mjs";
|
|
12
12
|
import { call as transportCall, hostTransportDigest } from "./transport.mjs";
|
|
13
13
|
import { deliveryBundle } from "./docs.mjs";
|
|
14
|
+
import { validateRootConfirmation } from "./onboarding.mjs";
|
|
14
15
|
|
|
15
16
|
const COMMON = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
16
17
|
|
|
@@ -18,6 +19,7 @@ export function defaultEngineDigest() {
|
|
|
18
19
|
const opslib = join(COMMON, "tools", "opslib");
|
|
19
20
|
const schemas = join(COMMON, "schemas");
|
|
20
21
|
const files = [
|
|
22
|
+
join(COMMON, "tools", "server-discover.sh"),
|
|
21
23
|
...readdirSync(opslib).filter((n) => n.endsWith(".mjs")).map((n) => join(opslib, n)),
|
|
22
24
|
...readdirSync(schemas).filter((n) => n.endsWith(".json")).map((n) => join(schemas, n)),
|
|
23
25
|
].sort();
|
|
@@ -245,6 +247,7 @@ function deliver(state, folder, plan, status, execution, ledger) {
|
|
|
245
247
|
}
|
|
246
248
|
const receipt = { schema_version: 1, run_id: plan.run_id, plan_digest: digest(plan), completed_at: now(), documents: bundle.acks, status: "both-sides-verified" };
|
|
247
249
|
writeJson(join(folder, "docs-receipt.json"), receipt);
|
|
250
|
+
for (const hid of Object.keys(plan.hosts)) writeJson(join(state, "hosts", hid, "docs-receipt.json"), receipt);
|
|
248
251
|
for (const did of eligible) {
|
|
249
252
|
const dep = status.deployments[did];
|
|
250
253
|
writeJson(join(state, "hosts", dep.host_id, "deployments", did, "docs-receipt.json"), receipt);
|
|
@@ -279,6 +282,7 @@ export function apply(state, run, { resume = false, docsOnly = false } = {}) {
|
|
|
279
282
|
verifyJournal(join(folder, "journal.jsonl"));
|
|
280
283
|
for (const [hid, h] of Object.entries(plan.hosts)) {
|
|
281
284
|
if (hostTransportDigest(h) !== plan.transport_digests[hid]) throw new OpsError("SSH connection/known_hosts changed since approval");
|
|
285
|
+
validateRootConfirmation(state, h, { fresh: true });
|
|
282
286
|
}
|
|
283
287
|
for (const [ref, wanted] of Object.entries(plan.credential_versions)) {
|
|
284
288
|
const [cid, v] = ref.split("@");
|
|
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { atomicWrite, digest, identifier, noSymlinks, OpsError, redact, withLock, writeJson } from "./core.mjs";
|
|
7
7
|
import { catalogUnlocked, ids, readBoundedJson, readRegistry, statePath, targetDigest } from "./workspace.mjs";
|
|
8
|
+
import { rootSummary } from "./onboarding.mjs";
|
|
8
9
|
import { latestCheck, readiness } from "./server_checks.mjs";
|
|
9
10
|
|
|
10
11
|
const TEMPLATE = join(dirname(fileURLToPath(import.meta.url)), "../../templates/FLEET.html");
|
|
@@ -44,7 +45,7 @@ function taskSummaries(state) {
|
|
|
44
45
|
});
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
export function projectFleet(registry, checks = {}, tasks = [], { at = new Date(), staleHours = 24 } = {}) {
|
|
48
|
+
export function projectFleet(registry, checks = {}, tasks = [], { at = new Date(), staleHours = 24, onboarding = {} } = {}) {
|
|
48
49
|
if (!Number.isFinite(at.getTime()) || !Number.isFinite(staleHours) || staleHours <= 0 || staleHours > 8760) throw new OpsError("invalid inventory time/staleness policy");
|
|
49
50
|
const deployments = sorted(registry.deployments).map((d) => ({
|
|
50
51
|
deployment_id: d.deployment_id, project_id: d.project_id, server_id: d.host_id,
|
|
@@ -68,7 +69,11 @@ export function projectFleet(registry, checks = {}, tasks = [], { at = new Date(
|
|
|
68
69
|
const own = byServer.get(h.host_id) || [];
|
|
69
70
|
return { server_id: h.host_id, name: text(h.display_name), platform: text(h.platform), transport: h.transport,
|
|
70
71
|
address: h.transport === "ssh" ? text(h.connection.hostname) : "local (explicit)", port: h.transport === "ssh" ? h.connection.port ?? 22 : null,
|
|
71
|
-
root: text(h.root),
|
|
72
|
+
root: text(h.root), login_account: text(h.connection.username ?? "local"),
|
|
73
|
+
root_confirmation: onboarding[h.host_id]?.root ?? { status: h.root_confirmation ? "unverified" : "legacy-registered", login_home: null, confirmed_at: null },
|
|
74
|
+
initialization: { registered: true, directory: checkStatus !== "stale" && checkStatus !== "unknown" ? c?.checks?.find(v => v.id === "deployment-root")?.status ?? "unknown" : "unknown",
|
|
75
|
+
documents: onboarding[h.host_id]?.documents ?? "unknown", runtime: checkStatus === "ready" && c?.profile !== "base" ? c.profile : "unverified" },
|
|
76
|
+
readiness: checkStatus, profile: c?.profile ?? null, checked_at: time(c?.checked_at),
|
|
72
77
|
checks: (c?.checks || []).map((v) => ({ id: v.id, status: v.status, required: v.required })),
|
|
73
78
|
project_ids: [...new Set(own.filter((d) => d.status !== "retired").map((d) => d.project_id))].sort(),
|
|
74
79
|
deployment_ids: own.map((d) => d.deployment_id),
|
|
@@ -96,7 +101,7 @@ export function projectFleet(registry, checks = {}, tasks = [], { at = new Date(
|
|
|
96
101
|
export function renderMarkdown(data, html = "index.html", json = "inventory.json") {
|
|
97
102
|
const table = (headers, rows) => `| ${headers.join(" | ")} |\n| ${headers.map(() => "---").join(" | ")} |\n` + (rows.length ? rows.map((r) => `| ${r.map(md).join(" | ")} |`).join("\n") : `| ${headers.map((_, i) => i ? "—" : "暂无记录").join(" | ")} |`) + "\n";
|
|
98
103
|
return `# OPS 服务器与部署清单\n\n[打开离线看板](${html}) · [结构化快照](${json})\n\n生成:${data.generated_at};资源修订:${data.source_revision};控制端:${data.controller_id}。\n\n这是离线记录,不是实时监控。ready 仅代表指定检查 profile 的必需项通过;completed 是历史执行结果。未测、过期、身份变化不视为正常。\n\n## 服务器 → 项目\n\n` +
|
|
99
|
-
table(["服务器", "名称 / 地址", "系统", "检查 / profile", "检查时间", "项目", "主机级服务"], data.servers.map((s) => [s.server_id, `${s.name} / ${s.address}`, s.platform, `${s.readiness} / ${s.profile ?? "未检查"}`, s.checked_at, s.project_ids.join(", "), s.host_services.map((v) => v.service_id).join(", ")])) +
|
|
104
|
+
table(["服务器", "名称 / 地址", "账号 / 持久化根", "根确认 / 文档", "系统", "检查 / profile", "检查时间", "项目", "主机级服务"], data.servers.map((s) => [s.server_id, `${s.name} / ${s.address}`, `${s.login_account} / ${s.root}`, `${s.root_confirmation.status} / ${s.initialization.documents}`, s.platform, `${s.readiness} / ${s.profile ?? "未检查"}`, s.checked_at, s.project_ids.join(", "), s.host_services.map((v) => v.service_id).join(", ")])) +
|
|
100
105
|
`\n## 项目 → 服务器\n\n` + table(["项目", "名称", "类别", "服务器", "部署实例"], data.projects.map((p) => [p.project_id, p.name, p.kind, p.server_ids.join(", "), p.deployment_ids.join(", ")])) +
|
|
101
106
|
`\n## 部署实例\n\n` + table(["部署", "项目", "服务器", "环境", "记录状态", "计划版本", "验证版本", "根目录"], data.deployments.map((d) => [d.deployment_id, d.project_id, d.server_id, d.environment, d.status, d.desired_version, d.observed_version, d.root])) +
|
|
102
107
|
`\n## 任务记录\n\n` + table(["任务", "范围", "状态", "服务器", "Run"], data.tasks.map((t) => [t.task_id, t.scope, t.status, t.server_ids.join(", "), t.run_ids.join(", ")])) +
|
|
@@ -123,7 +128,24 @@ export function generateFleet(state, { at = new Date(), staleHours = 24 } = {})
|
|
|
123
128
|
catalogUnlocked(state);
|
|
124
129
|
const registry = readRegistry(state);
|
|
125
130
|
const checks = Object.fromEntries(sorted(registry.hosts).map((h) => [h.host_id, latestCheck(state, h)]));
|
|
126
|
-
const
|
|
131
|
+
const rootEvidence = Object.fromEntries(sorted(registry.hosts).map(h => {
|
|
132
|
+
const root = rootSummary(state, h);
|
|
133
|
+
const receiptPath = join(state, "hosts", h.host_id, "docs-receipt.json");
|
|
134
|
+
noSymlinks(receiptPath);
|
|
135
|
+
let documents = "unknown";
|
|
136
|
+
if (existsSync(receiptPath)) {
|
|
137
|
+
const receipt = readBoundedJson(receiptPath), release = registry.releases[receipt.run_id];
|
|
138
|
+
const localKey = "hosts/" + h.host_id + "/README.md";
|
|
139
|
+
const local = join(state, localKey); noSymlinks(local);
|
|
140
|
+
const remoteKey = "remote:" + h.host_id + ":" + (h.platform === "windows" ? h.root + "\\README.md" : h.root + "/README.md");
|
|
141
|
+
const unresolved = Object.values(registry.releases).some(r => r.host_ids.includes(h.host_id) && r.status !== "completed" && Date.parse(r.updated_at) >= Date.parse(release?.updated_at));
|
|
142
|
+
if (!unresolved && receipt.status === "both-sides-verified" && release?.status === "completed" && release.host_ids.includes(h.host_id)
|
|
143
|
+
&& receipt.documents?.[remoteKey] && existsSync(local)
|
|
144
|
+
&& receipt.documents?.["local:" + localKey]?.sha256 === digest(readFileSync(local))) documents = "both-sides-verified";
|
|
145
|
+
}
|
|
146
|
+
return [h.host_id, { root, documents }];
|
|
147
|
+
}));
|
|
148
|
+
const data = projectFleet(registry, checks, taskSummaries(state), { at, staleHours, onboarding: rootEvidence });
|
|
127
149
|
const html = renderHtml(data), markdown = renderMarkdown(data);
|
|
128
150
|
// Snapshot identity includes renderer bytes, so a template update cannot collide with an older view.
|
|
129
151
|
const viewId = "view-" + digest({ data, html, markdown }).slice(0, 32);
|
|
@@ -2,9 +2,43 @@
|
|
|
2
2
|
import { identifier, exact, newId, now, OpsError, targetJoin, within } from "./core.mjs";
|
|
3
3
|
import { load } from "./model.mjs";
|
|
4
4
|
import { call as transportCall } from "./transport.mjs";
|
|
5
|
+
import { validateRootConfirmation, discoverServer } from "./onboarding.mjs";
|
|
5
6
|
|
|
6
7
|
export const hostRecipesHooks = { call: transportCall };
|
|
7
8
|
|
|
9
|
+
export function initializeHostSpec(state, hid) {
|
|
10
|
+
const status = load(state), host = status.hosts[hid];
|
|
11
|
+
if (!host) throw new OpsError("unknown server; complete S first");
|
|
12
|
+
validateRootConfirmation(state, host, { fresh: true });
|
|
13
|
+
const dirs = ["_host/cache", "_host/installers", "_host/toolchains", "_runtime", "docs/standards", "knowledge"];
|
|
14
|
+
const paths = dirs.map((p) => targetJoin(host, p));
|
|
15
|
+
const inventory = hostRecipesHooks.call(host, { action: "probe", paths, disk_roots: [host.root] }, { timeout: 180 });
|
|
16
|
+
if (inventory.identity !== host.identity) throw new OpsError("host initialization identity drift");
|
|
17
|
+
const actions = dirs.flatMap((path, i) => {
|
|
18
|
+
const observed = inventory.snapshots?.[paths[i]];
|
|
19
|
+
if (observed?.kind === "directory") return [];
|
|
20
|
+
if (observed?.kind !== "absent") throw new OpsError("initialization path conflict: " + paths[i]);
|
|
21
|
+
return [{ host_id: hid, kind: "mkdir", path, mode: 0o750, reason: "Prepare only the selected Host root; keep all existing data and account permissions" }];
|
|
22
|
+
});
|
|
23
|
+
return { schema_version: 1, worker: "H", operation: "prepare", hosts: [hid], host_actions: actions,
|
|
24
|
+
acknowledged_consumers: Object.values(status.deployments).filter((d) => d.host_id === hid && d.status !== "retired").map((d) => d.deployment_id).sort(),
|
|
25
|
+
reason: "Minimal Host initialization: managed directories and verified bilateral host documents; project runtimes are prepared only when needed",
|
|
26
|
+
rollback_note: "Preserve all existing directories and data. Inspect unknown runs; docs-sync repairs documents without reinstalling anything.", risk: "external-mutation" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function connectionSpec(state, hid, endpoint) {
|
|
30
|
+
const status = load(state), host = status.hosts[hid];
|
|
31
|
+
if (!host) throw new OpsError("unknown server; complete S first");
|
|
32
|
+
if (endpoint.platform !== host.platform || endpoint.transport !== host.transport) throw new OpsError("connection update cannot change platform or transport");
|
|
33
|
+
const discovery = discoverServer(state, hid, endpoint);
|
|
34
|
+
if (discovery.facts.identity !== host.identity) throw new OpsError("new connection is a different machine");
|
|
35
|
+
const updated = { ...host, connection: endpoint.connection };
|
|
36
|
+
validateRootConfirmation(state, updated, { fresh: true, freshLegacy: true });
|
|
37
|
+
return { schema_version: 1, worker: "H", operation: "maintain", hosts: [hid], resource_updates: { hosts: [updated] },
|
|
38
|
+
reason: "S verified connection update for the same machine; preserve Host identity, root and original root confirmation",
|
|
39
|
+
rollback_note: "Keep the old login channel available; use a new exact approved plan to restore it if needed. Never move the root to the new account home.", risk: "external-mutation" };
|
|
40
|
+
}
|
|
41
|
+
|
|
8
42
|
function shlexQuote(s) {
|
|
9
43
|
if (s === "") return "''";
|
|
10
44
|
if (/[^\w@%+=:,./-]/.test(s)) return "'" + s.replaceAll("'", "'\"'\"'") + "'";
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { existsSync, statSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { validateRootConfirmation, assertUniqueMachine } from "./onboarding.mjs";
|
|
5
6
|
import { readFileSync } from "node:fs";
|
|
6
7
|
import {
|
|
7
8
|
OpsError, canonical, digest, emptyStatus, exact, identifier, now, readJson, relative,
|
|
@@ -261,6 +262,11 @@ export function register(state, request) {
|
|
|
261
262
|
if (key in s[group] && digest(s[group][key]) !== digest(item)) {
|
|
262
263
|
throw new OpsError(`registered identity is immutable through register: ${group}/${key}; use a reviewed migration`);
|
|
263
264
|
}
|
|
265
|
+
if (group === "hosts" && !(key in s.hosts)) {
|
|
266
|
+
validateHost(item);
|
|
267
|
+
assertUniqueMachine(s, item);
|
|
268
|
+
validateRootConfirmation(state, item, { require: true, checkEndpoint: true });
|
|
269
|
+
}
|
|
264
270
|
s[group][key] = item;
|
|
265
271
|
}
|
|
266
272
|
}
|