@nodii/dev 0.5.0 → 0.6.1

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/dist/upload.js ADDED
@@ -0,0 +1,291 @@
1
+ // `nodii-dev docs check|validate|upload|ratify|parked` and `rulebook list|show`:
2
+ // AD documents (M-05; AD-era contract §1–3). Every document is checked
3
+ // locally first, then sent to Dev, which runs the same checks plus the ones
4
+ // that need its data, the version precondition and the parked-items gate.
5
+ // Dev's refusals are shown unchanged.
6
+ import { readdir, readFile, stat } from "node:fs/promises";
7
+ import { join, relative, resolve } from "node:path";
8
+ import { setTimeout as sleep } from "node:timers/promises";
9
+ import { CliError, devRequest, devTextRequest, encode, query } from "./core.js";
10
+ import { CHANGE_CLASSES, validateDocument } from "./docs.js";
11
+
12
+ /** Exit code when Dev is waiting on the operator to acknowledge parked items. */
13
+ export const EXIT_PARKED = 4;
14
+
15
+ /** Expand files and directories into the Markdown documents under them, sorted. */
16
+ export async function collectDocuments(paths) {
17
+ if (!paths.length) throw new CliError("USAGE", "docs needs at least one file or directory", 2);
18
+ const out = new Set();
19
+ async function walk(path) {
20
+ const info = await stat(path).catch(() => null);
21
+ if (!info) throw new CliError("NOT_FOUND", `No such file or directory: ${path}`, 2);
22
+ if (info.isFile()) return void out.add(resolve(path));
23
+ for (const entry of await readdir(path, { withFileTypes: true })) {
24
+ // `_`-prefixed files and folders are working material, not documents.
25
+ if (entry.name.startsWith("_") || entry.name.startsWith(".")) continue;
26
+ const child = join(path, entry.name);
27
+ if (entry.isDirectory()) await walk(child);
28
+ else if (entry.name.endsWith(".md")) out.add(resolve(child));
29
+ }
30
+ }
31
+ for (const path of paths) await walk(path);
32
+ return [...out].sort();
33
+ }
34
+
35
+ export async function checkDocuments(paths, cwd = process.cwd()) {
36
+ const files = await collectDocuments(paths);
37
+ const docs = await Promise.all(files.map(async (file) => {
38
+ const content = await readFile(file, "utf8");
39
+ return { path: relative(cwd, file) || file, content, ...validateDocument(content) };
40
+ }));
41
+ const byCode = new Map();
42
+ for (const d of docs) {
43
+ const code = d.meta.code;
44
+ if (!code) continue;
45
+ if (byCode.has(code)) d.errors.push({ code: "DUPLICATE_IN_UPLOAD", message: `code ${code} is also used by ${byCode.get(code)}` });
46
+ else byCode.set(code, d.path);
47
+ }
48
+ return docs;
49
+ }
50
+
51
+ const errorLines = (docs) =>
52
+ docs.flatMap((d) => (d.errors ?? []).map((e) => `${d.path}${e.line ? `:${e.line}` : ""}: ${e.code}: ${e.message}`));
53
+
54
+ const parkedLines = (docs) =>
55
+ docs.filter((d) => d.items?.length).flatMap((d) => [
56
+ `${d.label} has ${d.items.length} parked item${d.items.length === 1 ? "" : "s"}:`,
57
+ ...d.items.map((p, i) => ` ${i + 1}. ${p.text}${p.line ? ` (line ${p.line})` : ""}`),
58
+ ]);
59
+ const warningLines = (docs) =>
60
+ docs.flatMap((d) => (d.warnings ?? []).map((w) => `${d.path}${w.line ? `:${w.line}` : ""}: warning: ${w.message}`));
61
+
62
+ const localParked = (docs) => docs.map((d) => ({ label: `${d.path} (${d.meta.code ?? "?"})`, items: d.parked }));
63
+
64
+ const ACK_HINT =
65
+ "An operator must acknowledge each list in Dev (Parked items, or the document's entry page). nodii-dev is an OAuth app and cannot acknowledge for them. " +
66
+ "Re-run this command after they do, or add --wait to keep waiting.";
67
+
68
+ function localGate(docs, json) {
69
+ const lines = errorLines(docs);
70
+ if (!lines.length) return;
71
+ throw new CliError("DOCUMENT_INVALID", `${lines.length} problem${lines.length === 1 ? "" : "s"} found locally; nothing sent to Dev.${json ? "" : `\n${lines.join("\n")}`}`, 1,
72
+ json ? { documents: docs.filter((d) => d.errors.length).map((d) => ({ path: d.path, code: d.meta.code ?? null, errors: d.errors })) } : undefined);
73
+ }
74
+
75
+ function changeClass(flags) {
76
+ const value = flags.class ?? flags["change-class"];
77
+ if (!CHANGE_CLASSES.includes(value)) throw new CliError("USAGE", `--class is required: ${CHANGE_CLASSES.join(" | ")} (M05-8)`, 2);
78
+ return value;
79
+ }
80
+
81
+ /** `--base CODE=VERSION` (repeatable) or `--base VERSION` for one document. */
82
+ function baseFlags(flags, docs) {
83
+ const map = new Map();
84
+ for (const raw of [flags.base].flat().filter((x) => x !== undefined && x !== true)) {
85
+ const value = String(raw);
86
+ const at = value.indexOf("=");
87
+ if (at > 0) map.set(value.slice(0, at), value.slice(at + 1));
88
+ else if (docs.length === 1) map.set(docs[0].meta.code, value);
89
+ else throw new CliError("USAGE", "With several documents, pass --base CODE=VERSION for each existing one.", 2);
90
+ }
91
+ return map;
92
+ }
93
+
94
+ /**
95
+ * M05-11: each write names the version it was based on. A new document is
96
+ * based on nothing (null). For an existing one the author must say which
97
+ * version they started from; the CLI never fills in Dev's current version
98
+ * for them, because that would turn the precondition into a silent overwrite.
99
+ */
100
+ async function baseVersions(docs, flags, request) {
101
+ const given = baseFlags(flags, docs);
102
+ const missing = [];
103
+ for (const d of docs) {
104
+ let current = null;
105
+ try { current = (await request("GET", `/api/v1/documents/${encode(d.meta.code)}`)).data?.currentVersion ?? null; }
106
+ catch (error) { if (error.code !== "DOCUMENT_NOT_FOUND" && error.code !== "HTTP_404") throw error; }
107
+ d.currentVersion = current;
108
+ if (given.has(d.meta.code)) d.baseVersion = given.get(d.meta.code);
109
+ else if (current === null) d.baseVersion = null;
110
+ else missing.push(`${d.meta.code} (Dev is at ${current})`);
111
+ }
112
+ if (missing.length)
113
+ throw new CliError("BASE_VERSION_REQUIRED", `These documents already exist in Dev. Say which version you started from with --base CODE=VERSION (M05-11): ${missing.join(", ")}`, 2);
114
+ }
115
+
116
+ async function readJsonFile(path, flag) {
117
+ if (!path || path === true) return undefined;
118
+ try { return JSON.parse(await readFile(path, "utf8")); }
119
+ catch { throw new CliError("USAGE", `--${flag} must name a JSON file`, 2); }
120
+ }
121
+
122
+ const serverParked = (details) => (details?.documents ?? []).map((d) => ({ label: `${d.code} (${d.title ?? ""})`.replace(" ()", ""), code: d.code, itemsSha256: d.itemsSha256, items: d.items ?? [] }));
123
+
124
+ /**
125
+ * Poll Dev until every refused list is acknowledged. The acknowledgement
126
+ * records are matched on code and digest: the document being uploaded may
127
+ * not exist yet, or may differ from Dev's current version.
128
+ */
129
+ async function waitForAcknowledgement(pending, { request, interval, timeout, log }) {
130
+ const deadline = Date.now() + timeout;
131
+ const open = new Map(pending.map((p) => [p.code, p.itemsSha256]));
132
+ while (open.size) {
133
+ const acknowledged = (await request("GET", "/api/v1/documents/parked?status=acknowledged")).data ?? [];
134
+ for (const [code, digest] of [...open])
135
+ if (acknowledged.some((r) => r.docCode === code && r.itemsSha256 === digest)) { open.delete(code); log(`${code}: parked items acknowledged.`); }
136
+ if (!open.size) return;
137
+ if (Date.now() >= deadline) throw new CliError("PARKED_ITEMS_UNACKNOWLEDGED", `Still waiting on the operator for: ${[...open.keys()].join(", ")}`, EXIT_PARKED);
138
+ await sleep(interval);
139
+ }
140
+ }
141
+
142
+ /** Dev's per-document refusals (`details.documents[].errors`), one line each, as the local check prints them. */
143
+ function withDocumentErrors(error) {
144
+ const docs = error.details?.documents;
145
+ if (!(error instanceof CliError) || !Array.isArray(docs) || !docs.some((d) => d.errors?.length)) return error;
146
+ const lines = docs.flatMap((d) => (d.errors ?? []).map((e) => `${d.path ?? d.code}${e.line ? `:${e.line}` : ""}: ${e.code}: ${e.message}`));
147
+ return new CliError(error.code, `${error.message}\n${lines.join("\n")}`, error.exitCode, error.details);
148
+ }
149
+
150
+ /** Send a write; on the parked gate, show Dev's list and (with --wait) retry once acknowledged. */
151
+ async function withParkedGate(send, flags, { request, log, waitInterval = 15000 }) {
152
+ for (;;) {
153
+ try { return await send(); }
154
+ catch (error) {
155
+ if (error.code !== "PARKED_ITEMS_UNACKNOWLEDGED") throw withDocumentErrors(error);
156
+ const pending = serverParked(error.details);
157
+ if (!flags.wait) throw new CliError(error.code, `${error.message}\n${parkedLines(pending).join("\n")}\n${ACK_HINT}`, EXIT_PARKED, error.details);
158
+ log(`${parkedLines(pending).join("\n")}\nWaiting for the operator to acknowledge in Dev…`);
159
+ await waitForAcknowledgement(pending, { request, interval: waitInterval, timeout: Number(flags["wait-minutes"] ?? 120) * 60000, log });
160
+ }
161
+ }
162
+ }
163
+
164
+ const uploadBody = (docs, cls, flags, decision) => ({
165
+ documents: docs.map((d) => ({
166
+ path: d.path, content: d.content, changeClass: cls, baseVersion: d.baseVersion,
167
+ ...(flags["conformance-ref"] && cls === "conformance" ? { conformanceRef: String(flags["conformance-ref"]) } : {}),
168
+ ...(flags["meaning-unchanged"] && cls === "editorial" ? { meaningUnchangedReason: String(flags["meaning-unchanged"]) } : {}),
169
+ })),
170
+ ...(decision ? { decision } : {}),
171
+ });
172
+
173
+ export async function docsCommand(action, args, flags, { request = devRequest, log = console.error, cwd = process.cwd(), waitInterval } = {}) {
174
+ const json = Boolean(flags.json);
175
+ if (action === "check") {
176
+ const docs = await checkDocuments(args, cwd);
177
+ const lines = errorLines(docs);
178
+ const parked = parkedLines(localParked(docs));
179
+ if (lines.length) process.exitCode = 1;
180
+ return {
181
+ payload: { data: { ok: !lines.length, documents: docs.map((d) => ({ path: d.path, code: d.meta.code ?? null, kind: d.meta.devKind ?? null, version: d.meta.version ?? null, clauseCount: d.clauses.length, parked: d.parked, errors: d.errors, warnings: d.warnings })) } },
182
+ human: [...lines, ...warningLines(docs), ...parked, `${docs.length} document${docs.length === 1 ? "" : "s"} checked: ${lines.length ? `${lines.length} problem${lines.length === 1 ? "" : "s"}` : "no problems"}.`].join("\n"),
183
+ };
184
+ }
185
+
186
+ if (action === "validate" || action === "upload") {
187
+ const cls = changeClass(flags);
188
+ const decision = await readJsonFile(flags.decision, "decision");
189
+ if (cls === "ruling" && !decision) throw new CliError("RULING_NEEDS_DECISION", "A ruling write needs its decision: --decision DECISION.json (title, context, options, ruling, consequences, provenance, amends).", 2);
190
+ if (cls !== "ruling" && decision) throw new CliError("DECISION_NEEDS_RULING", "--decision goes with --class ruling only.", 2);
191
+ if (decision && !decision.amends?.length) throw new CliError("AMENDS_EMPTY", "A decision whose amends list is empty is refused (M05-13).", 2);
192
+ if (cls === "editorial" && flags["meaning-unchanged"] === true) throw new CliError("USAGE", "--meaning-unchanged needs a reason.", 2);
193
+ if (flags["conformance-ref"] === true) throw new CliError("USAGE", "--conformance-ref needs a value: a PR or REPO@SHA.", 2);
194
+ const docs = await checkDocuments(args, cwd);
195
+ localGate(docs, json);
196
+ await baseVersions(docs, flags, request);
197
+ const notes = [...warningLines(docs), ...parkedLines(localParked(docs))];
198
+ if (notes.length) log(notes.join("\n"));
199
+ const body = uploadBody(docs, cls, flags, decision);
200
+
201
+ if (action === "validate") {
202
+ const result = (await request("POST", "/api/v1/documents/validate", body).catch((e) => { throw withDocumentErrors(e); })).data;
203
+ if (!result.ok) process.exitCode = 1;
204
+ return {
205
+ payload: { data: result },
206
+ human: [
207
+ ...result.documents.flatMap((d) => [
208
+ `${d.path ?? d.code}\t${d.code} ${d.version} ${d.changeClass} ${d.kind} ${d.domain}\tclauses=${d.clauseCount} parked=${d.parkedCount}${d.parkedCount ? (d.parkedAcknowledged ? " (acknowledged)" : " (NOT acknowledged)") : ""}`,
209
+ ...d.errors.map((e) => ` ${e.code}${e.line ? ` (line ${e.line})` : ""}: ${e.message}`),
210
+ ...(d.warnings ?? []).map((w) => ` warning ${w.code}${w.line ? ` (line ${w.line})` : ""}: ${w.message}`),
211
+ ]),
212
+ ...(result.decisionErrors ?? []).map((e) => `decision: ${e.code}: ${e.message}`),
213
+ result.ok ? "Dev accepts this upload." : "Dev would refuse this upload.",
214
+ ].join("\n"),
215
+ };
216
+ }
217
+
218
+ const result = (await withParkedGate(() => request("POST", "/api/v1/documents/upload", body), flags, { request, log, waitInterval })).data;
219
+ return {
220
+ payload: { data: result },
221
+ human: [
222
+ ...result.documents.map((d) => `${d.created ? "+" : "~"} ${d.code} ${d.version}\t${d.changeNote}`),
223
+ ...(result.decision ? [`Locked ${result.decision.id}: ${result.decision.title}`] : []),
224
+ `Uploaded ${result.documents.length} document${result.documents.length === 1 ? "" : "s"} (${cls}).`,
225
+ ].join("\n"),
226
+ };
227
+ }
228
+
229
+ if (action === "ratify") {
230
+ // Bulk ratification (M05-2): one decision locks v1.0 of every draft in
231
+ // the named domains, and those domains become AD. A domain that is
232
+ // already AD may be named again to ratify drafts added to it since.
233
+ const domains = [flags.domain].flat().filter((x) => x && x !== true).flatMap((x) => String(x).split(",")).filter(Boolean);
234
+ if (!domains.length) throw new CliError("USAGE", "docs ratify needs --domain KEY (repeatable), e.g. --domain rulebook/methodology", 2);
235
+ const decision = await readJsonFile(flags.decision, "decision");
236
+ const parts = ["title", "context", "options", "ruling", "consequences", "provenance"];
237
+ const absent = parts.filter((k) => typeof decision?.[k] !== "string" || !decision[k].trim());
238
+ if (absent.length) throw new CliError("USAGE", `--decision DECISION.json needs non-empty ${absent.join(", ")} (Dev generates the amends).`, 2);
239
+ const documents = [];
240
+ for (const key of domains) {
241
+ const domain = (await request("GET", `/api/v1/domains/${encode(key)}`)).data;
242
+ for (const d of domain.documents ?? []) if (d.status === "draft") documents.push({ code: d.code, baseVersion: d.currentVersion, parkedCount: d.parkedCount ?? 0 });
243
+ }
244
+ if (!documents.length) throw new CliError("DOMAIN_EMPTY", `No draft documents in ${domains.join(", ")}; upload them first with \`nodii-dev docs upload\`.`, 1);
245
+ // Dev's ratify gate decides on the current versions' parked lists; its refusal carries the real list.
246
+ log(`Ratifying ${documents.length} document${documents.length === 1 ? "" : "s"} in ${domains.join(", ")} as v1.0:\n${documents.map((d) => ` ${d.code} ${d.baseVersion}${d.parkedCount ? ` (${d.parkedCount} parked)` : ""}`).join("\n")}`);
247
+ const body = { domains, documents: documents.map(({ code, baseVersion }) => ({ code, baseVersion })), decision: Object.fromEntries(parts.map((k) => [k, decision[k]])) };
248
+ const result = (await withParkedGate(() => request("POST", "/api/v1/documents/ratify", body), flags, { request, log, waitInterval })).data;
249
+ return {
250
+ payload: { data: result },
251
+ human: [`Locked ${result.decision.id}: ${result.decision.title}`, ...result.documents.map((d) => ` ${d.code} ${d.version}`), `AD now: ${result.domains.map((d) => d.key).join(", ")}`].join("\n"),
252
+ };
253
+ }
254
+
255
+ if (action === "parked") {
256
+ if (args[0]) {
257
+ const p = (await request("GET", `/api/v1/documents/${encode(args[0])}/parked`)).data;
258
+ const ack = p.current?.acknowledgement;
259
+ const lines = p.current
260
+ ? [`${p.code}: ${p.current.count} parked item${p.current.count === 1 ? "" : "s"} in the current version${ack ? ` (${ack.status}${ack.acknowledgedByLabel ? ` by ${ack.acknowledgedByLabel}` : ""})` : ""}`, ...p.current.items.map((x, i) => ` ${i + 1}. ${x.text}`)]
261
+ : [`${p.code}: not in Dev yet`];
262
+ for (const x of p.pending ?? []) lines.push(`Awaiting acknowledgement (${x.count} item${x.count === 1 ? "" : "s"}, requested ${x.requestedAt}):`, ...x.items.map((it, i) => ` ${i + 1}. ${it.text}`));
263
+ return { payload: { data: p }, human: lines.join("\n") };
264
+ }
265
+ const payload = await request("GET", `/api/v1/documents/parked${query({ status: flags.status })}`);
266
+ return { payload, human: (p) => p.data.map((x) => `${x.docCode}\t${x.status}\t${x.count} item${x.count === 1 ? "" : "s"}\t${x.title}`).join("\n") || "No parked-item lists." };
267
+ }
268
+ throw new CliError("USAGE", "docs check|validate|upload|ratify|parked", 2);
269
+ }
270
+
271
+ export async function rulebookCommand(action, args, flags, { request = devRequest, text = devTextRequest } = {}) {
272
+ for (const name of ["version", "from", "to"])
273
+ if (flags[name] === true) throw new CliError("USAGE", `--${name} needs a version, e.g. --${name} 1.0`, 2);
274
+ if (action === "list") {
275
+ const payload = await request("GET", `/api/v1/documents${query({ section: flags.section ?? (flags.all ? undefined : "rulebook"), kind: flags.kind, serviceId: flags.service, domain: flags.domain, status: flags.status, q: flags.q, limit: flags.limit ?? 500, offset: flags.offset })}`);
276
+ return { payload, human: (p) => p.data.map((x) => `${x.code}\t${x.currentVersion}\t${x.status}\t${x.kind}\t${x.title}${x.parkedCount ? `\tparked=${x.parkedCount}` : ""}`).join("\n") || "No documents." };
277
+ }
278
+ if (action === "show") {
279
+ const code = args[0];
280
+ if (!code) throw new CliError("USAGE", "rulebook show CODE [--version V]", 2);
281
+ if (flags.json) return { payload: await request("GET", flags.version ? `/api/v1/documents/${encode(code)}/versions/${encode(flags.version)}` : `/api/v1/documents/${encode(code)}`) };
282
+ return { payload: null, human: (await text("GET", `/api/v1/documents/${encode(code)}/body${query({ version: flags.version })}`)).trimEnd() };
283
+ }
284
+ if (action === "diff") {
285
+ const code = args[0];
286
+ if (!code) throw new CliError("USAGE", "rulebook diff CODE [--from V] [--to V]", 2);
287
+ const payload = await request("GET", `/api/v1/documents/${encode(code)}/diff${query({ from: flags.from, to: flags.to })}`);
288
+ return { payload, human: (p) => `${p.data.code} ${p.data.from} → ${p.data.to}\nclauses added: ${p.data.clauses.added.join(", ") || "none"}; removed: ${p.data.clauses.removed.join(", ") || "none"}; changed: ${p.data.clauses.changed.join(", ") || "none"}\n${p.data.unified}` };
289
+ }
290
+ throw new CliError("USAGE", "rulebook list|show|diff", 2);
291
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,8 @@
20
20
  "format": "git diff --check",
21
21
  "test": "node --test test/*.test.js",
22
22
  "pack:smoke": "node scripts/pack-smoke.mjs",
23
- "openapi:snapshot": "node scripts/openapi-snapshot.mjs"
23
+ "openapi:snapshot": "node scripts/openapi-snapshot.mjs",
24
+ "openapi:ad-era": "node scripts/ad-era-openapi-snapshot.mjs"
24
25
  },
25
26
  "license": "UNLICENSED",
26
27
  "publishConfig": {