@namewta/speculo 1.0.6 → 1.0.7

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.
Files changed (51) hide show
  1. package/dist/src/ops-resources.js +1 -1
  2. package/dist/src/ops-resources.js.map +1 -1
  3. package/package.json +1 -1
  4. package/template/workflows/ops/D-project-deploy/D-project-deploy.md +1 -1
  5. package/template/workflows/ops/H-host-manage/H-host-manage.md +1 -1
  6. package/template/workflows/ops/I-initialize/I-initialize.md +2 -2
  7. package/template/workflows/ops/README.md +2 -2
  8. package/template/workflows/ops/common/CAPABILITIES.md +2 -2
  9. package/template/workflows/ops/common/USAGE.md +24 -24
  10. package/template/workflows/ops/common/examples/README.md +1 -1
  11. package/template/workflows/ops/common/examples/register.example.json +1 -1
  12. package/template/workflows/ops/common/schemas/host.schema.json +1 -1
  13. package/template/workflows/ops/common/schemas/plan.schema.json +3 -3
  14. package/template/workflows/ops/common/schemas/spec.schema.json +1 -1
  15. package/template/workflows/ops/common/schemas/status.schema.json +1 -1
  16. package/template/workflows/ops/common/tests/test_ops.mjs +752 -0
  17. package/template/workflows/ops/common/tools/bootstrap.ps1 +2 -2
  18. package/template/workflows/ops/common/tools/bootstrap.sh +2 -2
  19. package/template/workflows/ops/common/tools/demo-local.mjs +101 -0
  20. package/template/workflows/ops/common/tools/ops.mjs +4 -0
  21. package/template/workflows/ops/common/tools/opslib/agent.mjs +873 -0
  22. package/template/workflows/ops/common/tools/opslib/cli.mjs +311 -0
  23. package/template/workflows/ops/common/tools/opslib/core.mjs +393 -0
  24. package/template/workflows/ops/common/tools/opslib/docs.mjs +252 -0
  25. package/template/workflows/ops/common/tools/opslib/execution.mjs +378 -0
  26. package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +109 -0
  27. package/template/workflows/ops/common/tools/opslib/model.mjs +272 -0
  28. package/template/workflows/ops/common/tools/opslib/{native_windows.py → native_windows.mjs} +16 -12
  29. package/template/workflows/ops/common/tools/opslib/planner.mjs +687 -0
  30. package/template/workflows/ops/common/tools/opslib/services.mjs +76 -0
  31. package/template/workflows/ops/common/tools/opslib/sources.mjs +56 -0
  32. package/template/workflows/ops/common/tools/opslib/transport.mjs +127 -0
  33. package/template/workflows/ops/common/tools/validate-ops.mjs +43 -30
  34. package/template/workflows/ops/common/tests/test_ops.py +0 -392
  35. package/template/workflows/ops/common/tools/demo-local.py +0 -64
  36. package/template/workflows/ops/common/tools/ops.py +0 -7
  37. package/template/workflows/ops/common/tools/opslib/__init__.py +0 -2
  38. package/template/workflows/ops/common/tools/opslib/__pycache__/__init__.cpython-312.pyc +0 -0
  39. package/template/workflows/ops/common/tools/opslib/__pycache__/core.cpython-312.pyc +0 -0
  40. package/template/workflows/ops/common/tools/opslib/__pycache__/model.cpython-312.pyc +0 -0
  41. package/template/workflows/ops/common/tools/opslib/agent.py +0 -510
  42. package/template/workflows/ops/common/tools/opslib/cli.py +0 -172
  43. package/template/workflows/ops/common/tools/opslib/core.py +0 -199
  44. package/template/workflows/ops/common/tools/opslib/docs.py +0 -199
  45. package/template/workflows/ops/common/tools/opslib/execution.py +0 -248
  46. package/template/workflows/ops/common/tools/opslib/host_recipes.py +0 -67
  47. package/template/workflows/ops/common/tools/opslib/model.py +0 -199
  48. package/template/workflows/ops/common/tools/opslib/planner.py +0 -497
  49. package/template/workflows/ops/common/tools/opslib/services.py +0 -47
  50. package/template/workflows/ops/common/tools/opslib/sources.py +0 -27
  51. package/template/workflows/ops/common/tools/opslib/transport.py +0 -55
