@nasebanal/cli 0.1.0
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/LICENSE +21 -0
- package/README.md +237 -0
- package/dist/apis.js +6 -0
- package/dist/auth.js +98 -0
- package/dist/config.js +97 -0
- package/dist/http.js +55 -0
- package/dist/index.js +53 -0
- package/dist/oauth.js +265 -0
- package/dist/output.js +26 -0
- package/dist/spec/build.js +185 -0
- package/dist/spec/loader.js +23 -0
- package/package.json +59 -0
- package/specs/account.yaml +990 -0
- package/specs/app-template.yaml +486 -0
- package/specs/recorder.yaml +2774 -0
- package/specs/target.yaml +1540 -0
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive Auth0 login for the CLI (gcloud-style).
|
|
3
|
+
*
|
|
4
|
+
* Two flows, sharing one Auth0 "Native" application (PKCE, no client secret):
|
|
5
|
+
*
|
|
6
|
+
* 1. Loopback (default) — start a throwaway HTTP server on localhost, open the
|
|
7
|
+
* browser to Auth0 `/authorize`, capture the redirected `?code=…`, and
|
|
8
|
+
* exchange it for tokens. This is what `gcloud auth login` does.
|
|
9
|
+
* 2. Device code (fallback / `--no-launch-browser`) — print a short user code
|
|
10
|
+
* and a verification URL, then poll `/oauth/token` until the user finishes
|
|
11
|
+
* in any browser. For SSH / headless boxes with no local browser.
|
|
12
|
+
*
|
|
13
|
+
* This module is pure transport: it talks to Auth0 and returns tokens. It never
|
|
14
|
+
* reads or writes the on-disk config — persistence lives in `config.ts`, which
|
|
15
|
+
* is also the only caller of `refreshTokens()`.
|
|
16
|
+
*
|
|
17
|
+
* Auth0 setup required (one time, by a tenant admin):
|
|
18
|
+
* - Create an Application of type **Native** (PKCE, public client).
|
|
19
|
+
* - Grant types: Authorization Code, Refresh Token, Device Code.
|
|
20
|
+
* - Allowed Callback URLs: this tenant validates the port, so register every
|
|
21
|
+
* port in LOOPBACK_PORTS as `http://localhost:<port>/callback`. The CLI binds
|
|
22
|
+
* the first free one (or the single NB_AUTH0_REDIRECT_PORT if set).
|
|
23
|
+
* - Enable "Allow Offline Access" on the NASEBANAL API (for refresh tokens).
|
|
24
|
+
* - Put the resulting Client ID in AUTH0_CLIENT_ID below (it is public — a
|
|
25
|
+
* native-app client ID is not a secret), or pass NB_AUTH0_CLIENT_ID.
|
|
26
|
+
*/
|
|
27
|
+
import { createServer } from "node:http";
|
|
28
|
+
import { spawn } from "node:child_process";
|
|
29
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
30
|
+
const AUTH0_DOMAIN = process.env.NB_AUTH0_DOMAIN || "dev-bv0p1636dyofjbmg.us.auth0.com";
|
|
31
|
+
const AUTH0_AUDIENCE = process.env.NB_AUTH0_AUDIENCE || "https://api.nasebanal.com";
|
|
32
|
+
/** Public Client ID of the Native app (not a secret for public PKCE clients). */
|
|
33
|
+
const AUTH0_CLIENT_ID = "EkdU317qo2fXg2DF3n2vUo7NWpUi9Dt2";
|
|
34
|
+
const SCOPE = "openid profile email offline_access";
|
|
35
|
+
/**
|
|
36
|
+
* Fixed loopback ports the CLI will bind, in order. This tenant validates the
|
|
37
|
+
* callback URL by exact match (port included), so EVERY port listed here must be
|
|
38
|
+
* registered in Auth0 Allowed Callback URLs as `http://localhost:<port>/callback`.
|
|
39
|
+
* The CLI uses the first one that is free. `NB_AUTH0_REDIRECT_PORT` pins one.
|
|
40
|
+
*/
|
|
41
|
+
const LOOPBACK_PORTS = [8085, 8086, 8087, 8088, 8089];
|
|
42
|
+
/** Thrown when the loopback flow can't even start, so the caller can fall back. */
|
|
43
|
+
export class LoopbackUnavailableError extends Error {
|
|
44
|
+
constructor(message) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = "LoopbackUnavailableError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function clientId() {
|
|
50
|
+
const id = process.env.NB_AUTH0_CLIENT_ID || AUTH0_CLIENT_ID;
|
|
51
|
+
if (!id) {
|
|
52
|
+
throw new Error("Auth0 client ID is not configured. Set NB_AUTH0_CLIENT_ID, or bake the " +
|
|
53
|
+
"Native app's Client ID into AUTH0_CLIENT_ID in src/oauth.ts.");
|
|
54
|
+
}
|
|
55
|
+
return id;
|
|
56
|
+
}
|
|
57
|
+
const base64url = (buf) => buf.toString("base64url");
|
|
58
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
59
|
+
function generatePkce() {
|
|
60
|
+
const verifier = base64url(randomBytes(32));
|
|
61
|
+
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
|
62
|
+
return { verifier, challenge };
|
|
63
|
+
}
|
|
64
|
+
/** Best-effort browser launch. Always also print the URL so manual paste works. */
|
|
65
|
+
function openBrowser(url) {
|
|
66
|
+
const { platform } = process;
|
|
67
|
+
const [cmd, args] = platform === "darwin"
|
|
68
|
+
? ["open", [url]]
|
|
69
|
+
: platform === "win32"
|
|
70
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
71
|
+
: ["xdg-open", [url]];
|
|
72
|
+
try {
|
|
73
|
+
const child = spawn(cmd, [...args], { stdio: "ignore", detached: true });
|
|
74
|
+
child.on("error", () => {
|
|
75
|
+
/* no browser available — manual URL is already printed */
|
|
76
|
+
});
|
|
77
|
+
child.unref();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* ignore — manual URL is already printed */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function toTokens(data) {
|
|
84
|
+
return {
|
|
85
|
+
access_token: data.access_token,
|
|
86
|
+
refresh_token: data.refresh_token,
|
|
87
|
+
id_token: data.id_token,
|
|
88
|
+
token_type: data.token_type,
|
|
89
|
+
scope: data.scope,
|
|
90
|
+
expires_at: typeof data.expires_in === "number" ? Date.now() + data.expires_in * 1000 : undefined,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async function postForm(path, body) {
|
|
94
|
+
const res = await fetch(`https://${AUTH0_DOMAIN}${path}`, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
97
|
+
body: new URLSearchParams(body).toString(),
|
|
98
|
+
});
|
|
99
|
+
return (await res.json().catch(() => ({})));
|
|
100
|
+
}
|
|
101
|
+
function tokenError(data, fallback) {
|
|
102
|
+
return new Error(`${fallback}: ${data.error ?? "unknown"}${data.error_description ? ` — ${data.error_description}` : ""}`);
|
|
103
|
+
}
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Loopback flow
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
const SUCCESS_HTML = `<!doctype html><meta charset="utf-8"><title>NASEBANAL CLI</title>
|
|
108
|
+
<body style="font-family:system-ui;max-width:32rem;margin:4rem auto;text-align:center">
|
|
109
|
+
<h1>✓ Logged in</h1><p>You can close this tab and return to your terminal.</p></body>`;
|
|
110
|
+
function listen(server, port) {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
server.once("error", reject);
|
|
113
|
+
server.listen(port, "127.0.0.1", () => {
|
|
114
|
+
server.removeListener("error", reject);
|
|
115
|
+
resolve();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/** Resolve with the auth `code` once Auth0 redirects to our loopback server. */
|
|
120
|
+
function waitForCode(server, expectedState, timeoutMs) {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const timer = setTimeout(() => {
|
|
123
|
+
reject(new Error("Timed out waiting for the browser login to complete."));
|
|
124
|
+
}, timeoutMs);
|
|
125
|
+
server.on("request", (req, res) => {
|
|
126
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
127
|
+
if (url.pathname !== "/callback") {
|
|
128
|
+
res.writeHead(404).end();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const error = url.searchParams.get("error");
|
|
132
|
+
const code = url.searchParams.get("code");
|
|
133
|
+
const state = url.searchParams.get("state");
|
|
134
|
+
res.writeHead(error ? 400 : 200, { "Content-Type": "text/html; charset=utf-8" });
|
|
135
|
+
res.end(error ? `Login failed: ${error}. You can close this tab.` : SUCCESS_HTML);
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
if (error)
|
|
138
|
+
return reject(tokenError({ error }, "Authorization failed"));
|
|
139
|
+
if (state !== expectedState)
|
|
140
|
+
return reject(new Error("State mismatch — possible CSRF; aborting."));
|
|
141
|
+
if (!code)
|
|
142
|
+
return reject(new Error("No authorization code in the callback."));
|
|
143
|
+
resolve(code);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
export async function loopbackLogin() {
|
|
148
|
+
const { verifier, challenge } = generatePkce();
|
|
149
|
+
const state = base64url(randomBytes(16));
|
|
150
|
+
const server = createServer();
|
|
151
|
+
const pinned = Number(process.env.NB_AUTH0_REDIRECT_PORT) || 0;
|
|
152
|
+
const candidates = pinned ? [pinned] : LOOPBACK_PORTS;
|
|
153
|
+
let bound = false;
|
|
154
|
+
for (const candidate of candidates) {
|
|
155
|
+
try {
|
|
156
|
+
await listen(server, candidate);
|
|
157
|
+
bound = true;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
/* port busy — try the next one */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!bound) {
|
|
165
|
+
throw new LoopbackUnavailableError(`Could not bind any loopback callback port (tried ${candidates.join(", ")}).`);
|
|
166
|
+
}
|
|
167
|
+
const port = server.address().port;
|
|
168
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
169
|
+
const authorizeUrl = new URL(`https://${AUTH0_DOMAIN}/authorize`);
|
|
170
|
+
authorizeUrl.search = new URLSearchParams({
|
|
171
|
+
response_type: "code",
|
|
172
|
+
client_id: clientId(),
|
|
173
|
+
redirect_uri: redirectUri,
|
|
174
|
+
scope: SCOPE,
|
|
175
|
+
audience: AUTH0_AUDIENCE,
|
|
176
|
+
code_challenge: challenge,
|
|
177
|
+
code_challenge_method: "S256",
|
|
178
|
+
state,
|
|
179
|
+
}).toString();
|
|
180
|
+
process.stderr.write(`Opening your browser to log in. If it doesn't open, visit this URL:\n\n ${authorizeUrl.toString()}\n\n`);
|
|
181
|
+
openBrowser(authorizeUrl.toString());
|
|
182
|
+
try {
|
|
183
|
+
const code = await waitForCode(server, state, 5 * 60_000);
|
|
184
|
+
const data = await postForm("/oauth/token", {
|
|
185
|
+
grant_type: "authorization_code",
|
|
186
|
+
client_id: clientId(),
|
|
187
|
+
code,
|
|
188
|
+
redirect_uri: redirectUri,
|
|
189
|
+
code_verifier: verifier,
|
|
190
|
+
});
|
|
191
|
+
if (!data.access_token)
|
|
192
|
+
throw tokenError(data, "Token exchange failed");
|
|
193
|
+
return toTokens(data);
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
server.close();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
export async function deviceCodeLogin() {
|
|
200
|
+
const res = await fetch(`https://${AUTH0_DOMAIN}/oauth/device/code`, {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
203
|
+
body: new URLSearchParams({ client_id: clientId(), scope: SCOPE, audience: AUTH0_AUDIENCE }).toString(),
|
|
204
|
+
});
|
|
205
|
+
const data = (await res.json().catch(() => ({})));
|
|
206
|
+
if (!res.ok || !data.device_code)
|
|
207
|
+
throw tokenError(data, "Could not start device login");
|
|
208
|
+
process.stderr.write(`\nTo log in, visit:\n\n ${data.verification_uri}\n\nand enter the code:\n\n ${data.user_code}\n\n`);
|
|
209
|
+
if (data.verification_uri_complete) {
|
|
210
|
+
process.stderr.write(`Or open this URL directly (code pre-filled):\n\n ${data.verification_uri_complete}\n\n`);
|
|
211
|
+
openBrowser(data.verification_uri_complete);
|
|
212
|
+
}
|
|
213
|
+
process.stderr.write("Waiting for you to finish in the browser…\n");
|
|
214
|
+
let intervalMs = (data.interval ?? 5) * 1000;
|
|
215
|
+
const deadline = Date.now() + data.expires_in * 1000;
|
|
216
|
+
while (Date.now() < deadline) {
|
|
217
|
+
await sleep(intervalMs);
|
|
218
|
+
const token = await postForm("/oauth/token", {
|
|
219
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
220
|
+
client_id: clientId(),
|
|
221
|
+
device_code: data.device_code,
|
|
222
|
+
});
|
|
223
|
+
if (token.access_token)
|
|
224
|
+
return toTokens(token);
|
|
225
|
+
switch (token.error) {
|
|
226
|
+
case "authorization_pending":
|
|
227
|
+
break;
|
|
228
|
+
case "slow_down":
|
|
229
|
+
intervalMs += 5_000;
|
|
230
|
+
break;
|
|
231
|
+
default:
|
|
232
|
+
throw tokenError(token, "Device login failed");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
throw new Error("Device login expired before it was approved. Run `nb auth login` again.");
|
|
236
|
+
}
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Public entry points
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
/** Interactive login. Tries loopback; falls back to device code when it can't. */
|
|
241
|
+
export async function login(opts) {
|
|
242
|
+
if (!opts.launchBrowser)
|
|
243
|
+
return deviceCodeLogin();
|
|
244
|
+
try {
|
|
245
|
+
return await loopbackLogin();
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
if (err instanceof LoopbackUnavailableError) {
|
|
249
|
+
process.stderr.write(`${err.message}\nFalling back to device-code login.\n`);
|
|
250
|
+
return deviceCodeLogin();
|
|
251
|
+
}
|
|
252
|
+
throw err;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** Exchange a refresh token for a fresh access token. Called by config.ts. */
|
|
256
|
+
export async function refreshTokens(refreshToken) {
|
|
257
|
+
const data = await postForm("/oauth/token", {
|
|
258
|
+
grant_type: "refresh_token",
|
|
259
|
+
client_id: clientId(),
|
|
260
|
+
refresh_token: refreshToken,
|
|
261
|
+
});
|
|
262
|
+
if (!data.access_token)
|
|
263
|
+
throw tokenError(data, "Token refresh failed");
|
|
264
|
+
return toTokens(data);
|
|
265
|
+
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output helpers. Default output is pretty JSON to stdout; errors go to stderr.
|
|
3
|
+
* A future iteration can add `--format table` once command metadata is richer.
|
|
4
|
+
*/
|
|
5
|
+
import { ApiError } from "./http.js";
|
|
6
|
+
export function printJson(data) {
|
|
7
|
+
if (typeof data === "string") {
|
|
8
|
+
process.stdout.write(data + "\n");
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
12
|
+
}
|
|
13
|
+
export function printError(err) {
|
|
14
|
+
if (err instanceof ApiError) {
|
|
15
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
16
|
+
if (err.data !== undefined && err.data !== "") {
|
|
17
|
+
const body = typeof err.data === "string" ? err.data : JSON.stringify(err.data, null, 2);
|
|
18
|
+
process.stderr.write(body + "\n");
|
|
19
|
+
}
|
|
20
|
+
if (err.status === 401) {
|
|
21
|
+
process.stderr.write("\nHint: run `nb auth login` or set NB_TOKEN to authenticate.\n");
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
process.stderr.write(`Error: ${err.message ?? String(err)}\n`);
|
|
26
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns an OpenAPI spec into a gcloud-style commander command tree.
|
|
3
|
+
*
|
|
4
|
+
* Mapping (deterministic, since the specs carry no operationId yet):
|
|
5
|
+
* - The URL path, minus a leading `/api/v1`, becomes a noun hierarchy.
|
|
6
|
+
* - Path parameters (`{id}`) become positional arguments, not command names.
|
|
7
|
+
* - A path ending in a plural noun is a collection; a singular noun is either a
|
|
8
|
+
* singleton resource or an action. The HTTP method + that distinction picks
|
|
9
|
+
* the leaf verb:
|
|
10
|
+
* GET collection -> list GET singleton -> get
|
|
11
|
+
* GET/…/{id} -> get DELETE/…/{id} -> delete
|
|
12
|
+
* POST collection -> create PUT -> replace PATCH -> update
|
|
13
|
+
* write singular segment -> the segment itself is the verb (action)
|
|
14
|
+
* - Query parameters become `--flag` options; a request body is `--data <json>`.
|
|
15
|
+
*
|
|
16
|
+
* e.g. GET /api/v1/me -> nb account me get
|
|
17
|
+
* GET /api/v1/me/tokens -> nb account me tokens list
|
|
18
|
+
* POST /api/v1/me/tokens -> nb account me tokens create --data '…'
|
|
19
|
+
* DELETE /api/v1/me/tokens/{id} -> nb account me tokens delete <id>
|
|
20
|
+
* POST /api/v1/shares/{id}/accept-> nb target shares accept <id>
|
|
21
|
+
* POST /api/v1/stripe/checkout -> nb account stripe checkout --data '…'
|
|
22
|
+
*
|
|
23
|
+
* When two operations collapse to the same command (e.g. `/shares/accept` and
|
|
24
|
+
* `/shares/{id}/accept`), the more specific one — more path parameters — wins,
|
|
25
|
+
* deterministically, regardless of spec order.
|
|
26
|
+
*
|
|
27
|
+
* When the specs gain operationId / x-cli-name hints, this is the single place
|
|
28
|
+
* that consumes them — the command surface stays a pure function of the contract.
|
|
29
|
+
*/
|
|
30
|
+
import { Command } from "commander";
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
import { loadSpec } from "./loader.js";
|
|
33
|
+
import { resolveBaseUrl, request } from "../http.js";
|
|
34
|
+
import { resolveAccessToken, resolveEnv } from "../config.js";
|
|
35
|
+
import { printJson } from "../output.js";
|
|
36
|
+
const METHODS = ["get", "post", "put", "patch", "delete"];
|
|
37
|
+
function stripBasePath(path) {
|
|
38
|
+
return path.replace(/^\/api\/v1(?=\/|$)/, "") || "/";
|
|
39
|
+
}
|
|
40
|
+
function pathParams(path) {
|
|
41
|
+
return [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
|
|
42
|
+
}
|
|
43
|
+
/** Rough plurality test — good enough to tell collections from singletons/actions. */
|
|
44
|
+
function isPlural(segment) {
|
|
45
|
+
return /s$/.test(segment) && !/ss$/.test(segment);
|
|
46
|
+
}
|
|
47
|
+
const PLURAL_VERB = {
|
|
48
|
+
get: "list",
|
|
49
|
+
post: "create",
|
|
50
|
+
put: "replace",
|
|
51
|
+
patch: "update",
|
|
52
|
+
delete: "delete",
|
|
53
|
+
};
|
|
54
|
+
/** Command-name chain for an operation, e.g. ["me", "tokens", "list"]. */
|
|
55
|
+
function commandChain(method, path) {
|
|
56
|
+
const segments = stripBasePath(path).split("/").filter(Boolean);
|
|
57
|
+
const nonParam = segments.filter((s) => !s.startsWith("{"));
|
|
58
|
+
const last = segments[segments.length - 1] ?? "";
|
|
59
|
+
// Item access by path parameter: /records/{id}, /me/tokens/{id}
|
|
60
|
+
if (last.startsWith("{")) {
|
|
61
|
+
const verb = method === "get" ? "get" : PLURAL_VERB[method];
|
|
62
|
+
return [...nonParam, verb];
|
|
63
|
+
}
|
|
64
|
+
const leafSegment = nonParam[nonParam.length - 1] ?? "";
|
|
65
|
+
const prefix = nonParam.slice(0, -1);
|
|
66
|
+
if (method === "get") {
|
|
67
|
+
return [...nonParam, isPlural(leafSegment) ? "list" : "get"];
|
|
68
|
+
}
|
|
69
|
+
// Write methods on a collection -> CRUD verb; on a singular segment -> action.
|
|
70
|
+
if (isPlural(leafSegment)) {
|
|
71
|
+
return [...nonParam, PLURAL_VERB[method]];
|
|
72
|
+
}
|
|
73
|
+
return [...prefix, leafSegment];
|
|
74
|
+
}
|
|
75
|
+
/** Find or create an intermediate group command under `parent`. */
|
|
76
|
+
function group(parent, name) {
|
|
77
|
+
const existing = parent.commands.find((c) => c.name() === name);
|
|
78
|
+
if (existing)
|
|
79
|
+
return existing;
|
|
80
|
+
const cmd = new Command(name).description(`${name} commands`);
|
|
81
|
+
parent.addCommand(cmd);
|
|
82
|
+
return cmd;
|
|
83
|
+
}
|
|
84
|
+
function readData(value) {
|
|
85
|
+
const raw = value.startsWith("@") ? readFileSync(value.slice(1), "utf8") : value;
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(raw);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new Error(`--data must be valid JSON (or @file containing JSON); got: ${value}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function attachLeaf(parent, name, pathTemplate, method, op, servers) {
|
|
94
|
+
const leaf = new Command(name);
|
|
95
|
+
leaf.description(op.summary || `${method.toUpperCase()} ${pathTemplate}`);
|
|
96
|
+
const params = pathParams(pathTemplate);
|
|
97
|
+
for (const p of params)
|
|
98
|
+
leaf.argument(`<${p}>`, `path parameter: ${p}`);
|
|
99
|
+
const queryParams = (op.parameters ?? []).filter((p) => p.in === "query");
|
|
100
|
+
for (const q of queryParams) {
|
|
101
|
+
leaf.option(`--${q.name} <value>`, q.description || `query parameter: ${q.name}`);
|
|
102
|
+
}
|
|
103
|
+
const acceptsBody = method !== "get" && method !== "delete";
|
|
104
|
+
if (acceptsBody || op.requestBody) {
|
|
105
|
+
leaf.option("--data <json>", "request body as JSON (or @file to read from a file)");
|
|
106
|
+
}
|
|
107
|
+
leaf.action(async (...args) => {
|
|
108
|
+
// commander passes: ...positionalArgs, options, command
|
|
109
|
+
const command = args[args.length - 1];
|
|
110
|
+
const positionals = args.slice(0, params.length);
|
|
111
|
+
const opts = command.optsWithGlobals();
|
|
112
|
+
const env = opts.env || resolveEnv();
|
|
113
|
+
const token = opts.token || (await resolveAccessToken());
|
|
114
|
+
// NB_BASE_URL overrides server resolution entirely (origin only — the spec
|
|
115
|
+
// path is still appended). Used by the CDD contract test to point the CLI at
|
|
116
|
+
// a Specmatic stub, and available for advanced/self-hosted setups.
|
|
117
|
+
const override = process.env.NB_BASE_URL?.replace(/\/$/, "");
|
|
118
|
+
const baseUrl = override ?? resolveBaseUrl(servers, env);
|
|
119
|
+
let realPath = pathTemplate;
|
|
120
|
+
params.forEach((p, i) => {
|
|
121
|
+
realPath = realPath.replace(`{${p}}`, encodeURIComponent(positionals[i]));
|
|
122
|
+
});
|
|
123
|
+
const query = {};
|
|
124
|
+
for (const q of queryParams)
|
|
125
|
+
query[q.name] = opts[q.name];
|
|
126
|
+
const body = opts.data !== undefined ? readData(opts.data) : undefined;
|
|
127
|
+
const res = await request({ baseUrl, path: realPath, method, query, body, token });
|
|
128
|
+
printJson(res.data);
|
|
129
|
+
});
|
|
130
|
+
parent.addCommand(leaf);
|
|
131
|
+
}
|
|
132
|
+
/** Build the top-level command group for one API from its spec. */
|
|
133
|
+
export function buildApiCommand(api) {
|
|
134
|
+
const spec = loadSpec(api.spec);
|
|
135
|
+
const root = new Command(api.name).description(api.summary || spec.info.title);
|
|
136
|
+
// Collect, then dedupe by command chain so the most specific operation (the
|
|
137
|
+
// one with the most path parameters) wins regardless of declaration order.
|
|
138
|
+
const byChain = new Map();
|
|
139
|
+
for (const [path, item] of Object.entries(spec.paths)) {
|
|
140
|
+
for (const method of METHODS) {
|
|
141
|
+
const op = item[method];
|
|
142
|
+
if (!op)
|
|
143
|
+
continue;
|
|
144
|
+
const entry = { chain: commandChain(method, path), path, method, op };
|
|
145
|
+
const key = entry.chain.join(" ");
|
|
146
|
+
const existing = byChain.get(key);
|
|
147
|
+
if (!existing || pathParams(path).length > pathParams(existing.path).length) {
|
|
148
|
+
byChain.set(key, entry);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// A write on a singular segment is named after the segment itself, so that
|
|
153
|
+
// `POST /me/accept-terms` reads as `me accept-terms`. That breaks down when
|
|
154
|
+
// the same segment is ALSO a group: `DELETE /api/v1/me` wants the leaf `me`
|
|
155
|
+
// while `GET /api/v1/me` and `GET /api/v1/me/tokens` want a group `me`, and
|
|
156
|
+
// commander rejects the second registration outright ("cannot add command
|
|
157
|
+
// 'me' as already have command 'me'"). Account deletion introduced exactly
|
|
158
|
+
// that shape in account 1.2.0 and took the whole CLI build down with it.
|
|
159
|
+
//
|
|
160
|
+
// Any chain that is a strict prefix of another chain is such a case; give it
|
|
161
|
+
// its verb back (`me delete`), which is what the non-colliding paths already
|
|
162
|
+
// look like. An entry is only re-keyed if the verb form is still free, so a
|
|
163
|
+
// real operation already sitting there is never displaced.
|
|
164
|
+
const chains = [...byChain.keys()].map((k) => k.split(" "));
|
|
165
|
+
const isGroupElsewhere = (chain) => chains.some((other) => other.length > chain.length && chain.every((s, i) => other[i] === s));
|
|
166
|
+
for (const entry of [...byChain.values()]) {
|
|
167
|
+
if (!isGroupElsewhere(entry.chain))
|
|
168
|
+
continue;
|
|
169
|
+
const verb = entry.method === "get" ? "get" : PLURAL_VERB[entry.method];
|
|
170
|
+
const renamed = [...entry.chain, verb];
|
|
171
|
+
const key = renamed.join(" ");
|
|
172
|
+
if (byChain.has(key))
|
|
173
|
+
continue;
|
|
174
|
+
byChain.delete(entry.chain.join(" "));
|
|
175
|
+
byChain.set(key, { ...entry, chain: renamed });
|
|
176
|
+
}
|
|
177
|
+
for (const { chain, path, method, op } of byChain.values()) {
|
|
178
|
+
const leafName = chain[chain.length - 1];
|
|
179
|
+
let parent = root;
|
|
180
|
+
for (const segment of chain.slice(0, -1))
|
|
181
|
+
parent = group(parent, segment);
|
|
182
|
+
attachLeaf(parent, leafName, path, method, op, spec.servers);
|
|
183
|
+
}
|
|
184
|
+
return root;
|
|
185
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads and minimally types the OpenAPI specs that drive the command tree.
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { parse } from "yaml";
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
// dist/spec/loader.js -> ../../specs ; src/spec/loader.ts -> ../../specs
|
|
10
|
+
const SPECS_DIR = join(__dirname, "..", "..", "specs");
|
|
11
|
+
export function specPath(file) {
|
|
12
|
+
return join(SPECS_DIR, file);
|
|
13
|
+
}
|
|
14
|
+
export function specExists(file) {
|
|
15
|
+
return existsSync(specPath(file));
|
|
16
|
+
}
|
|
17
|
+
export function loadSpec(file) {
|
|
18
|
+
const path = specPath(file);
|
|
19
|
+
if (!existsSync(path)) {
|
|
20
|
+
throw new Error(`Spec not found: ${path}\nRun \`npm run sync-specs\` (local dev) to populate the specs/ directory.`);
|
|
21
|
+
}
|
|
22
|
+
return parse(readFileSync(path, "utf8"));
|
|
23
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nasebanal/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "gcloud-style command-line interface for NASEBANAL APIs, generated from nb-api-specs OpenAPI contracts.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"nb": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"specs"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"sync-specs": "node scripts/sync-specs.mjs",
|
|
19
|
+
"build": "npm run sync-specs && tsc -p tsconfig.json",
|
|
20
|
+
"dev": "node --import tsx src/index.ts",
|
|
21
|
+
"start": "node dist/index.js",
|
|
22
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"prepublishOnly": "npm run build"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"commander": "^12.1.0",
|
|
28
|
+
"yaml": "^2.5.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^20.14.0",
|
|
32
|
+
"tsx": "^4.16.0",
|
|
33
|
+
"typescript": "^5.5.0",
|
|
34
|
+
"vitest": "^4.1.8"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/nasebanal/nb-cli.git"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"nasebanal",
|
|
42
|
+
"cli",
|
|
43
|
+
"openapi",
|
|
44
|
+
"cdd",
|
|
45
|
+
"api-client"
|
|
46
|
+
],
|
|
47
|
+
"overrides": {
|
|
48
|
+
"postcss": "^8.5.18",
|
|
49
|
+
"brace-expansion@<=1.1.17": "1.1.18",
|
|
50
|
+
"brace-expansion@2.0.0 - 2.1.3": "2.1.4",
|
|
51
|
+
"brace-expansion@4.0.0 - 5.0.8": "5.0.9",
|
|
52
|
+
"nanoid@<3.3.17": "3.3.18"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"registry": "https://registry.npmjs.org/",
|
|
56
|
+
"access": "public",
|
|
57
|
+
"@nasebanal:registry": "https://registry.npmjs.org/"
|
|
58
|
+
}
|
|
59
|
+
}
|