@canra/cli 0.1.0 → 0.2.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 (2) hide show
  1. package/canra.js +77 -21
  2. package/package.json +1 -1
package/canra.js CHANGED
@@ -5,7 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import readline from "node:readline";
7
7
 
8
- // Runs under Node >= 20 (via `npx canra`) and Bun: only node: built-ins and fetch.
8
+ // Runs under Node >= 20 (via `npx @canra/cli`) and Bun: only node: built-ins and fetch.
9
9
  // The version comes from package.json, so a release only bumps one place.
10
10
  const VERSION = JSON.parse(fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
11
11
  const CONFIG_DIR = path.join(os.homedir(), ".canra");
@@ -22,14 +22,39 @@ function readConfig() {
22
22
  }
23
23
  }
24
24
 
25
+ // The config holds an API token: readable by the owner only (the mode is
26
+ // re-applied in case the file or directory already existed with a looser one).
25
27
  function writeConfig(config) {
26
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
27
- fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`);
28
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
29
+ fs.chmodSync(CONFIG_DIR, 0o700);
30
+ fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
31
+ fs.chmodSync(CONFIG_PATH, 0o600);
32
+ }
33
+
34
+ // The token is sent to the registry on every authenticated call, so only an
35
+ // https registry is accepted -- or plain http to this machine, for development.
36
+ function registryError(registry) {
37
+ let url;
38
+ try {
39
+ url = new URL(registry);
40
+ } catch {
41
+ return `"${registry}" is not a valid URL`;
42
+ }
43
+ if (url.protocol === "https:") return null;
44
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
45
+ if (url.protocol === "http:" && local) return null;
46
+ return `the registry must use https (got ${url.protocol}//${url.host}); plain http is only allowed for localhost`;
28
47
  }
29
48
 
30
49
  function getRegistry() {
31
50
  const config = readConfig();
32
- return config.registry || "https://canra.dev";
51
+ const registry = config.registry || "https://canra.dev";
52
+ const problem = registryError(registry);
53
+ if (problem) {
54
+ console.error(`Error: ${problem}. Run "canra login" again.`);
55
+ process.exit(1);
56
+ }
57
+ return registry;
33
58
  }
34
59
 
35
60
  function getToken() {
@@ -97,17 +122,28 @@ function specApiUrl(registry, { owner, name }) {
97
122
 
98
123
  // --- Prompt helper ---
99
124
 
100
- function prompt(question) {
101
- const rl = readline.createInterface({
102
- input: process.stdin,
103
- output: process.stdout,
104
- });
105
- return new Promise((resolve) => {
106
- rl.question(question, (answer) => {
107
- rl.close();
108
- resolve(answer.trim());
109
- });
125
+ // Ask questions on ONE readline interface (separate interfaces on piped stdin
126
+ // let the first swallow every line and leave the next prompt hanging). Lines
127
+ // that arrive before a question is asked are queued, so piped answers work.
128
+ function createPrompter() {
129
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
130
+ const lines = [];
131
+ const waiting = [];
132
+ let closed = false;
133
+ rl.on("line", (line) => (waiting.length ? waiting.shift()(line) : lines.push(line)));
134
+ rl.on("close", () => {
135
+ closed = true;
136
+ while (waiting.length) waiting.shift()("");
110
137
  });
138
+ return {
139
+ ask(question) {
140
+ process.stdout.write(question);
141
+ if (lines.length) return Promise.resolve(lines.shift().trim());
142
+ if (closed) return Promise.resolve("");
143
+ return new Promise((resolve) => waiting.push((line) => resolve(line.trim())));
144
+ },
145
+ close: () => rl.close(),
146
+ };
111
147
  }
112
148
 
113
149
  // --- Commands ---
@@ -193,9 +229,18 @@ async function cmdSearch(query) {
193
229
  console.log(`\n${specs.length} result${specs.length !== 1 ? "s" : ""}`);
194
230
  }
195
231
 
196
- async function cmdPublish(filePath) {
232
+ async function cmdPublish(args) {
233
+ // canra publish <file> [--owner <org>]: --owner publishes into an org you
234
+ // belong to instead of your own scope.
235
+ const ownerAt = args.indexOf("--owner");
236
+ const owner = ownerAt >= 0 ? args[ownerAt + 1] : null;
237
+ const filePath = args.find((a, i) => !a.startsWith("--") && (ownerAt < 0 || i !== ownerAt + 1));
238
+ if (ownerAt >= 0 && (!owner || !OWNER_RE.test(owner))) {
239
+ console.error("Error: --owner needs a valid organization name");
240
+ process.exit(1);
241
+ }
197
242
  if (!filePath) {
198
- console.error("Usage: canra publish <file>");
243
+ console.error("Usage: canra publish <file> [--owner <org>]");
199
244
  process.exit(1);
200
245
  }
201
246
 
@@ -213,7 +258,8 @@ async function cmdPublish(filePath) {
213
258
  const registry = getRegistry();
214
259
  const headers = authHeaders();
215
260
 
216
- const res = await fetchRegistry(`${registry}/api/specs`, {
261
+ const target = owner ? `${registry}/api/specs?owner=${encodeURIComponent(owner)}` : `${registry}/api/specs`;
262
+ const res = await fetchRegistry(target, {
217
263
  method: "POST",
218
264
  body: content,
219
265
  headers,
@@ -232,8 +278,16 @@ async function cmdLogin() {
232
278
  const currentConfig = readConfig();
233
279
  const defaultRegistry = currentConfig.registry || "https://canra.dev";
234
280
 
235
- const registry = (await prompt(`Registry URL (${defaultRegistry}): `)) || defaultRegistry;
236
- const token = await prompt("API token: ");
281
+ const prompter = createPrompter();
282
+ const registry = ((await prompter.ask(`Registry URL (${defaultRegistry}): `)) || defaultRegistry).replace(/\/+$/, "");
283
+ const registryProblem = registryError(registry);
284
+ if (registryProblem) {
285
+ prompter.close();
286
+ console.error(`Error: ${registryProblem}`);
287
+ process.exit(1);
288
+ }
289
+ const token = await prompter.ask("API token: ");
290
+ prompter.close();
237
291
 
238
292
  if (!token) {
239
293
  console.error("Error: token is required");
@@ -393,7 +447,9 @@ Commands:
393
447
  fetch <owner>/<name>[@version]
394
448
  Fetch spec from registry, save to .canra/specs/
395
449
  search <query> Search registry, print formatted results
396
- publish <file> Publish a spec file (requires auth token)
450
+ publish <file> [--owner <org>]
451
+ Publish a spec file (requires auth token); --owner
452
+ publishes into an organization you belong to
397
453
  login Prompt for registry URL and token
398
454
  logout Remove saved credentials
399
455
  whoami Print the user your saved token authenticates as
@@ -424,7 +480,7 @@ async function main() {
424
480
  await cmdSearch(arg);
425
481
  break;
426
482
  case "publish":
427
- await cmdPublish(args[1]);
483
+ await cmdPublish(args.slice(1));
428
484
  break;
429
485
  case "login":
430
486
  await cmdLogin();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canra/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for Canra, the registry of machine-readable specs: fetch, search, and publish specs.",
5
5
  "keywords": [
6
6
  "canra",