@nodii/dev 0.0.0-stage → 0.3.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 CHANGED
@@ -1,3 +1,132 @@
1
- # Temporary Holding Version
1
+ # `@nodii/dev`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Install with `npm i -g @nodii/dev` (or `bun add -g @nodii/dev`), or run without
4
+ installing using `bunx @nodii/dev --help`. The `nodii-dev` command runs on
5
+ **Bun when it's installed** (about 40% faster start-up, which matters for the
6
+ Figma hook that runs on every Figma call) and falls back to Node 20+. macOS and
7
+ Linux only (the launcher is a POSIX shell script).
8
+
9
+ ## Authentication
10
+
11
+ ```sh
12
+ nodii-dev login # opens the approval page in your browser
13
+ nodii-dev login --no-browser # prints the link and code instead
14
+ nodii-dev login --scope devops.issues.read,devops.issues.write # narrower grant
15
+ nodii-dev agent register --name my-agent --service nodii-planning-hub
16
+
17
+ # Temporary compatibility path:
18
+ printf '%s' "$PLANNING_HUB_HMAC_SECRET" | nodii-dev login --person PERSON_ID --secret-stdin
19
+ nodii-dev login --hmac --person PERSON_ID --from-aws
20
+ ```
21
+
22
+ OAuth is the default: the CLI signs in as the platform tenant's nodii-dev app
23
+ (`NODII_DEV_OAUTH_CLIENT_ID` or `--client-id` overrides the built-in public
24
+ client id). With no `--scope`, auth grants the app's full ceiling: every live
25
+ `devops.*` key except `devops.releases.promote`, which is never granted. Pass
26
+ `--hmac` to keep the interim path. Login uses RFC 8628 and
27
+ opens the verification page on an interactive terminal unless `--no-browser`
28
+ is passed. Refresh tokens are stored only in macOS Keychain or Linux Secret
29
+ Service (`secret-tool`); the CLI refuses plaintext fallback. Access tokens stay
30
+ in memory. OAuth API calls use the platform edge `/devops` route and send the
31
+ registered `x-agent-id`. `logout` revokes the refresh chain before erasing local
32
+ credentials. `--auth-url`, `--edge-url`, and `--tenant-hint` select another real
33
+ deployment.
34
+
35
+ Every command accepts `--json`; without it, output is formatted for humans.
36
+ Hub failures print the hub error code to stderr and exit non-zero.
37
+
38
+ ## Commands
39
+
40
+ ```text
41
+ nodii-dev login [--no-browser]
42
+ nodii-dev login --hmac --person ID (--secret-stdin | --from-aws)
43
+ nodii-dev logout
44
+ nodii-dev whoami
45
+ nodii-dev config show
46
+
47
+ nodii-dev api list [--tag TAG] [--json]
48
+ nodii-dev api OPERATION_ID [--param name=value ...] [--body @file|-|JSON]
49
+
50
+ nodii-dev agent register --name NAME [--service SERVICE] [--session ID]
51
+ nodii-dev agent heartbeat
52
+ nodii-dev agents spawn SERVICE [--repo-root DIR]
53
+ nodii-dev agents cowork-tick [--json]
54
+ nodii-dev agents cowork-tick --plist [--interval SECONDS]
55
+
56
+ nodii-dev issues list [--service ID] [--project KEY] [--status STATUS]
57
+ [--kind KIND] [--priority PRIORITY] [--claimed true|false]
58
+ nodii-dev issues show ISSUE
59
+ nodii-dev issues create --service ID --title TEXT [--body TEXT|--body-file FILE]
60
+ [--kind KIND] [--priority PRIORITY] [--project KEY]
61
+ nodii-dev issues update ISSUE [the same mutable fields]
62
+ nodii-dev issues claim ISSUE [--ttl MINUTES]
63
+ nodii-dev issues release ISSUE [--note TEXT]
64
+
65
+ nodii-dev inbox list [--service ID] [--kind drift|request|question]
66
+ nodii-dev inbox show drift|request|question ID
67
+
68
+ nodii-dev drift file --service ID --severity local|consumer-visible|breaking
69
+ --summary TEXT (--body TEXT|--body-file FILE)
70
+ [--related-artifact-type TYPE --related-artifact-id ID]
71
+ nodii-dev drift list|show|resolve|triage
72
+ nodii-dev request file --from ID --to ID --kind KIND (--spec TEXT|--spec-file FILE)
73
+ [--required-capability NAME]
74
+ [--target-artifact-type TYPE --target-artifact-id ID]
75
+ nodii-dev requests list|show|claim|answer|resolve|reject|file
76
+
77
+ nodii-dev decisions list [--status STATUS] [--module NAME] [--tag TAG]
78
+ nodii-dev decisions get D123
79
+ nodii-dev decisions search WORDS
80
+ nodii-dev decisions create|lock|supersede|amend
81
+
82
+ nodii-dev open-questions list|show|answer
83
+ nodii-dev feature-docs list|show|claim|release|status
84
+ nodii-dev contracts grpc|rest|outbox|saga|table publish|status
85
+ nodii-dev backlog list|show|add|move # `backlogs` is also accepted
86
+ nodii-dev libraries register-version LIBRARY_ID --version VERSION
87
+ nodii-dev state diff
88
+ nodii-dev heartbeat audit [SERVICE]
89
+
90
+ nodii-dev design entries [--kind KIND] [--group GROUP] [--q TEXT]
91
+ nodii-dev design entry KEY
92
+ nodii-dev design guide PAGE_TYPE
93
+
94
+ nodii-dev figma lock FILE_KEY --title TEXT
95
+ nodii-dev figma check FILE_KEY
96
+ nodii-dev figma log-write CHANGE_SET --tool TOOL --nodes NODE[,NODE]
97
+ nodii-dev figma submit CHANGE_SET --file SUBMISSION.json
98
+ nodii-dev figma apply CHANGE_SET --figma-version VERSION
99
+ nodii-dev figma published CHANGE_SET --figma-version VERSION
100
+ nodii-dev figma abandon CHANGE_SET
101
+ nodii-dev figma release FILE_KEY
102
+
103
+ nodii-dev hook claude-figma-pre # reads Claude Code hook JSON on stdin
104
+ nodii-dev hook claude-figma-post # reads Claude Code hook JSON on stdin
105
+ nodii-dev hook print-settings # prints managed-settings JSON; writes nothing
106
+ ```
107
+
108
+ Every first-class resource command accepts positional path identifiers and
109
+ OpenAPI property names as kebab-case flags. For example:
110
+
111
+ ```sh
112
+ nodii-dev feature-docs status UUID --status implemented --completion-pct 100
113
+ nodii-dev requests resolve UUID --commit-sha abc1234 --resolution-md "shipped"
114
+ nodii-dev open-questions answer UUID --decision-id D1420
115
+ ```
116
+
117
+ The first-class resource families cover every live OpenAPI tag: services,
118
+ feature docs, contracts (gRPC, REST, outbox, sagas, tables), libraries,
119
+ workers, projects, slices, SQL lookups, issues, backlogs, builds, changes,
120
+ deploy reports, runtime, people, agents, inbox/gates, design, runbooks,
121
+ doctrine and exceptions, changelog, health verdicts, approval kinds,
122
+ notification registry, skills, questions, decisions, drift, requests,
123
+ pipelines, runners, routing, usage, audit, reconciliation, cron, composite
124
+ surfaces, and service edges. `api list` is the authoritative discovery view;
125
+ `api OPERATION_ID` makes every operation callable even without an alias.
126
+
127
+ `figma check` exits `0` when allowed and `3` when denied. The pre-hook permits
128
+ non-Figma tools and allowlisted Figma reads, but defaults to denying every
129
+ Figma write if the file key is absent, the hub cannot be reached, or the lock
130
+ is denied. It exits `2`, which makes Claude Code block the call. The post-hook
131
+ best-effort logs the write and never blocks. Per D1433, Codex remains read-only
132
+ for Figma; this package does not write to Figma itself.
package/bin/nodii-dev ADDED
@@ -0,0 +1,12 @@
1
+ #!/bin/sh
2
+ # nodii-dev launcher: run on Bun when it's installed (about 40% faster
3
+ # start-up, which matters because the Figma hook runs on every Figma call),
4
+ # otherwise on Node 20+.
5
+ self="$0"
6
+ while [ -L "$self" ]; do
7
+ link=$(readlink "$self")
8
+ case "$link" in /*) self="$link" ;; *) self="$(dirname "$self")/$link" ;; esac
9
+ done
10
+ entry="$(cd "$(dirname "$self")/.." && pwd)/dist/nodii-dev.js"
11
+ if command -v bun >/dev/null 2>&1; then exec bun "$entry" "$@"; fi
12
+ exec node "$entry" "$@"
package/dist/api.js ADDED
@@ -0,0 +1,151 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { stdin } from "node:process";
3
+ import { CliError, encode, hubRawRequest, readConfig } from "./core.js";
4
+
5
+ const methods = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
6
+ export async function loadOpenApi(fetcher = fetch) {
7
+ const { hubUrl } = await readConfig();
8
+ const response = await fetcher(`${hubUrl.replace(/\/$/, "")}/openapi.json`, { headers: { accept: "application/json" } });
9
+ if (!response.ok) throw new CliError("OPENAPI_UNAVAILABLE", `Cannot load OpenAPI: HTTP ${response.status}`);
10
+ return response.json();
11
+ }
12
+
13
+ export function operationsFrom(spec) {
14
+ const out = [];
15
+ for (const [path, item] of Object.entries(spec.paths ?? {}))
16
+ for (const [method, operation] of Object.entries(item))
17
+ if (methods.has(method) && operation.operationId)
18
+ out.push({ operationId: operation.operationId, method: method.toUpperCase(), path, tags: operation.tags ?? [], operation });
19
+ return out.sort((a, b) => a.operationId.localeCompare(b.operationId));
20
+ }
21
+
22
+ export function parseParams(value) {
23
+ const values = value === undefined ? [] : Array.isArray(value) ? value : [value];
24
+ const result = {};
25
+ for (const entry of values) {
26
+ const at = String(entry).indexOf("=");
27
+ if (at < 1) throw new CliError("USAGE", `--param needs k=v, got: ${entry}`, 2);
28
+ const key = entry.slice(0, at), raw = entry.slice(at + 1);
29
+ result[key] = raw;
30
+ }
31
+ return result;
32
+ }
33
+
34
+ const coerce = (raw, schema = {}) => {
35
+ if (schema.type === "boolean") return raw === true || raw === "true";
36
+ if (schema.type === "integer" || schema.type === "number") return Number(raw);
37
+ if (schema.type === "array") return Array.isArray(raw) ? raw : String(raw).split(",").filter(Boolean);
38
+ if (schema.type === "object" || raw?.startsWith?.("{") || raw?.startsWith?.("[")) {
39
+ try { return JSON.parse(raw); } catch { return raw; }
40
+ }
41
+ return raw;
42
+ };
43
+ const camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
44
+
45
+ async function stdinText() { let text = ""; for await (const chunk of stdin) text += chunk; return text; }
46
+ export async function readBody(bodyArg) {
47
+ if (bodyArg === undefined) return undefined;
48
+ const text = bodyArg === "-" ? await stdinText() : bodyArg.startsWith("@") ? await readFile(bodyArg.slice(1), "utf8") : bodyArg;
49
+ try { return { value: JSON.parse(text), text }; }
50
+ catch { throw new CliError("INVALID_BODY", "--body must contain valid JSON", 2); }
51
+ }
52
+
53
+ export function buildOperationRequest(entry, params = {}, bodyValue) {
54
+ let path = entry.path;
55
+ const query = new URLSearchParams();
56
+ const definitions = entry.operation.parameters ?? [];
57
+ for (const parameter of definitions) {
58
+ const raw = params[parameter.name];
59
+ if (parameter.in === "path") {
60
+ if (raw === undefined) throw new CliError("USAGE", `Missing --param ${parameter.name}=...`, 2);
61
+ path = path.replace(`{${parameter.name}}`, encode(raw));
62
+ } else if (parameter.in === "query" && raw !== undefined) {
63
+ for (const value of Array.isArray(raw) ? raw : [raw]) query.append(parameter.name, String(value));
64
+ }
65
+ }
66
+ const suffix = query.toString();
67
+ return { method: entry.method, path: `${path}${suffix ? `?${suffix}` : ""}`, body: bodyValue };
68
+ }
69
+
70
+ export function flagsToOperation(entry, positional, flags) {
71
+ const params = {};
72
+ let pos = 0;
73
+ for (const p of entry.operation.parameters ?? []) {
74
+ const flag = flags[p.name] ?? flags[p.name.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)];
75
+ if (p.in === "path") params[p.name] = flag ?? positional[pos++];
76
+ else if (flag !== undefined) params[p.name] = flag;
77
+ }
78
+ const schema = entry.operation.requestBody?.content?.["application/json"]?.schema;
79
+ const body = {};
80
+ for (const [name, property] of Object.entries(schema?.properties ?? {})) {
81
+ const raw = flags[name] ?? flags[name.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)];
82
+ if (raw !== undefined) body[name] = coerce(raw, property);
83
+ }
84
+ return { params, body: schema ? body : undefined, consumed: pos };
85
+ }
86
+
87
+ export async function callOperation(entry, params, body) {
88
+ const request = buildOperationRequest(entry, params, body);
89
+ return hubRawRequest(request.method, request.path, body === undefined ? undefined : JSON.stringify(body));
90
+ }
91
+
92
+ export const GROUPS = {
93
+ services: "Services", "feature-docs": "FeatureDocs", contracts: ["GrpcInterfaces", "RestInterfaces", "Outbox", "OutboxTopics", "Sagas", "Tables"],
94
+ sagas: "Sagas", "outbox-topics": ["Outbox", "OutboxTopics"], tables: "Tables", libraries: "Libraries", workers: "Workers",
95
+ projects: "Projects", issues: "Issues", backlog: "Backlogs", backlogs: "Backlogs", builds: "Delivery", changes: "Delivery", "deploy-reports": "Runtime",
96
+ runtime: "Runtime", people: "People", agents: "Agents", inbox: "Inbox", gate: "Inbox", design: "Design", runbooks: "Runbooks",
97
+ doctrine: ["Doctrine", "DoctrineExceptions"], rulebook: "Doctrine", changelog: "Changelog", "health-verdicts": "HealthVerdicts",
98
+ "approval-kinds": "ApprovalKinds", "notification-registry": "Notification registry", skills: "Skills", "open-questions": "OpenQuestions",
99
+ decisions: "Decisions", drift: "Drift", requests: "Requests", pipelines: "Pipelines", slices: "Slices", "sql-lookups": "SqlLookups",
100
+ runners: "Runners", routing: "Routing", usage: "Usage", audit: "AuditLog", reconciliation: "Reconciliation", cron: "Cron", health: "Health",
101
+ composite: "Composite", "service-edges": "ServiceEdges",
102
+ };
103
+
104
+ export const ALIASES = {
105
+ "requests:show": "getRequest", "requests:list": "listRequests", "requests:claim": "claimRequest", "requests:answer": "answerInboxItem",
106
+ "requests:resolve": "resolveRequest", "requests:reject": "rejectRequest", "requests:file": "fileRequest",
107
+ "drift:list": "listDrift", "drift:show": "getDrift", "drift:file": "fileDrift", "drift:resolve": "operatorResolveDrift", "drift:triage": "acceptDrift",
108
+ "decisions:get": "getDecision", "decisions:list": "listDecisions", "decisions:create": "createDecision", "decisions:lock": "lockDecision",
109
+ "decisions:supersede": "supersedeDecision", "decisions:amend": "createDecisionAmendment",
110
+ "open-questions:list": "listOpenQuestions", "open-questions:show": "getOpenQuestion", "open-questions:answer": "answerOpenQuestion",
111
+ "feature-docs:list": "listFeatureDocs", "feature-docs:show": "getFeatureDoc", "feature-docs:claim": "claimFeatureDoc",
112
+ "feature-docs:release": "releaseFeatureDocClaim", "feature-docs:status": "patchFeatureDocStatus",
113
+ "backlogs:list": "listBacklogs", "backlogs:show": "getBacklog", "backlogs:add": "addBacklogItem", "backlogs:move": "moveBacklogItem",
114
+ "backlog:list": "listBacklogs", "backlog:show": "getBacklog", "backlog:add": "addBacklogItem", "backlog:move": "moveBacklogItem",
115
+ "libraries:register-version": "registerLibraryVersion",
116
+ "services:list": "listServices", "services:show": "getService", "services:create": "createService", "services:update": "updateService",
117
+ "sagas:list": "listSagas", "sagas:show": "getSaga", "sagas:create": "createSaga", "sagas:update": "updateSaga",
118
+ "outbox-topics:list": "listOutboxTopics", "outbox-topics:show": "getOutboxTopic", "outbox-topics:create": "createOutboxTopic",
119
+ "tables:list": "listServiceTables", "tables:show": "getTable", "tables:create": "createTable", "tables:update": "updateTable",
120
+ "libraries:list": "listLibraries", "libraries:show": "getLibrary", "libraries:create": "createLibrary", "libraries:update": "updateLibrary",
121
+ "workers:list": "listWorkers", "workers:show": "getWorker", "workers:create": "createWorker", "workers:update": "updateWorker",
122
+ "projects:list": "listServiceProjects", "projects:show": "getProject", "projects:create": "createProject", "projects:update": "updateProject",
123
+ "builds:list": "listBuilds", "builds:show": "getBuild", "builds:report": "reportBuild",
124
+ "changes:list": "listChanges", "changes:show": "getChange", "changes:mark": "markChange",
125
+ "deploy-reports:report": "reportDeployment", "runtime:list": "listRuntimeInventory", "runtime:show": "getServiceRuntime",
126
+ "people:list": "listPeople", "people:show": "getPerson", "agents:list": "listAgents", "agents:show": "getAgent",
127
+ "gate:fleet": "getFleetGate", "gate:service": "getServiceGate", "gate:dry-run": "getGateDryRun",
128
+ "runbooks:list": "listRunbooks", "runbooks:show": "getRunbook", "runbooks:create": "registerRunbook", "runbooks:update": "updateRunbook",
129
+ "doctrine:list": "listDoctrine", "doctrine:show": "getDoctrine", "doctrine:create": "createDoctrine", "doctrine:revise": "reviseDoctrine",
130
+ "rulebook:list": "listDoctrine", "rulebook:show": "getDoctrine", "changelog:list": "listChangelog", "changelog:append": "appendChangelog",
131
+ "health-verdicts:list": "listHealthVerdicts", "health-verdicts:post": "postHealthVerdict",
132
+ "approval-kinds:list": "listApprovalKinds", "approval-kinds:show": "getApprovalKind", "approval-kinds:create": "createApprovalKind",
133
+ "notification-registry:list": "listNotificationEvents", "notification-registry:show": "getNotificationEvent", "notification-registry:propose": "proposeNotificationEvent",
134
+ "skills:list": "listSkills", "skills:show": "getSkillBySlug", "skills:create": "createSkill", "skills:revise": "reviseSkill",
135
+ "pipelines:list": "listPipelines", "pipelines:show": "getPipeline", "pipelines:create": "createPipeline",
136
+ "slices:list": "listServiceSlices", "slices:show": "getSlice", "slices:create": "createServiceSlice",
137
+ "sql-lookups:list": "listServiceSqlLookups", "sql-lookups:show": "getSqlLookup", "sql-lookups:create": "createSqlLookup",
138
+ "runners:list": "listRunners", "runners:register": "registerRunner", "routing:lookup": "routingLookup", "usage:show": "getUsageFanIn",
139
+ "audit:list": "listAuditLog", "health:show": "getHealth",
140
+ "composite:refresher": "getServiceRefresher", "composite:impact": "getImpact", "composite:search": "search",
141
+ "service-edges:list": "listServiceEdges", "service-edges:show": "getServiceEdgesForService",
142
+ };
143
+
144
+ export function findFirstClass(operations, group, action) {
145
+ const aliased = ALIASES[`${group}:${action}`];
146
+ if (aliased) return operations.find(x => x.operationId === aliased);
147
+ const tags = Array.isArray(GROUPS[group]) ? GROUPS[group] : [GROUPS[group]];
148
+ const candidates = operations.filter(x => x.tags.some(t => tags.includes(t)));
149
+ const normalized = action?.replace(/-([a-z])/g, (_, c) => c.toUpperCase()).toLowerCase();
150
+ return candidates.find(x => x.operationId.toLowerCase() === normalized || x.operationId.toLowerCase().endsWith(normalized));
151
+ }
package/dist/core.js ADDED
@@ -0,0 +1,339 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, createHmac } from "node:crypto";
3
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { homedir, platform } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+
7
+ export const VERSION = "0.3.1";
8
+ export const DEFAULT_HUB_URL = "https://planning.dev.nucleus-cloud.in";
9
+ export const OPERATOR_AGENT_ID = "00000000-0000-0000-0000-000000000001";
10
+ export const configPath = () =>
11
+ process.env.NODII_DEV_CONFIG_PATH ??
12
+ join(homedir(), ".config/nodii-dev/config.json");
13
+ export const secretPath = () =>
14
+ process.env.NODII_DEV_SECRET_PATH ??
15
+ join(dirname(configPath()), "hmac-secret");
16
+
17
+ export function signRequest({
18
+ secret,
19
+ agentId,
20
+ method,
21
+ path,
22
+ body = "",
23
+ timestamp = Math.floor(Date.now() / 1000),
24
+ }) {
25
+ const upper = method.toUpperCase();
26
+ const bodyHash = createHash("sha256").update(body).digest("hex");
27
+ const payload = `${timestamp}.${upper}.${new URL(path, "http://local").pathname}.${bodyHash}`;
28
+ return {
29
+ "x-agent-id": agentId,
30
+ "x-timestamp": String(timestamp),
31
+ "x-signature": createHmac("sha256", secret).update(payload).digest("hex"),
32
+ };
33
+ }
34
+
35
+ export function requestHash({ toService, kind, specMd, at = new Date() }) {
36
+ const minute = at.toISOString().slice(0, 16);
37
+ return createHash("sha256")
38
+ .update(`${toService}|${kind}|${specMd}|${minute}`)
39
+ .digest("hex")
40
+ .slice(0, 16);
41
+ }
42
+
43
+ export async function readConfig(required = true) {
44
+ try {
45
+ return JSON.parse(await readFile(configPath(), "utf8"));
46
+ } catch (error) {
47
+ if (!required && error?.code === "ENOENT") return null;
48
+ throw new CliError("NOT_LOGGED_IN", "Run `nodii-dev login` first.", 2);
49
+ }
50
+ }
51
+
52
+ export async function writeConfig(value) {
53
+ const file = configPath();
54
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
55
+ await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
56
+ await chmod(file, 0o600);
57
+ }
58
+
59
+ function run(command, args, options = {}) {
60
+ return new Promise((resolve, reject) => {
61
+ const child = spawn(command, args, {
62
+ ...options,
63
+ stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
64
+ });
65
+ let stdout = "";
66
+ let stderr = "";
67
+ child.stdout?.on("data", (x) => {
68
+ stdout += x;
69
+ });
70
+ child.stderr?.on("data", (x) => {
71
+ stderr += x;
72
+ });
73
+ child.on("error", reject);
74
+ child.on("close", (code) =>
75
+ code === 0
76
+ ? resolve(stdout)
77
+ : reject(new Error(stderr.trim() || `${command} exited ${code}`)),
78
+ );
79
+ });
80
+ }
81
+
82
+ const keychainService = "dev.nodii.cli";
83
+ export async function storeSecret(secret) {
84
+ if (
85
+ platform() === "darwin" &&
86
+ process.env.NODII_DEV_DISABLE_KEYCHAIN !== "1"
87
+ ) {
88
+ try {
89
+ await run("security", [
90
+ "add-generic-password",
91
+ "-U",
92
+ "-a",
93
+ "hmac",
94
+ "-s",
95
+ keychainService,
96
+ "-w",
97
+ secret,
98
+ ]);
99
+ await rm(secretPath(), { force: true });
100
+ return "keychain";
101
+ } catch {
102
+ /* fall through for minimal/non-interactive macOS images */
103
+ }
104
+ }
105
+ const file = secretPath();
106
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
107
+ await writeFile(file, secret, { mode: 0o600 });
108
+ await chmod(file, 0o600);
109
+ return "file";
110
+ }
111
+
112
+ export async function loadSecret() {
113
+ if (process.env.PLANNING_HUB_HMAC_SECRET)
114
+ return process.env.PLANNING_HUB_HMAC_SECRET;
115
+ if (
116
+ platform() === "darwin" &&
117
+ process.env.NODII_DEV_DISABLE_KEYCHAIN !== "1"
118
+ ) {
119
+ try {
120
+ return (
121
+ await run("security", [
122
+ "find-generic-password",
123
+ "-a",
124
+ "hmac",
125
+ "-s",
126
+ keychainService,
127
+ "-w",
128
+ ])
129
+ ).trim();
130
+ } catch {
131
+ /* try fallback file */
132
+ }
133
+ }
134
+ try {
135
+ return (await readFile(secretPath(), "utf8")).trim();
136
+ } catch {
137
+ throw new CliError(
138
+ "SECRET_NOT_FOUND",
139
+ "No HMAC credential is stored; run `nodii-dev login`.",
140
+ 2,
141
+ );
142
+ }
143
+ }
144
+
145
+ export async function deleteCredentials() {
146
+ if (
147
+ platform() === "darwin" &&
148
+ process.env.NODII_DEV_DISABLE_KEYCHAIN !== "1"
149
+ ) {
150
+ try {
151
+ await run("security", [
152
+ "delete-generic-password",
153
+ "-a",
154
+ "hmac",
155
+ "-s",
156
+ keychainService,
157
+ ]);
158
+ } catch {}
159
+ }
160
+ await Promise.all([
161
+ rm(configPath(), { force: true }),
162
+ rm(secretPath(), { force: true }),
163
+ ]);
164
+ }
165
+
166
+ export async function secretFromAws() {
167
+ const raw = await run("aws", [
168
+ "secretsmanager",
169
+ "get-secret-value",
170
+ "--secret-id",
171
+ "nodii/dev/app/planning_hub",
172
+ "--region",
173
+ "ap-south-1",
174
+ "--query",
175
+ "SecretString",
176
+ "--output",
177
+ "text",
178
+ ]);
179
+ const value = JSON.parse(raw);
180
+ if (!value.PLANNING_HUB_HMAC_SECRET)
181
+ throw new CliError(
182
+ "AWS_SECRET_KEY_MISSING",
183
+ "The AWS secret does not contain PLANNING_HUB_HMAC_SECRET.",
184
+ );
185
+ return value.PLANNING_HUB_HMAC_SECRET;
186
+ }
187
+
188
+ export class CliError extends Error {
189
+ constructor(code, message, exitCode, details) {
190
+ super(message);
191
+ this.code = code;
192
+ this.exitCode = exitCode;
193
+ this.details = details;
194
+ }
195
+ }
196
+
197
+ /** @typedef {{headers(input: {method:string, path:string, body:string}): Promise<Record<string,string>>}} AuthProvider */
198
+ /** HMAC v1 provider. A nodii-auth OAuth provider can implement the same boundary. */
199
+ export class HmacAuthProvider {
200
+ async headers({ method, path, body }) {
201
+ const config = await readConfig();
202
+ return signRequest({
203
+ secret: await loadSecret(),
204
+ agentId: config.agentId,
205
+ method,
206
+ path,
207
+ body,
208
+ });
209
+ }
210
+ }
211
+
212
+ let oauthAuthProvider;
213
+ export async function authProvider(config) {
214
+ config ??= await readConfig();
215
+ if (config.authProvider === "oauth") {
216
+ const { OAuthAuthProvider } = await import("./oauth.js");
217
+ oauthAuthProvider ??= new OAuthAuthProvider();
218
+ return oauthAuthProvider;
219
+ }
220
+ return new HmacAuthProvider();
221
+ }
222
+
223
+ export function requestUrl(config, path) {
224
+ if (config.authProvider === "oauth")
225
+ return `${config.edgeUrl.replace(/\/$/, "")}/devops${path}`;
226
+ return `${config.hubUrl.replace(/\/$/, "")}${path}`;
227
+ }
228
+
229
+ export async function hubRequest(
230
+ method,
231
+ path,
232
+ body,
233
+ { auth } = {},
234
+ ) {
235
+ const config = await readConfig();
236
+ auth ??= await authProvider(config);
237
+ const text = body === undefined ? "" : JSON.stringify(body);
238
+ const headers = { accept: "application/json" };
239
+ if (body !== undefined) headers["content-type"] = "application/json";
240
+ if (config.authProvider === "oauth" || !["GET", "HEAD", "OPTIONS"].includes(method))
241
+ Object.assign(headers, await auth.headers({ method, path, body: text }));
242
+ let response;
243
+ try {
244
+ response = await fetch(requestUrl(config, path), {
245
+ method,
246
+ headers,
247
+ body: body === undefined ? undefined : text,
248
+ });
249
+ } catch (error) {
250
+ throw new CliError(
251
+ "HUB_UNREACHABLE",
252
+ `Cannot reach the hub: ${error.message}`,
253
+ );
254
+ }
255
+ const raw = await response.text();
256
+ let payload;
257
+ try {
258
+ payload = raw ? JSON.parse(raw) : {};
259
+ } catch {
260
+ payload = { message: raw };
261
+ }
262
+ if (!response.ok)
263
+ throw new CliError(
264
+ payload.code ?? `HTTP_${response.status}`,
265
+ payload.message ?? `Hub returned HTTP ${response.status}`,
266
+ 1,
267
+ payload.details,
268
+ );
269
+ return payload;
270
+ }
271
+
272
+ export async function hubRawRequest(method, path, bodyText, contentType = "application/json") {
273
+ const config = await readConfig();
274
+ const auth = await authProvider(config);
275
+ const headers = { accept: "application/json" };
276
+ if (bodyText !== undefined) headers["content-type"] = contentType;
277
+ if (config.authProvider === "oauth" || !["GET", "HEAD", "OPTIONS"].includes(method))
278
+ Object.assign(headers, await auth.headers({ method, path, body: bodyText ?? "" }));
279
+ let response;
280
+ try {
281
+ response = await fetch(requestUrl(config, path), {
282
+ method,
283
+ headers,
284
+ body: bodyText === undefined ? undefined : bodyText,
285
+ });
286
+ } catch (error) {
287
+ throw new CliError("HUB_UNREACHABLE", `Cannot reach the hub: ${error.message}`);
288
+ }
289
+ const raw = await response.text();
290
+ let payload;
291
+ try { payload = raw ? JSON.parse(raw) : {}; } catch { payload = { message: raw }; }
292
+ if (!response.ok)
293
+ throw new CliError(payload.code ?? `HTTP_${response.status}`, payload.message ?? `Hub returned HTTP ${response.status}`, 1, payload.details);
294
+ return payload;
295
+ }
296
+
297
+ export const encode = (value) => encodeURIComponent(value);
298
+ export function parseArgs(argv) {
299
+ const flags = {};
300
+ const args = [];
301
+ for (let i = 0; i < argv.length; i++) {
302
+ const token = argv[i];
303
+ if (!token.startsWith("--")) {
304
+ args.push(token);
305
+ continue;
306
+ }
307
+ const [key, inline] = token.slice(2).split("=", 2);
308
+ const value =
309
+ inline !== undefined
310
+ ? inline
311
+ : argv[i + 1] && !argv[i + 1].startsWith("--")
312
+ ? argv[++i]
313
+ : true;
314
+ flags[key] =
315
+ flags[key] === undefined
316
+ ? value
317
+ : Array.isArray(flags[key])
318
+ ? [...flags[key], value]
319
+ : [flags[key], value];
320
+ }
321
+ return { args, flags };
322
+ }
323
+
324
+ export function requireFlag(flags, name) {
325
+ const value = flags[name];
326
+ if (value === undefined || value === true || value === "")
327
+ throw new CliError("USAGE", `Missing --${name}`, 2);
328
+ return value;
329
+ }
330
+
331
+ export const query = (pairs) => {
332
+ const p = new URLSearchParams();
333
+ for (const [key, value] of Object.entries(pairs))
334
+ if (value !== undefined && value !== false)
335
+ for (const item of Array.isArray(value) ? value : [value])
336
+ p.append(key, String(item));
337
+ const value = p.toString();
338
+ return value ? `?${value}` : "";
339
+ };