@canra/cli 0.1.2 → 0.3.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 +8 -2
- package/canra.js +411 -93
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,14 +23,20 @@ canra search json
|
|
|
23
23
|
|---|---|
|
|
24
24
|
| `canra search <query>` | Search the registry |
|
|
25
25
|
| `canra info <owner>/<name>` | Show a spec's metadata |
|
|
26
|
-
| `canra fetch <owner>/<name>[@version]` |
|
|
27
|
-
| `canra outdated` | Check fetched specs for newer versions |
|
|
26
|
+
| `canra fetch <owner>/<name>[@version]` | Download a spec (latest or a pinned version), verify its SHA-256, and save it to `.canra/specs/<owner>/<name>/` |
|
|
27
|
+
| `canra outdated [--json]` | Check fetched specs for newer versions; exits 1 if any is outdated |
|
|
28
|
+
| `canra update [<owner>/<name>]` | Re-fetch the latest version of every outdated spec (or the named one) |
|
|
29
|
+
| `canra remove <owner>/<name>` | Delete a fetched spec from `.canra/specs/` |
|
|
28
30
|
| `canra login` | Save a registry URL and API token |
|
|
29
31
|
| `canra whoami` | Show which account your saved token belongs to |
|
|
30
32
|
| `canra publish <file.spec.md>` | Publish a spec (needs a token) |
|
|
31
33
|
| `canra logout` | Remove saved credentials |
|
|
32
34
|
| `canra version` / `canra help` | Version and usage |
|
|
33
35
|
|
|
36
|
+
## Vendoring specs
|
|
37
|
+
|
|
38
|
+
Commit `.canra/` to source control. The saved specs are the record of which spec versions your project implemented (a version is exact and immutable). After `canra update`, `git diff .canra/` shows what changed in each spec.
|
|
39
|
+
|
|
34
40
|
## Publishing
|
|
35
41
|
|
|
36
42
|
Create an API token at **canra.dev → Account → API tokens**, then:
|
package/canra.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import fs from "node:fs";
|
|
4
5
|
import os from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import readline from "node:readline";
|
|
7
8
|
|
|
8
|
-
// Runs under Node >= 20 (via `npx canra`) and Bun: only node: built-ins and fetch.
|
|
9
|
+
// Runs under Node >= 20 (via `npx @canra/cli`) and Bun: only node: built-ins and fetch.
|
|
9
10
|
// The version comes from package.json, so a release only bumps one place.
|
|
10
11
|
const VERSION = JSON.parse(fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
|
|
11
12
|
const CONFIG_DIR = path.join(os.homedir(), ".canra");
|
|
@@ -22,14 +23,39 @@ function readConfig() {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
// The config holds an API token: readable by the owner only (the mode is
|
|
27
|
+
// re-applied in case the file or directory already existed with a looser one).
|
|
25
28
|
function writeConfig(config) {
|
|
26
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
27
|
-
fs.
|
|
29
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
30
|
+
fs.chmodSync(CONFIG_DIR, 0o700);
|
|
31
|
+
fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
32
|
+
fs.chmodSync(CONFIG_PATH, 0o600);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The token is sent to the registry on every authenticated call, so only an
|
|
36
|
+
// https registry is accepted -- or plain http to this machine, for development.
|
|
37
|
+
function registryError(registry) {
|
|
38
|
+
let url;
|
|
39
|
+
try {
|
|
40
|
+
url = new URL(registry);
|
|
41
|
+
} catch {
|
|
42
|
+
return `"${registry}" is not a valid URL`;
|
|
43
|
+
}
|
|
44
|
+
if (url.protocol === "https:") return null;
|
|
45
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
46
|
+
if (url.protocol === "http:" && local) return null;
|
|
47
|
+
return `the registry must use https (got ${url.protocol}//${url.host}); plain http is only allowed for localhost`;
|
|
28
48
|
}
|
|
29
49
|
|
|
30
50
|
function getRegistry() {
|
|
31
51
|
const config = readConfig();
|
|
32
|
-
|
|
52
|
+
const registry = config.registry || "https://canra.dev";
|
|
53
|
+
const problem = registryError(registry);
|
|
54
|
+
if (problem) {
|
|
55
|
+
console.error(`Error: ${problem}. Run "canra login" again.`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
return registry;
|
|
33
59
|
}
|
|
34
60
|
|
|
35
61
|
function getToken() {
|
|
@@ -97,58 +123,154 @@ function specApiUrl(registry, { owner, name }) {
|
|
|
97
123
|
|
|
98
124
|
// --- Prompt helper ---
|
|
99
125
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
126
|
+
// Ask questions on ONE readline interface (separate interfaces on piped stdin
|
|
127
|
+
// let the first swallow every line and leave the next prompt hanging). Lines
|
|
128
|
+
// that arrive before a question is asked are queued, so piped answers work.
|
|
129
|
+
function createPrompter() {
|
|
130
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
|
131
|
+
const lines = [];
|
|
132
|
+
const waiting = [];
|
|
133
|
+
let closed = false;
|
|
134
|
+
rl.on("line", (line) => (waiting.length ? waiting.shift()(line) : lines.push(line)));
|
|
135
|
+
rl.on("close", () => {
|
|
136
|
+
closed = true;
|
|
137
|
+
while (waiting.length) waiting.shift()("");
|
|
110
138
|
});
|
|
139
|
+
return {
|
|
140
|
+
ask(question) {
|
|
141
|
+
process.stdout.write(question);
|
|
142
|
+
if (lines.length) return Promise.resolve(lines.shift().trim());
|
|
143
|
+
if (closed) return Promise.resolve("");
|
|
144
|
+
return new Promise((resolve) => waiting.push((line) => resolve(line.trim())));
|
|
145
|
+
},
|
|
146
|
+
close: () => rl.close(),
|
|
147
|
+
};
|
|
111
148
|
}
|
|
112
149
|
|
|
113
150
|
// --- Commands ---
|
|
114
151
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
152
|
+
function specFrontmatter(text) {
|
|
153
|
+
const match = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+/.exec(text);
|
|
154
|
+
return match ? match[1] : "";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Registry strings are untrusted and end up in a terminal: control and bidi
|
|
158
|
+
// characters would let them inject escape sequences or reorder the output.
|
|
159
|
+
function printable(value) {
|
|
160
|
+
return [...String(value ?? "")]
|
|
161
|
+
.filter((c) => {
|
|
162
|
+
const code = c.codePointAt(0);
|
|
163
|
+
return (
|
|
164
|
+
code > 31 &&
|
|
165
|
+
code !== 127 &&
|
|
166
|
+
!(code >= 0x80 && code <= 0x9f) &&
|
|
167
|
+
!(code >= 0x202a && code <= 0x202e) &&
|
|
168
|
+
!(code >= 0x2066 && code <= 0x2069)
|
|
169
|
+
);
|
|
170
|
+
})
|
|
171
|
+
.join("");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Reads only the top-level keys (before the first [table]), so a `version` inside
|
|
175
|
+
// a table cannot win. A bare integer is accepted because toml.js accepts `version = 1`.
|
|
176
|
+
function frontmatterVersion(text) {
|
|
177
|
+
const topLevel = specFrontmatter(text).split(/^\[/m)[0];
|
|
178
|
+
const match = /^[ \t]*version[ \t]*=[ \t]*(?:"([^"\n]*)"|(\d+))[ \t\r]*(?:#.*)?$/m.exec(topLevel);
|
|
179
|
+
const version = match ? (match[1] ?? match[2]) : null;
|
|
180
|
+
return version !== null && printable(version) === version ? version : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// `.canra/` is committed and git stores symlinks, so a cloned repo can point
|
|
184
|
+
// these paths anywhere; writing or deleting through one would escape `.canra/`.
|
|
185
|
+
function refuseSymlinks(paths) {
|
|
186
|
+
for (const p of paths) {
|
|
187
|
+
let stat;
|
|
188
|
+
try {
|
|
189
|
+
stat = fs.lstatSync(p);
|
|
190
|
+
} catch {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (stat.isSymbolicLink()) throw new Error(`${printable(p)} is a symbolic link; refusing to follow it`);
|
|
119
194
|
}
|
|
195
|
+
}
|
|
120
196
|
|
|
121
|
-
|
|
197
|
+
function localSpecPaths(owner, name) {
|
|
198
|
+
const specsDir = path.join(LOCAL_DIR, "specs");
|
|
199
|
+
const ownerDir = path.join(specsDir, owner);
|
|
200
|
+
return [LOCAL_DIR, specsDir, ownerDir, path.join(ownerDir, name)];
|
|
201
|
+
}
|
|
122
202
|
|
|
123
|
-
|
|
124
|
-
const
|
|
203
|
+
function frontmatterFunctionCount(text) {
|
|
204
|
+
const api = /^\[api\][^\n]*\n([\s\S]*?)(?=^\[|$(?![\s\S]))/m.exec(specFrontmatter(text));
|
|
205
|
+
const list = api ? /^functions\s*=\s*\[([\s\S]*?)\]/m.exec(api[1]) : null;
|
|
206
|
+
return list ? (list[1].match(/"(?:[^"\\]|\\.)*"/g) || []).length : 0;
|
|
207
|
+
}
|
|
125
208
|
|
|
126
|
-
|
|
209
|
+
function localSpecDir(owner, name) {
|
|
210
|
+
return path.join(LOCAL_DIR, "specs", owner, name);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function downloadSpec(registry, { owner, name, version }) {
|
|
214
|
+
const base = `${registry}/specs/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
|
|
215
|
+
const res = await fetchRegistry(version ? `${base}/${encodeURIComponent(version)}.md` : `${base}.md`);
|
|
127
216
|
if (!res.ok) {
|
|
128
|
-
const body = await res.text();
|
|
129
|
-
|
|
130
|
-
process.exit(1);
|
|
217
|
+
const body = printable((await res.text()).trim());
|
|
218
|
+
throw new Error(`${res.status} - ${body}`);
|
|
131
219
|
}
|
|
132
220
|
|
|
133
|
-
|
|
221
|
+
// The hash covers the raw response bytes and those same bytes are written, so
|
|
222
|
+
// no decode/encode round trip can change what was verified. It detects
|
|
223
|
+
// corruption in transit, in a CDN or in the blob store; a malicious registry
|
|
224
|
+
// supplies a matching hash too.
|
|
225
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
226
|
+
const expected = res.headers.get("x-content-sha256");
|
|
227
|
+
if (!expected) {
|
|
228
|
+
throw new Error(`the registry sent no content hash for ${owner}/${name}; refusing to save unverified content`);
|
|
229
|
+
}
|
|
230
|
+
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
231
|
+
if (actual !== expected.trim().toLowerCase()) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`content hash mismatch for ${owner}/${name} (expected ${printable(expected)}, got ${actual}); nothing was saved`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
134
236
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
237
|
+
const text = bytes.toString("utf8");
|
|
238
|
+
const fetchedVersion = frontmatterVersion(text);
|
|
239
|
+
if (!fetchedVersion) throw new Error(`${owner}/${name} has no version in its frontmatter`);
|
|
240
|
+
if (version && version !== fetchedVersion) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`${owner}/${name}@${version} was requested but the registry returned version ${printable(fetchedVersion)}`,
|
|
140
243
|
);
|
|
141
|
-
process.exit(1);
|
|
142
244
|
}
|
|
143
245
|
|
|
144
|
-
|
|
145
|
-
|
|
246
|
+
const specDir = localSpecDir(owner, name);
|
|
247
|
+
refuseSymlinks([
|
|
248
|
+
...localSpecPaths(owner, name),
|
|
249
|
+
path.join(specDir, `${name}.spec.md`),
|
|
250
|
+
path.join(specDir, "metadata.json"),
|
|
251
|
+
]);
|
|
146
252
|
fs.mkdirSync(specDir, { recursive: true });
|
|
147
|
-
fs.writeFileSync(path.join(specDir,
|
|
253
|
+
fs.writeFileSync(path.join(specDir, `${name}.spec.md`), bytes);
|
|
254
|
+
fs.writeFileSync(
|
|
255
|
+
path.join(specDir, "metadata.json"),
|
|
256
|
+
`${JSON.stringify({ owner, name, version: fetchedVersion, content_hash: actual }, null, 2)}\n`,
|
|
257
|
+
);
|
|
258
|
+
return { owner, name, version: fetchedVersion, specDir, functions: frontmatterFunctionCount(text) };
|
|
259
|
+
}
|
|
148
260
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
261
|
+
async function cmdFetch(specArg) {
|
|
262
|
+
if (!specArg) {
|
|
263
|
+
console.error("Usage: canra fetch <owner>/<name>[@version]");
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const ref = parseSpecRef(specArg, "canra fetch <owner>/<name>[@version]");
|
|
268
|
+
const saved = await downloadSpec(getRegistry(), ref);
|
|
269
|
+
|
|
270
|
+
console.log(
|
|
271
|
+
`Fetched ${saved.owner}/${saved.name}@${printable(saved.version)} (${saved.functions} function${saved.functions !== 1 ? "s" : ""})`,
|
|
272
|
+
);
|
|
273
|
+
console.log(` Saved to ${saved.specDir}/${saved.name}.spec.md`);
|
|
152
274
|
}
|
|
153
275
|
|
|
154
276
|
async function cmdSearch(query) {
|
|
@@ -193,9 +315,18 @@ async function cmdSearch(query) {
|
|
|
193
315
|
console.log(`\n${specs.length} result${specs.length !== 1 ? "s" : ""}`);
|
|
194
316
|
}
|
|
195
317
|
|
|
196
|
-
async function cmdPublish(
|
|
318
|
+
async function cmdPublish(args) {
|
|
319
|
+
// canra publish <file> [--owner <org>]: --owner publishes into an org you
|
|
320
|
+
// belong to instead of your own scope.
|
|
321
|
+
const ownerAt = args.indexOf("--owner");
|
|
322
|
+
const owner = ownerAt >= 0 ? args[ownerAt + 1] : null;
|
|
323
|
+
const filePath = args.find((a, i) => !a.startsWith("--") && (ownerAt < 0 || i !== ownerAt + 1));
|
|
324
|
+
if (ownerAt >= 0 && (!owner || !OWNER_RE.test(owner))) {
|
|
325
|
+
console.error("Error: --owner needs a valid organization name");
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
197
328
|
if (!filePath) {
|
|
198
|
-
console.error("Usage: canra publish <file>");
|
|
329
|
+
console.error("Usage: canra publish <file> [--owner <org>]");
|
|
199
330
|
process.exit(1);
|
|
200
331
|
}
|
|
201
332
|
|
|
@@ -213,7 +344,8 @@ async function cmdPublish(filePath) {
|
|
|
213
344
|
const registry = getRegistry();
|
|
214
345
|
const headers = authHeaders();
|
|
215
346
|
|
|
216
|
-
const
|
|
347
|
+
const target = owner ? `${registry}/api/specs?owner=${encodeURIComponent(owner)}` : `${registry}/api/specs`;
|
|
348
|
+
const res = await fetchRegistry(target, {
|
|
217
349
|
method: "POST",
|
|
218
350
|
body: content,
|
|
219
351
|
headers,
|
|
@@ -232,8 +364,16 @@ async function cmdLogin() {
|
|
|
232
364
|
const currentConfig = readConfig();
|
|
233
365
|
const defaultRegistry = currentConfig.registry || "https://canra.dev";
|
|
234
366
|
|
|
235
|
-
const
|
|
236
|
-
const
|
|
367
|
+
const prompter = createPrompter();
|
|
368
|
+
const registry = ((await prompter.ask(`Registry URL (${defaultRegistry}): `)) || defaultRegistry).replace(/\/+$/, "");
|
|
369
|
+
const registryProblem = registryError(registry);
|
|
370
|
+
if (registryProblem) {
|
|
371
|
+
prompter.close();
|
|
372
|
+
console.error(`Error: ${registryProblem}`);
|
|
373
|
+
process.exit(1);
|
|
374
|
+
}
|
|
375
|
+
const token = await prompter.ask("API token: ");
|
|
376
|
+
prompter.close();
|
|
237
377
|
|
|
238
378
|
if (!token) {
|
|
239
379
|
console.error("Error: token is required");
|
|
@@ -318,70 +458,235 @@ async function cmdInfo(specArg) {
|
|
|
318
458
|
if (spec.updated_at) console.log(` Updated: ${spec.updated_at}`);
|
|
319
459
|
}
|
|
320
460
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (!fs.existsSync(specsDir)) {
|
|
324
|
-
console.log("No local specs found. Run `canra fetch <owner>/<name>` first.");
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
461
|
+
// The server rejects more than MAX_REFS (website/outdated.js) entries per request.
|
|
462
|
+
const OUTDATED_BATCH = 50;
|
|
327
463
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
464
|
+
function localVersion(owner, name) {
|
|
465
|
+
const dir = localSpecDir(owner, name);
|
|
466
|
+
let version = null;
|
|
331
467
|
try {
|
|
332
|
-
|
|
333
|
-
const ownerDir = path.join(specsDir, owner);
|
|
334
|
-
if (!fs.statSync(ownerDir).isDirectory()) continue;
|
|
335
|
-
for (const name of fs.readdirSync(ownerDir)) {
|
|
336
|
-
if (fs.existsSync(path.join(ownerDir, name, "metadata.json"))) refs.push({ owner, name });
|
|
337
|
-
}
|
|
338
|
-
}
|
|
468
|
+
version = JSON.parse(fs.readFileSync(path.join(dir, "metadata.json"), "utf8")).version;
|
|
339
469
|
} catch {
|
|
340
|
-
|
|
341
|
-
return;
|
|
470
|
+
version = null;
|
|
342
471
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
472
|
+
if (!usableVersion(version)) {
|
|
473
|
+
try {
|
|
474
|
+
version = frontmatterVersion(fs.readFileSync(path.join(dir, `${name}.spec.md`), "utf8"));
|
|
475
|
+
} catch {
|
|
476
|
+
version = null;
|
|
477
|
+
}
|
|
347
478
|
}
|
|
479
|
+
return usableVersion(version) ? version : null;
|
|
480
|
+
}
|
|
348
481
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
482
|
+
// The server rejects the whole batch (400) if any entry is empty or over its
|
|
483
|
+
// MAX_FIELD_LENGTH (website/outdated.js), so one bad local version is skipped here.
|
|
484
|
+
function usableVersion(version) {
|
|
485
|
+
return typeof version === "string" && version.length > 0 && version.length <= 200 && printable(version) === version;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function scanLocalSpecs() {
|
|
489
|
+
const specsDir = path.join(LOCAL_DIR, "specs");
|
|
490
|
+
const refs = [];
|
|
491
|
+
let owners;
|
|
492
|
+
try {
|
|
493
|
+
owners = fs.readdirSync(specsDir);
|
|
494
|
+
} catch {
|
|
495
|
+
return refs;
|
|
496
|
+
}
|
|
497
|
+
for (const owner of owners) {
|
|
498
|
+
if (!OWNER_RE.test(owner)) continue;
|
|
359
499
|
try {
|
|
360
|
-
|
|
500
|
+
refuseSymlinks([LOCAL_DIR, specsDir, path.join(specsDir, owner)]);
|
|
361
501
|
} catch {
|
|
362
502
|
continue;
|
|
363
503
|
}
|
|
364
|
-
|
|
365
|
-
let res;
|
|
504
|
+
let names;
|
|
366
505
|
try {
|
|
367
|
-
|
|
506
|
+
names = fs.readdirSync(path.join(specsDir, owner));
|
|
368
507
|
} catch {
|
|
369
508
|
continue;
|
|
370
509
|
}
|
|
371
|
-
|
|
510
|
+
for (const name of names) {
|
|
511
|
+
if (!NAME_RE.test(name)) continue;
|
|
512
|
+
try {
|
|
513
|
+
refuseSymlinks([path.join(specsDir, owner, name)]);
|
|
514
|
+
} catch {
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const version = localVersion(owner, name);
|
|
518
|
+
if (version) refs.push({ owner, name, version });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return refs;
|
|
522
|
+
}
|
|
372
523
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
524
|
+
async function checkRemote(registry, refs) {
|
|
525
|
+
const results = [];
|
|
526
|
+
for (let i = 0; i < refs.length; i += OUTDATED_BATCH) {
|
|
527
|
+
const batch = refs.slice(i, i + OUTDATED_BATCH);
|
|
528
|
+
const res = await fetchRegistry(`${registry}/api/outdated`, {
|
|
529
|
+
method: "POST",
|
|
530
|
+
headers: { "Content-Type": "application/json" },
|
|
531
|
+
body: JSON.stringify({ specs: batch }),
|
|
532
|
+
});
|
|
533
|
+
if (!res.ok) throw new Error(`${res.status} - ${printable((await res.text()).trim())}`);
|
|
534
|
+
const rows = (await res.json()).results;
|
|
535
|
+
if (!Array.isArray(rows) || rows.length !== batch.length) {
|
|
536
|
+
throw new Error("the registry returned an unexpected number of results");
|
|
377
537
|
}
|
|
538
|
+
// Rows are zipped with the submitted refs, and the local owner/name (never the
|
|
539
|
+
// registry's) are what every later path is built from.
|
|
540
|
+
rows.forEach((row, j) => {
|
|
541
|
+
if (row?.owner !== batch[j].owner || row?.name !== batch[j].name) {
|
|
542
|
+
throw new Error("the registry returned an unexpected result that does not match the request");
|
|
543
|
+
}
|
|
544
|
+
results.push({ ...row, owner: batch[j].owner, name: batch[j].name, current: batch[j].version });
|
|
545
|
+
});
|
|
378
546
|
}
|
|
547
|
+
return results;
|
|
548
|
+
}
|
|
379
549
|
|
|
380
|
-
|
|
381
|
-
|
|
550
|
+
function printOutdatedTable(results) {
|
|
551
|
+
const labels = results.map((r) => `${r.owner}/${r.name}`);
|
|
552
|
+
const currents = results.map((r) => printable(r.current));
|
|
553
|
+
const latests = results.map((r) => printable(r.latest ?? "-"));
|
|
554
|
+
const specWidth = Math.max(4, ...labels.map((l) => l.length)) + 2;
|
|
555
|
+
const currentWidth = Math.max(7, ...currents.map((c) => c.length)) + 2;
|
|
556
|
+
const latestWidth = Math.max(6, ...latests.map((l) => l.length)) + 2;
|
|
557
|
+
console.log(`${"SPEC".padEnd(specWidth)}${"CURRENT".padEnd(currentWidth)}${"LATEST".padEnd(latestWidth)}BEHIND`);
|
|
558
|
+
results.forEach((r, i) => {
|
|
559
|
+
let behind = "-";
|
|
560
|
+
if (r.status === "outdated") behind = String((r.behind || []).length);
|
|
561
|
+
else if (r.status !== "current") behind = printable(r.status);
|
|
562
|
+
const moved = r.moved_to ? ` moved → ${printable(r.moved_to)}` : "";
|
|
563
|
+
console.log(
|
|
564
|
+
`${labels[i].padEnd(specWidth)}${currents[i].padEnd(currentWidth)}${latests[i].padEnd(latestWidth)}${behind}${moved}`,
|
|
565
|
+
);
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function needsUpdate(row) {
|
|
570
|
+
return row.status === "outdated" || (row.status === "current" && Boolean(row.moved_to));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function cmdOutdated(args) {
|
|
574
|
+
const asJson = args.includes("--json");
|
|
575
|
+
const refs = scanLocalSpecs();
|
|
576
|
+
if (refs.length === 0) {
|
|
577
|
+
console.log(asJson ? "[]" : "No local specs found. Run `canra fetch <owner>/<name>` first.");
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const results = await checkRemote(getRegistry(), refs);
|
|
582
|
+
const outdated = results.filter(needsUpdate).length;
|
|
583
|
+
|
|
584
|
+
if (asJson) {
|
|
585
|
+
console.log(JSON.stringify(results, null, 2));
|
|
382
586
|
} else {
|
|
383
|
-
|
|
587
|
+
printOutdatedTable(results);
|
|
588
|
+
if (results.some(needsUpdate)) {
|
|
589
|
+
console.log(`\n${outdated} outdated spec${outdated !== 1 ? "s" : ""}.`);
|
|
590
|
+
console.log("Run canra update, then git diff .canra/ to review the changes.");
|
|
591
|
+
} else {
|
|
592
|
+
console.log("\nAll specs are up to date.");
|
|
593
|
+
}
|
|
384
594
|
}
|
|
595
|
+
if (outdated > 0) process.exitCode = 1;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function deleteLocalSpec(owner, name) {
|
|
599
|
+
const specsRoot = path.resolve(LOCAL_DIR, "specs");
|
|
600
|
+
const dir = path.resolve(localSpecDir(owner, name));
|
|
601
|
+
if (!dir.startsWith(specsRoot + path.sep)) throw new Error(`unexpected local path for ${owner}/${name}`);
|
|
602
|
+
refuseSymlinks(localSpecPaths(owner, name));
|
|
603
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
604
|
+
const ownerDir = path.join(LOCAL_DIR, "specs", owner);
|
|
605
|
+
if (fs.readdirSync(ownerDir).length === 0) fs.rmdirSync(ownerDir);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function cmdRemove(specArg) {
|
|
609
|
+
const usage = "canra remove <owner>/<name>";
|
|
610
|
+
if (!specArg) {
|
|
611
|
+
console.error("Error: remove needs a spec to delete");
|
|
612
|
+
console.error(`Usage: ${usage}`);
|
|
613
|
+
process.exit(1);
|
|
614
|
+
}
|
|
615
|
+
const ref = parseSpecRef(specArg, usage);
|
|
616
|
+
if (ref.version) {
|
|
617
|
+
console.error("Error: remove deletes the whole spec; omit @version");
|
|
618
|
+
console.error(`Usage: ${usage}`);
|
|
619
|
+
process.exit(1);
|
|
620
|
+
}
|
|
621
|
+
const label = `${ref.owner}/${ref.name}`;
|
|
622
|
+
if (!fs.existsSync(localSpecDir(ref.owner, ref.name))) {
|
|
623
|
+
console.error(`Error: ${label} is not fetched locally. Nothing to remove.`);
|
|
624
|
+
process.exit(1);
|
|
625
|
+
}
|
|
626
|
+
deleteLocalSpec(ref.owner, ref.name);
|
|
627
|
+
console.log(`Removed ${label}`);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function cmdUpdate(specArg) {
|
|
631
|
+
const only = specArg ? parseSpecRef(specArg, "canra update [<owner>/<name>]") : null;
|
|
632
|
+
if (only?.version) {
|
|
633
|
+
console.error("Error: update always moves to the latest version; omit @version");
|
|
634
|
+
console.error("Usage: canra update [<owner>/<name>]");
|
|
635
|
+
process.exit(1);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
let refs = scanLocalSpecs();
|
|
639
|
+
if (only) {
|
|
640
|
+
refs = refs.filter((r) => r.owner === only.owner && r.name === only.name);
|
|
641
|
+
if (refs.length === 0) {
|
|
642
|
+
console.error(`Error: ${only.owner}/${only.name} is not fetched locally. Run \`canra fetch\` first.`);
|
|
643
|
+
process.exit(1);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (refs.length === 0) {
|
|
647
|
+
console.log("No local specs found. Run `canra fetch <owner>/<name>` first.");
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const registry = getRegistry();
|
|
652
|
+
const targets = (await checkRemote(registry, refs)).filter(needsUpdate);
|
|
653
|
+
if (targets.length === 0) {
|
|
654
|
+
console.log("All specs are up to date.");
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
let failed = 0;
|
|
659
|
+
let updated = 0;
|
|
660
|
+
for (const row of targets) {
|
|
661
|
+
const label = `${row.owner}/${row.name}`;
|
|
662
|
+
try {
|
|
663
|
+
// moved_to comes from the registry and decides where files are written, so it
|
|
664
|
+
// must be exactly <owner>/<same name>.
|
|
665
|
+
const target = typeof row.moved_to === "string" ? /^([^/]+)\/([^/]+)$/.exec(row.moved_to) : null;
|
|
666
|
+
if (row.moved_to && !target) throw new Error(`unexpected move target '${printable(row.moved_to)}'`);
|
|
667
|
+
const toOwner = target ? target[1] : row.owner;
|
|
668
|
+
if (!OWNER_RE.test(toOwner) || (target && target[2] !== row.name)) {
|
|
669
|
+
throw new Error(`unexpected move target '${printable(row.moved_to)}'`);
|
|
670
|
+
}
|
|
671
|
+
const moving = toOwner !== row.owner;
|
|
672
|
+
if (moving && fs.existsSync(localSpecDir(toOwner, row.name))) {
|
|
673
|
+
throw new Error(`${toOwner}/${row.name} is already fetched locally; remove one of the two copies`);
|
|
674
|
+
}
|
|
675
|
+
const saved = await downloadSpec(registry, { owner: toOwner, name: row.name, version: null });
|
|
676
|
+
updated++;
|
|
677
|
+
if (!moving) {
|
|
678
|
+
console.log(`Updated ${label} ${row.current} → ${printable(saved.version)}`);
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
deleteLocalSpec(row.owner, row.name);
|
|
682
|
+
console.log(`Updated ${label} ${row.current} → ${toOwner}/${row.name}@${printable(saved.version)}`);
|
|
683
|
+
} catch (err) {
|
|
684
|
+
failed++;
|
|
685
|
+
console.error(`Error: could not update ${label}: ${printable(err.message)}`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
if (updated > 0) console.log("\nRun git diff .canra/ to review the changes.");
|
|
689
|
+
if (failed > 0) process.exitCode = 1;
|
|
385
690
|
}
|
|
386
691
|
|
|
387
692
|
function cmdHelp() {
|
|
@@ -391,14 +696,21 @@ Usage: canra <command> [args]
|
|
|
391
696
|
|
|
392
697
|
Commands:
|
|
393
698
|
fetch <owner>/<name>[@version]
|
|
394
|
-
|
|
699
|
+
Download a spec (latest, or a pinned version) and
|
|
700
|
+
save it, hash-verified, to .canra/specs/
|
|
395
701
|
search <query> Search registry, print formatted results
|
|
396
|
-
publish <file>
|
|
702
|
+
publish <file> [--owner <org>]
|
|
703
|
+
Publish a spec file (requires auth token); --owner
|
|
704
|
+
publishes into an organization you belong to
|
|
397
705
|
login Prompt for registry URL and token
|
|
398
706
|
logout Remove saved credentials
|
|
399
707
|
whoami Print the user your saved token authenticates as
|
|
400
708
|
info <owner>/<name> Print spec metadata
|
|
401
|
-
outdated
|
|
709
|
+
outdated [--json] Check .canra/specs/ for newer versions; exits 1 if
|
|
710
|
+
any spec is outdated; --json prints the results
|
|
711
|
+
update [<owner>/<name>] Re-fetch the latest version of every outdated spec
|
|
712
|
+
(or just the named one); review with git diff .canra/
|
|
713
|
+
remove <owner>/<name> Delete a fetched spec from .canra/specs/
|
|
402
714
|
help Print this help message
|
|
403
715
|
version Print CLI version
|
|
404
716
|
|
|
@@ -424,7 +736,7 @@ async function main() {
|
|
|
424
736
|
await cmdSearch(arg);
|
|
425
737
|
break;
|
|
426
738
|
case "publish":
|
|
427
|
-
await cmdPublish(args
|
|
739
|
+
await cmdPublish(args.slice(1));
|
|
428
740
|
break;
|
|
429
741
|
case "login":
|
|
430
742
|
await cmdLogin();
|
|
@@ -439,7 +751,13 @@ async function main() {
|
|
|
439
751
|
await cmdInfo(args[1]);
|
|
440
752
|
break;
|
|
441
753
|
case "outdated":
|
|
442
|
-
await cmdOutdated();
|
|
754
|
+
await cmdOutdated(args.slice(1));
|
|
755
|
+
break;
|
|
756
|
+
case "update":
|
|
757
|
+
await cmdUpdate(args[1]);
|
|
758
|
+
break;
|
|
759
|
+
case "remove":
|
|
760
|
+
cmdRemove(args[1]);
|
|
443
761
|
break;
|
|
444
762
|
case "version":
|
|
445
763
|
case "--version":
|
|
@@ -460,7 +778,7 @@ async function main() {
|
|
|
460
778
|
}
|
|
461
779
|
|
|
462
780
|
main().catch((err) => {
|
|
463
|
-
console.error(`Error: ${err.message}`);
|
|
781
|
+
console.error(`Error: ${printable(err.message)}`);
|
|
464
782
|
process.exit(1);
|
|
465
783
|
});
|
|
466
784
|
|