@junoflow/cli 0.9.0 → 0.10.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 CHANGED
@@ -205,10 +205,11 @@ under `files` by its basename and makes it the `entry`.
205
205
 
206
206
  **`session`** and **`config`**
207
207
 
208
- | Command | Tool | Positional | Effect |
209
- | --------------------------- | ---- | ----------- | ------ |
210
- | `juno session approve <id>` | none | session id | act |
211
- | `juno config set url <url>` | none | `url <url>` | local |
208
+ | Command | Tool | Positional | Effect |
209
+ | ----------------------------- | ---- | ------------- | ------ |
210
+ | `juno session approve <id>` | none | session id | act |
211
+ | `juno config set url <url>` | none | `url <url>` | local |
212
+ | `juno config set token <key>` | none | `token <key>` | local |
212
213
 
213
214
  There is no command that calls a tool by name. One permission rule for such a
214
215
  command would allow every act, so each tool has its own command instead, and a
@@ -395,10 +396,26 @@ with no setup. `juno config set url <url>` writes the fourth, which is what
395
396
  makes `juno` useful outside a project. A bare host such as `juno.local:8080`
396
397
  means `http://juno.local:8080`.
397
398
 
399
+ ## Authenticating
400
+
401
+ A Juno that identifies its callers answers a request with no credential
402
+ with 401. Create an API key under **Settings → API keys** and give it to the
403
+ CLI; it is sent as `Authorization: Bearer <key>` on every request. In order:
404
+
405
+ 1. `--token <key>`, before or after the command words;
406
+ 2. `$JUNO_TOKEN`;
407
+ 3. `token` in the user config file, which `juno config set token <key>` writes
408
+ (mode 0600; the key must start `juno_`).
409
+
410
+ There is no `package.json` source: a working copy's `package.json` is committed,
411
+ and a key never goes in a file that is. The CLI never prints the key, in an
412
+ error message included.
413
+
398
414
  Every request carries an `X-Juno-Client` header: `cli`, or `cli/claude-code`
399
- when `CLAUDECODE=1` is set, as Claude Code sets it in the shells it starts. Juno
400
- records a write made through a tool as `operator:<client>`. The header names who
401
- made a change; it is not a security control.
415
+ when `CLAUDECODE=1` is set, as Claude Code sets it in the shells it starts. The
416
+ header labels the calling client; Juno records a write made through a tool under
417
+ the principal the request authenticated as, and the header is not a security
418
+ control.
402
419
 
403
420
  ## Working copies
404
421
 
