@nodii/dev 0.4.1 → 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/README.md +117 -29
- package/dist/api.js +20 -11
- package/dist/cli.js +607 -0
- package/dist/core.js +80 -210
- package/dist/devops-openapi.json +1 -0
- package/dist/docs.js +204 -0
- package/dist/harness.js +343 -0
- package/dist/hooks.js +4 -4
- package/dist/lanes.js +7 -7
- package/dist/nodii-dev.js +2 -629
- package/dist/oauth.js +1 -1
- package/dist/setup.js +7 -7
- package/dist/upload.js +291 -0
- package/package.json +5 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { stdin, stdout } from "node:process";
|
|
3
|
+
import {
|
|
4
|
+
CliError,
|
|
5
|
+
VERSION,
|
|
6
|
+
deleteCredentials,
|
|
7
|
+
devRawRequest,
|
|
8
|
+
devRequest,
|
|
9
|
+
encode,
|
|
10
|
+
parseArgs,
|
|
11
|
+
query,
|
|
12
|
+
readConfig,
|
|
13
|
+
requestHash,
|
|
14
|
+
requireFlag,
|
|
15
|
+
writeConfig,
|
|
16
|
+
} from "./core.js";
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_AUTH_URL,
|
|
19
|
+
DEFAULT_CLIENT_ID,
|
|
20
|
+
DEFAULT_EDGE_URL,
|
|
21
|
+
PLATFORM_TENANT,
|
|
22
|
+
deviceLogin,
|
|
23
|
+
revokeOAuth,
|
|
24
|
+
} from "./oauth.js";
|
|
25
|
+
import { heartbeatCurrentLane, postHook, preHook, settingsSnippet, workflowSuggestion } from "./hooks.js";
|
|
26
|
+
import { setupCommand } from "./setup.js";
|
|
27
|
+
import { harnessCommand } from "./harness.js";
|
|
28
|
+
import { docsCommand, rulebookCommand } from "./upload.js";
|
|
29
|
+
import {
|
|
30
|
+
ALIASES,
|
|
31
|
+
GROUPS,
|
|
32
|
+
buildOperationRequest,
|
|
33
|
+
callOperation,
|
|
34
|
+
findFirstClass,
|
|
35
|
+
flagsToOperation,
|
|
36
|
+
loadOpenApi,
|
|
37
|
+
operationsFrom,
|
|
38
|
+
parseParams,
|
|
39
|
+
readBody,
|
|
40
|
+
} from "./api.js";
|
|
41
|
+
import {
|
|
42
|
+
agentWorkflowCommand,
|
|
43
|
+
builtCommand,
|
|
44
|
+
laneCommand,
|
|
45
|
+
machineCommand,
|
|
46
|
+
nextCommand,
|
|
47
|
+
peopleCommand,
|
|
48
|
+
} from "./lanes.js";
|
|
49
|
+
|
|
50
|
+
const HELP = `nodii-dev ${VERSION}
|
|
51
|
+
|
|
52
|
+
Usage: nodii-dev <command> [options] [--json]
|
|
53
|
+
|
|
54
|
+
login | logout | whoami | config show
|
|
55
|
+
agent register --name NAME | agent heartbeat
|
|
56
|
+
issues list|show|create|update|claim|release
|
|
57
|
+
inbox list|show
|
|
58
|
+
drift file | request file
|
|
59
|
+
decisions list|get|search
|
|
60
|
+
design entries|entry|guide
|
|
61
|
+
figma lock|check|log-write|submit|apply|published|abandon|release
|
|
62
|
+
hook claude-figma-pre|claude-figma-post|print-settings
|
|
63
|
+
setup [--yes|--check] # dry-run by default
|
|
64
|
+
harness install|update [--include-drafts[=false]] [--adopt PATH,...] [--dry-run]
|
|
65
|
+
harness update --check # exit 1 when Dev would write anything different
|
|
66
|
+
docs check|validate|upload <path...> --class ruling|conformance|editorial
|
|
67
|
+
[--base CODE=VERSION] [--decision FILE] [--conformance-ref REF]
|
|
68
|
+
[--meaning-unchanged REASON] [--wait]
|
|
69
|
+
docs ratify --domain KEY [--domain KEY ...] --decision FILE [--wait]
|
|
70
|
+
docs parked [CODE] [--status pending|acknowledged]
|
|
71
|
+
rulebook list|show CODE [--version V]|diff CODE
|
|
72
|
+
|
|
73
|
+
lane start <key> --title TEXT --kind KIND --side SIDE [--repo REPO] [--issue ISSUE]
|
|
74
|
+
lane step <key> --side SIDE --start|--done|--block <step>
|
|
75
|
+
lane evidence <key> --pr|--commit|--figma|--deploy|--dev-check REF
|
|
76
|
+
lane heartbeat|review|land|verify|prioritize|deprioritize|stop <key>
|
|
77
|
+
lane list [--kind KIND] [--status STATUS] [--mine] [--cursor CURSOR]
|
|
78
|
+
lane show <key> | next <key>
|
|
79
|
+
built list | built ack <key...>
|
|
80
|
+
machine list|show|set-coordinator|assign|report
|
|
81
|
+
agent lane-limit|grant|revoke
|
|
82
|
+
people reveal <id> --reason TEXT | people refresh-names
|
|
83
|
+
|
|
84
|
+
api list [--tag TAG]
|
|
85
|
+
api <operationId> [--param k=v] [--body @file|-|JSON]
|
|
86
|
+
<resource> <action> [ID ...] [--field VALUE]
|
|
87
|
+
|
|
88
|
+
Resource commands cover every OpenAPI tag, including services, feature-docs,
|
|
89
|
+
contracts, sagas, outbox-topics, tables, libraries, workers, projects, issues,
|
|
90
|
+
backlogs, builds, changes, deploy-reports, runtime, people, agents, inbox,
|
|
91
|
+
gate, design, runbooks, doctrine, changelog, health-verdicts, approval-kinds,
|
|
92
|
+
notification-registry, skills, open-questions, decisions, drift, requests,
|
|
93
|
+
rulebook, ad-decisions, domains, standardizations, archive, and harness.
|
|
94
|
+
|
|
95
|
+
Run a command with --help or see the package README for all flags.`;
|
|
96
|
+
|
|
97
|
+
// Verbs of the local-stack CLI, which shared this name until it became
|
|
98
|
+
// ./nodii-stack (operator G3-6, no alias). Named here only to point there.
|
|
99
|
+
const STACK_VERBS = new Set(["up", "down", "doctor", "sync", "seed", "wipe", "reset", "rebuild", "restart", "smoke", "urls", "peek", "trace", "stats", "bootstrap", "provenance", "stale", "status", "logs", "world", "totp", "token", "bench"]);
|
|
100
|
+
|
|
101
|
+
async function stdinText() {
|
|
102
|
+
let value = "";
|
|
103
|
+
for await (const chunk of stdin) value += chunk;
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
const bodyFlag = async (f, name = "body") =>
|
|
107
|
+
f[`${name}-file`] ? readFile(f[`${name}-file`], "utf8") : f[name];
|
|
108
|
+
function emit(payload, json, human) {
|
|
109
|
+
if (json) console.log(JSON.stringify(payload));
|
|
110
|
+
else
|
|
111
|
+
console.log(
|
|
112
|
+
typeof human === "function"
|
|
113
|
+
? human(payload)
|
|
114
|
+
: (human ?? JSON.stringify(payload.data ?? payload, null, 2)),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const qsFlags = (f, mapping) =>
|
|
118
|
+
Object.fromEntries(
|
|
119
|
+
Object.entries(mapping).map(([to, from]) => [to, f[from]]),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Flags of the shared-secret login removed in 0.5.0.
|
|
123
|
+
const REMOVED_LOGIN_FLAGS = ["hmac", "person", "secret-stdin", "from-aws", "hub-url", "agent-id"];
|
|
124
|
+
|
|
125
|
+
async function login(flags, json) {
|
|
126
|
+
const removed = REMOVED_LOGIN_FLAGS.filter((name) => flags[name] !== undefined);
|
|
127
|
+
if (removed.length)
|
|
128
|
+
throw new CliError(
|
|
129
|
+
"USAGE",
|
|
130
|
+
`${removed.map((x) => `--${x}`).join(", ")}: shared-secret login was removed in 0.5.0. ` +
|
|
131
|
+
"nodii-dev talks only to Dev, through the platform edge, signed in as the platform tenant's OAuth app. Run `nodii-dev login`.",
|
|
132
|
+
2,
|
|
133
|
+
);
|
|
134
|
+
const previous = await readConfig(false);
|
|
135
|
+
const result = await deviceLogin({
|
|
136
|
+
clientId: flags["client-id"] ?? process.env.NODII_DEV_OAUTH_CLIENT_ID ?? previous?.clientId ?? DEFAULT_CLIENT_ID,
|
|
137
|
+
authUrl: flags["auth-url"] ?? process.env.NODII_DEV_AUTH_URL ?? DEFAULT_AUTH_URL,
|
|
138
|
+
edgeUrl: flags["edge-url"] ?? process.env.NODII_DEV_EDGE_URL ?? DEFAULT_EDGE_URL,
|
|
139
|
+
tenantHint: flags["tenant-hint"] ?? process.env.NODII_DEV_PLATFORM_TENANT_ID ?? PLATFORM_TENANT,
|
|
140
|
+
noBrowser: Boolean(flags["no-browser"]),
|
|
141
|
+
scope: flags.scope,
|
|
142
|
+
output: json ? console.error : console.log,
|
|
143
|
+
});
|
|
144
|
+
return emit({ loggedIn: true, provider: "oauth", credentialStorage: result.storage }, json,
|
|
145
|
+
`OAuth login complete; refresh credential stored in ${result.storage}. Run \`nodii-dev agent register --name NAME\` next.`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function issues(action, args, f, json) {
|
|
149
|
+
if (action === "list") {
|
|
150
|
+
const payload = await devRequest(
|
|
151
|
+
"GET",
|
|
152
|
+
`/api/v1/issues${query(qsFlags(f, { serviceId: "service", project: "project", status: "status", kind: "kind", priority: "priority", claimed: "claimed", limit: "limit", cursor: "cursor" }))}`,
|
|
153
|
+
);
|
|
154
|
+
return emit(
|
|
155
|
+
payload,
|
|
156
|
+
json,
|
|
157
|
+
(p) =>
|
|
158
|
+
p.data
|
|
159
|
+
.map((x) => `${x.key}\t${x.status}\t${x.priority}\t${x.title}`)
|
|
160
|
+
.join("\n") || "No issues.",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const ref = args[0];
|
|
164
|
+
if (!ref && action !== "create")
|
|
165
|
+
throw new CliError("USAGE", `${action} needs an issue key or id`, 2);
|
|
166
|
+
if (action === "show")
|
|
167
|
+
return emit(await devRequest("GET", `/api/v1/issues/${encode(ref)}`), json);
|
|
168
|
+
const config = await readConfig();
|
|
169
|
+
if (action === "create" || action === "update") {
|
|
170
|
+
const bodyMd = await bodyFlag(f);
|
|
171
|
+
const payload = {
|
|
172
|
+
...(action === "create"
|
|
173
|
+
? {
|
|
174
|
+
serviceId: requireFlag(f, "service"),
|
|
175
|
+
title: requireFlag(f, "title"),
|
|
176
|
+
}
|
|
177
|
+
: {}),
|
|
178
|
+
...Object.fromEntries(
|
|
179
|
+
[
|
|
180
|
+
["title", f.title],
|
|
181
|
+
["kind", f.kind],
|
|
182
|
+
["priority", f.priority],
|
|
183
|
+
["project", f.project],
|
|
184
|
+
["bodyMd", bodyMd],
|
|
185
|
+
["onBehalfOf", config.person],
|
|
186
|
+
].filter(([, v]) => v !== undefined),
|
|
187
|
+
),
|
|
188
|
+
};
|
|
189
|
+
const result = await devRequest(
|
|
190
|
+
action === "create" ? "POST" : "PATCH",
|
|
191
|
+
action === "create" ? "/api/v1/issues" : `/api/v1/issues/${encode(ref)}`,
|
|
192
|
+
payload,
|
|
193
|
+
);
|
|
194
|
+
return emit(
|
|
195
|
+
result,
|
|
196
|
+
json,
|
|
197
|
+
(p) => `${p.data.key} ${p.data.status} — ${p.data.title}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (action === "claim")
|
|
201
|
+
return emit(
|
|
202
|
+
await devRequest("POST", `/api/v1/issues/${encode(ref)}/claim`, {
|
|
203
|
+
onBehalfOf: config.person,
|
|
204
|
+
...(f.ttl ? { ttlMinutes: Number(f.ttl) } : {}),
|
|
205
|
+
}),
|
|
206
|
+
json,
|
|
207
|
+
);
|
|
208
|
+
if (action === "release")
|
|
209
|
+
return emit(
|
|
210
|
+
await devRequest("POST", `/api/v1/issues/${encode(ref)}/release`, {
|
|
211
|
+
onBehalfOf: config.person,
|
|
212
|
+
...(f.note ? { note: f.note } : {}),
|
|
213
|
+
}),
|
|
214
|
+
json,
|
|
215
|
+
);
|
|
216
|
+
throw new CliError("USAGE", `Unknown issues action: ${action}`, 2);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
220
|
+
if (argv.length === 1 && ["--version", "-v"].includes(argv[0]))
|
|
221
|
+
return console.log(VERSION);
|
|
222
|
+
if (argv.length === 1 && ["--help", "-h"].includes(argv[0]))
|
|
223
|
+
return console.log(HELP);
|
|
224
|
+
const { args, flags: f } = parseArgs(argv);
|
|
225
|
+
const json = Boolean(f.json);
|
|
226
|
+
const [command, action, ...rest] = args;
|
|
227
|
+
if (!command || f.help || command === "help") return console.log(HELP);
|
|
228
|
+
if (command === "api") {
|
|
229
|
+
const operations = operationsFrom(await loadOpenApi());
|
|
230
|
+
if (action === "list") {
|
|
231
|
+
const selected = f.tag === undefined || f.tag === true
|
|
232
|
+
? operations
|
|
233
|
+
: operations.filter((x) => x.tags.some((t) => t.toLowerCase() === String(f.tag).toLowerCase()));
|
|
234
|
+
return emit(selected.map(({ operationId, method, path, tags }) => ({ operationId, method, path, tags })), json,
|
|
235
|
+
(rows) => rows.map((x) => `${x.operationId}\t${x.method}\t${x.path}\t${x.tags.join(",")}`).join("\n"));
|
|
236
|
+
}
|
|
237
|
+
if (!action) throw new CliError("USAGE", "api needs an operationId or list", 2);
|
|
238
|
+
const entry = operations.find((x) => x.operationId === action);
|
|
239
|
+
if (!entry) throw new CliError("UNKNOWN_OPERATION", `No OpenAPI operationId: ${action}`, 2);
|
|
240
|
+
const parsed = await readBody(f.body);
|
|
241
|
+
const request = buildOperationRequest(entry, parseParams(f.param), parsed?.value);
|
|
242
|
+
return emit(await devRawRequest(request.method, request.path, parsed?.text), json);
|
|
243
|
+
}
|
|
244
|
+
if (command === "login") return login(f, json);
|
|
245
|
+
if (command === "logout") {
|
|
246
|
+
const c = await readConfig(false);
|
|
247
|
+
if (c) {
|
|
248
|
+
try { await revokeOAuth(c); } finally { await deleteCredentials(); }
|
|
249
|
+
} else await deleteCredentials();
|
|
250
|
+
return emit({ loggedOut: true }, json, "Logged out.");
|
|
251
|
+
}
|
|
252
|
+
if (command === "config" && action === "show") {
|
|
253
|
+
const c = await readConfig();
|
|
254
|
+
return emit({ provider: "oauth", authUrl: c.authUrl, edgeUrl: c.edgeUrl, clientId: c.clientId, tenantHint: c.tenantHint, agentId: c.agentId }, json,
|
|
255
|
+
`${c.edgeUrl}/devops\nprovider: oauth\napp: ${c.clientId}\nagent: ${c.agentId ?? "not registered"}`);
|
|
256
|
+
}
|
|
257
|
+
if (command === "whoami") {
|
|
258
|
+
// Dev has no whoami route at the edge: the identity is the OAuth login
|
|
259
|
+
// itself, plus the registered agent read back from Dev.
|
|
260
|
+
const c = await readConfig();
|
|
261
|
+
const agent = c.agentId ? (await devRequest("GET", `/api/v1/agents/${encode(c.agentId)}`)).data : null;
|
|
262
|
+
return emit({ data: { person: c.person, app: c.clientId, installation: c.installationId, edge: c.edgeUrl, agent } }, json);
|
|
263
|
+
}
|
|
264
|
+
const emitCommand = async (result) => emit(result.payload, json, result.human);
|
|
265
|
+
if (command === "setup") return emitCommand(await setupCommand(f));
|
|
266
|
+
if (command === "harness" && ["install", "update"].includes(action)) {
|
|
267
|
+
// `--include-drafts` is a switch: `--include-drafts false` would leave `false` here and turn drafts on.
|
|
268
|
+
if (rest.length) throw new CliError("USAGE", `harness ${action} takes no arguments, got: ${rest.join(" ")}. Write a flag value as --include-drafts=false.`, 2);
|
|
269
|
+
return emitCommand(await harnessCommand(action, f));
|
|
270
|
+
}
|
|
271
|
+
if (command === "docs") return emitCommand(await docsCommand(action, rest, f));
|
|
272
|
+
if (command === "rulebook" && ["list", "show", "diff"].includes(action)) return emitCommand(await rulebookCommand(action, rest, f));
|
|
273
|
+
if (command === "lane") return emitCommand(await laneCommand(action, rest, f));
|
|
274
|
+
if (command === "next") return emitCommand(await nextCommand(action, f));
|
|
275
|
+
if (command === "built") return emitCommand(await builtCommand(action, rest, f));
|
|
276
|
+
if (command === "machine") return emitCommand(await machineCommand(action, rest, f));
|
|
277
|
+
if (command === "people" && ["reveal", "refresh-names"].includes(action))
|
|
278
|
+
return emitCommand(await peopleCommand(action, rest, f));
|
|
279
|
+
if (command === "state" && action === "diff") {
|
|
280
|
+
const [health, services, decisions, docs, requests, drift] = await Promise.all([
|
|
281
|
+
devRequest("GET", "/api/v1/health"), devRequest("GET", "/api/v1/services"),
|
|
282
|
+
devRequest("GET", "/api/v1/decisions"), devRequest("GET", "/api/v1/feature-docs"),
|
|
283
|
+
devRequest("GET", "/api/v1/requests"), devRequest("GET", "/api/v1/drift"),
|
|
284
|
+
]);
|
|
285
|
+
const count = (x) => Array.isArray(x.data) ? x.data.length : undefined;
|
|
286
|
+
return emit({ data: { health: health.data ?? health, counts: { services: count(services), decisions: count(decisions), featureDocs: count(docs), requests: count(requests), drift: count(drift) } } }, json);
|
|
287
|
+
}
|
|
288
|
+
if (command === "heartbeat" && action === "audit") {
|
|
289
|
+
const ids = rest[0] ? [rest[0]] : (await devRequest("GET", "/api/v1/services?limit=1000")).data.map((x) => x.id);
|
|
290
|
+
const rows = await Promise.all(ids.map(async (id) => ({ serviceId: id, ...(await devRequest("GET", `/api/v1/services/${encode(id)}/unblock-surface`)).data })));
|
|
291
|
+
const failed = rows.some((x) => (x.openDriftCount ?? 0) > 0 || (x.openInboxRequestCount ?? 0) > 0);
|
|
292
|
+
emit({ data: rows, clean: !failed }, json, (p) => p.data.map((x) => `${x.serviceId}\t${(x.openDriftCount ?? 0) || (x.openInboxRequestCount ?? 0) ? "ATTENTION" : "PASS"}\tdrift=${x.openDriftCount ?? 0}\tinbox=${x.openInboxRequestCount ?? 0}`).join("\n"));
|
|
293
|
+
if (failed) process.exitCode = 1;
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (command === "agents" && action === "spawn") {
|
|
297
|
+
const serviceId = rest[0] ?? requireFlag(f, "service");
|
|
298
|
+
const docs = await devRequest("GET", `/api/v1/feature-docs${query({ serviceId, status: "approved", limit: 200 })}`);
|
|
299
|
+
if (!docs.data?.length) throw new CliError("NO_APPROVED_WORK", `No approved feature docs for ${serviceId}`, 1);
|
|
300
|
+
const repo = `${f["repo-root"] ?? `${process.env.HOME}/Projects`}/${serviceId}`;
|
|
301
|
+
return emit({ data: { serviceId, repo, approvedFeatureDocs: docs.data.length, next: `cd ${repo} && nodii-dev agent register --name <name>` } }, json);
|
|
302
|
+
}
|
|
303
|
+
if (command === "agents" && action === "cowork-tick") {
|
|
304
|
+
if (f.plist) {
|
|
305
|
+
const interval = Number(f.interval ?? 1800);
|
|
306
|
+
return console.log(`<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>com.nodii.cowork</string><key>ProgramArguments</key><array><string>nodii-dev</string><string>agents</string><string>cowork-tick</string></array><key>StartInterval</key><integer>${interval}</integer><key>RunAtLoad</key><true/></dict></plist>`);
|
|
307
|
+
}
|
|
308
|
+
const docs = await devRequest("GET", "/api/v1/feature-docs?status=implementing&limit=1000");
|
|
309
|
+
const claims = docs.data.filter((x) => x.claimedByAgentId && (!x.claimExpiresAt || Date.parse(x.claimExpiresAt) > Date.now()));
|
|
310
|
+
return emit({ data: claims, dryRun: true, note: "Wakeup execution is workstation-local and has no Dev endpoint; this tick reports eligible claims without invoking an external agent runtime." }, json,
|
|
311
|
+
(p) => p.data.map((x) => `${x.serviceId}/${x.docKey}\t${x.claimedByAgentId}`).join("\n") || "No active claims.");
|
|
312
|
+
}
|
|
313
|
+
if (command === "agent") {
|
|
314
|
+
if (["lane-limit", "grant", "revoke"].includes(action))
|
|
315
|
+
return emitCommand(await agentWorkflowCommand(action, rest, f));
|
|
316
|
+
const c = await readConfig();
|
|
317
|
+
if (action === "register") {
|
|
318
|
+
// POST /api/v1/agents is the registration route the edge exposes; Dev
|
|
319
|
+
// binds the agent to the OAuth person, app and installation itself.
|
|
320
|
+
const result = await devRequest("POST", "/api/v1/agents", {
|
|
321
|
+
sessionId: f.session ?? crypto.randomUUID(),
|
|
322
|
+
name: requireFlag(f, "name"),
|
|
323
|
+
});
|
|
324
|
+
await writeConfig({ ...c, agentId: result.data.id });
|
|
325
|
+
// Dev ignores a self-claimed service: staff bind agents to services
|
|
326
|
+
// on the Dev Agents page (devops.agents.bind).
|
|
327
|
+
if (f.service)
|
|
328
|
+
process.stderr.write(
|
|
329
|
+
"--service is ignored: staff bind agents to a service on the Dev Agents page.\n",
|
|
330
|
+
);
|
|
331
|
+
await devRequest(
|
|
332
|
+
"POST",
|
|
333
|
+
`/api/v1/agents/${encode(result.data.id)}/heartbeat`,
|
|
334
|
+
{},
|
|
335
|
+
);
|
|
336
|
+
return emit(
|
|
337
|
+
result,
|
|
338
|
+
json,
|
|
339
|
+
`Registered ${result.data.name}: ${result.data.id}`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
if (action === "heartbeat")
|
|
343
|
+
return emit(
|
|
344
|
+
await devRequest(
|
|
345
|
+
"POST",
|
|
346
|
+
`/api/v1/agents/${encode(c.agentId)}/heartbeat`,
|
|
347
|
+
{},
|
|
348
|
+
),
|
|
349
|
+
json,
|
|
350
|
+
"Heartbeat recorded.",
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
if (command === "issues") return issues(action, rest, f, json);
|
|
354
|
+
if (command === "inbox") {
|
|
355
|
+
if (action === "list")
|
|
356
|
+
return emit(
|
|
357
|
+
await devRequest(
|
|
358
|
+
"GET",
|
|
359
|
+
`/api/v1/inbox${query({ serviceId: f.service, kind: f.kind, limit: f.limit, cursor: f.cursor })}`,
|
|
360
|
+
),
|
|
361
|
+
json,
|
|
362
|
+
);
|
|
363
|
+
if (action === "show") {
|
|
364
|
+
const [kind, id] = rest;
|
|
365
|
+
if (!kind || !id)
|
|
366
|
+
throw new CliError(
|
|
367
|
+
"USAGE",
|
|
368
|
+
"inbox show needs <drift|request|question> <id>",
|
|
369
|
+
2,
|
|
370
|
+
);
|
|
371
|
+
return emit(
|
|
372
|
+
await devRequest(
|
|
373
|
+
"GET",
|
|
374
|
+
`/api/v1/${kind === "question" ? "open-questions" : `${kind}s`}/${encode(id)}`,
|
|
375
|
+
),
|
|
376
|
+
json,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (command === "drift" && action === "file") {
|
|
381
|
+
const service = requireFlag(f, "service");
|
|
382
|
+
const bodyMd = await bodyFlag(f);
|
|
383
|
+
return emit(
|
|
384
|
+
await devRequest("POST", `/api/v1/services/${encode(service)}/drift`, {
|
|
385
|
+
severity: requireFlag(f, "severity"),
|
|
386
|
+
summary: requireFlag(f, "summary"),
|
|
387
|
+
bodyMd: bodyMd ?? "",
|
|
388
|
+
relatedArtifactType: f["related-artifact-type"] ?? null,
|
|
389
|
+
relatedArtifactId: f["related-artifact-id"] ?? null,
|
|
390
|
+
}),
|
|
391
|
+
json,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
if (command === "request" && action === "file") {
|
|
395
|
+
const specMd = f["spec-file"]
|
|
396
|
+
? await readFile(f["spec-file"], "utf8")
|
|
397
|
+
: requireFlag(f, "spec");
|
|
398
|
+
const body = {
|
|
399
|
+
fromService: requireFlag(f, "from"),
|
|
400
|
+
toService: requireFlag(f, "to"),
|
|
401
|
+
kind: requireFlag(f, "kind"),
|
|
402
|
+
specMd,
|
|
403
|
+
requestHash: requestHash({ toService: f.to, kind: f.kind, specMd }),
|
|
404
|
+
...(f["required-capability"]
|
|
405
|
+
? { requiredCapability: f["required-capability"] }
|
|
406
|
+
: {}),
|
|
407
|
+
targetArtifactType: f["target-artifact-type"] ?? null,
|
|
408
|
+
targetArtifactId: f["target-artifact-id"] ?? null,
|
|
409
|
+
};
|
|
410
|
+
return emit(await devRequest("POST", "/api/v1/requests", body), json);
|
|
411
|
+
}
|
|
412
|
+
if (command === "requests" && action === "file") {
|
|
413
|
+
const specMd = f["spec-file"] ? await readFile(f["spec-file"], "utf8") : requireFlag(f, "spec");
|
|
414
|
+
f.fromService = requireFlag(f, "from"); f.toService = requireFlag(f, "to"); f.specMd = specMd;
|
|
415
|
+
f.requestHash = requestHash({ toService: f.toService, kind: requireFlag(f, "kind"), specMd });
|
|
416
|
+
}
|
|
417
|
+
if (command === "decisions") {
|
|
418
|
+
if (action === "get")
|
|
419
|
+
return emit(
|
|
420
|
+
await devRequest("GET", `/api/v1/decisions/${encode(rest[0])}`),
|
|
421
|
+
json,
|
|
422
|
+
);
|
|
423
|
+
const q = action === "search" ? rest.join(" ") || f.q : undefined;
|
|
424
|
+
const payload = await devRequest(
|
|
425
|
+
"GET",
|
|
426
|
+
`/api/v1/decisions${query({ status: f.status, module: f.module, tag: f.tag, limit: f.limit ?? 200 })}`,
|
|
427
|
+
);
|
|
428
|
+
if (q)
|
|
429
|
+
payload.data = payload.data.filter((x) =>
|
|
430
|
+
`${x.id} ${x.title} ${(x.topicTags ?? []).join(" ")}`
|
|
431
|
+
.toLowerCase()
|
|
432
|
+
.includes(q.toLowerCase()),
|
|
433
|
+
);
|
|
434
|
+
return emit(
|
|
435
|
+
payload,
|
|
436
|
+
json,
|
|
437
|
+
(p) =>
|
|
438
|
+
p.data.map((x) => `${x.id}\t${x.status}\t${x.title}`).join("\n") ||
|
|
439
|
+
"No decisions.",
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
if (command === "design") {
|
|
443
|
+
if (action === "entries")
|
|
444
|
+
return emit(
|
|
445
|
+
await devRequest(
|
|
446
|
+
"GET",
|
|
447
|
+
`/api/v1/design/entries${query({ kind: f.kind, group: f.group, q: f.q, limit: f.limit })}`,
|
|
448
|
+
),
|
|
449
|
+
json,
|
|
450
|
+
);
|
|
451
|
+
if (action === "entry")
|
|
452
|
+
return emit(
|
|
453
|
+
await devRequest("GET", `/api/v1/design/entries/${encode(rest[0])}`),
|
|
454
|
+
json,
|
|
455
|
+
);
|
|
456
|
+
if (action === "guide")
|
|
457
|
+
return emit(
|
|
458
|
+
await devRequest("GET", `/api/v1/design/guides/${encode(rest[0])}`),
|
|
459
|
+
json,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
if (command === "figma") {
|
|
463
|
+
if (action === "lock")
|
|
464
|
+
return emit(
|
|
465
|
+
await devRequest(
|
|
466
|
+
"POST",
|
|
467
|
+
`/api/v1/design/files/${encode(rest[0])}/lock`,
|
|
468
|
+
{ changeSetTitle: requireFlag(f, "title") },
|
|
469
|
+
),
|
|
470
|
+
json,
|
|
471
|
+
);
|
|
472
|
+
if (action === "check") {
|
|
473
|
+
const c = await readConfig();
|
|
474
|
+
const result = await devRequest(
|
|
475
|
+
"GET",
|
|
476
|
+
`/api/v1/design/files/${encode(rest[0])}/lock/check?agentId=${encode(c.agentId)}`,
|
|
477
|
+
);
|
|
478
|
+
emit(
|
|
479
|
+
result,
|
|
480
|
+
json,
|
|
481
|
+
(p) =>
|
|
482
|
+
`${p.data.allowed ? "allowed" : "denied"}: ${p.data.reason ?? "lock held"}`,
|
|
483
|
+
);
|
|
484
|
+
if (!result.data.allowed) process.exitCode = 3;
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (action === "log-write")
|
|
488
|
+
return emit(
|
|
489
|
+
await devRequest(
|
|
490
|
+
"POST",
|
|
491
|
+
`/api/v1/design/change-sets/${encode(rest[0])}/writes`,
|
|
492
|
+
{
|
|
493
|
+
tool: requireFlag(f, "tool"),
|
|
494
|
+
nodeIds: requireFlag(f, "nodes").split(",").filter(Boolean),
|
|
495
|
+
},
|
|
496
|
+
),
|
|
497
|
+
json,
|
|
498
|
+
);
|
|
499
|
+
if (["submit", "apply", "published", "abandon"].includes(action)) {
|
|
500
|
+
const payload = f.file
|
|
501
|
+
? JSON.parse(await readFile(f.file, "utf8"))
|
|
502
|
+
: action === "abandon"
|
|
503
|
+
? {}
|
|
504
|
+
: { figmaVersion: requireFlag(f, "figma-version") };
|
|
505
|
+
return emit(
|
|
506
|
+
await devRequest(
|
|
507
|
+
"POST",
|
|
508
|
+
`/api/v1/design/change-sets/${encode(rest[0])}/${action}`,
|
|
509
|
+
payload,
|
|
510
|
+
),
|
|
511
|
+
json,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
if (action === "release")
|
|
515
|
+
return emit(
|
|
516
|
+
await devRequest(
|
|
517
|
+
"POST",
|
|
518
|
+
`/api/v1/design/files/${encode(rest[0])}/lock/release`,
|
|
519
|
+
{},
|
|
520
|
+
),
|
|
521
|
+
json,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
if (command === "hook") {
|
|
525
|
+
if (action === "print-settings")
|
|
526
|
+
return console.log(JSON.stringify(settingsSnippet(), null, 2));
|
|
527
|
+
if (action === "claude-figma-pre") {
|
|
528
|
+
// Fail closed: Claude Code only blocks a tool call on exit code 2, and
|
|
529
|
+
// treats any other non-zero exit as a non-blocking error that lets the
|
|
530
|
+
// write through. Every failure here (bad stdin, missing config, Dev
|
|
531
|
+
// error, lock denied) must therefore exit 2.
|
|
532
|
+
try {
|
|
533
|
+
const payload = JSON.parse(await stdinText());
|
|
534
|
+
return emit(await preHook(payload), json, "allowed");
|
|
535
|
+
} catch (error) {
|
|
536
|
+
throw new CliError(
|
|
537
|
+
error?.code ?? "FIGMA_HOOK_FAILED",
|
|
538
|
+
`Blocked Figma write: ${error?.message ?? String(error)}`,
|
|
539
|
+
2,
|
|
540
|
+
error?.details,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (action === "lane-heartbeat") {
|
|
545
|
+
const result = await heartbeatCurrentLane();
|
|
546
|
+
return emit(result, json, result.heartbeated ? `Heartbeat recorded for ${result.key}.` : "No current-branch lane; no heartbeat sent.");
|
|
547
|
+
}
|
|
548
|
+
const payload = JSON.parse(await stdinText());
|
|
549
|
+
if (action === "workflow-post") {
|
|
550
|
+
const suggestion = workflowSuggestion(payload);
|
|
551
|
+
if (suggestion) console.log(suggestion);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (action === "claude-figma-post") {
|
|
555
|
+
try {
|
|
556
|
+
return emit(await postHook(payload), json, "logged");
|
|
557
|
+
} catch (error) {
|
|
558
|
+
console.error(
|
|
559
|
+
`nodii-dev post hook: ${error.code ?? "ERROR"}: ${error.message}`,
|
|
560
|
+
);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (Object.hasOwn(GROUPS, command)) {
|
|
566
|
+
const operations = operationsFrom(await loadOpenApi());
|
|
567
|
+
let entry = findFirstClass(operations, command, action);
|
|
568
|
+
if (command === "drift" && action === "resolve" && f.status === "accepted-as-canonical")
|
|
569
|
+
entry = operations.find((x) => x.operationId === "acceptDrift");
|
|
570
|
+
if (command === "contracts") {
|
|
571
|
+
const [kind, verb, ...ids] = [action, ...rest];
|
|
572
|
+
const type = { grpc: "GrpcInterface", rest: "RestInterface", outbox: "OutboxTopic", saga: "Saga", table: "Table" }[kind];
|
|
573
|
+
const op = verb === "publish" ? `create${type}` : verb === "status" && f.status === "implemented" ? `implement${type}` : null;
|
|
574
|
+
if (!op) throw new CliError("USAGE", "contracts: use <grpc|rest|outbox|saga|table> publish|status; status currently supports --status implemented", 2);
|
|
575
|
+
entry = operations.find((x) => x.operationId === op);
|
|
576
|
+
rest.splice(0, rest.length, ...ids);
|
|
577
|
+
}
|
|
578
|
+
if (!entry) {
|
|
579
|
+
const tags = Array.isArray(GROUPS[command]) ? GROUPS[command] : [GROUPS[command]];
|
|
580
|
+
const available = operations.filter((x) => x.tags.some((t) => tags.includes(t))).map((x) => x.operationId).join(", ");
|
|
581
|
+
throw new CliError("USAGE", `Unknown ${command} action: ${action}. OpenAPI operations: ${available}`, 2);
|
|
582
|
+
}
|
|
583
|
+
const built = flagsToOperation(entry, rest, f);
|
|
584
|
+
if (command === "requests" && action === "answer")
|
|
585
|
+
Object.assign(built.params, { kind: "request", id: rest[0] });
|
|
586
|
+
if (command === "drift" && entry.operationId === "acceptDrift" && !built.body?.relatedDecisionId && !built.body?.newDecision)
|
|
587
|
+
throw new CliError("USAGE", "accepted-as-canonical drift requires --related-decision-id or --new-decision JSON", 2);
|
|
588
|
+
const explicit = await readBody(f.body);
|
|
589
|
+
return emit(await callOperation(entry, built.params, explicit?.value ?? built.body), json);
|
|
590
|
+
}
|
|
591
|
+
throw new CliError(
|
|
592
|
+
"USAGE",
|
|
593
|
+
`Unknown command: ${[command, action].filter(Boolean).join(" ")}${STACK_VERBS.has(command) ? `. The local-stack CLI is \`./nodii-stack ${command}\`, run from the nodii-local-stack checkout.` : ""}`,
|
|
594
|
+
2,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export function fail(error) {
|
|
599
|
+
const e =
|
|
600
|
+
error instanceof CliError
|
|
601
|
+
? error
|
|
602
|
+
: new CliError("UNEXPECTED", error?.message ?? String(error));
|
|
603
|
+
console.error(
|
|
604
|
+
`${e.code}: ${e.message}${e.details ? `\n${JSON.stringify(e.details)}` : ""}`,
|
|
605
|
+
);
|
|
606
|
+
process.exitCode = e.exitCode ?? 1;
|
|
607
|
+
}
|