@canra/cli 0.2.0 → 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.
Files changed (3) hide show
  1. package/README.md +8 -2
  2. package/canra.js +335 -73
  3. 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]` | Save a spec's metadata to `.canra/specs/<owner>/<name>/` |
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
- async function cmdFetch(specArg) {
152
- if (!specArg) {
153
- console.error("Usage: canra fetch <owner>/<name>[@version]");
154
- process.exit(1);
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
- const ref = parseSpecRef(specArg, "canra fetch <owner>/<name>[@version]");
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
- const registry = getRegistry();
160
- const url = specApiUrl(registry, ref);
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
- const res = await fetchRegistry(url);
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
- console.error(`Error: ${res.status} - ${body}`);
166
- process.exit(1);
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 spec = await res.json();
170
-
171
- // The registry serves only the latest version; never save a different one under
172
- // a pinned request.
173
- if (ref.version && ref.version !== spec.version) {
174
- console.error(
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
- // Save metadata locally
181
- const specDir = path.join(LOCAL_DIR, "specs", ref.owner, ref.name);
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, "metadata.json"), `${JSON.stringify(spec, null, 2)}\n`);
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
+ }
260
+
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);
184
269
 
185
- const fnCount = (spec.functions || []).length;
186
- console.log(`Fetched ${ref.owner}/${ref.name}@${spec.version} (${fnCount} function${fnCount !== 1 ? "s" : ""})`);
187
- console.log(` Saved to ${specDir}/metadata.json`);
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) {
@@ -372,70 +458,235 @@ async function cmdInfo(specArg) {
372
458
  if (spec.updated_at) console.log(` Updated: ${spec.updated_at}`);
373
459
  }
374
460
 
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
- }
461
+ // The server rejects more than MAX_REFS (website/outdated.js) entries per request.
462
+ const OUTDATED_BATCH = 50;
381
463
 
382
- const registry = getRegistry();
383
- // Fetched specs live at .canra/specs/<owner>/<name>/metadata.json.
384
- const refs = [];
464
+ function localVersion(owner, name) {
465
+ const dir = localSpecDir(owner, name);
466
+ let version = null;
385
467
  try {
386
- for (const owner of fs.readdirSync(specsDir)) {
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
- }
468
+ version = JSON.parse(fs.readFileSync(path.join(dir, "metadata.json"), "utf8")).version;
393
469
  } catch {
394
- console.log("No local specs found.");
395
- return;
470
+ version = null;
396
471
  }
397
-
398
- if (refs.length === 0) {
399
- console.log("No local specs found.");
400
- return;
472
+ if (!usableVersion(version)) {
473
+ try {
474
+ version = frontmatterVersion(fs.readFileSync(path.join(dir, `${name}.spec.md`), "utf8"));
475
+ } catch {
476
+ version = null;
477
+ }
401
478
  }
479
+ return usableVersion(version) ? version : null;
480
+ }
402
481
 
403
- const nameWidth = 20;
404
- const versionWidth = 12;
405
- console.log(`${"SPEC".padEnd(nameWidth)} ${"LOCAL".padEnd(versionWidth)} LATEST`);
406
- console.log(`${"-".repeat(nameWidth)} ${"-".repeat(versionWidth)} ${"-".repeat(versionWidth)}`);
407
-
408
- let outdatedCount = 0;
409
- for (const ref of refs) {
410
- const label = `${ref.owner}/${ref.name}`;
411
- const metaPath = path.join(specsDir, ref.owner, ref.name, "metadata.json");
412
- let local;
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;
413
499
  try {
414
- local = JSON.parse(fs.readFileSync(metaPath, "utf8"));
500
+ refuseSymlinks([LOCAL_DIR, specsDir, path.join(specsDir, owner)]);
415
501
  } catch {
416
502
  continue;
417
503
  }
418
-
419
- let res;
504
+ let names;
420
505
  try {
421
- res = await fetch(specApiUrl(registry, ref));
506
+ names = fs.readdirSync(path.join(specsDir, owner));
422
507
  } catch {
423
508
  continue;
424
509
  }
425
- if (!res.ok) continue;
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
+ }
426
523
 
427
- const remote = await res.json();
428
- if (local.version !== remote.version) {
429
- console.log(`${label.padEnd(nameWidth)} ${(local.version || "?").padEnd(versionWidth)} ${remote.version}`);
430
- outdatedCount++;
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");
431
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
+ });
432
546
  }
547
+ return results;
548
+ }
549
+
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
+ }
433
568
 
434
- if (outdatedCount === 0) {
435
- console.log("\nAll specs are up to date.");
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));
436
586
  } else {
437
- console.log(`\n${outdatedCount} outdated spec${outdatedCount !== 1 ? "s" : ""}.`);
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
+ }
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);
438
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;
439
690
  }
440
691
 
441
692
  function cmdHelp() {
@@ -445,7 +696,8 @@ Usage: canra <command> [args]
445
696
 
446
697
  Commands:
447
698
  fetch <owner>/<name>[@version]
448
- Fetch spec from registry, save to .canra/specs/
699
+ Download a spec (latest, or a pinned version) and
700
+ save it, hash-verified, to .canra/specs/
449
701
  search <query> Search registry, print formatted results
450
702
  publish <file> [--owner <org>]
451
703
  Publish a spec file (requires auth token); --owner
@@ -454,7 +706,11 @@ Commands:
454
706
  logout Remove saved credentials
455
707
  whoami Print the user your saved token authenticates as
456
708
  info <owner>/<name> Print spec metadata
457
- outdated Check local specs for newer versions
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/
458
714
  help Print this help message
459
715
  version Print CLI version
460
716
 
@@ -495,7 +751,13 @@ async function main() {
495
751
  await cmdInfo(args[1]);
496
752
  break;
497
753
  case "outdated":
498
- 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]);
499
761
  break;
500
762
  case "version":
501
763
  case "--version":
@@ -516,7 +778,7 @@ async function main() {
516
778
  }
517
779
 
518
780
  main().catch((err) => {
519
- console.error(`Error: ${err.message}`);
781
+ console.error(`Error: ${printable(err.message)}`);
520
782
  process.exit(1);
521
783
  });
522
784
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canra/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "CLI for Canra, the registry of machine-readable specs: fetch, search, and publish specs.",
5
5
  "keywords": [
6
6
  "canra",