@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 +131 -2
- package/bin/nodii-dev +12 -0
- package/dist/api.js +151 -0
- package/dist/core.js +339 -0
- package/dist/hooks.js +153 -0
- package/dist/nodii-dev.js +586 -0
- package/dist/oauth.js +195 -0
- package/package.json +25 -4
package/README.md
CHANGED
|
@@ -1,3 +1,132 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@nodii/dev`
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
};
|