@nasebanal/cli 0.6.0 → 0.8.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 +78 -3
- package/dist/auth.js +5 -0
- package/dist/http.js +5 -0
- package/dist/index.js +2 -0
- package/dist/output.js +24 -0
- package/dist/reports/ci.js +31 -0
- package/dist/reports/upload.js +221 -25
- package/dist/reports/zip.js +128 -0
- package/dist/specs/command.js +44 -0
- package/dist/specs/push.js +188 -0
- package/package.json +1 -1
- package/specs/assurance.yaml +1814 -158
|
@@ -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
|
+
}
|