@@ -650,6 +667,7 @@ working copy (see [`base_version`](#base_version)).
650
667
  | Flag | Meaning |
651
668
  | --------------- | ----------------------------------------------------------------------------------------------------------- |
652
669
  | `--url <url>` | the instance to talk to |
670
+ | `--token <key>` | the API key to send (see [Authenticating](#authenticating)) |
653
671
  | `--id <id>` | address an install under a different id than the manifest declares (`check`, `deploy`, `pull`) |
654
672
  | `--name <name>` | display name for a new integration (`init`) |
655
673
  | `--watch` | redeploy on every save (`deploy`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junoflow/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Develop Juno flows, integrations and apps, and operate a Juno instance from the command line",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,7 +40,7 @@
40
40
  "node": ">=20"
41
41
  },
42
42
  "dependencies": {
43
- "@junoflow/eslint-plugin": "0.9.0",
43
+ "@junoflow/eslint-plugin": "0.10.0",
44
44
  "@typescript-eslint/parser": "^8.0.0"
45
45
  },
46
46
  "devDependencies": {
package/src/api.js CHANGED
@@ -6,21 +6,22 @@ import {
6
6
  UserError,
7
7
  } from './errors.js';
8
8
  import { resolveTarget } from './target.js';
9
+ import { resolveToken } from './token.js';
9
10
 
10
11
  /**
11
12
  * clientName is the X-Juno-Client value: `cli/claude-code` inside a shell
12
- * Claude Code started (it sets CLAUDECODE=1), `cli` everywhere else. The
13
- * gateway records a write as `operator:<client>`; it is the actor, not a
14
- * security control.
13
+ * Claude Code started (it sets CLAUDECODE=1), `cli` everywhere else. It labels
14
+ * the calling client; the actor a write is recorded under is the request's
15
+ * principal, and the header is not a security control.
15
16
  */
16
17
  export function clientName(env = process.env) {
17
18
  return env.CLAUDECODE === '1' ? 'cli/claude-code' : 'cli';
18
19
  }
19
20
 
20
21
  /**
21
- * connect resolves the instance (target.js) and builds the client every
22
- * command uses, unless one was injected: `args.client` is how the tests hand
23
- * a command a client instead of a URL.
22
+ * connect resolves the instance (target.js) and the API key (token.js) and
23
+ * builds the client every command uses, unless one was injected:
24
+ * `args.client` is how the tests hand a command a client instead of a URL.
24
25
  */
25
26
  export async function connect(args, io) {
26
27
  const target = await resolveTarget({
@@ -28,9 +29,12 @@ export async function connect(args, io) {
28
29
  cwd: io.cwd,
29
30
  env: io.env,
30
31
  });
31
- const client =
32
- args.client ?? new JunoClient(target, { client: clientName(io.env) });
33
- return { target, client };
32
+ if (args.client) return { target, client: args.client };
33
+ const token = await resolveToken({ token: args.token, env: io.env });
34
+ return {
35
+ target,
36
+ client: new JunoClient(target, { client: clientName(io.env), token }),
37
+ };
34
38
  }
35
39
 
36
40
  /**
@@ -43,13 +47,18 @@ export async function connect(args, io) {
43
47
  export class JunoClient {
44
48
  constructor(
45
49
  target,
46
- { fetch: fetchImpl = globalThis.fetch, client = clientName() } = {}
50
+ { fetch: fetchImpl = globalThis.fetch, client = clientName(), token } = {}
47
51
  ) {
48
52
  this.target = target;
49
53
  this.fetch = fetchImpl;
50
54
  this.client = client;
55
+ // The API key, sent as a bearer on every request. A private field, so
56
+ // nothing that logs or serialises a client can print it.
57
+ this.#token = token;
51
58
  }
52
59
 
60
+ #token;
61
+
53
62
  /**
54
63
  * POST /api/integrations/scaffold — a whole starting project, path →
55
64
  * content. `target` is what this client reached the instance at, recorded
@@ -462,6 +471,7 @@ export class JunoClient {
462
471
  // Every request carries the header, the existing commands' included:
463
472
  // one client, one header.
464
473
  const headers = { 'X-Juno-Client': this.client };
474
+ if (this.#token) headers.authorization = `Bearer ${this.#token}`;
465
475
  if (body) headers['content-type'] = 'application/json';
466
476
  let res;
467
477
  try {
@@ -579,13 +589,22 @@ function problemDetail(res) {
579
589
  );
580
590
  }
581
591
 
582
- /** A UserError for a non-ok answer, carrying the server's own words. */
592
+ /**
593
+ * A UserError for a non-ok answer, carrying the server's own words. A 401
594
+ * adds how to get a credential; a 403 is the server's detail as is, because
595
+ * it already names what the key may not do.
596
+ */
583
597
  function failure(method, path, res) {
598
+ const message = `${method} ${path} failed (${res.status}): ${problemDetail(res)}`;
584
599
  return new UserError(
585
- `${method} ${path} failed (${res.status}): ${problemDetail(res)}`
600
+ res.status === 401 ? `${message}\n${UNAUTHORIZED_HINT}` : message
586
601
  );
587
602
  }
588
603
 
604
+ const UNAUTHORIZED_HINT =
605
+ 'Create an API key under Settings → API keys and pass it with --token, ' +
606
+ '$JUNO_TOKEN or `juno config set token`.';
607
+
589
608
  /**
590
609
  * The words of a 422: the gateway's own detail names the failing field (`/id`,
591
610
  * `/ops/0/when`, `/` for the root); Huma's request validation puts it in
@@ -29,6 +29,9 @@ export const APP_DEPLOY_USAGE = 'juno app deploy [--watch] [--force] [--json]';
29
29
  * was pulled is a 409 (exit 4) rather than a silent overwrite; --force sends
30
30
  * none and replaces whatever is active.
31
31
  *
32
+ * When the version binds something the previous one did not, the answer's
33
+ * `widened_links` says how many active app links it widens, and a line says so.
34
+ *
32
35
  * On success it writes back what the server now holds: the canonical
33
36
  * manifest, the three files under types/, and the new juno.base_version.
34
37
  * --watch redeploys on every save to src/, page/, public/ or manifest.json,
@@ -97,6 +100,14 @@ async function deployProject(client, project, argv, io, target) {
97
100
  const files = written.length ? ` (${written.sort().join(', ')})` : '';
98
101
  io.log(`deployed ${result.id} v${result.version} to ${target}${files}`);
99
102
  if (result.url) io.log(`open: ${result.url}`);
103
+ // A notice, not a refusal: the version is already live. It binds something
104
+ // the previous one did not, and every active app link gets it too.
105
+ const widened = result.widened_links ?? 0;
106
+ if (widened > 0) {
107
+ io.warn(
108
+ `This version widens what ${widened} active ${widened === 1 ? 'link' : 'links'} can do.`
109
+ );
110
+ }
100
111
  }
101
112
  return { manifestRaw };
102
113
  }
@@ -1,11 +1,10 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { join } from 'node:path';
3
3
 
4
- import { clientName, JunoClient } from '../api.js';
4
+ import { connect } from '../api.js';
5
5
  import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
6
6
  import { DiagnosticsError, UserError } from '../errors.js';
7
7
  import { readProject } from '../project.js';
8
- import { resolveTarget } from '../target.js';
9
8
  import { baseFor } from './deploy.js';
10
9
  import { verifyTypes } from './verify-types.js';
11
10
 
@@ -41,13 +40,7 @@ export async function check(argv, io) {
41
40
  if (await runLocalChecks(argv, io)) failed = true;
42
41
  }
43
42
 
44
- const target = await resolveTarget({
45
- url: argv.url,
46
- cwd: io.cwd,
47
- env: io.env,
48
- });
49
- const client =
50
- argv.client ?? new JunoClient(target, { client: clientName(io.env) });
43
+ const { target, client } = await connect(argv, io);
51
44
  const project = await readProject(io.cwd);
52
45
  let result;
53
46
  try {
@@ -2,25 +2,44 @@ import { readUserConfig, writeUserConfig } from '../config.js';
2
2
  import { UsageError } from '../errors.js';
3
3
  import { normalizeTarget } from '../target.js';
4
4
 
5
- export const CONFIG_SET_USAGE = 'juno config set url <url>';
5
+ export const CONFIG_SET_USAGE =
6
+ 'juno config set url <url> | juno config set token <key>';
7
+
8
+ const KEYS = ['url', 'token'];
6
9
 
7
10
  /**
8
- * `juno config set url <url>` writes the user config file, the last place
9
- * resolveTarget looks. `url` is the only key; it is normalised the way every
10
- * other source is, so what lands in the file is what a request would use.
11
+ * `juno config set url <url>` and `juno config set token <key>` write the
12
+ * user config file, the last place resolveTarget and resolveToken look. A url
13
+ * is normalised the way every other source is, so what lands in the file is
14
+ * what a request would use. A token must start `juno_`, the prefix of every
15
+ * API key, which catches a pasted password or a key's id alone; the file is
16
+ * written 0600, and neither the token nor any part of it is echoed, the
17
+ * refusal included.
11
18
  */
12
19
  export async function configSet(argv, io) {
13
20
  const [key, value, ...extra] = argv._;
14
21
  const usage = `usage: ${CONFIG_SET_USAGE}`;
15
22
  if (key === undefined) throw new UsageError(usage);
16
- if (key !== 'url') {
23
+ if (!KEYS.includes(key)) {
17
24
  throw new UsageError(`unknown config key "${key}"; ${usage}`);
18
25
  }
19
26
  if (value === undefined || extra.length) throw new UsageError(usage);
20
27
 
21
- const url = normalizeTarget(value);
28
+ let entry;
29
+ if (key === 'url') {
30
+ entry = { url: normalizeTarget(value) };
31
+ } else {
32
+ const token = value.trim();
33
+ if (!token.startsWith('juno_')) {
34
+ throw new UsageError(
35
+ 'that is not an API key: a key starts with "juno_". Create one under ' +
36
+ 'Settings → API keys.'
37
+ );
38
+ }
39
+ entry = { token };
40
+ }
22
41
  const current = await readUserConfig(io.env);
23
- const path = await writeUserConfig(io.env, { ...current, url });
42
+ const path = await writeUserConfig(io.env, { ...current, ...entry });
24
43
  io.warn(`wrote ${path}`);
25
44
  return 0;
26
45
  }
@@ -1,6 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
 
3
- import { clientName, JunoClient } from '../api.js';
3
+ import { connect } from '../api.js';
4
4
  import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
5
5
  import { DiagnosticsError } from '../errors.js';
6
6
  import {
@@ -9,7 +9,6 @@ import {
9
9
  updateIntegrationPackage,
10
10
  writeTypes,
11
11
  } from '../project.js';
12
- import { resolveTarget } from '../target.js';
13
12
  import { stampTypeFiles } from '../stamp.js';
14
13
  import { pickTypeFiles } from './gen-types.js';
15
14
  import { deployOnChange } from '../watch.js';
@@ -35,13 +34,7 @@ export const DEPLOY_USAGE =
35
34
  * of the summary line, for CI.
36
35
  */
37
36
  export async function deploy(argv, io) {
38
- const target = await resolveTarget({
39
- url: argv.url,
40
- cwd: io.cwd,
41
- env: io.env,
42
- });
43
- const client =
44
- argv.client ?? new JunoClient(target, { client: clientName(io.env) });
37
+ const { target, client } = await connect(argv, io);
45
38
 
46
39
  if (!argv.watch) {
47
40
  try {
@@ -1,7 +1,6 @@
1
- import { clientName, JunoClient } from '../api.js';
1
+ import { connect } from '../api.js';
2
2
  import { readManifest, readManifestRaw, writeTypes } from '../project.js';
3
3
  import { stampTypeFiles } from '../stamp.js';
4
- import { resolveTarget } from '../target.js';
5
4
 
6
5
  export const GEN_TYPES_USAGE = 'juno integration gen-types';
7
6
 
@@ -14,15 +13,9 @@ export const GEN_TYPES_USAGE = 'juno integration gen-types';
14
13
  * manifest does, which is the right person to require it of.
15
14
  */
16
15
  export async function genTypes(argv, io) {
17
- const target = await resolveTarget({
18
- url: argv.url,
19
- cwd: io.cwd,
20
- env: io.env,
21
- });
16
+ const { target, client } = await connect(argv, io);
22
17
  const raw = await readManifestRaw(io.cwd);
23
18
  const manifest = await readManifest(io.cwd);
24
- const client =
25
- argv.client ?? new JunoClient(target, { client: clientName(io.env) });
26
19
 
27
20
  const generated = await client.typegen(manifest);
28
21
  const written = await writeTypes(
@@ -1,10 +1,9 @@
1
1
  import { mkdir, writeFile, access } from 'node:fs/promises';
2
2
  import { dirname, join, resolve, sep } from 'node:path';
3
3
 
4
- import { clientName, JunoClient } from '../api.js';
4
+ import { connect } from '../api.js';
5
5
  import { printAuthoringIndex } from '../authoring-skills.js';
6
6
  import { UsageError, UserError } from '../errors.js';
7
- import { resolveTarget } from '../target.js';
8
7
 
9
8
  export const INIT_USAGE = 'juno integration init <id> [--name <name>] [--here]';
10
9
 
@@ -29,13 +28,7 @@ export async function init(argv, io) {
29
28
  throw new UserError(`${dir} already exists`);
30
29
  }
31
30
 
32
- const target = await resolveTarget({
33
- url: argv.url,
34
- cwd: io.cwd,
35
- env: io.env,
36
- });
37
- const client =
38
- argv.client ?? new JunoClient(target, { client: clientName(io.env) });
31
+ const { target, client } = await connect(argv, io);
39
32
  // The id is validated server-side, against the same rules an install is:
40
33
  // one answer to "is this a valid integration id" rather than two.
41
34
  const files = await client.scaffold(id, { name: argv.name, target });
@@ -1,4 +1,4 @@
1
- import { clientName, JunoClient } from '../api.js';
1
+ import { connect } from '../api.js';
2
2
  import { UserError } from '../errors.js';
3
3
  import {
4
4
  readIntegrationBaseVersion,
@@ -6,7 +6,6 @@ import {
6
6
  updateIntegrationPackage,
7
7
  } from '../project.js';
8
8
  import { stampTypeFiles } from '../stamp.js';
9
- import { resolveTarget } from '../target.js';
10
9
  import {
11
10
  baseArtifactPaths,
12
11
  MANIFEST,
@@ -44,11 +43,7 @@ export const PULL_USAGE = 'juno integration pull [--force] [--id <id>]';
44
43
  * the one thing git cannot get back for you.
45
44
  */
46
45
  export async function pull(argv, io) {
47
- const target = await resolveTarget({
48
- url: argv.url,
49
- cwd: io.cwd,
50
- env: io.env,
51
- });
46
+ const { target, client } = await connect(argv, io);
52
47
  let manifestId;
53
48
  try {
54
49
  manifestId = (await readManifest(io.cwd)).id;
@@ -70,8 +65,6 @@ export async function pull(argv, io) {
70
65
  io,
71
66
  });
72
67
 
73
- const client =
74
- argv.client ?? new JunoClient(target, { client: clientName(io.env) });
75
68
  const detail = await client.getIntegration(id);
76
69
  if (!detail?.manifest) {
77
70
  throw new UserError(
package/src/config.js CHANGED
@@ -1,12 +1,14 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
4
 
5
5
  /**
6
6
  * The user config file: `$XDG_CONFIG_HOME/juno/config.json`, or
7
7
  * `~/.config/juno/config.json` when XDG_CONFIG_HOME is unset. It holds
8
- * `{"url": "…"}`, the last place resolveTarget looks, and is what makes `juno`
9
- * usable outside a project after a global install.
8
+ * `{"url": "…", "token": "juno_…"}`: `url` is the last place resolveTarget
9
+ * looks, which makes `juno` usable outside a project after a global install,
10
+ * and `token` is the last place resolveToken looks. The token is a secret,
11
+ * so the file is mode 0600 (see writeUserConfig).
10
12
  */
11
13
  export function configPath(env = process.env) {
12
14
  const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config');
@@ -25,10 +27,23 @@ export async function readUserConfig(env = process.env) {
25
27
  }
26
28
  }
27
29
 
28
- /** Writes the whole config, creating its directory. */
30
+ /**
31
+ * Writes the whole config, creating its directory. The file is created 0600,
32
+ * and one that already exists is narrowed to 0600 before the new content
33
+ * goes in, so a token never sits in a file other users can read, not even
34
+ * for the length of one write. (`mode` alone only applies on creation.)
35
+ */
29
36
  export async function writeUserConfig(env = process.env, cfg) {
30
37
  const path = configPath(env);
31
38
  await mkdir(dirname(path), { recursive: true });
32
- await writeFile(path, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
39
+ try {
40
+ await chmod(path, 0o600);
41
+ } catch (err) {
42
+ if (err.code !== 'ENOENT') throw err;
43
+ }
44
+ await writeFile(path, `${JSON.stringify(cfg, null, 2)}\n`, {
45
+ encoding: 'utf8',
46
+ mode: 0o600,
47
+ });
33
48
  return path;
34
49
  }
package/src/index.js CHANGED
@@ -62,7 +62,11 @@ import {
62
62
  import { closest, closestByVerb, levenshtein } from './suggest.js';
63
63
  import { groupOf, normalizeVerb, rowFor, rows } from './verbs.js';
64
64
 
65
- const URL_OPTION = { url: { type: 'string' } };
65
+ /**
66
+ * The two flags every command that reaches an instance takes, and the two a
67
+ * leading position accepts (`juno --url <u> --token <k> state get x`).
68
+ */
69
+ const URL_OPTION = { url: { type: 'string' }, token: { type: 'string' } };
66
70
 
67
71
  /**
68
72
  * The verbs.json rows that call no tool, by command. A row is dispatchable
@@ -300,6 +304,8 @@ ${workingCopyListing(DISPATCHABLE, handlerFor)}
300
304
 
301
305
  --url <url> picks the instance; else $JUNO_URL, package.json juno.target,
302
306
  then the user config (juno config set url <url>).
307
+ --token <key> is the API key to send; else $JUNO_TOKEN, then the user config
308
+ (juno config set token <key>). Create one under Settings → API keys.
303
309
  `;
304
310
 
305
311
  /**
@@ -342,11 +348,11 @@ export async function run(argv, io = defaultIO()) {
342
348
  /**
343
349
  * dispatch is two-level: the first word is a top-level row (`discover`) or a
344
350
  * group, and a group's second word is the verb. A
345
- * leading `--url` is accepted before the first word, so
351
+ * leading `--url` or `--token` is accepted before the first word, so
346
352
  * `juno --url <u> state get x` reads the way an alias would write it.
347
353
  */
348
354
  async function dispatch(argv, io) {
349
- const { url, rest } = leadingURL(argv);
355
+ const { leading, rest } = leadingFlags(argv);
350
356
  const [first, ...tail] = rest;
351
357
 
352
358
  if (!first || first === '--help' || first === '-h' || first === 'help') {
@@ -355,7 +361,7 @@ async function dispatch(argv, io) {
355
361
  }
356
362
 
357
363
  const top = dispatchable(first);
358
- if (top) return runRow(top, tail, url, io);
364
+ if (top) return runRow(top, tail, leading, io);
359
365
 
360
366
  if (GROUPS.has(first)) {
361
367
  const [verb, ...args] = tail;
@@ -365,7 +371,7 @@ async function dispatch(argv, io) {
365
371
  return runCommand(
366
372
  { options: URL_OPTION },
367
373
  tail,
368
- url,
374
+ leading,
369
375
  io,
370
376
  (parsed) => showGroupHelp(first, parsed, io, EXIT_OK),
371
377
  (parsed) => showGroupHelp(first, parsed, io, EXIT_USAGE)
@@ -377,7 +383,7 @@ async function dispatch(argv, io) {
377
383
  `${first} ${normalizeVerb(verb)}`,
378
384
  ]);
379
385
  }
380
- return runRow(row, args, url, io);
386
+ return runRow(row, args, leading, io);
381
387
  }
382
388
 
383
389
  const second = tail[0] && !tail[0].startsWith('-') ? tail[0] : null;
@@ -396,7 +402,7 @@ async function showGroupHelp(group, args, io, code) {
396
402
  }
397
403
 
398
404
  /** runRow runs one verbs.json row, a tool's or a non-tool command's. */
399
- function runRow(row, argv, url, io) {
405
+ function runRow(row, argv, leading, io) {
400
406
  if (!row.tool) {
401
407
  const handler = handlerFor(row);
402
408
  const run =
@@ -409,7 +415,7 @@ function runRow(row, argv, url, io) {
409
415
  return runCommand(
410
416
  handler,
411
417
  argv,
412
- url,
418
+ leading,
413
419
  io,
414
420
  () => {
415
421
  io.log(nonToolHelp(row, handler));
@@ -430,7 +436,7 @@ function runRow(row, argv, url, io) {
430
436
  return runCommand(
431
437
  { run: (args) => runTool(row, args, io), options },
432
438
  argv,
433
- url,
439
+ leading,
434
440
  io,
435
441
  async (args) => {
436
442
  io.log(await verbHelp(row, args, io));
@@ -442,10 +448,10 @@ function runRow(row, argv, url, io) {
442
448
  /**
443
449
  * runCommand parses a command's own flags (plus --help) and runs it, or its
444
450
  * help. A `--url` given before the command word fills in when the command's
445
- * own is absent. `run` defaults to the command's own; a group without a verb
451
+ * own is absent, and likewise `--token`. `run` defaults to the command's own; a group without a verb
446
452
  * passes its help instead.
447
453
  */
448
- async function runCommand(command, argv, url, io, help, run = command.run) {
454
+ async function runCommand(command, argv, leading, io, help, run = command.run) {
449
455
  let parsed;
450
456
  try {
451
457
  parsed = parseArgs({
@@ -461,7 +467,9 @@ async function runCommand(command, argv, url, io, help, run = command.run) {
461
467
  }
462
468
 
463
469
  const args = { ...parsed.values, _: parsed.positionals, client: io.client };
464
- if (url !== undefined && args.url === undefined) args.url = url;
470
+ for (const [flag, value] of Object.entries(leading)) {
471
+ if (value !== undefined && args[flag] === undefined) args[flag] = value;
472
+ }
465
473
  if (parsed.values.help) return help(args);
466
474
  return (await run(args, io)) ?? EXIT_OK;
467
475
  }
@@ -483,22 +491,29 @@ function refusePositional(row, args) {
483
491
  );
484
492
  }
485
493
 
486
- /** leadingURL takes `--url <u>` / `--url=<u>` off the front of argv. */
487
- function leadingURL(argv) {
488
- let url;
494
+ /**
495
+ * leadingFlags takes `--url <u>` / `--url=<u>` and `--token <k>` /
496
+ * `--token=<k>` off the front of argv, in either order.
497
+ */
498
+ function leadingFlags(argv) {
499
+ const leading = {};
489
500
  let i = 0;
490
501
  while (i < argv.length) {
491
- if (argv[i] === '--url' && i + 1 < argv.length) {
492
- url = argv[i + 1];
502
+ const flag = ['url', 'token'].find(
503
+ (name) =>
504
+ (argv[i] === `--${name}` && i + 1 < argv.length) ||
505
+ argv[i].startsWith(`--${name}=`)
506
+ );
507
+ if (!flag) break;
508
+ if (argv[i] === `--${flag}`) {
509
+ leading[flag] = argv[i + 1];
493
510
  i += 2;
494
- } else if (argv[i].startsWith('--url=')) {
495
- url = argv[i].slice('--url='.length);
496
- i += 1;
497
511
  } else {
498
- break;
512
+ leading[flag] = argv[i].slice(`--${flag}=`.length);
513
+ i += 1;
499
514
  }
500
515
  }
501
- return { url, rest: argv.slice(i) };
516
+ return { leading, rest: argv.slice(i) };
502
517
  }
503
518
 
504
519
  /**
package/src/token.js ADDED
@@ -0,0 +1,30 @@
1
+ import { readUserConfig } from './config.js';
2
+
3
+ /**
4
+ * Resolve the API key to send as `Authorization: Bearer`, in precedence
5
+ * order: an explicit --token, then $JUNO_TOKEN, then `token` in the user
6
+ * config file `juno config set token` writes. It is the order resolveTarget
7
+ * follows for the URL, minus package.json: a working copy's package.json is
8
+ * committed, and a secret never goes in a file that is.
9
+ *
10
+ * Returns undefined when there is none: a request goes out unauthenticated,
11
+ * which is right for an instance behind a proxy that identifies the person,
12
+ * or one started with --no-auth. A blank value counts as unset, so an empty
13
+ * $JUNO_TOKEN does not shadow the config file.
14
+ *
15
+ * The token is a secret. Nothing in this CLI prints it, in an error message
16
+ * included.
17
+ */
18
+ export async function resolveToken({ token, env = process.env } = {}) {
19
+ return (
20
+ clean(token) ??
21
+ clean(env.JUNO_TOKEN) ??
22
+ clean((await readUserConfig(env)).token)
23
+ );
24
+ }
25
+
26
+ function clean(value) {
27
+ if (typeof value !== 'string') return undefined;
28
+ const trimmed = value.trim();
29
+ return trimmed === '' ? undefined : trimmed;
30
+ }
package/verbs.json CHANGED
@@ -265,7 +265,7 @@
265
265
  {
266
266
  "command": "config set",
267
267
  "effect": "local",
268
- "summary": "Remember the instance URL in the user config file"
268
+ "summary": "Remember the instance URL or the API key in the user config file"
269
269
  },
270
270
  {
271
271
  "command": "agent-skill install",