@junoflow/cli 0.9.0 → 0.11.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 +25 -7
- package/package.json +2 -2
- package/src/api.js +31 -12
- package/src/commands/app-deploy.js +11 -0
- package/src/commands/check.js +2 -9
- package/src/commands/config.js +26 -7
- package/src/commands/deploy.js +2 -9
- package/src/commands/gen-types.js +2 -9
- package/src/commands/init.js +2 -9
- package/src/commands/pull.js +2 -9
- package/src/config.js +20 -5
- package/src/index.js +37 -22
- package/src/token.js +30 -0
- package/verbs.json +1 -1
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
|
|
209
|
-
|
|
|
210
|
-
| `juno session approve <id>`
|
|
211
|
-
| `juno config set url <url>`
|
|
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.
|
|
400
|
-
records a write made through a tool
|
|
401
|
-
|
|
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.
|
|
3
|
+
"version": "0.11.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.
|
|
43
|
+
"@junoflow/eslint-plugin": "0.11.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.
|
|
13
|
-
*
|
|
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
|
|
22
|
-
* command uses, unless one was injected:
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
return {
|
|
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
|
-
/**
|
|
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
|
-
`${
|
|
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
|
}
|
package/src/commands/check.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import {
|
|
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
|
|
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 {
|
package/src/commands/config.js
CHANGED
|
@@ -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 =
|
|
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>`
|
|
9
|
-
*
|
|
10
|
-
* other source is, so what lands in the file is
|
|
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
|
|
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
|
-
|
|
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,
|
|
42
|
+
const path = await writeUserConfig(io.env, { ...current, ...entry });
|
|
24
43
|
io.warn(`wrote ${path}`);
|
|
25
44
|
return 0;
|
|
26
45
|
}
|
package/src/commands/deploy.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
|
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 {
|
|
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
|
|
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(
|
package/src/commands/init.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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 });
|
package/src/commands/pull.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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": "…"}
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
492
|
-
|
|
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
|
-
|
|
512
|
+
leading[flag] = argv[i].slice(`--${flag}=`.length);
|
|
513
|
+
i += 1;
|
|
499
514
|
}
|
|
500
515
|
}
|
|
501
|
-
return {
|
|
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",
|