@nodii/dev 0.5.0 → 0.6.2
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 +110 -1
- package/dist/api.js +20 -4
- package/dist/cli.js +33 -2
- package/dist/core.js +24 -6
- package/dist/devops-openapi.json +1 -1
- package/dist/docs.js +204 -0
- package/dist/guidelines.js +223 -0
- package/dist/harness.js +343 -0
- package/dist/upload.js +291 -0
- package/package.json +3 -2
package/dist/harness.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// `nodii-dev harness install|update`: write the agent harness Dev generates
|
|
2
|
+
// for one repository from the ratified methodology (AD-era contract §7).
|
|
3
|
+
// Nothing about the rules is authored here. This module places Dev's files
|
|
4
|
+
// (Dev stamps each with its methodology version) and keeps an ownership
|
|
5
|
+
// record, so it never touches a file, block or hook entry it did not write.
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
import { dirname, join, normalize, sep } from "node:path";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
import { CliError, devRequest, query } from "./core.js";
|
|
12
|
+
|
|
13
|
+
/** What this CLI wrote, per path: { mode, sha256 }, or for merge-json the hook entries it `added`. */
|
|
14
|
+
export const STATE_PATH = ".nodii/harness-state.json";
|
|
15
|
+
export const BEGIN = "<!-- nodii-dev:harness:begin -->";
|
|
16
|
+
export const END = "<!-- nodii-dev:harness:end -->";
|
|
17
|
+
const MODES = new Set(["managed-block", "replace", "merge-json"]);
|
|
18
|
+
|
|
19
|
+
const sha = (text) => createHash("sha256").update(text).digest("hex");
|
|
20
|
+
// Key-order independent, so a tool that rewrites settings.json does not hide an entry.
|
|
21
|
+
const stable = (value) => JSON.stringify(value, (_, v) => (v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b))) : v));
|
|
22
|
+
|
|
23
|
+
// Dev's stamp file carries the time of each request; a new time alone is not a change.
|
|
24
|
+
function sameStamp(current, content) {
|
|
25
|
+
try {
|
|
26
|
+
const strip = (text) => stable({ ...JSON.parse(text), generatedAt: null });
|
|
27
|
+
return strip(current) === strip(content);
|
|
28
|
+
} catch { return false; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function optional(path) {
|
|
32
|
+
try { return await readFile(path, "utf8"); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The only files Dev generates (AD-era contract §7). Anything else, such as a
|
|
36
|
+
// git hook or a CI workflow, is refused whatever the disk's case rules.
|
|
37
|
+
const HARNESS_PATHS = [/^CLAUDE\.md$/, /^AGENTS\.md$/, /^\.claude\/settings\.json$/, /^\.nodii\/harness\.json$/, /^\.claude\/skills\/nodii-[a-z0-9][a-z0-9._-]*\/SKILL\.md$/];
|
|
38
|
+
|
|
39
|
+
/** A generated path must be one of the harness files the contract names. */
|
|
40
|
+
export function safeRelative(path) {
|
|
41
|
+
const raw = String(path ?? "");
|
|
42
|
+
const clean = normalize(raw).split(sep).join("/");
|
|
43
|
+
if (clean !== raw || !HARNESS_PATHS.some((p) => p.test(clean)))
|
|
44
|
+
throw new CliError("HARNESS_INVALID", `Dev sent a path that is not a harness file: ${path}`, 1);
|
|
45
|
+
return clean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── managed blocks ─────────────────────────────────────────────────────────
|
|
49
|
+
const blockPattern = new RegExp(`${BEGIN.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`);
|
|
50
|
+
export const blockOf = (content) => {
|
|
51
|
+
const trimmed = content.trim();
|
|
52
|
+
return `${trimmed.startsWith(BEGIN) ? trimmed : `${BEGIN}\n${trimmed}\n${END}`}\n`;
|
|
53
|
+
};
|
|
54
|
+
export const currentBlock = (text) => blockPattern.exec(text ?? "")?.[0] ?? null;
|
|
55
|
+
// An editor may drop the final newline or check the file out with CRLF; neither is an edit.
|
|
56
|
+
const blockSha = (block) => sha(block.replace(/\r\n/g, "\n").replace(/\n?$/, "\n"));
|
|
57
|
+
export function withBlock(text, block) {
|
|
58
|
+
if (text === null || text.trim() === "") return block;
|
|
59
|
+
if (blockPattern.test(text)) return text.replace(blockPattern, () => block);
|
|
60
|
+
return `${text.trimEnd()}\n\n${block}`;
|
|
61
|
+
}
|
|
62
|
+
// Only the gap the block sat in is closed; the rest of the file is left as it was.
|
|
63
|
+
export function withoutBlock(text) {
|
|
64
|
+
const match = blockPattern.exec(text);
|
|
65
|
+
if (!match) return text;
|
|
66
|
+
const before = text.slice(0, match.index).replace(/\n+$/, "");
|
|
67
|
+
const after = text.slice(match.index + match[0].length).replace(/^\n+/, "");
|
|
68
|
+
const out = before && after ? `${before}\n\n${after}` : before || after;
|
|
69
|
+
return out.trim() === "" ? "" : `${out.trimEnd()}\n`;
|
|
70
|
+
}
|
|
71
|
+
// A CRLF checkout of a file nodii-dev wrote is not an edit.
|
|
72
|
+
const fileSha = (text) => sha(text.replace(/\r\n/g, "\n"));
|
|
73
|
+
|
|
74
|
+
// ─── merged JSON (Claude Code settings hooks) ───────────────────────────────
|
|
75
|
+
function parseJson(text, path) {
|
|
76
|
+
try { return text === null || text.trim() === "" ? {} : JSON.parse(text); }
|
|
77
|
+
catch { throw new CliError("HARNESS_SETTINGS_INVALID", `${path} is not valid JSON; fix it before installing the harness.`, 2); }
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Remove exactly the hook entries nodii-dev added before (`added`), then add
|
|
81
|
+
* the new fragment's entries that are not already there. Returns the merged
|
|
82
|
+
* settings and the entries this merge owns: the ones it added now, plus the
|
|
83
|
+
* ones it added before and still wants. An entry the user had first is never
|
|
84
|
+
* owned, so a later update never removes it.
|
|
85
|
+
*/
|
|
86
|
+
export function mergeHooks(current, added = {}, fragment = {}) {
|
|
87
|
+
const hooks = Object.fromEntries(Object.entries(current.hooks ?? {}).map(([e, list]) => [e, [...list]]));
|
|
88
|
+
const owned = {};
|
|
89
|
+
const wanted = (event, entry) => (fragment.hooks?.[event] ?? []).some((x) => stable(x) === stable(entry));
|
|
90
|
+
for (const [event, entries] of Object.entries(added.hooks ?? {}))
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (wanted(event, entry) && (hooks[event] ?? []).some((x) => stable(x) === stable(entry))) { (owned[event] ??= []).push(entry); continue; }
|
|
93
|
+
// Only the one copy nodii-dev added goes; an identical copy the user added stays.
|
|
94
|
+
const list = hooks[event] ?? [];
|
|
95
|
+
const at = list.findLastIndex((x) => stable(x) === stable(entry));
|
|
96
|
+
if (at !== -1) list.splice(at, 1);
|
|
97
|
+
if (!list.length) delete hooks[event];
|
|
98
|
+
}
|
|
99
|
+
for (const [event, entries] of Object.entries(fragment.hooks ?? {}))
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
const list = (hooks[event] ??= []);
|
|
102
|
+
if (list.some((x) => stable(x) === stable(entry))) continue;
|
|
103
|
+
list.push(entry);
|
|
104
|
+
(owned[event] ??= []).push(entry);
|
|
105
|
+
}
|
|
106
|
+
const out = { ...current };
|
|
107
|
+
if (Object.keys(hooks).length) out.hooks = hooks;
|
|
108
|
+
else delete out.hooks;
|
|
109
|
+
return { merged: out, owned: { hooks: owned } };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The first event whose recorded entry is no longer in the settings exactly as written, or null. */
|
|
113
|
+
export function missingHooks(current, added = {}) {
|
|
114
|
+
for (const [event, entries] of Object.entries(added.hooks ?? {}))
|
|
115
|
+
if (entries.some((entry) => !(current.hooks?.[event] ?? []).some((x) => stable(x) === stable(entry)))) return event;
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function readState(root) {
|
|
120
|
+
const text = await optional(join(root, STATE_PATH));
|
|
121
|
+
if (text === null) return null;
|
|
122
|
+
try { return JSON.parse(text); }
|
|
123
|
+
catch { throw new CliError("HARNESS_STATE_INVALID", `${STATE_PATH} is not valid JSON; restore it from git before updating.`, 2); }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Check Dev's response before anything is planned from it. */
|
|
127
|
+
export function checkHarness(harness) {
|
|
128
|
+
if (!harness?.methodologyVersion || !Array.isArray(harness.files)) throw new CliError("HARNESS_INVALID", "Dev's harness response has no methodologyVersion or files.", 1);
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
return harness.files.map((file) => {
|
|
131
|
+
const path = safeRelative(file.path);
|
|
132
|
+
if (seen.has(path)) throw new CliError("HARNESS_INVALID", `Dev sent ${path} twice.`, 1);
|
|
133
|
+
seen.add(path);
|
|
134
|
+
if (!MODES.has(file.mode)) throw new CliError("HARNESS_INVALID", `Dev sent ${path} with unknown mode ${file.mode}.`, 1);
|
|
135
|
+
const content = String(file.content ?? "");
|
|
136
|
+
if (file.sha256 && sha(content) !== file.sha256) throw new CliError("HARNESS_INVALID", `${path} does not match the sha256 Dev sent with it.`, 1);
|
|
137
|
+
if (file.mode === "merge-json") {
|
|
138
|
+
try { JSON.parse(content); } catch { throw new CliError("HARNESS_INVALID", `Dev sent ${path} with content that is not JSON.`, 1); }
|
|
139
|
+
}
|
|
140
|
+
return { path, mode: file.mode, kind: file.kind, content };
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Compare Dev's harness with the repository. `adopt` names existing files the
|
|
146
|
+
* operator lets nodii-dev take over (on install, or when an update adds a file). Returns the planned
|
|
147
|
+
* writes, the new ownership state, and every reason the plan cannot apply.
|
|
148
|
+
*/
|
|
149
|
+
export async function planHarness(root, harness, { install, adopt = [], request = {} }) {
|
|
150
|
+
const files = checkHarness(harness);
|
|
151
|
+
const state = await readState(root);
|
|
152
|
+
if (install && state) throw new CliError("HARNESS_INSTALLED", `This repository already has the nodii-dev harness for ${state.repo} (methodology ${state.methodologyVersion}). Run \`nodii-dev harness update\`.`, 2);
|
|
153
|
+
if (!install && !state) throw new CliError("HARNESS_NOT_INSTALLED", "No nodii-dev harness here. Run `nodii-dev harness install` first.", 2);
|
|
154
|
+
if (state && (state.repo !== harness.repo || (state.serviceId ?? null) !== (harness.serviceId ?? null)))
|
|
155
|
+
throw new CliError("HARNESS_SERVICE_MISMATCH", `This repository's harness is for ${state.repo}${state.serviceId ? ` (${state.serviceId})` : ""}, not ${harness.repo}${harness.serviceId ? ` (${harness.serviceId})` : ""}. One service per repository.`, 2);
|
|
156
|
+
const owned = state?.files ?? {};
|
|
157
|
+
const adopted = new Set(adopt.map((path) => {
|
|
158
|
+
try { return safeRelative(path); }
|
|
159
|
+
catch { throw new CliError("USAGE", `--adopt ${path} is not a harness file (CLAUDE.md, AGENTS.md, .claude/settings.json, .claude/skills/nodii-CODE/SKILL.md).`, 2); }
|
|
160
|
+
}));
|
|
161
|
+
const changes = [];
|
|
162
|
+
const conflicts = [];
|
|
163
|
+
const next = {};
|
|
164
|
+
for (const file of files) {
|
|
165
|
+
const before = owned[file.path];
|
|
166
|
+
const current = await optional(join(root, file.path));
|
|
167
|
+
const unowned = current !== null && !before && !adopted.has(file.path);
|
|
168
|
+
if (file.mode === "replace") {
|
|
169
|
+
if (unowned) { conflicts.push({ path: file.path, reason: "exists and was not written by nodii-dev" }); continue; }
|
|
170
|
+
if (current !== null && before && fileSha(current) !== before.sha256) { conflicts.push({ path: file.path, reason: "edited since nodii-dev wrote it" }); continue; }
|
|
171
|
+
if (current !== null && file.kind === "stamp" && sameStamp(current, file.content)) { next[file.path] = { mode: file.mode, sha256: fileSha(current) }; continue; }
|
|
172
|
+
next[file.path] = { mode: file.mode, sha256: sha(file.content) };
|
|
173
|
+
// A CRLF checkout of the same text is not a change.
|
|
174
|
+
if (current === null || fileSha(current) !== sha(file.content)) changes.push({ path: file.path, mode: file.mode, action: current === null ? "create" : "update", text: file.content });
|
|
175
|
+
} else if (file.mode === "managed-block") {
|
|
176
|
+
const block = blockOf(file.content);
|
|
177
|
+
const existing = currentBlock(current);
|
|
178
|
+
// A BEGIN marker without its END: appending would later swallow the text between them.
|
|
179
|
+
if (!existing && (current ?? "").includes(BEGIN)) { conflicts.push({ path: file.path, reason: `has a ${BEGIN} line without its ${END}; restore the end marker or remove the begin marker` }); continue; }
|
|
180
|
+
if (unowned && current.trim() !== "") { conflicts.push({ path: file.path, reason: "exists and was not written by nodii-dev; adopt it to add the managed block" }); continue; }
|
|
181
|
+
if (before && existing && blockSha(existing) !== before.sha256) { conflicts.push({ path: file.path, reason: "its nodii-dev block was edited since nodii-dev wrote it" }); continue; }
|
|
182
|
+
next[file.path] = { mode: file.mode, sha256: blockSha(block) };
|
|
183
|
+
const text = withBlock(current, block);
|
|
184
|
+
// The same block with CRLF line endings or no final newline is not a change.
|
|
185
|
+
if (text !== current && !(existing && blockSha(existing) === blockSha(block))) changes.push({ path: file.path, mode: file.mode, action: current === null ? "create" : existing ? "update" : "add block", text });
|
|
186
|
+
} else {
|
|
187
|
+
const fragment = JSON.parse(file.content);
|
|
188
|
+
if (unowned) { conflicts.push({ path: file.path, reason: "exists and was not written by nodii-dev; adopt it to merge the hooks" }); continue; }
|
|
189
|
+
const parsed = parseJson(current, file.path);
|
|
190
|
+
const edited = current !== null && missingHooks(parsed, before?.added);
|
|
191
|
+
if (edited) { conflicts.push({ path: file.path, reason: `the hook entry nodii-dev added under ${edited} was edited or removed since nodii-dev wrote it` }); continue; }
|
|
192
|
+
const { merged, owned: added } = mergeHooks(parsed, before?.added, fragment);
|
|
193
|
+
next[file.path] = { mode: file.mode, added };
|
|
194
|
+
const text = `${JSON.stringify(merged, null, 2)}\n`;
|
|
195
|
+
// Compare content, not formatting: another indent or key order is not a change.
|
|
196
|
+
if (current === null || stable(merged) !== stable(parsed))
|
|
197
|
+
changes.push({ path: file.path, mode: file.mode, action: current === null ? "create" : "merge", text });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const [path, before] of Object.entries(owned)) {
|
|
201
|
+
if (next[path] || files.some((f) => f.path === path)) continue;
|
|
202
|
+
const current = await optional(join(root, path));
|
|
203
|
+
if (current === null) continue;
|
|
204
|
+
if (before.mode === "replace") {
|
|
205
|
+
if (fileSha(current) !== before.sha256) conflicts.push({ path, reason: "edited since nodii-dev wrote it; Dev no longer generates it" });
|
|
206
|
+
else changes.push({ path, mode: before.mode, action: "remove" });
|
|
207
|
+
} else if (before.mode === "managed-block") {
|
|
208
|
+
const existing = currentBlock(current);
|
|
209
|
+
if (existing && blockSha(existing) !== before.sha256) conflicts.push({ path, reason: "its nodii-dev block was edited; Dev no longer generates it" });
|
|
210
|
+
else if (existing) {
|
|
211
|
+
const text = withoutBlock(current);
|
|
212
|
+
changes.push(text === "" ? { path, mode: before.mode, action: "remove" } : { path, mode: before.mode, action: "remove block", text });
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
const parsed = parseJson(current, path);
|
|
216
|
+
const edited = missingHooks(parsed, before.added);
|
|
217
|
+
if (edited) { conflicts.push({ path, reason: `the hook entry nodii-dev added under ${edited} was edited or removed; Dev no longer generates it` }); continue; }
|
|
218
|
+
const { merged } = mergeHooks(parsed, before.added, {});
|
|
219
|
+
changes.push({ path, mode: before.mode, action: "unmerge", text: `${JSON.stringify(merged, null, 2)}\n` });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const nextState = {
|
|
223
|
+
repo: harness.repo,
|
|
224
|
+
serviceId: harness.serviceId ?? null,
|
|
225
|
+
methodologyVersion: harness.methodologyVersion,
|
|
226
|
+
generatedAt: harness.generatedAt ?? null,
|
|
227
|
+
sources: harness.sources ?? [],
|
|
228
|
+
// What install asked Dev for, so update asks the same.
|
|
229
|
+
request,
|
|
230
|
+
files: Object.fromEntries(Object.entries(next).sort(([a], [b]) => a.localeCompare(b))),
|
|
231
|
+
};
|
|
232
|
+
return { state, nextState, changes, conflicts };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function applyHarness(root, plan) {
|
|
236
|
+
if (plan.conflicts.length) throw new CliError("HARNESS_CONFLICT", "Refusing to apply a harness plan with conflicts.", 1);
|
|
237
|
+
const writeState = async (state) => {
|
|
238
|
+
await mkdir(dirname(join(root, STATE_PATH)), { recursive: true });
|
|
239
|
+
await writeFile(join(root, STATE_PATH), `${JSON.stringify(state, null, 2)}\n`);
|
|
240
|
+
};
|
|
241
|
+
const done = [];
|
|
242
|
+
try {
|
|
243
|
+
for (const change of plan.changes) {
|
|
244
|
+
const target = join(root, change.path);
|
|
245
|
+
if (change.action === "remove") await rm(target, { force: true });
|
|
246
|
+
else {
|
|
247
|
+
await mkdir(dirname(target), { recursive: true });
|
|
248
|
+
await writeFile(target, change.text);
|
|
249
|
+
}
|
|
250
|
+
done.push(change.path);
|
|
251
|
+
}
|
|
252
|
+
} catch (error) {
|
|
253
|
+
// Record ownership of what was written before the failure, so the next
|
|
254
|
+
// run neither disowns those files nor reports them as edited.
|
|
255
|
+
const files = { ...(plan.state?.files ?? {}) };
|
|
256
|
+
for (const path of done) if (plan.nextState.files[path]) files[path] = plan.nextState.files[path]; else delete files[path];
|
|
257
|
+
await writeState({ ...(plan.state ?? plan.nextState), files }).catch(() => {});
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
await writeState(plan.nextState);
|
|
261
|
+
return plan.nextState;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Which methodology sources moved between two states: "M05 0.2 → 1.0", "+M10 1.0", "-M04". */
|
|
265
|
+
export function sourceChanges(before = [], after = []) {
|
|
266
|
+
const from = new Map(before.map((s) => [s.code, s.version]));
|
|
267
|
+
const to = new Map(after.map((s) => [s.code, s.version]));
|
|
268
|
+
const out = [];
|
|
269
|
+
for (const [code, version] of to)
|
|
270
|
+
if (!from.has(code)) out.push(`+${code} ${version}`);
|
|
271
|
+
else if (from.get(code) !== version) out.push(`${code} ${from.get(code)} → ${version}`);
|
|
272
|
+
for (const code of from.keys()) if (!to.has(code)) out.push(`-${code}`);
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ─── the command ────────────────────────────────────────────────────────────
|
|
277
|
+
const execFileAsync = promisify(execFile);
|
|
278
|
+
|
|
279
|
+
/** The repository root and Dev's `repo` key: the git remote's name, else the folder's. */
|
|
280
|
+
export async function repoContext(cwd = process.cwd()) {
|
|
281
|
+
let root;
|
|
282
|
+
try { root = (await execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" })).stdout.trim(); }
|
|
283
|
+
catch { throw new CliError("GIT_REQUIRED", "Run harness commands inside the repository's git checkout, or pass --root DIR --repo NAME.", 2); }
|
|
284
|
+
let name;
|
|
285
|
+
try { name = (await execFileAsync("git", ["remote", "get-url", "origin"], { cwd: root, encoding: "utf8" })).stdout.trim(); }
|
|
286
|
+
catch { name = root; }
|
|
287
|
+
return { root, repo: name.replace(/\.git$/, "").split(/[/:]/).filter(Boolean).at(-1).toLowerCase() };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const flagList = (value) => (value === undefined || value === true ? [] : [value].flat().flatMap((x) => String(x).split(",")).filter(Boolean));
|
|
291
|
+
const changeLine = (c) => `${{ create: "+", remove: "-" }[c.action] ?? "~"} ${c.path}${["create", "update", "remove"].includes(c.action) ? "" : ` (${c.action})`}`;
|
|
292
|
+
|
|
293
|
+
export async function harnessCommand(action, flags, { request = devRequest, context = repoContext } = {}) {
|
|
294
|
+
if (!["install", "update"].includes(action)) throw new CliError("USAGE", "harness install|update [--repo NAME] [--service ID] [--include-drafts[=false]] [--adopt PATH,...] [--dry-run] [--check]", 2);
|
|
295
|
+
if (flags.fleet || flags.all) throw new CliError("USAGE", "The harness is installed one service at a time, in that service's repository. There is no fleet mode.", 2);
|
|
296
|
+
if (flags.root === true || flags.repo === true) throw new CliError("USAGE", "--root and --repo need a value.", 2);
|
|
297
|
+
const ctx = flags.root ? { root: flags.root, repo: flags.repo } : await context();
|
|
298
|
+
const repo = flags.repo && flags.repo !== true ? String(flags.repo) : ctx.repo;
|
|
299
|
+
if (!repo) throw new CliError("USAGE", "Pass --repo NAME.", 2);
|
|
300
|
+
const state = await readState(ctx.root);
|
|
301
|
+
// Update asks Dev for what install asked for, unless told otherwise.
|
|
302
|
+
const saved = action === "update" ? (state?.request ?? {}) : {};
|
|
303
|
+
const serviceId = flags.service && flags.service !== true ? String(flags.service) : (saved.serviceId ?? undefined);
|
|
304
|
+
const includeDrafts = flags["include-drafts"] !== undefined ? flags["include-drafts"] !== "false" : Boolean(saved.includeDrafts);
|
|
305
|
+
const params = { repo, serviceId, includeDrafts: includeDrafts ? "true" : undefined };
|
|
306
|
+
|
|
307
|
+
if (action === "install" && flags.check) throw new CliError("USAGE", "--check goes with `harness update`; to preview an install use --dry-run.", 2);
|
|
308
|
+
if (action === "update" && flags.check) {
|
|
309
|
+
if (!state) throw new CliError("HARNESS_NOT_INSTALLED", "No nodii-dev harness here. Run `nodii-dev harness install` first.", 2);
|
|
310
|
+
// Compare the files Dev would write now, not only the version: the
|
|
311
|
+
// CLAUDE.md block also changes when the service's domain becomes AD.
|
|
312
|
+
const harness = (await request("GET", `/api/v1/harness${query(params)}`)).data;
|
|
313
|
+
const plan = await planHarness(ctx.root, harness, { install: false });
|
|
314
|
+
const moved = sourceChanges(state.sources, harness.sources);
|
|
315
|
+
const behind = harness.methodologyVersion !== state.methodologyVersion || plan.changes.length > 0 || plan.conflicts.length > 0;
|
|
316
|
+
const payload = { data: { repo, current: !behind, installed: state.methodologyVersion, available: harness.methodologyVersion, sources: moved, changes: plan.changes.map(({ path, action: a, mode }) => ({ path, action: a, mode })), conflicts: plan.conflicts } };
|
|
317
|
+
if (behind) process.exitCode = 1;
|
|
318
|
+
return { payload, human: behind ? [`Harness is behind: ${state.methodologyVersion} → ${harness.methodologyVersion}`, ...moved.map((x) => ` source ${x}`), ...plan.changes.map(changeLine), ...plan.conflicts.map((c) => ` conflict ${c.path}: ${c.reason}`), "Run `nodii-dev harness update`."].join("\n") : `Harness is current (${state.methodologyVersion}).` };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const harness = (await request("GET", `/api/v1/harness${query(params)}`)).data;
|
|
322
|
+
const plan = await planHarness(ctx.root, harness, { install: action === "install", adopt: flagList(flags.adopt), request: { ...(serviceId ? { serviceId } : {}), includeDrafts } });
|
|
323
|
+
const summary = {
|
|
324
|
+
repo: harness.repo, serviceId: harness.serviceId ?? null,
|
|
325
|
+
from: state?.methodologyVersion ?? null, to: harness.methodologyVersion,
|
|
326
|
+
sources: sourceChanges(state?.sources, harness.sources),
|
|
327
|
+
changes: plan.changes.map(({ path, action: a, mode }) => ({ path, action: a, mode })),
|
|
328
|
+
conflicts: plan.conflicts, warnings: harness.warnings ?? [],
|
|
329
|
+
};
|
|
330
|
+
const lines = [
|
|
331
|
+
`${harness.repo}${harness.serviceId ? ` (${harness.serviceId})` : ""}: methodology ${summary.from ? `${summary.from} → ` : ""}${summary.to}`,
|
|
332
|
+
...summary.sources.map((x) => ` source ${x}`),
|
|
333
|
+
...plan.changes.map(changeLine),
|
|
334
|
+
...summary.warnings.map((w) => `warning: ${w}`),
|
|
335
|
+
];
|
|
336
|
+
if (plan.conflicts.length)
|
|
337
|
+
throw new CliError("HARNESS_CONFLICT",
|
|
338
|
+
`Nothing written. These files are not nodii-dev's to change:\n${plan.conflicts.map((c) => ` ${c.path}: ${c.reason}`).join("\n")}\n` +
|
|
339
|
+
"Restore files nodii-dev wrote (git checkout, or remove your edits). For your own files, remove them or let nodii-dev take them over with --adopt PATH[,PATH].", 1, summary);
|
|
340
|
+
if (flags["dry-run"]) return { payload: { data: { ...summary, dryRun: true } }, human: `${lines.join("\n")}\n${plan.changes.length ? "Dry run: nothing written." : "No changes."}` };
|
|
341
|
+
await applyHarness(ctx.root, plan);
|
|
342
|
+
return { payload: { data: { ...summary, applied: true } }, human: `${lines.join("\n")}\n${plan.changes.length ? `${action === "install" ? "Installed" : "Updated"}: ${plan.changes.length} change${plan.changes.length === 1 ? "" : "s"}.` : "Harness already current."}` };
|
|
343
|
+
}
|
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
|
+
}
|