@@ -0,0 +1,311 @@
1
+ /** CLI for explicit local recording and approved target mutations. */
2
+ import { existsSync, readdirSync, readFileSync, statSync, lstatSync, unlinkSync, rmdirSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, relative as pathRelative, resolve, sep } from "node:path";
4
+ import { hostname } from "node:os";
5
+ import {
6
+ atomicWrite, digest, emptyStatus, identifier, newId, now, noSymlinks, OpsError,
7
+ privateDir, readJson, VERSION, withLock, writeJson,
8
+ } from "./core.mjs";
9
+ import { load, ledgerLoad, putCredential, register, save, validate, validateHost, validateStatus } from "./model.mjs";
10
+ import { call, probeLocal as transportProbeLocal } from "./transport.mjs";
11
+ import { compilePlan, locatePlan } from "./planner.mjs";
12
+ import { approval, apply, verifyJournal, requestBase } from "./execution.mjs";
13
+ import { STANDARD } from "./docs.mjs";
14
+ import { environmentSpec } from "./host_recipes.mjs";
15
+ import { fetchSource } from "./sources.mjs";
16
+
17
+ export const cliHooks = { probeLocal: transportProbeLocal };
18
+ export function probeLocal() { return cliHooks.probeLocal(); }
19
+
20
+ function isRelativeTo(p, root) {
21
+ const rel = pathRelative(root, p);
22
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
23
+ }
24
+
25
+ export function initialize(state, controllerId) {
26
+ identifier(controllerId);
27
+ if (existsSync(state)) {
28
+ noSymlinks(state);
29
+ if (existsSync(join(state, "status.json"))) {
30
+ const existing = readJson(join(state, "status.json"));
31
+ if (existing.schema_version === 2 && (existing.active || []).length === 0 && (existing.archived || []).length === 0) {
32
+ const extras = readdirSync(state).filter((n) => !["status.json", "changes", "archive"].includes(n));
33
+ const leftover = [];
34
+ for (const name of ["changes", "archive"]) {
35
+ const dir = join(state, name);
36
+ if (!existsSync(dir)) continue;
37
+ const walk = (d) => {
38
+ for (const n of readdirSync(d)) {
39
+ const p = join(d, n);
40
+ const st = lstatSync(p);
41
+ if (st.isDirectory()) walk(p);
42
+ else if (st.isFile() && n !== ".gitkeep") leftover.push(p);
43
+ }
44
+ };
45
+ walk(dir);
46
+ }
47
+ if (extras.length || leftover.length) throw new OpsError("legacy runtime contains evidence; use import-legacy into a new state root");
48
+ } else {
49
+ validateStatus(existing);
50
+ if (existing.controller != null) {
51
+ if (existing.controller.controller_id !== controllerId) throw new OpsError("controller identity already fixed");
52
+ return { status: "already-initialized", controller_id: controllerId, state_root: state };
53
+ }
54
+ }
55
+ }
56
+ }
57
+ privateDir(state);
58
+ return withLock(join(state, ".locks", "catalog"), { operation: "initialize" }, () => {
59
+ const s = emptyStatus();
60
+ s.controller = { controller_id: controllerId, state_root: state, created_at: now() };
61
+ for (const rel of ["hosts", "projects", "releases", "private", "controller/inventory", "docs/standards", "knowledge"]) {
62
+ privateDir(join(state, ...rel.split("/")));
63
+ }
64
+ save(state, s);
65
+ writeJson(join(state, "private", "credentials.json"), { schema_version: 1, entries: {} });
66
+ const inv = probeLocal();
67
+ writeJson(join(state, "controller", "inventory", newId("snapshot") + ".json"), inv);
68
+ atomicWrite(join(state, ".gitignore"), "*\n!.gitignore\n");
69
+ atomicWrite(join(state, "docs", "standards", "DEPLOYMENT-STANDARD.md"), STANDARD);
70
+ atomicWrite(join(state, "README.md"), "# OPS 控制端\n\n资源事实:status.json;主机:hosts/;发布:releases/;明文账本:private/credentials.json。替换静态 workflow 时绝不能删除这里。首次部署后自动生成双边详细档案。\n");
71
+ return { status: "initialized", controller_id: controllerId, state_root: state, inventory_recorded: true };
72
+ });
73
+ }
74
+
75
+ export function analyze(source) {
76
+ noSymlinks(source, { allowMissing: false });
77
+ if (!statSync(source).isDirectory()) throw new OpsError("source must be a project directory");
78
+ const skip = new Set([".git", "node_modules", ".venv", "venv", "data", "backups", ".speculo", "dist", "build", "target"]);
79
+ const evidence = [];
80
+ const detected = [];
81
+ const names = { "pyproject.toml": "python", "requirements.txt": "python", "package.json": "node", "pom.xml": "java", "build.gradle": "java", Dockerfile: "docker", "compose.yaml": "compose", "docker-compose.yml": "compose", "Cargo.toml": "rust", "go.mod": "go" };
82
+ const walk = (root) => {
83
+ let entries;
84
+ try { entries = readdirSync(root, { withFileTypes: true }); } catch { return; }
85
+ for (const ent of entries) {
86
+ const p = join(root, ent.name);
87
+ if (ent.isSymbolicLink()) continue;
88
+ if (ent.isDirectory()) {
89
+ if (!skip.has(ent.name)) walk(p);
90
+ continue;
91
+ }
92
+ if (ent.name === ".env" || ent.name.endsWith(".env")) continue;
93
+ if (ent.name in names) {
94
+ if (statSync(p).size > 1024 * 1024) throw new OpsError("manifest exceeds scan bound");
95
+ evidence.push({ path: pathRelative(source, p).replaceAll("\\", "/"), sha256: digest(readFileSync(p)), kind: names[ent.name] });
96
+ detected.push(names[ent.name]);
97
+ }
98
+ if (evidence.length > 2000) throw new OpsError("source analysis bound exceeded");
99
+ }
100
+ };
101
+ walk(source);
102
+ return {
103
+ source,
104
+ detected: [...new Set(detected)].sort(),
105
+ evidence,
106
+ side_effects: "read-only; no repository code executed, no network download",
107
+ next: "Create a deployment spec from actual project manifests; fix revision and storage/health/dependency mappings.",
108
+ };
109
+ }
110
+
111
+ export function inspectRun(state, run) {
112
+ const p = locatePlan(state, run);
113
+ const plan = readJson(p);
114
+ const ledger = ledgerLoad(state);
115
+ const results = {};
116
+ for (const [hid, h] of Object.entries(plan.hosts)) {
117
+ try { results[hid] = call(h, { ...requestBase(plan, hid, ledger), action: "receipts" }); }
118
+ catch (e) { if (e instanceof OpsError) results[hid] = { unavailable: e.message }; else throw e; }
119
+ }
120
+ return { run_id: plan.run_id, targets: results, journal: verifyJournal(join(dirname(p), "journal.jsonl")) };
121
+ }
122
+
123
+ export function breakControllerLock(state, ack) {
124
+ if (ack !== "I-VERIFIED-NO-EXECUTION-IS-RUNNING") throw new OpsError("explicit recovery acknowledgement required");
125
+ const p = join(state, ".locks", "catalog");
126
+ noSymlinks(p);
127
+ const owner = readJson(join(p, "owner.json"));
128
+ if (owner.machine !== hostname()) throw new OpsError("lock owner belongs to a different controller machine; verify there first");
129
+ const pid = owner.pid;
130
+ try { process.kill(pid, 0); }
131
+ catch (e) {
132
+ if (e.code === "ESRCH") { /* gone */ }
133
+ else if (e.code === "EPERM") throw new OpsError("cannot prove owner process is stopped");
134
+ else throw e;
135
+ // ESRCH: process not found, continue
136
+ const evidence = join(state, "controller", "recovery", newId("lock") + ".json");
137
+ writeJson(evidence, { owner, ack, recovered_at: now() });
138
+ unlinkSync(join(p, "owner.json"));
139
+ rmdirSync(p);
140
+ return { status: "controller-lock-released", evidence, target_locks: "not modified; inspect remote receipts before resume" };
141
+ }
142
+ throw new OpsError("owner PID is still alive (or reused); refusing automatic lock removal");
143
+ }
144
+
145
+ export function importLegacy(state, source, controllerId) {
146
+ noSymlinks(source, { allowMissing: false });
147
+ if (existsSync(state) && readdirSync(state).length) throw new OpsError("legacy import destination must be empty and separate");
148
+ if (source === state || isRelativeTo(state, source) || isRelativeTo(source, state)) throw new OpsError("legacy source/destination must be disjoint");
149
+ const walkAll = (d, acc = []) => {
150
+ for (const n of readdirSync(d, { withFileTypes: true })) {
151
+ const p = join(d, n.name);
152
+ if (n.isSymbolicLink()) throw new OpsError("legacy evidence contains symlinks; preserve manually without following them");
153
+ if (n.isDirectory()) walkAll(p, acc);
154
+ else acc.push(p);
155
+ }
156
+ return acc;
157
+ };
158
+ walkAll(source);
159
+ const result = initialize(state, controllerId);
160
+ const dest = join(state, "legacy", newId("import"));
161
+ privateDir(dest);
162
+ const manifest = {};
163
+ const files = [];
164
+ const collect = (d) => {
165
+ for (const n of readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
166
+ const p = join(d, n.name);
167
+ if (n.isDirectory()) collect(p);
168
+ else if (n.isFile()) files.push(p);
169
+ }
170
+ };
171
+ collect(source);
172
+ files.sort();
173
+ for (const p of files) {
174
+ const rel = pathRelative(source, p);
175
+ const data = readFileSync(p);
176
+ atomicWrite(join(dest, rel), data);
177
+ manifest[rel.replaceAll("\\", "/")] = { sha256: digest(data), source_mode: statSync(p).mode & 0o777 };
178
+ }
179
+ writeJson(join(dest, "IMPORT-MANIFEST.json"), { source, at: now(), files: manifest, approvals_reused: false });
180
+ return { ...result, legacy_evidence: dest, note: "source preserved, no hosts/deployments/passwords/approvals inferred" };
181
+ }
182
+
183
+ function parseArgs(argv) {
184
+ argv = [...argv];
185
+ const out = { _: [] };
186
+ const take = (flag) => {
187
+ const i = argv.indexOf(flag);
188
+ if (i < 0) return undefined;
189
+ if (i + 1 >= argv.length) throw new Error(`${flag} needs a value`);
190
+ const v = argv[i + 1];
191
+ argv.splice(i, 2);
192
+ return v;
193
+ };
194
+ const flag = (name) => {
195
+ const i = argv.indexOf(name);
196
+ if (i < 0) return false;
197
+ argv.splice(i, 1);
198
+ return true;
199
+ };
200
+ if (flag("--version")) return { command: "version" };
201
+ out.state = take("--state");
202
+ const command = argv.shift();
203
+ if (!command) throw new Error("command required");
204
+ out.command = command;
205
+ const grab = (name) => { const v = take(name); if (v !== undefined) out[name.slice(2).replaceAll("-", "_")] = v; };
206
+ if (command === "init") grab("--controller-id");
207
+ else if (command === "probe") { grab("--host"); grab("--connection-file"); grab("--output"); }
208
+ else if (command === "register") grab("--file");
209
+ else if (command === "mirror-probe") { grab("--host"); grab("--file"); out.allow_network = flag("--allow-network"); }
210
+ else if (command === "credential-put") grab("--file");
211
+ else if (command === "analyze") grab("--source");
212
+ else if (command === "environment-spec") { grab("--host"); grab("--file"); grab("--output"); }
213
+ else if (command === "source-fetch") { grab("--project"); grab("--repository"); grab("--commit"); out.allow_network = flag("--allow-network"); }
214
+ else if (command === "plan") grab("--file");
215
+ else if (command === "approve") { grab("--run"); grab("--digest"); grab("--by"); grab("--statement"); }
216
+ else if (["apply", "resume", "docs-sync", "inspect-run"].includes(command)) grab("--run");
217
+ else if (command === "validate") { grab("--file"); grab("--schema"); }
218
+ else if (command === "status") { /* none */ }
219
+ else if (command === "recover-controller-lock") grab("--ack");
220
+ else if (command === "import-legacy") { grab("--source"); grab("--controller-id"); }
221
+ else throw new Error("unknown command: " + command);
222
+ if (argv.length) throw new Error("unrecognized arguments: " + argv.join(" "));
223
+ return out;
224
+ }
225
+
226
+ export function main(argv = process.argv.slice(2)) {
227
+ let args;
228
+ try { args = parseArgs(argv); }
229
+ catch (e) {
230
+ process.stderr.write(String(e.message || e) + "\n");
231
+ return 2;
232
+ }
233
+ if (args.command === "version") {
234
+ process.stdout.write(VERSION + "\n");
235
+ return 0;
236
+ }
237
+ if (!args.state && !["analyze", "probe", "validate"].includes(args.command)) {
238
+ process.stderr.write("--state is required; it is never guessed from cwd\n");
239
+ return 2;
240
+ }
241
+ const state = args.state ? resolve(args.state) : null;
242
+ try {
243
+ let result;
244
+ const cmd = args.command;
245
+ if (cmd === "init") {
246
+ if (!args.controller_id) throw new OpsError("--controller-id is required");
247
+ result = initialize(state, args.controller_id);
248
+ } else if (cmd === "probe") {
249
+ if (args.host) {
250
+ if (state == null) throw new OpsError("--host needs --state");
251
+ const s = load(state);
252
+ result = call(s.hosts[args.host], { action: "probe" }, { timeout: 180 });
253
+ writeJson(join(state, "hosts", args.host, "inventory", newId("snapshot") + ".json"), result);
254
+ } else if (args.connection_file) {
255
+ const h = readJson(args.connection_file);
256
+ const discovery = h.identity === "discover";
257
+ if (discovery) h.identity = "0".repeat(64);
258
+ validateHost(h);
259
+ result = call(h, { action: "probe", ...(discovery ? { identity: null } : {}) }, { timeout: 180 });
260
+ if (discovery) result.registration_note = "Read-only discovery over your pinned known_hosts. Review identity and store it explicitly; discover is never valid for register/apply.";
261
+ } else result = probeLocal();
262
+ if (args.output) writeJson(resolve(args.output), result);
263
+ } else if (cmd === "register") result = register(state, readJson(args.file));
264
+ else if (cmd === "mirror-probe") {
265
+ if (!args.allow_network) throw new OpsError("mirror benchmarking requires explicit --allow-network");
266
+ const s = load(state);
267
+ const data = readJson(args.file);
268
+ result = call(s.hosts[args.host], { action: "mirror-probe", candidates: data.candidates, rounds: data.rounds ?? 3 }, { timeout: 180 });
269
+ const path = join(state, "hosts", args.host, "mirrors", newId("probe") + ".json");
270
+ writeJson(path, result);
271
+ result.local_report = path;
272
+ } else if (cmd === "credential-put") result = putCredential(state, readJson(args.file));
273
+ else if (cmd === "analyze") result = analyze(resolve(args.source));
274
+ else if (cmd === "environment-spec") {
275
+ const spec = environmentSpec(state, args.host, readJson(args.file));
276
+ writeJson(resolve(args.output), spec);
277
+ result = { spec_path: resolve(args.output), status: "generated-not-executed", next: "plan --file this-spec" };
278
+ } else if (cmd === "source-fetch") {
279
+ result = fetchSource(state, args.project, args.repository, args.commit, args.allow_network);
280
+ } else if (cmd === "plan") result = compilePlan(state, resolve(args.file));
281
+ else if (cmd === "approve") result = approval(state, args.run, args.digest, args.by, args.statement);
282
+ else if (cmd === "apply" || cmd === "resume" || cmd === "docs-sync") {
283
+ result = apply(state, args.run, { resume: cmd === "resume", docsOnly: cmd === "docs-sync" });
284
+ } else if (cmd === "inspect-run") result = inspectRun(state, args.run);
285
+ else if (cmd === "validate") {
286
+ if (!args.file && state == null) throw new OpsError("validate requires --state or --file --schema");
287
+ if (args.file) {
288
+ if (!args.schema) throw new OpsError("--file requires --schema");
289
+ const value = readJson(args.file);
290
+ validate(value, args.schema);
291
+ if (args.schema === "status") validateStatus(value);
292
+ result = { valid: true, schema: args.schema };
293
+ } else result = { valid: true, revision: load(state).revision };
294
+ } else if (cmd === "status") {
295
+ const s = load(state);
296
+ result = {
297
+ controller: s.controller, revision: s.revision, hosts: Object.keys(s.hosts),
298
+ deployments: Object.values(s.deployments).map((d) => ({
299
+ deployment_id: d.deployment_id, host_id: d.host_id, project_id: d.project_id,
300
+ status: d.status, version: d.version, observed_version: d.observed_version,
301
+ })),
302
+ };
303
+ } else if (cmd === "recover-controller-lock") result = breakControllerLock(state, args.ack);
304
+ else if (cmd === "import-legacy") result = importLegacy(state, resolve(args.source), args.controller_id);
305
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
306
+ return ["failed", "partial", "unknown", "docs_pending"].includes(result.status) ? 2 : 0;
307
+ } catch (e) {
308
+ process.stderr.write(JSON.stringify({ status: "blocked", error: e.message || String(e) }) + "\n");
309
+ return 2;
310
+ }
311
+ }