@canra/cli 0.2.0 → 0.4.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 +358 -92
- 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,5 +1,6 @@
|
|
|
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";
|
|
@@ -148,43 +149,128 @@ function createPrompter() {
|
|
|
148
149
|
|
|
149
150
|
// --- Commands ---
|
|
150
151
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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`);
|
|
155
194
|
}
|
|
195
|
+
}
|
|
156
196
|
|
|
157
|
-
|
|
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
|
+
}
|
|
158
202
|
|
|
159
|
-
|
|
160
|
-
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
|
+
}
|
|
161
208
|
|
|
162
|
-
|
|
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`);
|
|
163
216
|
if (!res.ok) {
|
|
164
|
-
const body = await res.text();
|
|
165
|
-
|
|
166
|
-
|
|
217
|
+
const body = printable((await res.text()).trim());
|
|
218
|
+
throw new Error(`${res.status} - ${body}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
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
|
+
);
|
|
167
235
|
}
|
|
168
236
|
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
`Error: ${ref.owner}/${ref.name}@${ref.version} is not available (the registry serves the latest, ${spec.version})`,
|
|
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)}`,
|
|
176
243
|
);
|
|
177
|
-
process.exit(1);
|
|
178
244
|
}
|
|
179
245
|
|
|
180
|
-
|
|
181
|
-
|
|
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
|
+
]);
|
|
182
252
|
fs.mkdirSync(specDir, { recursive: true });
|
|
183
|
-
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
|
+
}
|
|
184
260
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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`);
|
|
188
274
|
}
|
|
189
275
|
|
|
190
276
|
async function cmdSearch(query) {
|
|
@@ -220,9 +306,13 @@ async function cmdSearch(query) {
|
|
|
220
306
|
console.log(`${"-".repeat(nameWidth)} ${"-".repeat(versionWidth)} ${"-".repeat(descWidth)}`);
|
|
221
307
|
|
|
222
308
|
for (const spec of specs) {
|
|
223
|
-
const name = (spec.name
|
|
224
|
-
const version = (spec.version
|
|
225
|
-
const desc = (
|
|
309
|
+
const name = printable(spec.name).padEnd(nameWidth).slice(0, nameWidth);
|
|
310
|
+
const version = printable(spec.version).padEnd(versionWidth).slice(0, versionWidth);
|
|
311
|
+
const desc = printable(
|
|
312
|
+
String(spec.description || spec.title || "")
|
|
313
|
+
.replace(/\s+/g, " ")
|
|
314
|
+
.trim(),
|
|
315
|
+
).slice(0, descWidth);
|
|
226
316
|
console.log(`${name} ${version} ${desc}`);
|
|
227
317
|
}
|
|
228
318
|
|
|
@@ -266,12 +356,12 @@ async function cmdPublish(args) {
|
|
|
266
356
|
});
|
|
267
357
|
if (!res.ok) {
|
|
268
358
|
const body = await res.text();
|
|
269
|
-
console.error(`Error: ${res.status} - ${body}`);
|
|
359
|
+
console.error(`Error: ${res.status} - ${printable(body)}`);
|
|
270
360
|
process.exit(1);
|
|
271
361
|
}
|
|
272
362
|
|
|
273
363
|
const spec = await res.json();
|
|
274
|
-
console.log(`Published ${spec.name}@${spec.version}`);
|
|
364
|
+
console.log(`Published ${printable(spec.name)}@${printable(spec.version)}`);
|
|
275
365
|
}
|
|
276
366
|
|
|
277
367
|
async function cmdLogin() {
|
|
@@ -333,9 +423,9 @@ async function cmdWhoami() {
|
|
|
333
423
|
process.exit(1);
|
|
334
424
|
}
|
|
335
425
|
const me = await res.json();
|
|
336
|
-
console.log(me.user);
|
|
426
|
+
console.log(printable(me.user));
|
|
337
427
|
console.log(` Registry: ${registry}`);
|
|
338
|
-
if (me.scopes?.length) console.log(` Scopes: ${me.scopes.join(", ")}`);
|
|
428
|
+
if (me.scopes?.length) console.log(` Scopes: ${printable(me.scopes.join(", "))}`);
|
|
339
429
|
}
|
|
340
430
|
|
|
341
431
|
async function cmdInfo(specArg) {
|
|
@@ -360,82 +450,247 @@ async function cmdInfo(specArg) {
|
|
|
360
450
|
}
|
|
361
451
|
|
|
362
452
|
const spec = await res.json();
|
|
363
|
-
console.log(`${ref.owner}/${ref.name}@${spec.version}`);
|
|
364
|
-
if (spec.title) console.log(` Title: ${spec.title}`);
|
|
365
|
-
if (spec.status) console.log(` Status: ${spec.status}`);
|
|
366
|
-
if (spec.difficulty) console.log(` Difficulty: ${spec.difficulty}`);
|
|
367
|
-
if (spec.tags?.length) console.log(` Tags: ${spec.tags.join(", ")}`);
|
|
368
|
-
if (spec.functions?.length) console.log(` Functions: ${spec.functions.join(", ")}`);
|
|
369
|
-
if (spec.types?.length) console.log(` Types: ${spec.types.join(", ")}`);
|
|
370
|
-
if (spec.test_vectors_inline) console.log(` Test vectors: ${spec.test_vectors_inline} inline`);
|
|
371
|
-
if (spec.owner) console.log(` Owner: ${spec.owner}`);
|
|
372
|
-
if (spec.updated_at) console.log(` Updated: ${spec.updated_at}`);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
async function cmdOutdated() {
|
|
376
|
-
const specsDir = path.join(LOCAL_DIR, "specs");
|
|
377
|
-
if (!fs.existsSync(specsDir)) {
|
|
378
|
-
console.log("No local specs found. Run `canra fetch <owner>/<name>` first.");
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
453
|
+
console.log(`${ref.owner}/${ref.name}@${printable(spec.version)}`);
|
|
454
|
+
if (spec.title) console.log(` Title: ${printable(spec.title)}`);
|
|
455
|
+
if (spec.status) console.log(` Status: ${printable(spec.status)}`);
|
|
456
|
+
if (spec.difficulty) console.log(` Difficulty: ${printable(spec.difficulty)}`);
|
|
457
|
+
if (spec.tags?.length) console.log(` Tags: ${printable(spec.tags.join(", "))}`);
|
|
458
|
+
if (spec.functions?.length) console.log(` Functions: ${printable(spec.functions.join(", "))}`);
|
|
459
|
+
if (spec.types?.length) console.log(` Types: ${printable(spec.types.join(", "))}`);
|
|
460
|
+
if (spec.test_vectors_inline) console.log(` Test vectors: ${printable(spec.test_vectors_inline)} inline`);
|
|
461
|
+
if (spec.owner) console.log(` Owner: ${printable(spec.owner)}`);
|
|
462
|
+
if (spec.updated_at) console.log(` Updated: ${printable(spec.updated_at)}`);
|
|
463
|
+
}
|
|
381
464
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
465
|
+
// The server rejects more than MAX_REFS (website/outdated.js) entries per request.
|
|
466
|
+
const OUTDATED_BATCH = 50;
|
|
467
|
+
|
|
468
|
+
function localVersion(owner, name) {
|
|
469
|
+
const dir = localSpecDir(owner, name);
|
|
470
|
+
let version = null;
|
|
385
471
|
try {
|
|
386
|
-
|
|
387
|
-
const ownerDir = path.join(specsDir, owner);
|
|
388
|
-
if (!fs.statSync(ownerDir).isDirectory()) continue;
|
|
389
|
-
for (const name of fs.readdirSync(ownerDir)) {
|
|
390
|
-
if (fs.existsSync(path.join(ownerDir, name, "metadata.json"))) refs.push({ owner, name });
|
|
391
|
-
}
|
|
392
|
-
}
|
|
472
|
+
version = JSON.parse(fs.readFileSync(path.join(dir, "metadata.json"), "utf8")).version;
|
|
393
473
|
} catch {
|
|
394
|
-
|
|
395
|
-
return;
|
|
474
|
+
version = null;
|
|
396
475
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
476
|
+
if (!usableVersion(version)) {
|
|
477
|
+
try {
|
|
478
|
+
version = frontmatterVersion(fs.readFileSync(path.join(dir, `${name}.spec.md`), "utf8"));
|
|
479
|
+
} catch {
|
|
480
|
+
version = null;
|
|
481
|
+
}
|
|
401
482
|
}
|
|
483
|
+
return usableVersion(version) ? version : null;
|
|
484
|
+
}
|
|
402
485
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
486
|
+
// The server rejects the whole batch (400) if any entry is empty or over its
|
|
487
|
+
// MAX_FIELD_LENGTH (website/outdated.js), so one bad local version is skipped here.
|
|
488
|
+
function usableVersion(version) {
|
|
489
|
+
return typeof version === "string" && version.length > 0 && version.length <= 200 && printable(version) === version;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function scanLocalSpecs() {
|
|
493
|
+
const specsDir = path.join(LOCAL_DIR, "specs");
|
|
494
|
+
const refs = [];
|
|
495
|
+
let owners;
|
|
496
|
+
try {
|
|
497
|
+
owners = fs.readdirSync(specsDir);
|
|
498
|
+
} catch {
|
|
499
|
+
return refs;
|
|
500
|
+
}
|
|
501
|
+
for (const owner of owners) {
|
|
502
|
+
if (!OWNER_RE.test(owner)) continue;
|
|
413
503
|
try {
|
|
414
|
-
|
|
504
|
+
refuseSymlinks([LOCAL_DIR, specsDir, path.join(specsDir, owner)]);
|
|
415
505
|
} catch {
|
|
416
506
|
continue;
|
|
417
507
|
}
|
|
418
|
-
|
|
419
|
-
let res;
|
|
508
|
+
let names;
|
|
420
509
|
try {
|
|
421
|
-
|
|
510
|
+
names = fs.readdirSync(path.join(specsDir, owner));
|
|
422
511
|
} catch {
|
|
423
512
|
continue;
|
|
424
513
|
}
|
|
425
|
-
|
|
514
|
+
for (const name of names) {
|
|
515
|
+
if (!NAME_RE.test(name)) continue;
|
|
516
|
+
try {
|
|
517
|
+
refuseSymlinks([path.join(specsDir, owner, name)]);
|
|
518
|
+
} catch {
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
const version = localVersion(owner, name);
|
|
522
|
+
if (version) refs.push({ owner, name, version });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return refs;
|
|
526
|
+
}
|
|
426
527
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
528
|
+
async function checkRemote(registry, refs) {
|
|
529
|
+
const results = [];
|
|
530
|
+
for (let i = 0; i < refs.length; i += OUTDATED_BATCH) {
|
|
531
|
+
const batch = refs.slice(i, i + OUTDATED_BATCH);
|
|
532
|
+
const res = await fetchRegistry(`${registry}/api/outdated`, {
|
|
533
|
+
method: "POST",
|
|
534
|
+
headers: { "Content-Type": "application/json" },
|
|
535
|
+
body: JSON.stringify({ specs: batch }),
|
|
536
|
+
});
|
|
537
|
+
if (!res.ok) throw new Error(`${res.status} - ${printable((await res.text()).trim())}`);
|
|
538
|
+
const rows = (await res.json()).results;
|
|
539
|
+
if (!Array.isArray(rows) || rows.length !== batch.length) {
|
|
540
|
+
throw new Error("the registry returned an unexpected number of results");
|
|
431
541
|
}
|
|
542
|
+
// Rows are zipped with the submitted refs, and the local owner/name (never the
|
|
543
|
+
// registry's) are what every later path is built from.
|
|
544
|
+
rows.forEach((row, j) => {
|
|
545
|
+
if (row?.owner !== batch[j].owner || row?.name !== batch[j].name) {
|
|
546
|
+
throw new Error("the registry returned an unexpected result that does not match the request");
|
|
547
|
+
}
|
|
548
|
+
results.push({ ...row, owner: batch[j].owner, name: batch[j].name, current: batch[j].version });
|
|
549
|
+
});
|
|
432
550
|
}
|
|
551
|
+
return results;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function printOutdatedTable(results) {
|
|
555
|
+
const labels = results.map((r) => `${r.owner}/${r.name}`);
|
|
556
|
+
const currents = results.map((r) => printable(r.current));
|
|
557
|
+
const latests = results.map((r) => printable(r.latest ?? "-"));
|
|
558
|
+
const specWidth = Math.max(4, ...labels.map((l) => l.length)) + 2;
|
|
559
|
+
const currentWidth = Math.max(7, ...currents.map((c) => c.length)) + 2;
|
|
560
|
+
const latestWidth = Math.max(6, ...latests.map((l) => l.length)) + 2;
|
|
561
|
+
console.log(`${"SPEC".padEnd(specWidth)}${"CURRENT".padEnd(currentWidth)}${"LATEST".padEnd(latestWidth)}BEHIND`);
|
|
562
|
+
results.forEach((r, i) => {
|
|
563
|
+
let behind = "-";
|
|
564
|
+
if (r.status === "outdated") behind = String((r.behind || []).length);
|
|
565
|
+
else if (r.status !== "current") behind = printable(r.status);
|
|
566
|
+
const moved = r.moved_to ? ` moved → ${printable(r.moved_to)}` : "";
|
|
567
|
+
console.log(
|
|
568
|
+
`${labels[i].padEnd(specWidth)}${currents[i].padEnd(currentWidth)}${latests[i].padEnd(latestWidth)}${behind}${moved}`,
|
|
569
|
+
);
|
|
570
|
+
});
|
|
571
|
+
}
|
|
433
572
|
|
|
434
|
-
|
|
435
|
-
|
|
573
|
+
function needsUpdate(row) {
|
|
574
|
+
return row.status === "outdated" || (row.status === "current" && Boolean(row.moved_to));
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function cmdOutdated(args) {
|
|
578
|
+
const asJson = args.includes("--json");
|
|
579
|
+
const refs = scanLocalSpecs();
|
|
580
|
+
if (refs.length === 0) {
|
|
581
|
+
console.log(asJson ? "[]" : "No local specs found. Run `canra fetch <owner>/<name>` first.");
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const results = await checkRemote(getRegistry(), refs);
|
|
586
|
+
const outdated = results.filter(needsUpdate).length;
|
|
587
|
+
|
|
588
|
+
if (asJson) {
|
|
589
|
+
console.log(JSON.stringify(results, null, 2));
|
|
436
590
|
} else {
|
|
437
|
-
|
|
591
|
+
printOutdatedTable(results);
|
|
592
|
+
if (results.some(needsUpdate)) {
|
|
593
|
+
console.log(`\n${outdated} outdated spec${outdated !== 1 ? "s" : ""}.`);
|
|
594
|
+
console.log("Run canra update, then git diff .canra/ to review the changes.");
|
|
595
|
+
} else {
|
|
596
|
+
console.log("\nAll specs are up to date.");
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (outdated > 0) process.exitCode = 1;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function deleteLocalSpec(owner, name) {
|
|
603
|
+
const specsRoot = path.resolve(LOCAL_DIR, "specs");
|
|
604
|
+
const dir = path.resolve(localSpecDir(owner, name));
|
|
605
|
+
if (!dir.startsWith(specsRoot + path.sep)) throw new Error(`unexpected local path for ${owner}/${name}`);
|
|
606
|
+
refuseSymlinks(localSpecPaths(owner, name));
|
|
607
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
608
|
+
const ownerDir = path.join(LOCAL_DIR, "specs", owner);
|
|
609
|
+
if (fs.readdirSync(ownerDir).length === 0) fs.rmdirSync(ownerDir);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function cmdRemove(specArg) {
|
|
613
|
+
const usage = "canra remove <owner>/<name>";
|
|
614
|
+
if (!specArg) {
|
|
615
|
+
console.error("Error: remove needs a spec to delete");
|
|
616
|
+
console.error(`Usage: ${usage}`);
|
|
617
|
+
process.exit(1);
|
|
618
|
+
}
|
|
619
|
+
const ref = parseSpecRef(specArg, usage);
|
|
620
|
+
if (ref.version) {
|
|
621
|
+
console.error("Error: remove deletes the whole spec; omit @version");
|
|
622
|
+
console.error(`Usage: ${usage}`);
|
|
623
|
+
process.exit(1);
|
|
438
624
|
}
|
|
625
|
+
const label = `${ref.owner}/${ref.name}`;
|
|
626
|
+
if (!fs.existsSync(localSpecDir(ref.owner, ref.name))) {
|
|
627
|
+
console.error(`Error: ${label} is not fetched locally. Nothing to remove.`);
|
|
628
|
+
process.exit(1);
|
|
629
|
+
}
|
|
630
|
+
deleteLocalSpec(ref.owner, ref.name);
|
|
631
|
+
console.log(`Removed ${label}`);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function cmdUpdate(specArg) {
|
|
635
|
+
const only = specArg ? parseSpecRef(specArg, "canra update [<owner>/<name>]") : null;
|
|
636
|
+
if (only?.version) {
|
|
637
|
+
console.error("Error: update always moves to the latest version; omit @version");
|
|
638
|
+
console.error("Usage: canra update [<owner>/<name>]");
|
|
639
|
+
process.exit(1);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
let refs = scanLocalSpecs();
|
|
643
|
+
if (only) {
|
|
644
|
+
refs = refs.filter((r) => r.owner === only.owner && r.name === only.name);
|
|
645
|
+
if (refs.length === 0) {
|
|
646
|
+
console.error(`Error: ${only.owner}/${only.name} is not fetched locally. Run \`canra fetch\` first.`);
|
|
647
|
+
process.exit(1);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (refs.length === 0) {
|
|
651
|
+
console.log("No local specs found. Run `canra fetch <owner>/<name>` first.");
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const registry = getRegistry();
|
|
656
|
+
const targets = (await checkRemote(registry, refs)).filter(needsUpdate);
|
|
657
|
+
if (targets.length === 0) {
|
|
658
|
+
console.log("All specs are up to date.");
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
let failed = 0;
|
|
663
|
+
let updated = 0;
|
|
664
|
+
for (const row of targets) {
|
|
665
|
+
const label = `${row.owner}/${row.name}`;
|
|
666
|
+
try {
|
|
667
|
+
// moved_to comes from the registry and decides where files are written, so it
|
|
668
|
+
// must be exactly <owner>/<same name>.
|
|
669
|
+
const target = typeof row.moved_to === "string" ? /^([^/]+)\/([^/]+)$/.exec(row.moved_to) : null;
|
|
670
|
+
if (row.moved_to && !target) throw new Error(`unexpected move target '${printable(row.moved_to)}'`);
|
|
671
|
+
const toOwner = target ? target[1] : row.owner;
|
|
672
|
+
if (!OWNER_RE.test(toOwner) || (target && target[2] !== row.name)) {
|
|
673
|
+
throw new Error(`unexpected move target '${printable(row.moved_to)}'`);
|
|
674
|
+
}
|
|
675
|
+
const moving = toOwner !== row.owner;
|
|
676
|
+
if (moving && fs.existsSync(localSpecDir(toOwner, row.name))) {
|
|
677
|
+
throw new Error(`${toOwner}/${row.name} is already fetched locally; remove one of the two copies`);
|
|
678
|
+
}
|
|
679
|
+
const saved = await downloadSpec(registry, { owner: toOwner, name: row.name, version: null });
|
|
680
|
+
updated++;
|
|
681
|
+
if (!moving) {
|
|
682
|
+
console.log(`Updated ${label} ${row.current} → ${printable(saved.version)}`);
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
deleteLocalSpec(row.owner, row.name);
|
|
686
|
+
console.log(`Updated ${label} ${row.current} → ${toOwner}/${row.name}@${printable(saved.version)}`);
|
|
687
|
+
} catch (err) {
|
|
688
|
+
failed++;
|
|
689
|
+
console.error(`Error: could not update ${label}: ${printable(err.message)}`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (updated > 0) console.log("\nRun git diff .canra/ to review the changes.");
|
|
693
|
+
if (failed > 0) process.exitCode = 1;
|
|
439
694
|
}
|
|
440
695
|
|
|
441
696
|
function cmdHelp() {
|
|
@@ -445,7 +700,8 @@ Usage: canra <command> [args]
|
|
|
445
700
|
|
|
446
701
|
Commands:
|
|
447
702
|
fetch <owner>/<name>[@version]
|
|
448
|
-
|
|
703
|
+
Download a spec (latest, or a pinned version) and
|
|
704
|
+
save it, hash-verified, to .canra/specs/
|
|
449
705
|
search <query> Search registry, print formatted results
|
|
450
706
|
publish <file> [--owner <org>]
|
|
451
707
|
Publish a spec file (requires auth token); --owner
|
|
@@ -454,7 +710,11 @@ Commands:
|
|
|
454
710
|
logout Remove saved credentials
|
|
455
711
|
whoami Print the user your saved token authenticates as
|
|
456
712
|
info <owner>/<name> Print spec metadata
|
|
457
|
-
outdated
|
|
713
|
+
outdated [--json] Check .canra/specs/ for newer versions; exits 1 if
|
|
714
|
+
any spec is outdated; --json prints the results
|
|
715
|
+
update [<owner>/<name>] Re-fetch the latest version of every outdated spec
|
|
716
|
+
(or just the named one); review with git diff .canra/
|
|
717
|
+
remove <owner>/<name> Delete a fetched spec from .canra/specs/
|
|
458
718
|
help Print this help message
|
|
459
719
|
version Print CLI version
|
|
460
720
|
|
|
@@ -495,7 +755,13 @@ async function main() {
|
|
|
495
755
|
await cmdInfo(args[1]);
|
|
496
756
|
break;
|
|
497
757
|
case "outdated":
|
|
498
|
-
await cmdOutdated();
|
|
758
|
+
await cmdOutdated(args.slice(1));
|
|
759
|
+
break;
|
|
760
|
+
case "update":
|
|
761
|
+
await cmdUpdate(args[1]);
|
|
762
|
+
break;
|
|
763
|
+
case "remove":
|
|
764
|
+
cmdRemove(args[1]);
|
|
499
765
|
break;
|
|
500
766
|
case "version":
|
|
501
767
|
case "--version":
|
|
@@ -516,7 +782,7 @@ async function main() {
|
|
|
516
782
|
}
|
|
517
783
|
|
|
518
784
|
main().catch((err) => {
|
|
519
|
-
console.error(`Error: ${err.message}`);
|
|
785
|
+
console.error(`Error: ${printable(err.message)}`);
|
|
520
786
|
process.exit(1);
|
|
521
787
|
});
|
|
522
788
|
|