@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NASEBANAL
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# NASEBANAL CLI
|
|
2
|
+
|
|
3
|
+
`nb` — a gcloud-style command-line interface for the NASEBANAL APIs.
|
|
4
|
+
|
|
5
|
+
The command tree is **generated from the OpenAPI contracts** published by
|
|
6
|
+
[`nb-api-specs`](https://github.com/nasebanal/nb-api-specs). Every endpoint
|
|
7
|
+
becomes a `nb <api> <resource> <verb>` command, so the CLI stays a pure function
|
|
8
|
+
of the API contracts — it is the command-line consumer in the NASEBANAL
|
|
9
|
+
Contract-Driven Development (CDD) pipeline, alongside the web frontends.
|
|
10
|
+
|
|
11
|
+
```console
|
|
12
|
+
$ nb account me get
|
|
13
|
+
$ nb recorder records list
|
|
14
|
+
$ nb target shares accept <id>
|
|
15
|
+
$ nb account me tokens create --data '{"name":"ci"}'
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Tech Stack
|
|
19
|
+
|
|
20
|
+
- **Language:** TypeScript (ESM, Node ≥ 20)
|
|
21
|
+
- **CLI framework:** commander
|
|
22
|
+
- **Spec parsing:** yaml
|
|
23
|
+
- **Contracts:** `@nasebanal/api-specs-*` (OpenAPI 3.0) — the same packages backends and frontends consume
|
|
24
|
+
- **Authentication:** browser/device-code Auth0 login, or a NASEBANAL Personal Access Token (`nbpat_…`)
|
|
25
|
+
- **Testing:** Vitest
|
|
26
|
+
- **Distribution:** npm (`npx @nasebanal/cli`); single-binary builds are a planned follow-up
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
The CLI installs the `nb` command. Node ≥ 20 is required.
|
|
31
|
+
|
|
32
|
+
### From the npm registry (end users)
|
|
33
|
+
|
|
34
|
+
`@nasebanal/cli` is published to the public npm registry — no `git clone`, no
|
|
35
|
+
registry configuration, no token:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install -g @nasebanal/cli # global: the `nb` command on your PATH
|
|
39
|
+
nb --version
|
|
40
|
+
nb --help
|
|
41
|
+
|
|
42
|
+
# or run without installing (pin a version for reproducible scripts)
|
|
43
|
+
npx @nasebanal/cli@0.1.0 account me get
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The published package bundles the OpenAPI specs, so no `nb-api-specs` checkout is
|
|
47
|
+
needed for a registry install.
|
|
48
|
+
|
|
49
|
+
> **NASEBANAL developers:** if your `~/.npmrc` maps `@nasebanal:registry` to
|
|
50
|
+
> GitHub Packages (needed for the private `@nasebanal/shared-navigation` /
|
|
51
|
+
> `api-specs-*` packages), it captures this package too. Install with
|
|
52
|
+
> `npm install -g @nasebanal/cli --@nasebanal:registry=https://registry.npmjs.org/`.
|
|
53
|
+
|
|
54
|
+
### Versioning and releases
|
|
55
|
+
|
|
56
|
+
Versions follow SemVer (0.x until 1.0: breaking changes bump the minor). Each
|
|
57
|
+
release bundles a fixed set of API contract versions, recorded in
|
|
58
|
+
[`spec-versions.json`](spec-versions.json). A release is cut by pushing a
|
|
59
|
+
`vX.Y.Z` tag that matches `package.json`; the `Release` workflow publishes it to
|
|
60
|
+
npm with provenance.
|
|
61
|
+
|
|
62
|
+
### From source (contributors)
|
|
63
|
+
|
|
64
|
+
Requires a sibling `nb-api-specs` checkout (the build syncs specs from it):
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
git clone https://github.com/nasebanal/nb-cli.git
|
|
68
|
+
cd nb-cli
|
|
69
|
+
npm install
|
|
70
|
+
npm run build # sync specs + compile to dist/
|
|
71
|
+
npm link # symlink the `nb` command onto your PATH
|
|
72
|
+
nb --help
|
|
73
|
+
|
|
74
|
+
# undo later with: npm unlink -g @nasebanal/cli
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Without linking, you can always run the compiled binary directly
|
|
78
|
+
(`node dist/index.js …`) or from source via `npm run dev -- …` (see
|
|
79
|
+
[Local Development](#local-development)).
|
|
80
|
+
|
|
81
|
+
### First run
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
nb auth login # browser login (see Authentication below)
|
|
85
|
+
nb account me get # verify the token works against a real API
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Command Mapping
|
|
89
|
+
|
|
90
|
+
The specs carry no `operationId` yet, so commands are derived deterministically
|
|
91
|
+
from each path + method:
|
|
92
|
+
|
|
93
|
+
| OpenAPI operation | Command |
|
|
94
|
+
|-------------------|---------|
|
|
95
|
+
| `GET /api/v1/me` (singleton) | `nb account me get` |
|
|
96
|
+
| `GET /api/v1/me/tokens` (collection) | `nb account me tokens list` |
|
|
97
|
+
| `POST /api/v1/me/tokens` | `nb account me tokens create --data '…'` |
|
|
98
|
+
| `DELETE /api/v1/me/tokens/{id}` | `nb account me tokens delete <id>` |
|
|
99
|
+
| `POST /api/v1/shares/{id}/accept` (action) | `nb target shares accept <id>` |
|
|
100
|
+
| `POST /api/v1/stripe/checkout` | `nb account stripe checkout --data '…'` |
|
|
101
|
+
|
|
102
|
+
Rules: a path ending in a plural noun is a collection (`list` / `create`); a
|
|
103
|
+
singular noun is a singleton (`get`) or, for write methods, an action whose
|
|
104
|
+
segment becomes the verb. Path parameters become positional arguments; query
|
|
105
|
+
parameters become `--flags`; a request body is `--data <json>` (or `--data @file`).
|
|
106
|
+
When two operations collapse to the same command (e.g. `/shares/accept` and
|
|
107
|
+
`/shares/{id}/accept`), the more specific one — more path parameters — wins.
|
|
108
|
+
|
|
109
|
+
> When the specs gain `operationId` / `x-cli-name` hints, `src/spec/build.ts` is
|
|
110
|
+
> the single place that consumes them. Adding those hints is the recommended way
|
|
111
|
+
> to refine command names without touching the CLI.
|
|
112
|
+
|
|
113
|
+
## Authentication
|
|
114
|
+
|
|
115
|
+
`nb auth login` opens a browser, you log in with Auth0, and control returns to
|
|
116
|
+
the terminal automatically — the same experience as `gcloud auth login`.
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
nb auth login # browser login (loopback OAuth + PKCE)
|
|
120
|
+
nb auth login --no-launch-browser # device-code flow (SSH / headless boxes)
|
|
121
|
+
nb auth status # show active credential source + environment
|
|
122
|
+
nb auth logout # remove stored credentials
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Under the hood this is OAuth 2.0 Authorization Code + PKCE against a shared
|
|
126
|
+
Auth0 **Native** application. On login the CLI stores the access token and a
|
|
127
|
+
refresh token; expired access tokens are refreshed automatically on the next
|
|
128
|
+
command, so you rarely need to log in again.
|
|
129
|
+
|
|
130
|
+
**PAT / CI auth** is still supported for scripting:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
nb auth login --token nbpat_… # store a Personal Access Token non-interactively
|
|
134
|
+
nb auth login --token-stdin # paste a PAT from stdin (no shell history)
|
|
135
|
+
NB_TOKEN=nbpat_… nb … # env override (wins over everything; no login needed)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Create a PAT in the web app under **Settings → API tokens** (Account API).
|
|
139
|
+
|
|
140
|
+
- Credentials live at `~/.config/nasebanal/config.json` with `0600` permissions.
|
|
141
|
+
- Precedence on each request: `NB_TOKEN` env → OAuth tokens → stored PAT.
|
|
142
|
+
|
|
143
|
+
### Auth0 setup (one time, tenant admin)
|
|
144
|
+
|
|
145
|
+
The browser flow needs a public **Native** application in Auth0:
|
|
146
|
+
|
|
147
|
+
1. Create an Application of type **Native** (PKCE, no client secret).
|
|
148
|
+
2. Grant types: **Authorization Code**, **Refresh Token**, **Device Code**.
|
|
149
|
+
3. Allowed Callback URLs — this tenant validates the port, so register every
|
|
150
|
+
loopback port the CLI may use (the CLI binds the first free one):
|
|
151
|
+
`http://localhost:8085/callback, http://localhost:8086/callback, http://localhost:8087/callback, http://localhost:8088/callback, http://localhost:8089/callback`
|
|
152
|
+
(`NB_AUTH0_REDIRECT_PORT` pins a single port if you prefer.)
|
|
153
|
+
4. Enable **Allow Offline Access** on the NASEBANAL API (for refresh tokens).
|
|
154
|
+
5. Put the resulting **Client ID** (public, not a secret) into `AUTH0_CLIENT_ID`
|
|
155
|
+
in `src/oauth.ts`, or set `NB_AUTH0_CLIENT_ID` to test before baking it in.
|
|
156
|
+
|
|
157
|
+
Overridable env vars: `NB_AUTH0_DOMAIN`, `NB_AUTH0_AUDIENCE`,
|
|
158
|
+
`NB_AUTH0_CLIENT_ID`, `NB_AUTH0_REDIRECT_PORT`.
|
|
159
|
+
|
|
160
|
+
## Global Options
|
|
161
|
+
|
|
162
|
+
| Option | Description |
|
|
163
|
+
|--------|-------------|
|
|
164
|
+
| `--env <production\|local>` | Target environment; picks the matching `servers` entry from the spec. Also `NB_ENV`. |
|
|
165
|
+
| `--token <token>` | Override stored credentials with a PAT for one invocation. |
|
|
166
|
+
| `-V, --version` | Print the CLI version. |
|
|
167
|
+
|
|
168
|
+
The active environment defaults to `production`; `--env local` targets the
|
|
169
|
+
`wrangler dev` servers declared in each spec.
|
|
170
|
+
|
|
171
|
+
## Adding a New API
|
|
172
|
+
|
|
173
|
+
1. Publish/own its spec in `nb-api-specs` (it ships as `@nasebanal/api-specs-<api>`).
|
|
174
|
+
2. Add an entry to `API_CATALOG` in `src/apis.ts` and to the list in `scripts/sync-specs.mjs`.
|
|
175
|
+
3. `npm run sync-specs && npm run build` — the command group appears automatically.
|
|
176
|
+
|
|
177
|
+
## Project Structure
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
nb-cli/
|
|
181
|
+
├── src/
|
|
182
|
+
│ ├── index.ts # Entry: global options, auth group, one group per API (★)
|
|
183
|
+
│ ├── apis.ts # API catalog (★ register new APIs here)
|
|
184
|
+
│ ├── auth.ts # `nb auth login | logout | status`
|
|
185
|
+
│ ├── oauth.ts # Auth0 loopback + device-code login, PKCE, token refresh
|
|
186
|
+
│ ├── config.ts # credentials + environment persisted to ~/.config/nasebanal/config.json
|
|
187
|
+
│ ├── http.ts # base-URL resolution + bearer request layer
|
|
188
|
+
│ ├── output.ts # JSON / error formatting
|
|
189
|
+
│ └── spec/
|
|
190
|
+
│ ├── loader.ts # read + parse spec YAML from specs/
|
|
191
|
+
│ ├── build.ts # ★ OpenAPI -> commander command tree (the core mapping)
|
|
192
|
+
│ └── build.test.ts # command-mapping tests
|
|
193
|
+
├── scripts/
|
|
194
|
+
│ └── sync-specs.mjs # Copy specs into specs/ (local dev convenience)
|
|
195
|
+
├── specs/ # ★ Generated: OpenAPI specs, not committed (see below)
|
|
196
|
+
├── package.json
|
|
197
|
+
├── tsconfig.json
|
|
198
|
+
└── README.md
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Files marked with ★ are the usual customization targets.
|
|
202
|
+
|
|
203
|
+
## Development
|
|
204
|
+
|
|
205
|
+
### Local Development
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
npm install
|
|
209
|
+
npm run sync-specs # copy specs from a sibling nb-api-specs checkout into specs/
|
|
210
|
+
npm run dev -- --help # run from source (tsx), e.g. `npm run dev -- account me get`
|
|
211
|
+
npm run build # sync specs + type-check + emit dist/
|
|
212
|
+
npm test # Vitest
|
|
213
|
+
npm run typecheck # tsc --noEmit
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`sync-specs` resolves each spec from, in order:
|
|
217
|
+
|
|
218
|
+
1. `node_modules/@nasebanal/api-specs-<api>/spec.yaml` (installed package), then
|
|
219
|
+
2. `../nb-api-specs/packages/<api>/spec.yaml` (local monorepo checkout).
|
|
220
|
+
|
|
221
|
+
In production the published `@nasebanal/api-specs-*` packages are pinned as
|
|
222
|
+
dependencies — the consumer side of CDD. In local dev we copy straight from the
|
|
223
|
+
sibling repo so contract edits are visible without a publish (mirroring how
|
|
224
|
+
`@nasebanal/shared-navigation` has both a published and a local-copy path).
|
|
225
|
+
|
|
226
|
+
### Conventions
|
|
227
|
+
|
|
228
|
+
- **The command surface is generated, not hand-written** — refine commands by
|
|
229
|
+
improving the contract (e.g. add `operationId`), not by special-casing the CLI.
|
|
230
|
+
- **Specs are dependencies, not source** — `specs/` is git-ignored; production
|
|
231
|
+
pins the published `@nasebanal/api-specs-*` packages.
|
|
232
|
+
- **Never commit a PAT** — config lives outside the repo; CI uses `NB_TOKEN`.
|
|
233
|
+
- **Deploy/release via PR merge and CI** — never publish from a local machine.
|
|
234
|
+
|
|
235
|
+
## License
|
|
236
|
+
|
|
237
|
+
MIT
|
package/dist/apis.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const API_CATALOG = [
|
|
2
|
+
{ name: "account", spec: "account.yaml", summary: "User profiles, subscriptions, billing, and API tokens" },
|
|
3
|
+
{ name: "recorder", spec: "recorder.yaml", summary: "Records and tags (NASEBANAL Recorder)" },
|
|
4
|
+
{ name: "target", spec: "target.yaml", summary: "Targets, cells, and shares (NASEBANAL Target)" },
|
|
5
|
+
{ name: "app-template", spec: "app-template.yaml", summary: "Reference API surface (App Template)" },
|
|
6
|
+
];
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nb auth` commands.
|
|
3
|
+
*
|
|
4
|
+
* The default `nb auth login` is an interactive Auth0 login (gcloud-style): it
|
|
5
|
+
* opens a browser, you log in, and control returns to the terminal automatically
|
|
6
|
+
* (loopback flow), falling back to a device code on headless boxes. For CI and
|
|
7
|
+
* scripting a NASEBANAL Personal Access Token (`nbpat_…`) is still supported via
|
|
8
|
+
* `--token` / `--token-stdin`, or the `NB_TOKEN` env var (which overrides both).
|
|
9
|
+
*/
|
|
10
|
+
import { Command } from "commander";
|
|
11
|
+
import { createInterface } from "node:readline";
|
|
12
|
+
import { loadConfig, saveConfig, resolveToken, resolveEnv, configFilePath } from "./config.js";
|
|
13
|
+
import { login } from "./oauth.js";
|
|
14
|
+
function readSecretLine(promptText) {
|
|
15
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
|
16
|
+
process.stdout.write(promptText);
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
rl.once("line", (line) => {
|
|
19
|
+
rl.close();
|
|
20
|
+
resolve(line.trim());
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function storePat(config, token) {
|
|
25
|
+
if (!token) {
|
|
26
|
+
process.stderr.write("No token provided.\n");
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!token.startsWith("nbpat_")) {
|
|
31
|
+
process.stderr.write("warning: token does not start with 'nbpat_'; storing anyway.\n");
|
|
32
|
+
}
|
|
33
|
+
config.token = token;
|
|
34
|
+
delete config.auth;
|
|
35
|
+
saveConfig(config);
|
|
36
|
+
process.stdout.write(`Token saved to ${configFilePath()}\n`);
|
|
37
|
+
}
|
|
38
|
+
export function buildAuthCommand() {
|
|
39
|
+
const auth = new Command("auth").description("Manage CLI authentication");
|
|
40
|
+
auth
|
|
41
|
+
.command("login")
|
|
42
|
+
.description("Log in via the browser (or store a PAT with --token / --token-stdin)")
|
|
43
|
+
.option("--no-launch-browser", "use the device-code flow instead of opening a browser")
|
|
44
|
+
.option("--token <token>", "store a Personal Access Token (nbpat_…) non-interactively")
|
|
45
|
+
.option("--token-stdin", "read a Personal Access Token from stdin")
|
|
46
|
+
.action(async (opts) => {
|
|
47
|
+
const config = loadConfig();
|
|
48
|
+
if (opts.token !== undefined) {
|
|
49
|
+
storePat(config, opts.token);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (opts.tokenStdin) {
|
|
53
|
+
storePat(config, await readSecretLine("Paste your NASEBANAL PAT (nbpat_…): "));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const tokens = await login({ launchBrowser: opts.launchBrowser !== false });
|
|
57
|
+
config.auth = tokens;
|
|
58
|
+
delete config.token;
|
|
59
|
+
saveConfig(config);
|
|
60
|
+
const expiry = tokens.expires_at ? new Date(tokens.expires_at).toLocaleString() : "n/a";
|
|
61
|
+
process.stdout.write(`\n✓ Logged in. Credentials saved to ${configFilePath()}\n`);
|
|
62
|
+
process.stdout.write(` Access token expires: ${expiry}${tokens.refresh_token ? " (auto-refreshes)" : ""}\n`);
|
|
63
|
+
});
|
|
64
|
+
auth
|
|
65
|
+
.command("logout")
|
|
66
|
+
.description("Remove stored credentials")
|
|
67
|
+
.action(() => {
|
|
68
|
+
const config = loadConfig();
|
|
69
|
+
delete config.token;
|
|
70
|
+
delete config.auth;
|
|
71
|
+
saveConfig(config);
|
|
72
|
+
process.stdout.write("Credentials removed.\n");
|
|
73
|
+
});
|
|
74
|
+
auth
|
|
75
|
+
.command("status")
|
|
76
|
+
.description("Show the active credential source and target environment")
|
|
77
|
+
.action(() => {
|
|
78
|
+
const config = loadConfig();
|
|
79
|
+
const token = resolveToken(config);
|
|
80
|
+
const source = process.env.NB_TOKEN
|
|
81
|
+
? "NB_TOKEN env"
|
|
82
|
+
: config.auth
|
|
83
|
+
? "OAuth login"
|
|
84
|
+
: config.token
|
|
85
|
+
? "stored PAT"
|
|
86
|
+
: "none";
|
|
87
|
+
const masked = token ? `${token.slice(0, 10)}…${token.slice(-4)}` : "(not set)";
|
|
88
|
+
process.stdout.write(`Environment: ${resolveEnv(config)}\n`);
|
|
89
|
+
process.stdout.write(`Token: ${masked} [source: ${source}]\n`);
|
|
90
|
+
if (config.auth?.expires_at) {
|
|
91
|
+
const expired = config.auth.expires_at <= Date.now();
|
|
92
|
+
process.stdout.write(`Expires: ${new Date(config.auth.expires_at).toLocaleString()}` +
|
|
93
|
+
`${expired ? config.auth.refresh_token ? " (expired — will refresh)" : " (expired)" : ""}\n`);
|
|
94
|
+
}
|
|
95
|
+
process.stdout.write(`Config file: ${configFilePath()}\n`);
|
|
96
|
+
});
|
|
97
|
+
return auth;
|
|
98
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent CLI configuration (credentials, target environment).
|
|
3
|
+
*
|
|
4
|
+
* Stored as JSON at `$XDG_CONFIG_HOME/nasebanal/config.json` (falling back to
|
|
5
|
+
* `~/.config/nasebanal/config.json`). Two credential shapes can live here:
|
|
6
|
+
* - `auth` — OAuth tokens from `nb auth login` (browser / device-code flow).
|
|
7
|
+
* - `token` — a Personal Access Token (`nbpat_…`) for manual / scripted use.
|
|
8
|
+
* `NB_TOKEN` in the environment always overrides both, which is the supported
|
|
9
|
+
* path for CI.
|
|
10
|
+
*/
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync } from "node:fs";
|
|
14
|
+
import { refreshTokens } from "./oauth.js";
|
|
15
|
+
function configDir() {
|
|
16
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
17
|
+
return join(base, "nasebanal");
|
|
18
|
+
}
|
|
19
|
+
function configPath() {
|
|
20
|
+
return join(configDir(), "config.json");
|
|
21
|
+
}
|
|
22
|
+
export function loadConfig() {
|
|
23
|
+
const path = configPath();
|
|
24
|
+
if (!existsSync(path))
|
|
25
|
+
return {};
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function saveConfig(config) {
|
|
34
|
+
const dir = configDir();
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
const path = configPath();
|
|
37
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
|
38
|
+
// Tighten perms in case the file already existed with a looser mode.
|
|
39
|
+
try {
|
|
40
|
+
chmodSync(path, 0o600);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
/* best effort */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the active token synchronously, WITHOUT refreshing — for display only
|
|
48
|
+
* (`nb auth status`). Precedence: `NB_TOKEN` env, OAuth access token, stored PAT.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveToken(config = loadConfig()) {
|
|
51
|
+
return process.env.NB_TOKEN || config.auth?.access_token || config.token;
|
|
52
|
+
}
|
|
53
|
+
/** Refresh OAuth tokens in-place when the access token is expired (or close to). */
|
|
54
|
+
async function ensureFreshAuth(config) {
|
|
55
|
+
const auth = config.auth;
|
|
56
|
+
if (!auth)
|
|
57
|
+
return undefined;
|
|
58
|
+
const skewMs = 60_000;
|
|
59
|
+
if (!auth.expires_at || auth.expires_at - skewMs > Date.now())
|
|
60
|
+
return auth;
|
|
61
|
+
if (!auth.refresh_token)
|
|
62
|
+
return auth; // can't refresh; let the request 401
|
|
63
|
+
try {
|
|
64
|
+
const next = await refreshTokens(auth.refresh_token);
|
|
65
|
+
// Token rotation may not return a new refresh token — keep the old one.
|
|
66
|
+
const merged = { ...next, refresh_token: next.refresh_token ?? auth.refresh_token };
|
|
67
|
+
config.auth = merged;
|
|
68
|
+
saveConfig(config);
|
|
69
|
+
return merged;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return auth; // refresh failed; surface as a 401 on the actual request
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the token to send on API requests, refreshing an expired OAuth access
|
|
77
|
+
* token first. Precedence: `NB_TOKEN` env, OAuth (refreshed), stored PAT.
|
|
78
|
+
*/
|
|
79
|
+
export async function resolveAccessToken(config = loadConfig()) {
|
|
80
|
+
if (process.env.NB_TOKEN)
|
|
81
|
+
return process.env.NB_TOKEN;
|
|
82
|
+
if (config.auth) {
|
|
83
|
+
const fresh = await ensureFreshAuth(config);
|
|
84
|
+
return fresh?.access_token;
|
|
85
|
+
}
|
|
86
|
+
return config.token;
|
|
87
|
+
}
|
|
88
|
+
/** Resolve the active environment: `NB_ENV` env wins, else stored, else production. */
|
|
89
|
+
export function resolveEnv(config = loadConfig()) {
|
|
90
|
+
const fromEnv = process.env.NB_ENV;
|
|
91
|
+
if (fromEnv === "local" || fromEnv === "production")
|
|
92
|
+
return fromEnv;
|
|
93
|
+
return config.env ?? "production";
|
|
94
|
+
}
|
|
95
|
+
export function configFilePath() {
|
|
96
|
+
return configPath();
|
|
97
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export class ApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
data;
|
|
4
|
+
constructor(message, status, data) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.data = data;
|
|
8
|
+
this.name = "ApiError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Pick the server URL matching the active environment. */
|
|
12
|
+
export function resolveBaseUrl(servers, env) {
|
|
13
|
+
if (!servers || servers.length === 0) {
|
|
14
|
+
throw new Error("Spec declares no servers; cannot resolve a base URL.");
|
|
15
|
+
}
|
|
16
|
+
const isLocal = (s) => /localhost|127\.0\.0\.1/.test(s.url);
|
|
17
|
+
const match = env === "local" ? servers.find(isLocal) : servers.find((s) => !isLocal(s));
|
|
18
|
+
return (match ?? servers[0]).url.replace(/\/$/, "");
|
|
19
|
+
}
|
|
20
|
+
export async function request(opts) {
|
|
21
|
+
const url = new URL(opts.baseUrl + opts.path);
|
|
22
|
+
for (const [key, value] of Object.entries(opts.query ?? {})) {
|
|
23
|
+
if (value !== undefined)
|
|
24
|
+
url.searchParams.set(key, value);
|
|
25
|
+
}
|
|
26
|
+
const headers = { Accept: "application/json" };
|
|
27
|
+
if (opts.token)
|
|
28
|
+
headers.Authorization = `Bearer ${opts.token}`;
|
|
29
|
+
const init = { method: opts.method.toUpperCase(), headers };
|
|
30
|
+
if (opts.body !== undefined) {
|
|
31
|
+
headers["Content-Type"] = "application/json";
|
|
32
|
+
init.body = JSON.stringify(opts.body);
|
|
33
|
+
}
|
|
34
|
+
let res;
|
|
35
|
+
try {
|
|
36
|
+
res = await fetch(url, init);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
throw new Error(`Request to ${url.host} failed: ${err.message}`);
|
|
40
|
+
}
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
let data = text;
|
|
43
|
+
if (text) {
|
|
44
|
+
try {
|
|
45
|
+
data = JSON.parse(text);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* leave as raw text */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
throw new ApiError(`${res.status} ${res.statusText}`, res.status, data);
|
|
53
|
+
}
|
|
54
|
+
return { status: res.status, ok: true, data };
|
|
55
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `nb` — gcloud-style CLI for NASEBANAL APIs.
|
|
4
|
+
*
|
|
5
|
+
* The command tree under each API group is generated at startup from the
|
|
6
|
+
* OpenAPI specs in `specs/` (synced from nb-api-specs). This file only wires up
|
|
7
|
+
* the global options, the `auth` group, and one command group per catalog entry.
|
|
8
|
+
*/
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
import { Command } from "commander";
|
|
11
|
+
import { API_CATALOG } from "./apis.js";
|
|
12
|
+
import { buildApiCommand } from "./spec/build.js";
|
|
13
|
+
import { buildAuthCommand } from "./auth.js";
|
|
14
|
+
import { specExists } from "./spec/loader.js";
|
|
15
|
+
import { printError } from "./output.js";
|
|
16
|
+
// Single source of truth for the version: package.json (dist/ and src/ both sit
|
|
17
|
+
// next to it). A hard-coded string here drifts from the release tag.
|
|
18
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
19
|
+
const program = new Command();
|
|
20
|
+
program
|
|
21
|
+
.name("nb")
|
|
22
|
+
.description("Command-line interface for NASEBANAL APIs (generated from OpenAPI contracts).")
|
|
23
|
+
.version(version)
|
|
24
|
+
.option("--env <env>", "target environment: production | local")
|
|
25
|
+
.option("--token <token>", "override the stored PAT for this invocation")
|
|
26
|
+
.showHelpAfterError();
|
|
27
|
+
program.addCommand(buildAuthCommand());
|
|
28
|
+
for (const api of API_CATALOG) {
|
|
29
|
+
if (!specExists(api.spec)) {
|
|
30
|
+
// Spec not synced yet — register a stub that explains how to fix it rather
|
|
31
|
+
// than crashing the whole CLI.
|
|
32
|
+
program
|
|
33
|
+
.command(api.name)
|
|
34
|
+
.description(`${api.summary} (spec not loaded)`)
|
|
35
|
+
.allowUnknownOption()
|
|
36
|
+
.action(() => {
|
|
37
|
+
printError(new Error(`Spec '${api.spec}' is missing. Run \`npm run sync-specs\` (local dev) or reinstall the package.`));
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
});
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
program.addCommand(buildApiCommand(api));
|
|
43
|
+
}
|
|
44
|
+
async function main() {
|
|
45
|
+
try {
|
|
46
|
+
await program.parseAsync(process.argv);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
printError(err);
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
void main();
|