@canra/cli 0.4.0 → 0.6.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 +4 -1
  2. package/canra.js +89 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -29,10 +29,13 @@ canra search json
29
29
  | `canra remove <owner>/<name>` | Delete a fetched spec from `.canra/specs/` |
30
30
  | `canra login` | Save a registry URL and API token |
31
31
  | `canra whoami` | Show which account your saved token belongs to |
32
- | `canra publish <file.spec.md>` | Publish a spec (needs a token) |
32
+ | `canra publish <file.spec.md> [--accepts <suggestion-id>]` | Publish a spec (needs a token); `--accepts` marks that suggestion on the spec accepted |
33
+ | `canra suggest <owner>/<name> --title "..." [--body "..."] [--file <path.spec.md>]` | Suggest a change to a spec you don't own (needs a token); `--file` attaches a full edited spec, which the owner reviews as a diff |
33
34
  | `canra logout` | Remove saved credentials |
34
35
  | `canra version` / `canra help` | Version and usage |
35
36
 
37
+ `<owner>` is a user or organization name in any case: `Canra/json` and `canra/json` are the same spec, saved under the lowercase owner in `.canra/specs/`.
38
+
36
39
  ## Vendoring specs
37
40
 
38
41
  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.
package/canra.js CHANGED
@@ -89,12 +89,13 @@ async function fetchRegistry(url, opts) {
89
89
 
90
90
  // --- Spec references ---
91
91
 
92
- // Mirror the server's naming rules (route-utils.js USERNAME_RE, specs-api.js
92
+ // Mirror the server's naming rules (username.js USERNAME_RE and grandfathered names, specs-api.js
93
93
  // NAME_RE). Owner and name become local path segments under .canra/specs/, so
94
94
  // anything path-like ("..", ".", backslashes) must be rejected here — the CLI is
95
95
  // meant to be driven by agents whose arguments may come from untrusted content.
96
- const OWNER_RE = /^[a-z][a-z0-9_-]{1,38}[a-z0-9]$/;
96
+ const OWNER_RE = /^(?:(?=.{1,39}$)[a-z0-9]+(?:-[a-z0-9]+)*|[a-z][a-z0-9_-]{1,38}[a-z0-9])$/i;
97
97
  const NAME_RE = /^[a-z][a-z0-9-]*$/;
98
+ const SUGGESTION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
98
99
 
99
100
  // Specs are namespaced by (owner, name), like GitHub repos. Parse an
100
101
  // "<owner>/<name>[@version]" argument, exiting with usage help on anything else.
@@ -196,7 +197,7 @@ function refuseSymlinks(paths) {
196
197
 
197
198
  function localSpecPaths(owner, name) {
198
199
  const specsDir = path.join(LOCAL_DIR, "specs");
199
- const ownerDir = path.join(specsDir, owner);
200
+ const ownerDir = path.join(specsDir, owner.toLowerCase());
200
201
  return [LOCAL_DIR, specsDir, ownerDir, path.join(ownerDir, name)];
201
202
  }
202
203
 
@@ -206,8 +207,10 @@ function frontmatterFunctionCount(text) {
206
207
  return list ? (list[1].match(/"(?:[^"\\]|\\.)*"/g) || []).length : 0;
207
208
  }
208
209
 
210
+ // Owner directories are lowercased everywhere (here, localSpecPaths, deleteLocalSpec) so Canra/json and
211
+ // canra/json are one local spec on case-sensitive and case-insensitive filesystems alike.
209
212
  function localSpecDir(owner, name) {
210
- return path.join(LOCAL_DIR, "specs", owner, name);
213
+ return path.join(LOCAL_DIR, "specs", owner.toLowerCase(), name);
211
214
  }
212
215
 
213
216
  async function downloadSpec(registry, { owner, name, version }) {
@@ -324,13 +327,21 @@ async function cmdPublish(args) {
324
327
  // belong to instead of your own scope.
325
328
  const ownerAt = args.indexOf("--owner");
326
329
  const owner = ownerAt >= 0 ? args[ownerAt + 1] : null;
327
- const filePath = args.find((a, i) => !a.startsWith("--") && (ownerAt < 0 || i !== ownerAt + 1));
330
+ const acceptsAt = args.indexOf("--accepts");
331
+ const accepts = acceptsAt >= 0 ? args[acceptsAt + 1] : null;
332
+ const filePath = args.find(
333
+ (a, i) => !a.startsWith("--") && (ownerAt < 0 || i !== ownerAt + 1) && (acceptsAt < 0 || i !== acceptsAt + 1),
334
+ );
328
335
  if (ownerAt >= 0 && (!owner || !OWNER_RE.test(owner))) {
329
336
  console.error("Error: --owner needs a valid organization name");
330
337
  process.exit(1);
331
338
  }
339
+ if (acceptsAt >= 0 && (!accepts || accepts.startsWith("--") || !SUGGESTION_ID_RE.test(accepts))) {
340
+ console.error("Error: --accepts needs a suggestion id");
341
+ process.exit(1);
342
+ }
332
343
  if (!filePath) {
333
- console.error("Usage: canra publish <file> [--owner <org>]");
344
+ console.error("Usage: canra publish <file> [--owner <org>] [--accepts <suggestion-id>]");
334
345
  process.exit(1);
335
346
  }
336
347
 
@@ -347,6 +358,7 @@ async function cmdPublish(args) {
347
358
  const content = fs.readFileSync(filePath, "utf8");
348
359
  const registry = getRegistry();
349
360
  const headers = authHeaders();
361
+ if (accepts) headers["X-Suggestion"] = accepts;
350
362
 
351
363
  const target = owner ? `${registry}/api/specs?owner=${encodeURIComponent(owner)}` : `${registry}/api/specs`;
352
364
  const res = await fetchRegistry(target, {
@@ -362,6 +374,65 @@ async function cmdPublish(args) {
362
374
 
363
375
  const spec = await res.json();
364
376
  console.log(`Published ${printable(spec.name)}@${printable(spec.version)}`);
377
+ if (accepts) {
378
+ const id = printable(spec.suggestion?.id ?? accepts);
379
+ if (spec.suggestion?.status === "accepted") console.log(`Suggestion ${id} accepted`);
380
+ else console.warn(`Warning: Suggestion ${id} was not accepted (not open or not for this spec)`);
381
+ }
382
+ }
383
+
384
+ async function cmdSuggest(args) {
385
+ const usage = 'canra suggest <owner>/<name> --title "..." [--body "..."] [--file <path.spec.md>]';
386
+ const flags = { "--title": null, "--body": null, "--file": null };
387
+ let target = null;
388
+ for (let i = 0; i < args.length; i++) {
389
+ if (Object.hasOwn(flags, args[i])) {
390
+ const value = args[i + 1];
391
+ if (value === undefined || value.startsWith("--")) {
392
+ console.error(`Error: ${args[i]} needs a value`);
393
+ console.error(`Usage: ${usage}`);
394
+ process.exit(1);
395
+ }
396
+ flags[args[i]] = value;
397
+ i++;
398
+ } else if (!args[i].startsWith("--") && target === null) {
399
+ target = args[i];
400
+ }
401
+ }
402
+ const title = flags["--title"]?.trim();
403
+ if (!target || !title) {
404
+ console.error(`Usage: ${usage}`);
405
+ process.exit(1);
406
+ }
407
+ const ref = parseSpecRef(target, usage);
408
+ if (ref.version !== null) {
409
+ console.error("Error: suggestions apply to the latest version; leave off @<version>");
410
+ console.error(`Usage: ${usage}`);
411
+ process.exit(1);
412
+ }
413
+
414
+ const payload = { title, body: flags["--body"] ?? "" };
415
+ if (flags["--file"]) {
416
+ if (!fs.existsSync(flags["--file"])) {
417
+ console.error(`Error: file not found: ${flags["--file"]}`);
418
+ process.exit(1);
419
+ }
420
+ payload.content = fs.readFileSync(flags["--file"], "utf8");
421
+ }
422
+
423
+ const registry = getRegistry();
424
+ const res = await fetchRegistry(`${specApiUrl(registry, ref)}/suggestions`, {
425
+ method: "POST",
426
+ body: JSON.stringify(payload),
427
+ headers: { ...authHeaders(), "Content-Type": "application/json" },
428
+ });
429
+ if (!res.ok) {
430
+ const body = await res.text();
431
+ console.error(`Error: ${res.status} - ${printable(body)}`);
432
+ process.exit(1);
433
+ }
434
+ const created = await res.json();
435
+ console.log(`Suggestion filed: ${printable(registry)}${printable(created.url)}`);
365
436
  }
366
437
 
367
438
  async function cmdLogin() {
@@ -605,7 +676,7 @@ function deleteLocalSpec(owner, name) {
605
676
  if (!dir.startsWith(specsRoot + path.sep)) throw new Error(`unexpected local path for ${owner}/${name}`);
606
677
  refuseSymlinks(localSpecPaths(owner, name));
607
678
  fs.rmSync(dir, { recursive: true, force: true });
608
- const ownerDir = path.join(LOCAL_DIR, "specs", owner);
679
+ const ownerDir = path.join(LOCAL_DIR, "specs", owner.toLowerCase());
609
680
  if (fs.readdirSync(ownerDir).length === 0) fs.rmdirSync(ownerDir);
610
681
  }
611
682
 
@@ -641,7 +712,7 @@ async function cmdUpdate(specArg) {
641
712
 
642
713
  let refs = scanLocalSpecs();
643
714
  if (only) {
644
- refs = refs.filter((r) => r.owner === only.owner && r.name === only.name);
715
+ refs = refs.filter((r) => r.owner.toLowerCase() === only.owner.toLowerCase() && r.name === only.name);
645
716
  if (refs.length === 0) {
646
717
  console.error(`Error: ${only.owner}/${only.name} is not fetched locally. Run \`canra fetch\` first.`);
647
718
  process.exit(1);
@@ -672,7 +743,7 @@ async function cmdUpdate(specArg) {
672
743
  if (!OWNER_RE.test(toOwner) || (target && target[2] !== row.name)) {
673
744
  throw new Error(`unexpected move target '${printable(row.moved_to)}'`);
674
745
  }
675
- const moving = toOwner !== row.owner;
746
+ const moving = toOwner.toLowerCase() !== row.owner.toLowerCase();
676
747
  if (moving && fs.existsSync(localSpecDir(toOwner, row.name))) {
677
748
  throw new Error(`${toOwner}/${row.name} is already fetched locally; remove one of the two copies`);
678
749
  }
@@ -703,9 +774,13 @@ Commands:
703
774
  Download a spec (latest, or a pinned version) and
704
775
  save it, hash-verified, to .canra/specs/
705
776
  search <query> Search registry, print formatted results
706
- publish <file> [--owner <org>]
777
+ publish <file> [--owner <org>] [--accepts <suggestion-id>]
707
778
  Publish a spec file (requires auth token); --owner
708
- publishes into an organization you belong to
779
+ publishes into an organization you belong to;
780
+ --accepts marks that suggestion accepted
781
+ suggest <owner>/<name> --title "..." [--body "..."] [--file <path.spec.md>]
782
+ Suggest a change to a spec (requires auth token);
783
+ --file proposes an edited spec as a diff
709
784
  login Prompt for registry URL and token
710
785
  logout Remove saved credentials
711
786
  whoami Print the user your saved token authenticates as
@@ -742,6 +817,9 @@ async function main() {
742
817
  case "publish":
743
818
  await cmdPublish(args.slice(1));
744
819
  break;
820
+ case "suggest":
821
+ await cmdSuggest(args.slice(1));
822
+ break;
745
823
  case "login":
746
824
  await cmdLogin();
747
825
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canra/cli",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "CLI for Canra, the registry of machine-readable specs: fetch, search, and publish specs.",
5
5
  "keywords": [
6
6
  "canra",