@nasebanal/cli 0.5.0 → 0.7.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/README.md CHANGED
@@ -64,6 +64,10 @@ nb assurance report upload --file specmatic/junit/TEST-junit-jupiter.xml --kind
64
64
  # One repository, several API versions: label the result (the version is part of the suite's identity, so v1 and v2 of the same name sit side by side)
65
65
  nb assurance report upload --file specmatic/v2/junit.xml --kind contract --tool specmatic --api-version v2
66
66
  # unit tests hold for every version: leave --api-version out
67
+ # set NB_API_VERSION once (a CI variable per job or branch) instead of repeating the flag; the flag wins, an empty variable means none
68
+ NB_API_VERSION=v2 nb assurance report upload --file specmatic/v2/junit.xml --kind contract --tool specmatic
69
+ # a project may require a version for some kinds of result (Assurance > Projects > the project's settings): a result of such a kind
70
+ # without one is refused with 422 API_VERSION_REQUIRED (the message says to pass --api-version or set NB_API_VERSION)
67
71
 
68
72
  # Extra numbers for the suite (repeatable), e.g. ZAP's findings by severity - what the Assurance security gate reads
69
73
  nb assurance report upload --file zap/report/baseline-junit.xml --kind security --tool zap --metric high=0 --metric medium=3 --metric low=2
@@ -74,12 +78,54 @@ nb assurance report upload --dir locust/logs/20261001_090000
74
78
 
75
79
  - **Project:** found by name, created when missing (`--no-create-project` to fail
76
80
  instead). In CI the name defaults to the repository (`GITHUB_REPOSITORY` /
77
- `CI_PROJECT_PATH`); elsewhere pass `--project`.
81
+ `CI_PROJECT_PATH`); elsewhere pass `--project`. That is the zero-configuration
82
+ start; once the project exists, **pin it by id** (`--project-id <id>` or
83
+ `NB_ASSURANCE_PROJECT_ID`): the upload then goes straight to that project, nothing
84
+ is looked up by name or created, so renaming the project, a typo, or a renamed
85
+ repository can no longer send results elsewhere. An unknown id is an error, never a
86
+ new project. The id is printed after every upload (and, in GitHub Actions, written
87
+ to `$GITHUB_OUTPUT` as `project_id`). Keep one id per environment (`--env`), and
88
+ take care when copying a workflow to another repository: its id goes with it.
89
+ - **Link to the report in the repository:** the CLI also sends the CI `--provider`
90
+ (`github` | `gitlab` | `other`, detected) and, when the report file is **committed**
91
+ (tracked, unchanged since `HEAD`, and `HEAD` is the reported commit), its path
92
+ relative to the repository root, so Assurance can link to the file at that commit.
93
+ A report that CI has just generated is not in the commit, so no path is sent;
94
+ override with `--report-path`.
95
+ - **Tie the project to its API in the catalog (`--spec-id`, `--spec-environment`):**
96
+ `--spec-id <id>` links the project to a spec of the API catalog (its id in Assurance /
97
+ Specs), `--spec-environment production` says which environment of it the project covers
98
+ (needs `--spec-id`). A new project is created already linked; an existing one is
99
+ changed **only when it differs**, so a pipeline that runs on every commit writes nothing
100
+ once the link is right. Pointing it at another spec clears the old environment unless one
101
+ is given too. The id is not looked up in the catalog (it is another service): a typo shows
102
+ as "no spec" on the project. The link is set after the result is in, so a failure there
103
+ leaves the result reported and says so. `--spec-file <openapi.yaml>` does the whole chain in one command (see below).
104
+ - **Register the spec file and tie the project to it in one command (`--spec-file`):**
105
+ `nb assurance report upload ... --spec-file openapi.yaml [--spec-name N] [--spec-group G]
106
+ [--spec-org ID] [--spec-environment production]` first runs `nb specs push` on the file,
107
+ then creates / links the project to the spec it returns, then reports the result. The
108
+ catalog's answer never costs the result: if registering the spec fails (a plan without the
109
+ catalog, the limit of specs), the result is still reported, without the link, and the command
110
+ ends with the reason and a non-zero exit. Exclusive with `--spec-id`.
78
111
  - **Run:** every job of one pipeline run shares a run key (CI: `GITHUB_RUN_ID` +
79
112
  attempt, `CI_PIPELINE_ID`; locally `local-<sha>-<day>`), so a unit job and an e2e
80
113
  job land in the same run, and re-running a job replaces its own result.
81
114
  Commit, branch and CI URL are detected the same way; override with `--run-key`,
82
115
  `--commit`, `--branch`, `--ci-url`.
116
+ - **Keep the tool's own report (`--attach <file|folder>`):** a CI runner is thrown away
117
+ when the job ends, and the summary above is not the tool's report (Playwright's HTML
118
+ report, Specmatic's report, a JUnit file with everything in it). `--attach` sends the
119
+ original with the result: a file as it is, a folder as a zip made by the CLI (regular
120
+ files only, symbolic links are never followed). Up to 20 MiB per result and 200 MiB per
121
+ project; the CLI checks the size before anything is sent. It is **opt-in twice** - nothing is
122
+ attached unless you pass it, and the server refuses it (403 `ATTACHMENTS_DISABLED`) until
123
+ the project has turned on "Attaching original reports" in its settings in Assurance (off by
124
+ default). Make sure the report holds no secrets or personal data.
125
+ The server only stores it (next to the result's cases, deleted with the result and by
126
+ the project's retention) and offers it as a **download** to the project's members in
127
+ Assurance; it is never opened, unzipped or shown in the browser. A failed attachment
128
+ leaves the result reported and the command exits non-zero with the reason.
83
129
  - **Pass / fail:** JUnit - any failed or errored test fails the suite. Locust - an
84
130
  unhandled task exception, or a failure ratio above `--max-fail-ratio` (default
85
131
  `$LOCUST_MAX_FAIL_RATIO`, else 0), fails it - the same rule as nb-quickstarts'
@@ -91,6 +137,30 @@ nb assurance report upload --dir locust/logs/20261001_090000
91
137
  is sent in total (names and outcomes always stay). Locust reports have no cases.
92
138
  - `--dry-run` prints what would be sent and calls nothing.
93
139
 
140
+ ## The API catalog from a pipeline (`nb specs push`)
141
+
142
+ `nb specs push --file openapi.yaml` registers an OpenAPI document in the catalog (your own
143
+ specs, Evolution): a new spec, or a new version of the one that has the same name (or `--id`).
144
+ It is made to run on **every commit**:
145
+
146
+ - **Nothing is uploaded when the file is already the latest version.** The CLI compares the
147
+ document's SHA-256 with the `content_hash` the catalog keeps for the latest version before it
148
+ sends anything; the same bytes print `unchanged (already vN: nothing uploaded)`. (The catalog
149
+ refuses an identical upload itself, `409 UNCHANGED`, which is also taken as "unchanged", for two
150
+ jobs racing.) The comparison is of bytes: a generator that writes a timestamp into the
151
+ document makes a new version every run, so keep the output stable.
152
+ - **No group has to exist.** A spec is registered in no group unless `--group <name>` is given
153
+ (the group is made when missing); an existing spec is never moved by a push (the catalog
154
+ moves it, by drag and drop or the select on its page).
155
+ - The name is `--name`, else the document's `info.title`, else the file's name. A new version
156
+ carries a note made from the commit, the branch and the CI run (`--note` replaces it), so
157
+ "which commit made v7" has an answer.
158
+ - `--org <id|personal>` (or `$NB_SPECS_ORG`) says whose catalog it is: the catalog is per
159
+ owner and needs `X-Active-Organization-Id`; `personal` (the default) is your own, an
160
+ organization's needs its Enterprise plan. `--dry-run` reads the catalog and says what would
161
+ happen. `NB_SPECS_BASE_URL` points it at another host (local: `http://localhost:8792`).
162
+ - In GitHub Actions the spec's id is written to `$GITHUB_OUTPUT` as `spec_id`.
163
+
94
164
  ## Installation
95
165
 
96
166
  The CLI installs the `nb` command. Node ≥ 20 is required.
@@ -117,6 +187,20 @@ needed for a registry install.
117
187
  > `api-specs-*` packages), it captures this package too. Install with
118
188
  > `npm install -g @nasebanal/cli --@nasebanal:registry=https://registry.npmjs.org/`.
119
189
 
