@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.
- package/README.md +4 -1
- package/canra.js +89 -11
- 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
|
|
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 (
|
|
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
|
|
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;
|