asylumwarden 0.0.0 → 0.1.0

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.
@@ -0,0 +1,342 @@
1
+ // CLI — language-neutral JSON entry. Envelope + exit codes per CONTRACT §8.
2
+
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+
7
+ import { WardenError, fail, violation } from "../kernel/errors.mjs";
8
+ import { ensureDir } from "../kernel/fsutil.mjs";
9
+ import { makeContext } from "../context.mjs";
10
+ import { planWave } from "../manifest/plan.mjs";
11
+ import { claim, renew, release, requeue } from "../lease/lease.mjs";
12
+ import { doneWithReceipt, failCell } from "../receipt/receipt.mjs";
13
+ import { readyCells, statusReport, verifyAll } from "../views/views.mjs";
14
+ import { initAnchor, maybeAnchor } from "../ledger/anchor.mjs";
15
+
16
+ const VALUE_FLAGS = new Set([
17
+ "config", "state-home", "actor", "ttl", "epoch", "receipt", "reason", "self", "root", "wave", "registry",
18
+ ]);
19
+ const BOOLEAN_FLAGS = new Set(["force", "steal", "json", "help", "version", "bootstrap-registry"]);
20
+
21
+ function parseArgs(argv) {
22
+ const positional = [];
23
+ const flags = {};
24
+ for (let i = 0; i < argv.length; i += 1) {
25
+ const token = argv[i];
26
+ if (token === "--") {
27
+ positional.push(...argv.slice(i + 1));
28
+ break;
29
+ }
30
+ if (token.startsWith("--")) {
31
+ const eq = token.indexOf("=");
32
+ const name = eq === -1 ? token.slice(2) : token.slice(2, eq);
33
+ if (VALUE_FLAGS.has(name)) {
34
+ let value;
35
+ if (eq !== -1) value = token.slice(eq + 1);
36
+ else {
37
+ value = argv[i + 1];
38
+ i += 1;
39
+ }
40
+ if (value === undefined || (value.startsWith("--") && value.length > 2)) {
41
+ fail("USAGE", `--${name} requires a value`, [violation(`--${name}`, "flag-value", "")]);
42
+ }
43
+ flags[name] = value;
44
+ continue;
45
+ }
46
+ if (BOOLEAN_FLAGS.has(name)) {
47
+ flags[name] = true;
48
+ continue;
49
+ }
50
+ fail("USAGE", `unknown flag: --${name}`, [violation(`--${name}`, "unknown-flag", "")]);
51
+ }
52
+ positional.push(token);
53
+ }
54
+ return { positional, flags };
55
+ }
56
+
57
+ function requireFlag(flags, name) {
58
+ if (flags[name] === undefined) {
59
+ fail("USAGE", `--${name} is required`, [violation(`--${name}`, "required-flag", "")]);
60
+ }
61
+ return flags[name];
62
+ }
63
+
64
+ function envelope(code, message, extra = {}) {
65
+ return {
66
+ ok: code === "OK",
67
+ code,
68
+ message,
69
+ wave: null,
70
+ cell: null,
71
+ actor: null,
72
+ epoch: null,
73
+ state: null,
74
+ expires_at: null,
75
+ receipt: null,
76
+ violations: [],
77
+ warnings: [],
78
+ degraded: null,
79
+ ...extra,
80
+ };
81
+ }
82
+
83
+ function twoArgs(positional, command) {
84
+ if (positional.length !== 2) {
85
+ fail("USAGE", `${command} requires <wave> <cell>`, [violation(command, "arity", "")]);
86
+ }
87
+ return { wave: positional[0], cell: positional[1] };
88
+ }
89
+
90
+ function ttlOf(flags) {
91
+ return flags.ttl === undefined ? undefined : Number(flags.ttl);
92
+ }
93
+ function epochOf(flags) {
94
+ return Number(requireFlag(flags, "epoch"));
95
+ }
96
+
97
+ async function maybeDegraded(ctx) {
98
+ try {
99
+ return (await maybeAnchor(ctx)).degraded ?? null;
100
+ } catch {
101
+ return "ANCHOR-DEGRADED";
102
+ }
103
+ }
104
+
105
+ async function commandInit(flags) {
106
+ if (flags["bootstrap-registry"]) {
107
+ // Runs BEFORE any context load: creates an absent registry file and
108
+ // prints its sha256 for the operator to reference in asylumwarden.json.
109
+ const { bootstrapRegistry } = await import("../config/bootstrap.mjs");
110
+ const created = await bootstrapRegistry({
111
+ registryPath: requireFlag(flags, "registry"),
112
+ self: flags.self,
113
+ root: flags.root,
114
+ force: Boolean(flags.force),
115
+ });
116
+ return envelope("OK", "registry bootstrapped; set registry_sha256 in asylumwarden.json", {
117
+ registry: created,
118
+ });
119
+ }
120
+ const ctx = await makeContext({
121
+ configPath: flags.config,
122
+ stateHome: flags["state-home"],
123
+ });
124
+ await ensureDir(ctx.sl.stateHome);
125
+ await ensureDir(ctx.sl.locks);
126
+ const created = await ctx.ledger.appendGenesisIfAbsent(ctx);
127
+ await initAnchor(ctx);
128
+ const chain = await ctx.ledger.verify();
129
+ return envelope("OK", "state home initialized", {
130
+ state: created ? "genesis_created" : "already_initialized",
131
+ warnings: [],
132
+ state_home: ctx.sl.stateHome,
133
+ ledger_head: chain.head,
134
+ });
135
+ }
136
+
137
+ async function commandPlan(flags, positional) {
138
+ const ctx = await makeContext({ configPath: flags.config, stateHome: flags["state-home"] });
139
+ if (positional.length !== 1) {
140
+ fail("USAGE", "plan requires <wave.json>", [violation("plan", "arity", "")]);
141
+ }
142
+ const result = await planWave(ctx, {
143
+ manifestPath: path.resolve(positional[0]),
144
+ force: Boolean(flags.force),
145
+ });
146
+ return envelope("OK", `planned wave ${result.wave}`, {
147
+ wave: result.wave,
148
+ cells: result.cells,
149
+ warnings: result.warnings ?? [],
150
+ degraded: await maybeDegraded(ctx),
151
+ });
152
+ }
153
+
154
+ async function commandReady(flags, positional) {
155
+ const ctx = await makeContext({ configPath: flags.config, stateHome: flags["state-home"] });
156
+ if (positional.length !== 1) fail("USAGE", "ready requires <wave>", [violation("ready", "arity", "")]);
157
+ const ready = await readyCells(ctx, positional[0]);
158
+ return envelope("OK", `${ready.filter((c) => c.ready).length} ready`, {
159
+ wave: positional[0],
160
+ ready,
161
+ });
162
+ }
163
+
164
+ async function commandClaim(flags, positional) {
165
+ const ctx = await makeContext({ configPath: flags.config, stateHome: flags["state-home"] });
166
+ const { wave, cell } = twoArgs(positional, "claim");
167
+ const owner = await claim(ctx, {
168
+ wave,
169
+ cell,
170
+ actor: requireFlag(flags, "actor"),
171
+ ttlSeconds: ttlOf(flags),
172
+ steal: Boolean(flags.steal),
173
+ });
174
+ return envelope("OK", `claimed ${cell}`, {
175
+ wave,
176
+ cell,
177
+ actor: owner.actor,
178
+ epoch: owner.epoch,
179
+ state: "claimed",
180
+ expires_at: owner.expires_at,
181
+ degraded: await maybeDegraded(ctx),
182
+ });
183
+ }
184
+
185
+ async function commandRenew(flags, positional) {
186
+ const ctx = await makeContext({ configPath: flags.config, stateHome: flags["state-home"] });
187
+ const { wave, cell } = twoArgs(positional, "renew");
188
+ const owner = await renew(ctx, {
189
+ wave,
190
+ cell,
191
+ actor: requireFlag(flags, "actor"),
192
+ epoch: epochOf(flags),
193
+ ttlSeconds: ttlOf(flags),
194
+ });
195
+ return envelope("OK", `renewed ${cell}`, {
196
+ wave, cell, actor: owner.actor, epoch: owner.epoch, expires_at: owner.expires_at,
197
+ degraded: await maybeDegraded(ctx),
198
+ });
199
+ }
200
+
201
+ async function commandRelease(flags, positional) {
202
+ const ctx = await makeContext({ configPath: flags.config, stateHome: flags["state-home"] });
203
+ const { wave, cell } = twoArgs(positional, "release");
204
+ await release(ctx, { wave, cell, actor: requireFlag(flags, "actor"), epoch: epochOf(flags) });
205
+ return envelope("OK", `released ${cell}`, {
206
+ wave, cell, state: "pending", degraded: await maybeDegraded(ctx),
207
+ });
208
+ }
209
+
210
+ async function commandRequeue(flags, positional) {
211
+ const ctx = await makeContext({ configPath: flags.config, stateHome: flags["state-home"] });
212
+ const { wave, cell } = twoArgs(positional, "requeue");
213
+ await requeue(ctx, { wave, cell });
214
+ return envelope("OK", `requeued ${cell}`, { wave, cell, state: "pending" });
215
+ }
216
+
217
+ async function commandDone(flags, positional) {
218
+ const ctx = await makeContext({ configPath: flags.config, stateHome: flags["state-home"] });
219
+ const { wave, cell } = twoArgs(positional, "done");
220
+ const result = await doneWithReceipt(ctx, {
221
+ wave,
222
+ cell,
223
+ actor: requireFlag(flags, "actor"),
224
+ epoch: epochOf(flags),
225
+ receiptPath: path.resolve(requireFlag(flags, "receipt")),
226
+ });
227
+ return envelope("OK", `done ${cell}`, {
228
+ wave,
229
+ cell,
230
+ state: "done",
231
+ receipt: { file: result.file, sha256: result.sha256 },
232
+ degraded: await maybeDegraded(ctx),
233
+ });
234
+ }
235
+
236
+ async function commandFail(flags, positional) {
237
+ const ctx = await makeContext({ configPath: flags.config, stateHome: flags["state-home"] });
238
+ const { wave, cell } = twoArgs(positional, "fail");
239
+ const result = await failCell(ctx, {
240
+ wave,
241
+ cell,
242
+ actor: requireFlag(flags, "actor"),
243
+ epoch: epochOf(flags),
244
+ reason: requireFlag(flags, "reason"),
245
+ });
246
+ return envelope("OK", `failed ${cell}`, {
247
+ wave, cell, state: "failed",
248
+ receipt: { file: result.file, sha256: result.sha256 },
249
+ degraded: await maybeDegraded(ctx),
250
+ });
251
+ }
252
+
253
+ async function commandStatus(flags, positional) {
254
+ const ctx = await makeContext({ configPath: flags.config, stateHome: flags["state-home"] });
255
+ const report = await statusReport(ctx, positional[0]);
256
+ return envelope("OK", "status", { waves: report.waves });
257
+ }
258
+
259
+ async function commandVerify(flags, positional) {
260
+ const ctx = await makeContext({ configPath: flags.config, stateHome: flags["state-home"] });
261
+ const result = await verifyAll(ctx, { wave: flags.wave ?? positional[0] });
262
+ return envelope("OK", "ledger verified", { ...result });
263
+ }
264
+
265
+ async function commandRegistry(flags, positional) {
266
+ const ctx = await makeContext({ configPath: flags.config, stateHome: flags["state-home"] });
267
+ if (positional[0] !== "check") {
268
+ fail("USAGE", "registry requires the check subcommand", [violation("registry", "subcommand", "")]);
269
+ }
270
+ return envelope("OK", "registry ok", {
271
+ registry: {
272
+ path: ctx.registryPath,
273
+ sha256: ctx.registrySha256,
274
+ lines: Object.fromEntries(
275
+ Object.entries(ctx.registry.lines).map(([name, line]) => [name, {
276
+ roots: line.roots,
277
+ forbidden_surfaces: line.forbidden_surfaces,
278
+ forbidden_absolute: line.forbidden_absolute ?? [],
279
+ read_roots: line.read_roots ?? [],
280
+ }]),
281
+ ),
282
+ },
283
+ });
284
+ }
285
+
286
+ async function commandVersion() {
287
+ const pkgPath = path.resolve(import.meta.dirname, "../../package.json");
288
+ const pkg = JSON.parse(await fs.readFile(pkgPath, "utf8"));
289
+ return envelope("OK", "asylumwarden", { name: pkg.name, version: pkg.version, contract: 1 });
290
+ }
291
+
292
+ const COMMANDS = {
293
+ init: (flags, positional) => commandInit(flags, positional),
294
+ plan: commandPlan,
295
+ ready: commandReady,
296
+ claim: commandClaim,
297
+ renew: commandRenew,
298
+ release: commandRelease,
299
+ requeue: commandRequeue,
300
+ done: commandDone,
301
+ fail: commandFail,
302
+ status: commandStatus,
303
+ verify: commandVerify,
304
+ registry: commandRegistry,
305
+ };
306
+
307
+ export async function runCli(argv, { write = (s) => process.stdout.write(s) } = {}) {
308
+ try {
309
+ if (argv.length === 0 || argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
310
+ write(`${JSON.stringify(envelope("OK", `usage: asylumwarden <${Object.keys(COMMANDS).join("|")}> [options]`), null, 2)}\n`);
311
+ return 0;
312
+ }
313
+ // Global flags may appear before or after the command: parse everything,
314
+ // then the first positional is the command.
315
+ const { positional, flags } = parseArgs(argv);
316
+ if (flags.version) {
317
+ write(`${JSON.stringify(await commandVersion(), null, 2)}\n`);
318
+ return 0;
319
+ }
320
+ const command = positional.shift();
321
+ const handler = COMMANDS[command];
322
+ if (!handler) {
323
+ throw new WardenError("UNKNOWN-COMMAND", `unknown command: ${command}`, [
324
+ violation("command", "unknown", command),
325
+ ]);
326
+ }
327
+ const result = await handler(flags, positional);
328
+ write(`${JSON.stringify(result, null, 2)}\n`);
329
+ return 0;
330
+ } catch (error) {
331
+ let out;
332
+ if (error instanceof WardenError) {
333
+ out = envelope(error.code, error.message, { violations: error.violations ?? [] });
334
+ } else if (error && error.code === "ENOENT") {
335
+ out = envelope("STATE-CORRUPT", `unexpected missing file: ${error.message}`);
336
+ } else {
337
+ out = envelope("INTERNAL", `unexpected error: ${error?.message ?? String(error)}`);
338
+ }
339
+ write(`${JSON.stringify(out, null, 2)}\n`);
340
+ return error instanceof WardenError ? error.exitClass : 2;
341
+ }
342
+ }
@@ -0,0 +1,36 @@
1
+ // Registry bootstrap — creates an ABSENT machine registry from explicit
2
+ // operator inputs, then prints registry_sha256 for the operator to reference
3
+ // from asylumwarden.json. It never edits config files (config is the
4
+ // operator's declaration) and never overwrites without --force.
5
+
6
+ import path from "node:path";
7
+
8
+ import { fail, violation } from "../kernel/errors.mjs";
9
+ import { atomicWriteJson, expandHome, fileExists } from "../kernel/fsutil.mjs";
10
+ import { sha256Hex } from "../kernel/canon.mjs";
11
+ import { SAFE_ID_RE } from "../kernel/state.mjs";
12
+
13
+ export async function bootstrapRegistry({ registryPath, self, root, force = false }) {
14
+ if (!self || !SAFE_ID_RE.test(self)) {
15
+ fail("USAGE", "--self must be a safe id", [violation("--self", "safe-id", String(self))]);
16
+ }
17
+ if (!root) {
18
+ fail("USAGE", "--root is required with --bootstrap-registry", [violation("--root", "required", "")]);
19
+ }
20
+ const absRoot = path.normalize(expandHome(root));
21
+ const absRegistry = path.normalize(expandHome(registryPath));
22
+ if ((await fileExists(absRegistry)) && !force) {
23
+ fail("USAGE", `registry already exists: ${absRegistry} (use --force to replace)`, [
24
+ violation("registry", "exists", absRegistry),
25
+ ]);
26
+ }
27
+ const registry = {
28
+ version: 1,
29
+ lines: {
30
+ [self]: { roots: [absRoot], forbidden_surfaces: [] },
31
+ },
32
+ };
33
+ await atomicWriteJson(absRegistry, registry);
34
+ const bytes = await (await import("node:fs/promises")).readFile(absRegistry);
35
+ return { registryPath: absRegistry, sha256: sha256Hex(bytes) };
36
+ }