@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.
- package/canra.js +77 -21
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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(
|
|
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
|
|
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
|
|
236
|
-
const
|
|
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>
|
|
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
|
|
483
|
+
await cmdPublish(args.slice(1));
|
|
428
484
|
break;
|
|
429
485
|
case "login":
|
|
430
486
|
await cmdLogin();
|