190
+ ### Upgrading
191
+
192
+ ```bash
193
+ nb upgrade # install the latest published version
194
+ nb upgrade --check # only report whether a newer version exists
195
+ ```
196
+
197
+ When a newer version is out, interactive runs print a one-line notice on stderr
198
+ (`Update available: 0.5.0 -> 0.6.0`). The registry is checked at most once a day
199
+ and the result is cached in `~/.config/nasebanal/update-check.json`. Nothing is
200
+ printed when stderr is not a terminal or `CI` is set; `NB_NO_UPDATE_NOTIFIER=1`
201
+ turns the notice off. A dev link to a source checkout is never overwritten:
202
+ `nb upgrade` tells you to `git pull && npm run build` instead.
203
+
120
204
  ### Versioning and releases
121
205
 
122
206
  Versions follow SemVer (0.x until 1.0: breaking changes bump the minor). Each
@@ -190,7 +274,10 @@ Notes:
190
274
 
191
275
  ### From source (contributors)
192
276
 
193
- Requires a sibling `nb-api-specs` checkout (the build syncs specs from it):
277
+ No registry token and no sibling checkout needed: the build fetches the pinned,
278
+ public API contracts from `api-specs.nasebanal.com` (see
279
+ [Local Development](#local-development)). Contributing: see
280
+ [CONTRIBUTING.md](CONTRIBUTING.md).
194
281
 
195
282
  ```bash
196
283
  git clone https://github.com/nasebanal/nb-cli.git
@@ -345,7 +432,9 @@ npm run typecheck # tsc --noEmit
345
432
  `sync-specs` resolves each spec from, in order:
346
433
 
347
434
  1. `node_modules/@nasebanal/api-specs-<api>/spec.yaml` (installed package), then
348
- 2. `../nb-api-specs/packages/<api>/spec.yaml` (local monorepo checkout).
435
+ 2. `../nb-api-specs/packages/<api>/spec.yaml` (local monorepo checkout), then
436
+ 3. the version pinned in `spec-versions.json`, fetched from the public specs host
437
+ (the only source under CI, and what an outside contributor gets).
349
438
 
350
439
  In production the published `@nasebanal/api-specs-*` packages are pinned as
351
440
  dependencies — the consumer side of CDD. In local dev we copy straight from the
package/dist/auth.js CHANGED
@@ -11,6 +11,7 @@ import { Command } from "commander";
11
11
  import { createInterface } from "node:readline";
12
12
  import { loadConfig, saveConfig, resolveToken, resolveEnv, configFilePath } from "./config.js";
13
13
  import { login } from "./oauth.js";
14
+ import { envTokenHint } from "./output.js";
14
15
  function readSecretLine(promptText) {
15
16
  const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false });
16
17
  process.stdout.write(promptText);
@@ -60,6 +61,10 @@ export function buildAuthCommand() {
60
61
  const expiry = tokens.expires_at ? new Date(tokens.expires_at).toLocaleString() : "n/a";
61
62
  process.stdout.write(`\n✓ Logged in. Credentials saved to ${configFilePath()}\n`);
62
63
  process.stdout.write(` Access token expires: ${expiry}${tokens.refresh_token ? " (auto-refreshes)" : ""}\n`);
64
+ // The login is saved, but a token exported in this shell still wins: say so now, not after a confusing 401 later.
65
+ const shadowed = envTokenHint();
66
+ if (shadowed)
67
+ process.stderr.write(`\n! ${shadowed.replace("takes priority over `nb auth login`.", "takes priority over this login.")}\n`);
63
68
  });
64
69
  auth
65
70
  .command("logout")
package/dist/config.js CHANGED
@@ -12,7 +12,7 @@ import { homedir } from "node:os";
12
12
  import { join } from "node:path";
13
13
  import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync } from "node:fs";
14
14
  import { refreshTokens } from "./oauth.js";
15
- function configDir() {
15
+ export function configDir() {
16
16
  const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
17
17
  return join(base, "nasebanal");
18
18
  }
package/dist/http.js CHANGED
@@ -26,11 +26,16 @@ export async function request(opts) {
26
26
  const headers = { Accept: "application/json" };
27
27
  if (opts.token)
28
28
  headers.Authorization = `Bearer ${opts.token}`;
29
+ Object.assign(headers, opts.headers);
29
30
  const init = { method: opts.method.toUpperCase(), headers };
30
31
  if (opts.body !== undefined) {
31
32
  headers["Content-Type"] = "application/json";
32
33
  init.body = JSON.stringify(opts.body);
33
34
  }
35
+ else if (opts.rawBody !== undefined) {
36
+ headers["Content-Type"] = opts.contentType ?? "application/octet-stream";
37
+ init.body = opts.rawBody;
38
+ }
34
39
  let res;
35
40
  try {
36
41
  res = await fetch(url, init);
package/dist/index.js CHANGED
@@ -14,6 +14,8 @@ import { buildAuthCommand } from "./auth.js";
14
14
  import { specExists } from "./spec/loader.js";
15
15
  import { printError } from "./output.js";
16
16
  import { buildReportUploadCommand } from "./reports/upload.js";
17
+ import { buildSpecsCommand } from "./specs/command.js";
18
+ import { buildUpgradeCommand, startUpdateNotifier } from "./update.js";
17
19
  // Single source of truth for the version: package.json (dist/ and src/ both sit
18
20
  // next to it). A hard-coded string here drifts from the release tag.
19
21
  const { version } = createRequire(import.meta.url)("../package.json");
@@ -26,6 +28,8 @@ program
26
28
  .option("--token <token>", "override the stored PAT for this invocation")
27
29
  .showHelpAfterError();
28
30
  program.addCommand(buildAuthCommand());
31
+ program.addCommand(buildUpgradeCommand(version));
32
+ program.addCommand(buildSpecsCommand());
29
33
  for (const api of API_CATALOG) {
30
34
  if (!specExists(api.spec)) {
31
35
  // Spec not synced yet — register a stub that explains how to fix it rather
@@ -50,8 +54,10 @@ for (const api of API_CATALOG) {
50
54
  program.addCommand(group);
51
55
  }
52
56
  async function main() {
57
+ const notifier = startUpdateNotifier(version, process.argv);
53
58
  try {
54
59
  await program.parseAsync(process.argv);
60
+ await notifier.refresh();
55
61
  }
56
62
  catch (err) {
57
63
  printError(err);
package/dist/output.js CHANGED
@@ -2,7 +2,28 @@
2
2
  * Output helpers. Default output is pretty JSON to stdout; errors go to stderr.
3
3
  * A future iteration can add `--format table` once command metadata is richer.
4
4
  */
5
+ import { appendFileSync } from "node:fs";
5
6
  import { ApiError } from "./http.js";
7
+ /** In GitHub Actions, make a value available to later steps as `steps.<id>.outputs.<key>`. A missing output file never fails a command that succeeded. */
8
+ export function exportGithubOutput(key, value) {
9
+ const file = process.env.GITHUB_OUTPUT;
10
+ if (!file)
11
+ return;
12
+ try {
13
+ appendFileSync(file, `${key}=${value}\n`);
14
+ }
15
+ catch {
16
+ // The command succeeded; its output file is a convenience.
17
+ }
18
+ }
19
+ /**
20
+ * Why a 401 may come from the shell and not from the login: `NB_TOKEN` in the environment always wins over `nb auth login`
21
+ * (the CI path), so a token left exported - typically a PAT made for production - is what gets sent, and a local server has
22
+ * never heard of it. Null when `NB_TOKEN` is not set (then the hint to log in is the whole story).
23
+ */
24
+ export function envTokenHint() {
25
+ return process.env.NB_TOKEN ? "NB_TOKEN is set in this shell and takes priority over `nb auth login`. If it is not valid for this server (a PAT made for production is unknown to a local one), run `unset NB_TOKEN` and try again." : null;
26
+ }
6
27
  export function printJson(data) {
7
28
  if (typeof data === "string") {
8
29
  process.stdout.write(data + "\n");
@@ -19,6 +40,9 @@ export function printError(err) {
19
40
  }
20
41
  if (err.status === 401) {
21
42
  process.stderr.write("\nHint: run `nb auth login` or set NB_TOKEN to authenticate.\n");
43
+ const envHint = envTokenHint();
44
+ if (envHint)
45
+ process.stderr.write(`${envHint}\n`);
22
46
  }
23
47
  return;
24
48
  }
@@ -5,6 +5,7 @@
5
5
  * piles up). Every value can still be overridden on the command line.
6
6
  */
7
7
  import { execFileSync } from "node:child_process";
8
+ import { basename, dirname, resolve } from "node:path";
8
9
  export function readGit() {
9
10
  const git = (...args) => {
10
11
  try {
@@ -16,6 +17,36 @@ export function readGit() {
16
17
  };
17
18
  return { sha: git("rev-parse", "HEAD"), branch: git("branch", "--show-current") };
18
19
  }
20
+ const runGit = (args, cwd) => {
21
+ try {
22
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ };
28
+ /**
29
+ * The path of a report file inside the repository, relative to its root (`reports/junit.xml`), for a
30
+ * link to the file at the run's commit. Only a file that is COMMITTED qualifies: tracked, identical to
31
+ * HEAD, and HEAD is the commit being reported. A report that CI just generated is none of those, and a
32
+ * link to a file that is not in the commit would be a dead one, so the answer is then null.
33
+ */
34
+ export function readReportPath(file, commitSha, git = runGit) {
35
+ const abs = resolve(file);
36
+ const cwd = dirname(abs);
37
+ const name = basename(abs);
38
+ const path = git(["ls-files", "--full-name", "--", name], cwd);
39
+ if (!path)
40
+ return null;
41
+ if (git(["diff", "--quiet", "HEAD", "--", name], cwd) === null)
42
+ return null;
43
+ if (commitSha) {
44
+ const head = git(["rev-parse", "HEAD"], cwd);
45
+ if (!head || !(head === commitSha || head.startsWith(commitSha) || commitSha.startsWith(head)))
46
+ return null;
47
+ }
48
+ return path.replace(/\\/g, "/");
49
+ }
19
50
  const nonEmpty = (v) => (v && v.trim() !== "" ? v : null);
20
51
  export function detectCi(env, git = readGit, now = new Date()) {
21
52
  if (env.GITHUB_ACTIONS === "true") {
@@ -12,17 +12,62 @@
12
12
  * instead of a megabyte of report.
13
13
  */
14
14
  import { Command } from "commander";
15
- import { existsSync, readFileSync, readdirSync } from "node:fs";
15
+ import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
16
16
  import { basename, dirname, join } from "node:path";
17
17
  import { resolveAccessToken, resolveEnv } from "../config.js";
18
18
  import { request, resolveBaseUrl, ApiError } from "../http.js";
19
19
  import { loadSpec } from "../spec/loader.js";
20
- import { printJson } from "../output.js";
21
- import { detectCi } from "./ci.js";
20
+ import { exportGithubOutput, printJson } from "../output.js";
21
+ import { detectCi, readReportPath } from "./ci.js";
22
22
  import { parseJunit } from "./junit.js";
23
23
  import { summariseLocust } from "./locust.js";
24
24
  import { parseSpecmaticCoverage } from "./specmatic.js";
25
+ import { MAX_ATTACHMENT_BYTES, zipDirectory } from "./zip.js";
26
+ import { checkOrg, ciNote, pushSpec, readSpecFile, specNameFor, hashText, specsBaseUrl } from "../specs/push.js";
25
27
  export const SUITE_KINDS = ["unit", "e2e", "load", "contract", "security"];
28
+ export const RUN_PROVIDERS = ["github", "gitlab", "other"];
29
+ /** What the API's ids look like (12 letters and digits); anything else is a typo, not a project. */
30
+ const PROJECT_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
31
+ /** A spec's id in the API catalog (nb-specs-api): letters, digits, `_` and `-`, up to 64. */
32
+ const SPEC_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
33
+ const MAX_SPEC_ENVIRONMENT = 40;
34
+ /** `--spec-id`: trimmed; it is never looked up here (the catalog is another service; a typo shows as "no spec" in the project). */
35
+ export function checkSpecId(raw) {
36
+ const v = raw.trim();
37
+ if (!SPEC_ID_PATTERN.test(v))
38
+ throw new Error(`--spec-id '${raw}' is not a catalog spec id (letters, digits, '-' and '_', up to 64)`);
39
+ return v;
40
+ }
41
+ /** `--spec-environment`: which environment of the API the project covers (production, staging, ...). */
42
+ export function checkSpecEnvironment(raw) {
43
+ const v = raw.trim();
44
+ if (v === "" || v.length > MAX_SPEC_ENVIRONMENT)
45
+ throw new Error(`--spec-environment must be 1 to ${MAX_SPEC_ENVIRONMENT} characters`);
46
+ return v;
47
+ }
48
+ /** `--project-id` / `NB_ASSURANCE_PROJECT_ID`: trimmed; empty is none. */
49
+ export function checkProjectId(raw) {
50
+ const v = raw?.trim();
51
+ if (!v)
52
+ return undefined;
53
+ if (!PROJECT_ID_PATTERN.test(v))
54
+ throw new Error(`--project-id '${raw}' is not a project id (letters, digits, '-' and '_'): copy it from the project's page or from a previous upload's output`);
55
+ return v;
56
+ }
57
+ export function checkProvider(raw) {
58
+ const v = raw.trim().toLowerCase();
59
+ if (!RUN_PROVIDERS.includes(v))
60
+ throw new Error(`--provider must be one of ${RUN_PROVIDERS.join(" | ")}`);
61
+ return v;
62
+ }
63
+ /** `--report-path`: a path relative to the repository root, as the API accepts it. */
64
+ export function checkReportPath(raw) {
65
+ const v = raw.trim();
66
+ if (v === "" || v.length > 512 || v.startsWith("/") || v.includes("\\") || v.split("/").some((s) => s === ".." || s === "")) {
67
+ throw new Error("--report-path must be a path relative to the repository root, e.g. reports/junit.xml");
68
+ }
69
+ return v;
70
+ }
26
71
  /**
27
72
  * The tool is a label (nb-assurance-api `TOOL_PATTERN`): what a result means is carried by its kind and its
28
73
  * numbers, so any scanner or runner can be reported without a change here, as long as its report is
@@ -72,7 +117,7 @@ export function parseMetrics(pairs) {
72
117
  export function checkApiVersion(label) {
73
118
  const v = label.trim();
74
119
  if (!API_VERSION_PATTERN.test(v)) {
75
- throw new Error(`--api-version '${label}' is not a version label: use letters, digits, '.', '_' and '-' (up to 32), e.g. v1 or 2026-10`);
120
+ throw new Error(`--api-version / NB_API_VERSION '${label}' is not a version label: use letters, digits, '.', '_' and '-' (up to 32), e.g. v1 or 2026-10`);
76
121
  }
77
122
  return v;
78
123
  }
@@ -168,15 +213,16 @@ export function parseReport(opts) {
168
213
  };
169
214
  }
170
215
  /** Find the project by name, or create it (unless told not to). Returns its id. */
171
- export async function ensureProject(conn, name, repo, create) {
216
+ export async function ensureProject(conn, name, repo, create, spec) {
172
217
  const find = async () => {
173
218
  const res = await request({ ...conn, method: "GET", path: "/api/v1/projects", query: { name } });
174
219
  const list = res.data.data.data;
175
- return list[0]?.id ?? null;
220
+ const hit = list[0];
221
+ return hit ? { id: hit.id, created: false, spec: { id: hit.spec_id ?? null, environment: hit.spec_environment ?? null } } : null;
176
222
  };
177
223
  const existing = await find();
178
224
  if (existing)
179
- return { id: existing, created: false };
225
+ return existing;
180
226
  if (!create) {
181
227
  throw new Error(`No project named '${name}' (create it first, or drop --no-create-project).`);
182
228
  }
@@ -185,30 +231,78 @@ export async function ensureProject(conn, name, repo, create) {
185
231
  ...conn,
186
232
  method: "POST",
187
233
  path: "/api/v1/projects",
188
- body: { name, ...(repo ? { repo } : {}) },
234
+ // A new project is born linked: nothing to compare, nothing to patch afterwards.
235
+ body: { name, ...(repo ? { repo } : {}), ...(spec ? { spec_id: spec.id, ...(spec.environment ? { spec_environment: spec.environment } : {}) } : {}) },
189
236
  });
190
- return { id: res.data.data.id, created: true };
237
+ return { id: res.data.data.id, created: true, spec: spec ? { id: spec.id, environment: spec.environment ?? null } : undefined, specLinked: !!spec };
191
238
  }
192
239
  catch (err) {
193
240
  // Another job of the same pipeline created it between our lookup and our create.
194
241
  if (err instanceof ApiError && err.status === 409) {
195
242
  const raced = await find();
196
243
  if (raced)
197
- return { id: raced, created: false };
244
+ return raced;
198
245
  }
199
246
  throw err;
200
247
  }
201
248
  }
249
+ /**
250
+ * Bring the project's link to the catalog in line with what the CI says. Only what differs is written, so a pipeline that
251
+ * runs on every commit leaves the project untouched once it is linked. Pointing at another spec clears the environment
252
+ * unless one is given too (the API's rule: the old environment belonged to the old spec).
253
+ */
254
+ async function syncSpecLink(conn, project, spec) {
255
+ const want = spec.environment ?? null;
256
+ if (project.specLinked)
257
+ return { id: spec.id, environment: want, changed: true, was: null };
258
+ let current = project.spec;
259
+ if (!current) {
260
+ const res = await request({ ...conn, method: "GET", path: `/api/v1/projects/${encodeURIComponent(project.id)}` });
261
+ const row = res.data.data;
262
+ current = { id: row.spec_id ?? null, environment: row.spec_environment ?? null };
263
+ }
264
+ const sameId = current.id === spec.id;
265
+ const patch = {};
266
+ if (!sameId)
267
+ patch.spec_id = spec.id;
268
+ if (spec.environment !== undefined && (!sameId || current.environment !== want))
269
+ patch.spec_environment = spec.environment;
270
+ if (Object.keys(patch).length === 0)
271
+ return { id: spec.id, environment: current.environment, changed: false, was: current.id };
272
+ await request({ ...conn, method: "PATCH", path: `/api/v1/projects/${encodeURIComponent(project.id)}`, body: patch });
273
+ return { id: spec.id, environment: spec.environment ?? (sameId ? current.environment : null), changed: true, was: current.id };
274
+ }
202
275
  export async function uploadReport(p) {
203
- const project = await ensureProject(p.conn, p.project, p.repo, p.createProject);
204
- const res = await request({
205
- ...p.conn,
206
- method: "POST",
207
- path: `/api/v1/projects/${encodeURIComponent(project.id)}/results`,
208
- body: p.payload,
209
- });
276
+ const project = p.projectId ? { id: p.projectId, created: false } : await ensureProject(p.conn, p.project, p.repo, p.createProject, p.spec);
277
+ let res;
278
+ try {
279
+ res = await request({
280
+ ...p.conn,
281
+ method: "POST",
282
+ path: `/api/v1/projects/${encodeURIComponent(project.id)}/results`,
283
+ body: p.payload,
284
+ });
285
+ }
286
+ catch (err) {
287
+ // A pinned id that is not found (or not yours) is a wrong id, not a project to create.
288
+ if (p.projectId && err instanceof ApiError && err.status === 404) {
289
+ throw new Error(`No project with id '${p.projectId}' (check --project-id / NB_ASSURANCE_PROJECT_ID, and --env).`);
290
+ }
291
+ throw err;
292
+ }
210
293
  const data = res.data.data;
211
- return { project_id: project.id, project_created: project.created, ...data };
294
+ const done = { project_id: project.id, project_created: project.created, ...data };
295
+ if (p.spec) {
296
+ // The result is in; a link that fails now leaves it as it is and says so.
297
+ try {
298
+ done.spec = await syncSpecLink(p.conn, project, p.spec);
299
+ }
300
+ catch (err) {
301
+ const detail = err instanceof ApiError ? (err.data?.error?.message ?? err.message) : err.message;
302
+ throw new Error(`The result was reported, but the project could not be linked to the catalog: ${detail}`);
303
+ }
304
+ }
305
+ return done;
212
306
  }
213
307
  function fail(message) {
214
308
  throw new Error(message);
@@ -275,6 +369,40 @@ function resolveFormat(opts, file) {
275
369
  return "junit";
276
370
  return fail("Cannot tell the report format from the file name; pass --format junit|locust");
277
371
  }
372
+ /** `--attach`: a file as it is, or a folder as a zip. Refuses before anything is sent: missing, empty, or too big for the server. */
373
+ export function prepareAttachment(path, limit = MAX_ATTACHMENT_BYTES) {
374
+ if (!existsSync(path))
375
+ throw new Error(`--attach: not found: ${path}`);
376
+ const stat = lstatSync(path);
377
+ if (stat.isSymbolicLink())
378
+ throw new Error(`--attach: ${path} is a symbolic link; pass the real path`);
379
+ if (stat.isDirectory())
380
+ return { name: `${basename(path.replace(/[\\/]+$/, "")) || "report"}.zip`, bytes: zipDirectory(path, limit) };
381
+ if (!stat.isFile())
382
+ throw new Error(`--attach: ${path} is not a file or a folder`);
383
+ if (stat.size === 0)
384
+ throw new Error(`--attach: ${path} is empty`);
385
+ if (stat.size > limit)
386
+ throw new Error(`--attach: ${path} is ${(stat.size / 1048576).toFixed(1)} MiB, more than the ${limit / 1048576} MiB the server takes`);
387
+ return { name: basename(path), bytes: readFileSync(path) };
388
+ }
389
+ /** Keep the original report with a result that was just reported. A failure here leaves the result as it is. */
390
+ export async function uploadAttachment(conn, ids, a) {
391
+ try {
392
+ await request({
393
+ ...conn,
394
+ method: "PUT",
395
+ path: `/api/v1/projects/${encodeURIComponent(ids.project_id)}/runs/${encodeURIComponent(ids.run_id)}/suites/${encodeURIComponent(ids.suite_id)}/attachment`,
396
+ query: { name: a.name },
397
+ rawBody: a.bytes,
398
+ contentType: a.name.endsWith(".zip") ? "application/zip" : "application/octet-stream",
399
+ });
400
+ }
401
+ catch (err) {
402
+ const detail = err instanceof ApiError ? (err.data?.error?.message ?? err.message) : err.message;
403
+ throw new Error(`The result was reported, but the attached report was not kept: ${detail}`);
404
+ }
405
+ }
278
406
  export function buildReportUploadCommand() {
279
407
  return new Command("upload")
280
408
  .description("Report a test result file (JUnit XML or Locust CSV) to an Assurance project")
@@ -288,9 +416,19 @@ export function buildReportUploadCommand() {
288
416
  .option("--label <name>", "tells apart results that are otherwise the same, such as two scans of one tool on the same components (e.g. --label baseline, --label full-scan); part of the result's identity, so the same label again replaces the result; optional")
289
417
  .option("--tool-version <version>", "the version of the tool that produced the report (e.g. 9.1.0): informational, shown next to the tool, handy when a tool's new version changes its layout; optional")
290
418
  .option("--metric <key=value>", "an extra number for the suite, repeatable (e.g. --metric high=0 --metric medium=3; for security: critical, high, medium, low, info)", (v, all = []) => [...all, v])
291
- .option("--api-version <label>", "the API version the result is for (e.g. v1, v2), part of the result's identity next to its kind and components; leave it out for results that hold for every version, such as unit tests")
419
+ .option("--api-version <label>", "the API version the result is for (e.g. v1, v2), part of the result's identity next to its kind and components; leave it out for results that hold for every version, such as unit tests. Default: the NB_API_VERSION environment variable (set it once per CI job or branch). A project can require a version for some kinds of result, and then refuses one without it")
292
420
  .option("--project <name>", "project name (CI: the repository); found, or created if missing")
421
+ .option("--attach <path>", "keep the tool's own report with the result: a file as it is, or a folder as a zip (up to 20 MiB). The server only stores it and offers it for download; nothing opens it. Nothing is attached unless you pass this, so check the report holds no secrets")
422
+ .option("--spec-id <id>", "tie the project to this API of the catalog (the spec's id in Assurance / Specs): set when it differs, so a pipeline that runs on every commit leaves it alone once it is linked. A new project is created already linked. The id is not looked up here")
423
+ .option("--spec-file <path>", "register this OpenAPI file in the API catalog first (a new spec, or a new version when its content changed; nothing is uploaded when it is already the latest) and tie the project to it: the whole chain in one command. Needs the catalog (Evolution) access of the account. Exclusive with --spec-id")
424
+ .option("--spec-name <name>", "with --spec-file: the spec's name in the catalog (default: the document's info.title)")
425
+ .option("--spec-group <name>", "with --spec-file: file a NEW spec in this group (made when missing); default none. An existing spec is never moved")
426
+ .option("--spec-org <id>", "with --spec-file: the organization whose catalog it is (an id), or 'personal' (default; or $NB_SPECS_ORG)")
427
+ .option("--spec-environment <name>", "which environment of that API the project covers (production, staging, ...); needs --spec-id")
428
+ .option("--project-id <id>", "send to this project by id (or $NB_ASSURANCE_PROJECT_ID): no lookup by name and nothing is created, so a rename never moves the results; recommended for a pipeline once the project exists")
293
429
  .option("--repo <repo>", "repository recorded on a newly created project (CI: detected)")
430
+ .option("--provider <provider>", `the CI provider: ${RUN_PROVIDERS.join(" | ")} (CI: detected)`)
431
+ .option("--report-path <path>", "the report file's path in the repository, relative to its root (default: detected, and only for a file committed at the reported commit)")
294
432
  .option("--run-key <key>", "identity of the pipeline run (CI: detected); all jobs of a run share it")
295
433
  .option("--commit <sha>", "commit SHA (CI: detected)")
296
434
  .option("--branch <name>", "branch (CI: detected)")
@@ -318,7 +456,9 @@ export function buildReportUploadCommand() {
318
456
  const maxFailRatio = Number(ratioRaw);
319
457
  if (!Number.isFinite(maxFailRatio) || maxFailRatio < 0 || maxFailRatio > 1)
320
458
  fail("--max-fail-ratio must be a number between 0 and 1");
321
- const apiVersion = opts.apiVersion === undefined ? undefined : checkApiVersion(opts.apiVersion);
459
+ // The flag wins; NB_API_VERSION (a CI variable) is the default, and an empty one (a CI that declares it but does not set it) is none.
460
+ const versionRaw = opts.apiVersion ?? (process.env.NB_API_VERSION || undefined);
461
+ const apiVersion = versionRaw === undefined ? undefined : checkApiVersion(versionRaw);
322
462
  const parsed = parseReport({
323
463
  format,
324
464
  kind,
@@ -351,22 +491,44 @@ export function buildReportUploadCommand() {
351
491
  }
352
492
  const ci = detectCi(process.env);
353
493
  const runKey = opts.runKey ?? ci.runKey ?? fail("Cannot determine the run key; pass --run-key");
354
- const project = opts.project ?? ci.project ?? fail("Cannot determine the project name; pass --project <name>");
494
+ const attachment = opts.attach !== undefined ? prepareAttachment(opts.attach) : undefined;
495
+ if (opts.specId !== undefined && opts.specFile !== undefined)
496
+ fail("--spec-id and --spec-file are exclusive: one names a spec of the catalog, the other registers a file");
497
+ if (opts.specEnvironment !== undefined && opts.specId === undefined && opts.specFile === undefined)
498
+ fail("--spec-environment needs --spec-id or --spec-file");
499
+ for (const [flag, value] of [["--spec-name", opts.specName], ["--spec-group", opts.specGroup], ["--spec-org", opts.specOrg]]) {
500
+ if (value !== undefined && opts.specFile === undefined)
501
+ fail(`${flag} needs --spec-file`);
502
+ }
503
+ const specEnvironment = opts.specEnvironment !== undefined ? checkSpecEnvironment(opts.specEnvironment) : undefined;
504
+ let spec = opts.specId !== undefined ? { id: checkSpecId(opts.specId), ...(specEnvironment !== undefined ? { environment: specEnvironment } : {}) } : undefined;
505
+ // The file is read and its name settled before anything is sent, so a bad path fails the command while nothing has happened yet.
506
+ const specText = opts.specFile !== undefined ? readSpecFile(opts.specFile) : undefined;
507
+ const specOrg = opts.specFile !== undefined ? checkOrg(opts.specOrg ?? process.env.NB_SPECS_ORG) : undefined;
508
+ const projectId = checkProjectId(opts.projectId ?? process.env.NB_ASSURANCE_PROJECT_ID);
509
+ const project = opts.project ?? ci.project ?? projectId ?? fail("Cannot determine the project name; pass --project <name> or --project-id <id>");
510
+ if (projectId && opts.project)
511
+ process.stderr.write("--project is ignored: --project-id decides where the result goes\n");
355
512
  const commit = opts.commit ?? ci.commitSha;
356
513
  const branch = opts.branch ?? ci.branch;
357
514
  const ciUrl = opts.ciUrl ?? ci.ciUrl;
515
+ const provider = opts.provider !== undefined ? checkProvider(opts.provider) : ci.provider === "local" ? undefined : ci.provider;
516
+ const reportPath = opts.reportPath !== undefined ? checkReportPath(opts.reportPath) : readReportPath(file, commit);
517
+ if (reportPath)
518
+ parsed.suite.report_path = reportPath;
358
519
  const payload = {
359
520
  run: {
360
521
  key: runKey,
361
522
  ...(commit ? { commit_sha: commit } : {}),
362
523
  ...(branch ? { branch } : {}),
363
524
  ...(ciUrl ? { ci_url: ciUrl } : {}),
525
+ ...(provider ? { provider } : {}),
364
526
  },
365
527
  suite: parsed.suite,
366
528
  };
367
529
  const repo = opts.repo ?? ci.repo;
368
530
  if (opts.dryRun) {
369
- printJson({ project, repo, payload });
531
+ printJson({ project, ...(projectId ? { project_id: projectId } : {}), repo, payload, ...(spec ? { spec } : {}), ...(specText !== undefined && opts.specFile !== undefined ? { spec_file: { file: opts.specFile, name: specNameFor(specText, opts.specFile, opts.specName), content_hash: hashText(specText), ...(opts.specGroup ? { group: opts.specGroup } : {}) } } : {}), ...(attachment ? { attachment: { name: attachment.name, size_bytes: attachment.bytes.length } } : {}) });
370
532
  process.stderr.write(`(dry run) ${basename(dirname(file)) || "."}/${basename(file)}: ${parsed.note}\n`);
371
533
  return;
372
534
  }
@@ -374,15 +536,49 @@ export function buildReportUploadCommand() {
374
536
  const override = process.env.NB_BASE_URL?.replace(/\/$/, "");
375
537
  const baseUrl = override ?? resolveBaseUrl(loadSpec("assurance.yaml").servers, env);
376
538
  const token = opts.token || (await resolveAccessToken());
539
+ // The catalog comes first: the project is tied to the spec it returns. It must not cost the result: a failure here is
540
+ // kept, the result is reported without the link, and the command says so at the end.
541
+ let pushed;
542
+ let specError;
543
+ if (opts.specFile !== undefined && specOrg !== undefined) {
544
+ try {
545
+ pushed = await pushSpec({
546
+ conn: { baseUrl: specsBaseUrl(env), token, org: specOrg },
547
+ file: opts.specFile,
548
+ name: opts.specName,
549
+ group: opts.specGroup,
550
+ note: ciNote(ci, commit),
551
+ });
552
+ spec = { id: pushed.id, ...(specEnvironment !== undefined ? { environment: specEnvironment } : {}) };
553
+ }
554
+ catch (err) {
555
+ specError = err;
556
+ }
557
+ }
377
558
  const result = await uploadReport({
378
559
  conn: { baseUrl, token },
379
560
  project,
561
+ projectId,
380
562
  repo,
381
563
  createProject: opts.createProject,
382
564
  payload,
565
+ spec,
383
566
  });
384
- printJson(result);
385
- process.stderr.write(`✓ ${kind}/${checkedTool} ${payload.suite.status}: ${parsed.note} -> project '${project}'` +
386
- `${result.project_created ? " (created)" : ""}, run '${runKey}'${result.replaced ? " (replaced)" : ""}\n`);
567
+ if (attachment)
568
+ await uploadAttachment({ baseUrl, token }, result, attachment);
569
+ printJson({ ...result, ...(attachment ? { attachment: { name: attachment.name, size_bytes: attachment.bytes.length } } : {}), ...(pushed ? { catalog: pushed } : {}) });
570
+ exportGithubOutput("project_id", result.project_id);
571
+ if (pushed)
572
+ exportGithubOutput("spec_id", pushed.id);
573
+ process.stderr.write(`✓ ${kind}/${checkedTool} ${payload.suite.status}: ${parsed.note} -> project '${project}' (id ${result.project_id})` +
574
+ `${result.project_created ? " (created)" : ""}, run '${runKey}'${result.replaced ? " (replaced)" : ""}` +
575
+ `${attachment ? `, attached ${attachment.name} (${(attachment.bytes.length / 1024).toFixed(0)} KiB)` : ""}` +
576
+ `${pushed ? `, spec ${pushed.name} v${pushed.version} (${pushed.action.replace("_", " ")})` : ""}` +
577
+ `${result.spec ? `, catalog ${result.spec.id}${result.spec.environment ? ` (${result.spec.environment})` : ""} ${result.spec.changed ? (result.spec.was ? `(changed from ${result.spec.was})` : "(linked)") : "(already linked)"}` : ""}\n`);
578
+ if (!projectId) {
579
+ process.stderr.write(` Pin it so a rename or a typo cannot send results elsewhere: --project-id ${result.project_id} (or NB_ASSURANCE_PROJECT_ID=${result.project_id})\n`);
580
+ }
581
+ if (specError)
582
+ throw new Error(`The result was reported, but the spec could not be registered in the catalog (the project is not tied to it): ${specError.message}`);
387
583
  });
388
584
  }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * A minimal zip writer for `--attach <folder>`: regular files only, deflate, no dependency.
3
+ *
4
+ * What it will not do is as deliberate as what it does: a symbolic link is never followed (a report folder must
5
+ * not smuggle in a file from elsewhere), nothing is stored under a name that climbs out of the folder, and the
6
+ * size and file count are checked BEFORE anything is read, so a huge folder fails fast instead of eating memory.
7
+ * The server never unzips what it receives, so these are the only checks on the way in.
8
+ */
9
+ import { lstatSync, readdirSync, readFileSync } from "node:fs";
10
+ import { join, posix, sep } from "node:path";
11
+ import { deflateRawSync } from "node:zlib";
12
+ export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
13
+ export const MAX_ATTACHMENT_FILES = 2000;
14
+ const CRC_TABLE = (() => {
15
+ const t = new Uint32Array(256);
16
+ for (let n = 0; n < 256; n++) {
17
+ let c = n;
18
+ for (let k = 0; k < 8; k++)
19
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
20
+ t[n] = c >>> 0;
21
+ }
22
+ return t;
23
+ })();
24
+ export function crc32(data) {
25
+ let c = 0xffffffff;
26
+ for (let i = 0; i < data.length; i++)
27
+ c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
28
+ return (c ^ 0xffffffff) >>> 0;
29
+ }
30
+ /** MS-DOS date and time, which is what a zip entry carries (2-second resolution, from 1980). */
31
+ function dosDateTime(d) {
32
+ const year = Math.max(d.getFullYear(), 1980);
33
+ return {
34
+ date: ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
35
+ time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1),
36
+ };
37
+ }
38
+ /** Every regular file under `dir`, as `{ name, abs, size, mtime }`, in a stable order. Links and special files are skipped. */
39
+ export function listFiles(dir) {
40
+ const out = [];
41
+ const walk = (abs, rel) => {
42
+ for (const entry of readdirSync(abs).sort()) {
43
+ const full = join(abs, entry);
44
+ const stat = lstatSync(full);
45
+ if (stat.isSymbolicLink())
46
+ continue;
47
+ if (stat.isDirectory())
48
+ walk(full, [...rel, entry]);
49
+ else if (stat.isFile())
50
+ out.push({ name: [...rel, entry].join(posix.sep), abs: full, size: stat.size, mtime: stat.mtime });
51
+ if (out.length > MAX_ATTACHMENT_FILES)
52
+ throw new Error(`--attach: more than ${MAX_ATTACHMENT_FILES} files in the folder`);
53
+ }
54
+ };
55
+ walk(dir, []);
56
+ return out;
57
+ }
58
+ export function buildZip(entries) {
59
+ const parts = [];
60
+ const central = [];
61
+ let offset = 0;
62
+ for (const e of entries) {
63
+ if (e.name === "" || e.name.startsWith("/") || e.name.includes("\\") || e.name.split("/").some((s) => s === ".." || s === "")) {
64
+ throw new Error(`--attach: refusing the path '${e.name}'`);
65
+ }
66
+ const name = Buffer.from(e.name, "utf8");
67
+ const raw = Buffer.from(e.data.buffer, e.data.byteOffset, e.data.byteLength);
68
+ const packed = deflateRawSync(raw);
69
+ // Store as is when deflate does not help (already-compressed images and the like).
70
+ const useDeflate = packed.length < raw.length;
71
+ const body = useDeflate ? packed : raw;
72
+ const method = useDeflate ? 8 : 0;
73
+ const crc = crc32(raw);
74
+ const { date, time } = dosDateTime(e.mtime);
75
+ const local = Buffer.alloc(30);
76
+ local.writeUInt32LE(0x04034b50, 0);
77
+ local.writeUInt16LE(20, 4); // version needed
78
+ local.writeUInt16LE(0x0800, 6); // flag: names are UTF-8
79
+ local.writeUInt16LE(method, 8);
80
+ local.writeUInt16LE(time, 10);
81
+ local.writeUInt16LE(date, 12);
82
+ local.writeUInt32LE(crc, 14);
83
+ local.writeUInt32LE(body.length, 18);
84
+ local.writeUInt32LE(raw.length, 22);
85
+ local.writeUInt16LE(name.length, 26);
86
+ local.writeUInt16LE(0, 28); // extra length
87
+ parts.push(local, name, body);
88
+ const dir = Buffer.alloc(46);
89
+ dir.writeUInt32LE(0x02014b50, 0);
90
+ dir.writeUInt16LE(20, 4); // version made by
91
+ dir.writeUInt16LE(20, 6); // version needed
92
+ dir.writeUInt16LE(0x0800, 8);
93
+ dir.writeUInt16LE(method, 10);
94
+ dir.writeUInt16LE(time, 12);
95
+ dir.writeUInt16LE(date, 14);
96
+ dir.writeUInt32LE(crc, 16);
97
+ dir.writeUInt32LE(body.length, 20);
98
+ dir.writeUInt32LE(raw.length, 24);
99
+ dir.writeUInt16LE(name.length, 28);
100
+ dir.writeUInt32LE(offset, 42);
101
+ central.push(dir, name);
102
+ offset += local.length + name.length + body.length;
103
+ }
104
+ const centralBytes = Buffer.concat(central);
105
+ const end = Buffer.alloc(22);
106
+ end.writeUInt32LE(0x06054b50, 0);
107
+ end.writeUInt16LE(entries.length, 8);
108
+ end.writeUInt16LE(entries.length, 10);
109
+ end.writeUInt32LE(centralBytes.length, 12);
110
+ end.writeUInt32LE(offset, 16);
111
+ return Buffer.concat([...parts, centralBytes, end]);
112
+ }
113
+ /** A folder as a zip, or a refusal: too many files, or more than the server takes. */
114
+ export function zipDirectory(dir, limit = MAX_ATTACHMENT_BYTES) {
115
+ const files = listFiles(dir);
116
+ if (files.length === 0)
117
+ throw new Error(`--attach: no files in ${dir}`);
118
+ // Before reading anything: a folder this much bigger than the limit cannot shrink to it (deflate rarely does better than 10:1 on reports).
119
+ const total = files.reduce((n, f) => n + f.size, 0);
120
+ if (total > limit * 20)
121
+ throw new Error(`--attach: ${dir} is ${Math.round(total / 1048576)} MiB; attach only the report (at most ${limit / 1048576} MiB zipped)`);
122
+ const zip = buildZip(files.map((f) => ({ name: f.name, data: readFileSync(f.abs), mtime: f.mtime })));
123
+ if (zip.length > limit)
124
+ throw new Error(`--attach: the zip of ${dir} is ${(zip.length / 1048576).toFixed(1)} MiB, more than the ${limit / 1048576} MiB the server takes`);
125
+ return zip;
126
+ }
127
+ /** The platform's separator never reaches the zip. */
128
+ export const toPosix = (p) => p.split(sep).join(posix.sep);
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `nb specs` - the API catalog (your own OpenAPI documents) from the command line. Hand-written: the CLI's generated
3
+ * command tree comes from the pinned contracts, and the catalog's is not among them.
4
+ */
5
+ import { Command } from "commander";
6
+ import { resolveAccessToken, resolveEnv } from "../config.js";
7
+ import { exportGithubOutput, printJson } from "../output.js";
8
+ import { detectCi } from "../reports/ci.js";
9
+ import { checkOrg, ciNote, pushSpec, specsBaseUrl } from "./push.js";
10
+ export function buildPushCommand() {
11
+ return new Command("push")
12
+ .description("Register an OpenAPI file in the API catalog: a new spec, or a new version when its content changed. Safe to run on every commit: a file that is already the latest version uploads nothing")
13
+ .requiredOption("--file <path>", "the OpenAPI document (YAML or JSON, up to 2 MiB)")
14
+ .option("--name <name>", "the spec's name in the catalog (default: the document's info.title, then the file's name)")
15
+ .option("--id <id>", "push to this spec by id: a rename cannot make a second one")
16
+ .option("--group <name>", "file a NEW spec in this group (made when missing); default none. An existing spec is never moved by a push")
17
+ .option("--description <text>", "a new spec's description")
18
+ .option("--note <text>", "what changed, shown in the version list (default: the commit, branch and CI run)")
19
+ .option("--org <id>", "the organization whose catalog it is (an id), or 'personal' (default; or $NB_SPECS_ORG)")
20
+ .option("--dry-run", "read the catalog and say what would happen; write nothing")
21
+ .action(async (_flags, command) => {
22
+ const opts = command.optsWithGlobals();
23
+ const env = opts.env || resolveEnv();
24
+ const token = opts.token || (await resolveAccessToken());
25
+ const ci = detectCi(process.env);
26
+ const result = await pushSpec({
27
+ conn: { baseUrl: specsBaseUrl(env), token, org: checkOrg(opts.org ?? process.env.NB_SPECS_ORG) },
28
+ file: opts.file,
29
+ name: opts.name,
30
+ group: opts.group,
31
+ description: opts.description,
32
+ note: opts.note ?? ciNote(ci, undefined),
33
+ id: opts.id,
34
+ dryRun: opts.dryRun,
35
+ });
36
+ printJson(result);
37
+ exportGithubOutput("spec_id", result.id);
38
+ const what = { created: "created (v1)", new_version: `new version v${result.version}`, unchanged: `unchanged (already v${result.version}: nothing uploaded)` }[result.action];
39
+ process.stderr.write(`${opts.dryRun ? "(dry run) " : ""}✓ ${result.name} -> ${what}\n`);
40
+ });
41
+ }
42
+ export function buildSpecsCommand() {
43
+ return new Command("specs").description("The API catalog: your own OpenAPI documents (NASEBANAL Evolution)").addCommand(buildPushCommand());
44
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * `nb specs push` - register an OpenAPI document in the API catalog (nb-specs-api) from a file, the way a pipeline
3
+ * wants it: no group has to exist, nothing is uploaded when the file is already the latest version, and running it on
4
+ * every commit is safe.
5
+ *
6
+ * - the spec is found by name (or by `--id`); new -> created with its first version, known -> a new version
7
+ * - the document's SHA-256 is compared with the latest version's `content_hash` BEFORE anything is sent: the same
8
+ * bytes mean "unchanged", and the 2 MiB stay where they are. (The server refuses an identical upload with
9
+ * 409 UNCHANGED too; that is treated as "unchanged", for two jobs racing.)
10
+ * - a group is optional: `--group <name>` files a NEW spec in that group (made when missing); a spec that already
11
+ * exists is never moved by a push (moving is the catalog's job).
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { existsSync, readFileSync, statSync } from "node:fs";
15
+ import { basename, extname } from "node:path";
16
+ import { parse } from "yaml";
17
+ import { ApiError, request } from "../http.js";
18
+ import { envTokenHint } from "../output.js";
19
+ /** The catalog takes documents up to this size (2 MiB); the CLI refuses a larger one before sending anything. */
20
+ export const MAX_SPEC_BYTES = 2 * 1024 * 1024;
21
+ const MAX_NOTE = 500;
22
+ /** The catalog's host for the active environment (`NB_SPECS_BASE_URL` overrides; the CLI ships no spec for this API). */
23
+ export function specsBaseUrl(env) {
24
+ const override = process.env.NB_SPECS_BASE_URL?.replace(/\/$/, "");
25
+ if (override)
26
+ return override;
27
+ return env === "local" ? "http://localhost:8792" : "https://api.specs.nasebanal.com";
28
+ }
29
+ /** `--org` / `NB_SPECS_ORG`: `personal`, or an organization's numeric id. Unset or empty (a CI variable left blank) is `personal`. */
30
+ export function checkOrg(raw) {
31
+ const v = raw?.trim() || "personal";
32
+ if (v === "personal" || /^[1-9]\d{0,17}$/.test(v))
33
+ return v;
34
+ throw new Error(`--org '${raw}' must be 'personal' or an organization id (a number)`);
35
+ }
36
+ /** SHA-256 (hex) of the document as text - what the catalog stores as `content_hash`. */
37
+ export function hashText(text) {
38
+ return createHash("sha256").update(text, "utf8").digest("hex");
39
+ }
40
+ /** What a pipeline says about a version it pushes: the commit, the branch and the CI run, so "which commit made v7" has an answer. */
41
+ export function ciNote(ci, commit) {
42
+ const sha = (commit ?? ci.commitSha)?.slice(0, 7);
43
+ const parts = [ci.repo && sha ? `${ci.repo}@${sha}` : (sha ?? ci.repo), ci.branch ? `(${ci.branch})` : null, ci.ciUrl].filter((p) => !!p);
44
+ return parts.length > 0 ? parts.join(" ").slice(0, MAX_NOTE) : undefined;
45
+ }
46
+ export function readSpecFile(file) {
47
+ if (!existsSync(file))
48
+ throw new Error(`--file: not found: ${file}`);
49
+ const stat = statSync(file);
50
+ if (!stat.isFile())
51
+ throw new Error(`--file: ${file} is not a file`);
52
+ if (stat.size === 0)
53
+ throw new Error(`--file: ${file} is empty`);
54
+ if (stat.size > MAX_SPEC_BYTES)
55
+ throw new Error(`--file: ${file} is ${(stat.size / 1048576).toFixed(1)} MiB, more than the ${MAX_SPEC_BYTES / 1048576} MiB the catalog takes`);
56
+ return readFileSync(file, "utf8");
57
+ }
58
+ /** `info.title` of the document, or null (not parseable, or no title): the server decides whether it is a valid OpenAPI. */
59
+ export function titleOf(text) {
60
+ try {
61
+ const doc = parse(text);
62
+ const title = doc?.info?.title;
63
+ return typeof title === "string" && title.trim() !== "" ? title.trim() : null;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /** The name a spec is registered under: asked for, else its title, else its file's name. At most 100 characters. */
70
+ export function specNameFor(text, file, asked) {
71
+ const name = (asked ?? titleOf(text) ?? basename(file, extname(file))).trim();
72
+ if (name === "")
73
+ throw new Error("The spec has no name: pass --name");
74
+ return name.slice(0, 100);
75
+ }
76
+ function headersOf(conn) {
77
+ return { baseUrl: conn.baseUrl, token: conn.token, headers: { "X-Active-Organization-Id": conn.org } };
78
+ }
79
+ function reasonOf(err) {
80
+ if (err instanceof ApiError)
81
+ return err.data?.error?.message ?? err.message;
82
+ return err.message;
83
+ }
84
+ const codeOf = (err) => (err instanceof ApiError ? err.data?.error?.code : undefined);
85
+ async function findSpec(conn, name, id) {
86
+ if (id) {
87
+ try {
88
+ const res = await request({ ...headersOf(conn), method: "GET", path: `/api/v1/specs/${encodeURIComponent(id)}` });
89
+ return res.data.data;
90
+ }
91
+ catch (err) {
92
+ if (err instanceof ApiError && err.status === 404)
93
+ throw new Error(`No spec with id '${id}' in the catalog (check --id, and --org: the catalog is per owner).`);
94
+ throw err;
95
+ }
96
+ }
97
+ const res = await request({ ...headersOf(conn), method: "GET", path: "/api/v1/specs" });
98
+ const list = res.data.data.data;
99
+ return list.find((s) => s.name.toLowerCase() === name.toLowerCase()) ?? null;
100
+ }
101
+ /** The id of the owner's group of that name, made when there is none. Refuses (with the catalog's reason) at the limit. */
102
+ async function groupIdFor(conn, name, create) {
103
+ const res = await request({ ...headersOf(conn), method: "GET", path: "/api/v1/groups" });
104
+ const groups = res.data.data.data;
105
+ const hit = groups.find((g) => g.name.toLowerCase() === name.toLowerCase());
106
+ if (hit)
107
+ return hit.id;
108
+ if (!create)
109
+ return null;
110
+ const made = await request({ ...headersOf(conn), method: "POST", path: "/api/v1/groups", body: { name } });
111
+ return made.data.data.id;
112
+ }
113
+ /**
114
+ * Push a file to the catalog. Whatever the catalog refuses - at the first read as much as at a write (a plan without the
115
+ * catalog is a 403 on the very first call) - comes back with the catalog's own reason, not a bare "403 Forbidden".
116
+ */
117
+ export async function pushSpec(p) {
118
+ try {
119
+ return await pushSpecInner(p);
120
+ }
121
+ catch (err) {
122
+ if (err instanceof ApiError) {
123
+ const hint = err.status === 401 ? envTokenHint() : null;
124
+ throw new Error(`The catalog refused the request: ${reasonOf(err)}${hint ? `\n${hint}` : ""}`);
125
+ }
126
+ throw err;
127
+ }
128
+ }
129
+ async function pushSpecInner(p) {
130
+ const text = readSpecFile(p.file);
131
+ const hash = hashText(text);
132
+ const name = specNameFor(text, p.file, p.name);
133
+ const note = p.note !== undefined ? p.note.trim().slice(0, MAX_NOTE) || undefined : undefined;
134
+ const existing = await findSpec(p.conn, name, p.id);
135
+ if (existing) {
136
+ // Nothing to upload when the file is already the latest version: the hash settles it without sending the document.
137
+ const versions = await request({ ...headersOf(p.conn), method: "GET", path: `/api/v1/specs/${encodeURIComponent(existing.id)}/versions` });
138
+ const latest = versions.data.data.data[0];
139
+ if (latest?.content_hash === hash) {
140
+ return { id: existing.id, name: existing.name, version: latest.version, group_id: existing.group_id, action: "unchanged", content_hash: hash };
141
+ }
142
+ if (p.dryRun)
143
+ return { id: existing.id, name: existing.name, version: existing.latest_version + 1, group_id: existing.group_id, action: "new_version", content_hash: hash };
144
+ try {
145
+ const res = await request({
146
+ ...headersOf(p.conn),
147
+ method: "POST",
148
+ path: `/api/v1/specs/${encodeURIComponent(existing.id)}/versions`,
149
+ body: { content: text, ...(note ? { note } : {}) },
150
+ });
151
+ const version = res.data.data.version;
152
+ return { id: existing.id, name: existing.name, version, group_id: existing.group_id, action: "new_version", content_hash: hash };
153
+ }
154
+ catch (err) {
155
+ // Another job pushed the same document between our comparison and our upload: the catalog says so, and that is the goal.
156
+ if (codeOf(err) === "UNCHANGED")
157
+ return { id: existing.id, name: existing.name, version: existing.latest_version, group_id: existing.group_id, action: "unchanged", content_hash: hash };
158
+ throw new Error(`The catalog refused the new version: ${reasonOf(err)}`);
159
+ }
160
+ }
161
+ // A new spec. The group is by name and made on the way; none asked = in no group.
162
+ if (p.dryRun) {
163
+ const gid = p.group ? await groupIdFor(p.conn, p.group, false) : null;
164
+ return { id: "(new)", name, version: 1, group_id: gid, action: "created", content_hash: hash };
165
+ }
166
+ let groupId = null;
167
+ try {
168
+ if (p.group)
169
+ groupId = await groupIdFor(p.conn, p.group, true);
170
+ const res = await request({
171
+ ...headersOf(p.conn),
172
+ method: "POST",
173
+ path: "/api/v1/specs",
174
+ body: { name, content: text, ...(groupId ? { group_id: groupId } : {}), ...(p.description ? { description: p.description } : {}) },
175
+ });
176
+ const created = res.data.data;
177
+ return { id: created.id, name: created.name, version: 1, group_id: created.group_id ?? null, action: "created", content_hash: hash };
178
+ }
179
+ catch (err) {
180
+ if (codeOf(err) === "NAME_TAKEN") {
181
+ // Two jobs creating the same spec: the loser pushes to the winner's.
182
+ const raced = await findSpec(p.conn, name);
183
+ if (raced)
184
+ return pushSpecInner({ ...p, id: raced.id });
185
+ }
186
+ throw new Error(`The catalog refused the spec: ${reasonOf(err)}`);
187
+ }
188
+ }
package/dist/update.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Self-update: a passive "new version available" notice and `nb upgrade`.
3
+ *
4
+ * Follows the usual CLI convention (npm, gh, gcloud): the notice is a single
5
+ * line on stderr, shown at most once a day from a cached registry lookup, and
6
+ * silent for non-interactive use (pipes, CI) so scripted output never changes.
7
+ * `NB_NO_UPDATE_NOTIFIER=1` turns it off.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { Command } from "commander";
13
+ import { configDir } from "./config.js";
14
+ const PACKAGE = "@nasebanal/cli";
15
+ const REGISTRY = "https://registry.npmjs.org";
16
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
17
+ const FETCH_TIMEOUT_MS = 1500;
18
+ /** 1 if a > b, -1 if a < b, 0 if equal (plain `x.y.z`; pre-release tags are ignored). */
19
+ export function compareVersions(a, b) {
20
+ const pa = a.split("-")[0].split(".").map(Number);
21
+ const pb = b.split("-")[0].split(".").map(Number);
22
+ for (let i = 0; i < 3; i++) {
23
+ const d = (pa[i] || 0) - (pb[i] || 0);
24
+ if (d !== 0)
25
+ return d > 0 ? 1 : -1;
26
+ }
27
+ return 0;
28
+ }
29
+ function cachePath() {
30
+ return join(configDir(), "update-check.json");
31
+ }
32
+ function readCache() {
33
+ try {
34
+ return JSON.parse(readFileSync(cachePath(), "utf8"));
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
40
+ function writeCache(cache) {
41
+ try {
42
+ mkdirSync(configDir(), { recursive: true });
43
+ writeFileSync(cachePath(), JSON.stringify(cache) + "\n");
44
+ }
45
+ catch {
46
+ /* best effort */
47
+ }
48
+ }
49
+ /** Latest published version. Goes to the public registry directly, so a `~/.npmrc` scope mapping cannot hide it. */
50
+ export async function fetchLatest(timeoutMs = FETCH_TIMEOUT_MS) {
51
+ try {
52
+ const res = await fetch(`${REGISTRY}/${PACKAGE.replace("/", "%2F")}/latest`, {
53
+ signal: AbortSignal.timeout(timeoutMs),
54
+ });
55
+ if (!res.ok)
56
+ return undefined;
57
+ return (await res.json()).version;
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ }
63
+ function notifierEnabled() {
64
+ return !process.env.NB_NO_UPDATE_NOTIFIER && !process.env.CI && !!process.stderr.isTTY;
65
+ }
66
+ /**
67
+ * Arrange for the update notice to print when the process exits. Reads the
68
+ * cache synchronously; when it is older than a day, refreshes it afterwards
69
+ * (bounded by a short timeout) so the notice appears from the next run on.
70
+ */
71
+ export function startUpdateNotifier(current, argv) {
72
+ const noop = { refresh: async () => { } };
73
+ if (!notifierEnabled() || argv.slice(2).some((a) => a === "upgrade" || a === "--version" || a === "-V"))
74
+ return noop;
75
+ const cache = readCache();
76
+ if (cache?.latest && compareVersions(cache.latest, current) > 0) {
77
+ const latest = cache.latest;
78
+ process.on("exit", () => {
79
+ process.stderr.write(`\nUpdate available: ${current} -> ${latest}. Run \`nb upgrade\` to install it.\n`);
80
+ });
81
+ }
82
+ const stale = !cache || Date.now() - cache.checkedAt > CHECK_INTERVAL_MS;
83
+ return {
84
+ refresh: async () => {
85
+ if (!stale)
86
+ return;
87
+ const latest = await fetchLatest();
88
+ // Record the attempt even on failure so an offline machine is not retried on every command.
89
+ writeCache({ checkedAt: Date.now(), latest: latest ?? cache?.latest });
90
+ },
91
+ };
92
+ }
93
+ /** True when `nb` is a dev link to a checkout rather than an npm-installed copy. */
94
+ function isLinkedCheckout() {
95
+ try {
96
+ return !realpathSync(process.argv[1]).includes("node_modules");
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
102
+ export function buildUpgradeCommand(current) {
103
+ return new Command("upgrade")
104
+ .description("Upgrade nb to the latest published version")
105
+ .option("--check", "only report whether a newer version exists")
106
+ .action(async (opts) => {
107
+ const latest = await fetchLatest(5000);
108
+ if (!latest)
109
+ throw new Error("Could not reach the npm registry to look up the latest version.");
110
+ writeCache({ checkedAt: Date.now(), latest });
111
+ if (compareVersions(latest, current) <= 0) {
112
+ process.stdout.write(`nb ${current} is up to date.\n`);
113
+ return;
114
+ }
115
+ process.stdout.write(`Update available: ${current} -> ${latest}\n`);
116
+ if (opts.check)
117
+ return;
118
+ if (isLinkedCheckout()) {
119
+ process.stdout.write("This nb is linked to a source checkout: `git pull && npm run build` there instead.\n");
120
+ return;
121
+ }
122
+ const r = spawnSync("npm", ["install", "-g", `${PACKAGE}@${latest}`, `--@nasebanal:registry=${REGISTRY}/`], { stdio: "inherit" });
123
+ if (r.status !== 0) {
124
+ throw new Error(`npm install failed. Run it yourself: npm install -g ${PACKAGE}@latest --@nasebanal:registry=${REGISTRY}/`);
125
+ }
126
+ });
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nasebanal/cli",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "gcloud-style command-line interface for NASEBANAL APIs, generated from nb-api-specs OpenAPI contracts.",
5
5
  "license": "MIT",
6
6
  "type": "module",