@junoflow/cli 0.3.0 → 0.9.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 (59) hide show
  1. package/README.md +693 -52
  2. package/app-client.js +306 -0
  3. package/bin/juno.js +7 -0
  4. package/eslint.app.config.js +34 -0
  5. package/eslint.flow.config.js +35 -0
  6. package/package.json +12 -4
  7. package/src/api.js +541 -30
  8. package/src/app-dev/build.js +134 -0
  9. package/src/app-dev/harness.js +108 -0
  10. package/src/app-dev/panel.html +336 -0
  11. package/src/app-dev/panel.js +290 -0
  12. package/src/app-dev/server.js +347 -0
  13. package/src/app-dev/stream.js +224 -0
  14. package/src/app-dev/watch.js +47 -0
  15. package/src/app-project.js +133 -0
  16. package/src/authoring-skills.js +43 -0
  17. package/src/commands/agent-skill-install.js +64 -0
  18. package/src/commands/app-check.js +75 -0
  19. package/src/commands/app-deploy.js +102 -0
  20. package/src/commands/app-dev.js +141 -0
  21. package/src/commands/app-gen-types.js +37 -0
  22. package/src/commands/app-init.js +46 -0
  23. package/src/commands/app-pull.js +106 -0
  24. package/src/commands/ask.js +60 -0
  25. package/src/commands/check.js +71 -27
  26. package/src/commands/config.js +26 -0
  27. package/src/commands/credential-set.js +68 -0
  28. package/src/commands/deploy.js +95 -51
  29. package/src/commands/flow-check.js +75 -0
  30. package/src/commands/flow-deploy.js +110 -0
  31. package/src/commands/flow-fixture.js +67 -0
  32. package/src/commands/flow-gen-types.js +37 -0
  33. package/src/commands/flow-init.js +59 -0
  34. package/src/commands/flow-pull.js +115 -0
  35. package/src/commands/{types.js → gen-types.js} +12 -5
  36. package/src/commands/init.js +28 -16
  37. package/src/commands/pull.js +111 -63
  38. package/src/commands/session-approve.js +73 -0
  39. package/src/commands/skill.js +73 -0
  40. package/src/commands/tool.js +249 -0
  41. package/src/commands/verify-types.js +4 -2
  42. package/src/config.js +34 -0
  43. package/src/diagnostics.js +14 -0
  44. package/src/errors.js +40 -0
  45. package/src/exit.js +22 -0
  46. package/src/flow-project.js +78 -0
  47. package/src/help.js +199 -0
  48. package/src/index.js +567 -75
  49. package/src/installed-skill.js +55 -0
  50. package/src/project.js +76 -8
  51. package/src/prompt.js +196 -0
  52. package/src/stamp.js +10 -9
  53. package/src/suggest.js +67 -0
  54. package/src/target.js +22 -7
  55. package/src/verbs.js +34 -0
  56. package/src/watch.js +87 -0
  57. package/src/working-copy.js +262 -0
  58. package/tsconfig.page.json +15 -0
  59. package/verbs.json +276 -0
@@ -1,33 +1,44 @@
1
1
  import { mkdir, writeFile, access } from 'node:fs/promises';
2
2
  import { dirname, join, resolve, sep } from 'node:path';
3
3
 
4
- import { JunoClient } from '../api.js';
5
- import { UserError } from '../errors.js';
4
+ import { clientName, JunoClient } from '../api.js';
5
+ import { printAuthoringIndex } from '../authoring-skills.js';
6
+ import { UsageError, UserError } from '../errors.js';
6
7
  import { resolveTarget } from '../target.js';
7
8
 
9
+ export const INIT_USAGE = 'juno integration init <id> [--name <name>] [--here]';
10
+
8
11
  /**
9
- * `juno init [name]` — ask a Juno for a starting project and write it down.
12
+ * `juno integration init <id>` — a working copy of the install's active
13
+ * version, or a starting project for an id with none, written into ./<id>
14
+ * (or the current directory with --here).
10
15
  *
11
16
  * Every file comes from `POST /api/integrations/scaffold`, which renders it
12
17
  * with the same generator behind **Develop locally → Download project**. The
13
18
  * CLI holds no copy of the layout: a scaffold and an ejected project cannot
14
- * drift, and the starter arrives with `types/` already generated, which only
15
- * the server can produce.
19
+ * drift, and the project arrives with `types/` already generated, which only
20
+ * the server can produce. `--name` shapes only a starter. After the next steps
21
+ * it prints the integration author's skill index.
16
22
  */
17
23
  export async function init(argv, io) {
18
- const name = argv._[0] ?? argv.name;
19
- if (!name) throw new UserError('usage: juno init <name>');
24
+ const [id, ...extra] = argv._;
25
+ if (!id || extra.length > 0) throw new UsageError(`usage: ${INIT_USAGE}`);
20
26
 
21
- const dir = resolve(io.cwd, argv.here ? '.' : name);
27
+ const dir = resolve(io.cwd, argv.here ? '.' : id);
22
28
  if (!argv.here && (await exists(dir))) {
23
29
  throw new UserError(`${dir} already exists`);
24
30
  }
25
31
 
26
- const target = await resolveTarget({ url: argv.url, cwd: io.cwd });
27
- const client = argv.client ?? new JunoClient(target);
28
- // The name is validated server-side, against the same rules an install is:
29
- // one answer to "is this a valid integration name" rather than two.
30
- const files = await client.scaffold(name, { target });
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) });
39
+ // The id is validated server-side, against the same rules an install is:
40
+ // one answer to "is this a valid integration id" rather than two.
41
+ const files = await client.scaffold(id, { name: argv.name, target });
31
42
 
32
43
  for (const [rel, content] of Object.entries(files)) {
33
44
  const path = join(dir, safeRelative(rel));
@@ -37,9 +48,10 @@ export async function init(argv, io) {
37
48
 
38
49
  io.log(`created ${dir}`);
39
50
  io.log('');
40
- io.log(argv.here ? 'npm install' : `cd ${name} && npm install`);
51
+ io.log(argv.here ? 'npm install' : `cd ${id} && npm install`);
41
52
  io.log('npm test');
42
53
  io.log('npm run deploy');
54
+ await printAuthoringIndex('integration', client, target, io);
43
55
  return 0;
44
56
  }
45
57
 
@@ -48,7 +60,7 @@ export async function init(argv, io) {
48
60
  * but "write these paths" is the one place a bad answer escapes the project
49
61
  * directory, and checking costs a line.
50
62
  */
51
- function safeRelative(rel) {
63
+ export function safeRelative(rel) {
52
64
  const parts = rel.split('/');
53
65
  if (rel.startsWith('/') || parts.includes('..') || parts.includes('')) {
54
66
  throw new UserError(
@@ -58,7 +70,7 @@ function safeRelative(rel) {
58
70
  return parts.join(sep);
59
71
  }
60
72
 
61
- async function exists(path) {
73
+ export async function exists(path) {
62
74
  try {
63
75
  await access(path);
64
76
  return true;
@@ -1,89 +1,137 @@
1
- import { execFile } from 'node:child_process';
2
- import { mkdir, writeFile } from 'node:fs/promises';
3
- import { dirname, join } from 'node:path';
4
- import { promisify } from 'node:util';
5
-
6
- import { JunoClient } from '../api.js';
1
+ import { clientName, JunoClient } from '../api.js';
7
2
  import { UserError } from '../errors.js';
8
- import { readManifest } from '../project.js';
3
+ import {
4
+ readIntegrationBaseVersion,
5
+ readManifest,
6
+ updateIntegrationPackage,
7
+ } from '../project.js';
8
+ import { stampTypeFiles } from '../stamp.js';
9
9
  import { resolveTarget } from '../target.js';
10
+ import {
11
+ baseArtifactPaths,
12
+ MANIFEST,
13
+ manifestText,
14
+ pullArtifacts,
15
+ requireCleanTree,
16
+ setDependencies,
17
+ writeChangedFiles,
18
+ } from '../working-copy.js';
19
+ import { pickTypeFiles } from './gen-types.js';
10
20
 
11
- const execFileAsync = promisify(execFile);
21
+ export const PULL_USAGE = 'juno integration pull [--force] [--id <id>]';
12
22
 
13
23
  /**
14
- * `juno pull` — write back the manifest and sources Juno holds.
24
+ * `juno integration pull` — make the working copy's artifact files the
25
+ * install's active version: manifest.json and every file under src/ and
26
+ * test/, exactly as the version stores them, plus types/ regenerated and
27
+ * stamped against the pulled manifest, and package.json's
28
+ * `juno.base_version` (the version pulled, which the next check and deploy
29
+ * send).
15
30
  *
16
31
  * This exists because the zip is a good first touch and a bad second one: once
17
32
  * the authoring agent has edited an integration you deployed, downloading the
18
33
  * project again would clobber your working tree, while pull touches only the
19
- * two things Juno owns. It still refuses a dirty tree without --force, because
20
- * an uncommitted edit is the one thing git cannot get back for you.
34
+ * files Juno stores. The scaffold's script lines are not among them: they are
35
+ * project content, and an author's own edits to them are theirs — a fresh
36
+ * `juno integration init` is how to get the current ones. `dependencies` is
37
+ * replaced only when the pulled manifest declares some (an agent-authored
38
+ * integration); otherwise they live in package.json alone, which Juno does not
39
+ * store, and stay. Every other key in package.json, and of `juno`, stays.
40
+ *
41
+ * A local file is deleted only when the base version had it and the pulled
42
+ * one does not; a file neither has is one not deployed yet, and stays. It
43
+ * still refuses a dirty tree without --force, because an uncommitted edit is
44
+ * the one thing git cannot get back for you.
21
45
  */
22
46
  export async function pull(argv, io) {
23
- const target = await resolveTarget({ url: argv.url, cwd: io.cwd });
24
- const name = argv.name ?? (await readManifest(io.cwd)).name;
25
- if (!name) {
47
+ const target = await resolveTarget({
48
+ url: argv.url,
49
+ cwd: io.cwd,
50
+ env: io.env,
51
+ });
52
+ let manifestId;
53
+ try {
54
+ manifestId = (await readManifest(io.cwd)).id;
55
+ } catch (err) {
56
+ // With --id the manifest is about to be replaced, so a broken one is
57
+ // no reason to refuse.
58
+ if (argv.id === undefined) throw err;
59
+ }
60
+ const id = argv.id ?? manifestId;
61
+ if (!id) {
26
62
  throw new UserError(
27
- 'manifest.json declares no name — pass --name <integration>'
63
+ 'manifest.json declares no id — pass --id <integration>'
28
64
  );
29
65
  }
30
66
 
31
- if (!argv.force) {
32
- const dirty = await dirtyPaths(io.cwd);
33
- if (dirty === null) {
34
- io.warn(
35
- 'not a git repository — pull will overwrite manifest.json and src/ with no undo'
36
- );
37
- } else if (dirty.length > 0) {
38
- throw new UserError(
39
- `working tree has uncommitted changes:\n${dirty.map((p) => ` ${p}`).join('\n')}\n` +
40
- 'Commit or stash them, or pass --force to overwrite.'
41
- );
42
- }
43
- }
67
+ await requireCleanTree(io.cwd, {
68
+ force: argv.force,
69
+ overwrites: `${MANIFEST}, src/, test/ and types/`,
70
+ io,
71
+ });
44
72
 
45
- const client = argv.client ?? new JunoClient(target);
46
- const detail = await client.getIntegration(name);
73
+ const client =
74
+ argv.client ?? new JunoClient(target, { client: clientName(io.env) });
75
+ const detail = await client.getIntegration(id);
47
76
  if (!detail?.manifest) {
48
77
  throw new UserError(
49
- `${name} has no active version on ${target} — nothing to pull`
78
+ `${id} has no active version on ${target} — nothing to pull`
50
79
  );
51
80
  }
81
+ // Everything is fetched before anything is written, so a failure leaves
82
+ // the working copy as it was.
83
+ const manifestRaw = manifestText(detail.manifest);
84
+ const stored = { [MANIFEST]: manifestRaw, ...detail.source_files };
85
+ const types = stampTypeFiles(
86
+ pickTypeFiles(await client.typegen(detail.manifest)),
87
+ manifestRaw
88
+ );
89
+ // The base belongs to the install the manifest names; pulling another
90
+ // (--id) has no base to diff against, so it deletes nothing.
91
+ const base = await baseArtifactPaths({
92
+ base:
93
+ id === manifestId ? await readIntegrationBaseVersion(io.cwd) : undefined,
94
+ version: detail.version,
95
+ stored,
96
+ read: async (v) => {
97
+ const b = await client.getIntegrationVersion(id, v);
98
+ return b ? [MANIFEST, ...Object.keys(b.SourceFiles ?? {})] : null;
99
+ },
100
+ id,
101
+ target,
102
+ io,
103
+ });
52
104
 
53
- const written = ['manifest.json'];
54
- await writeFile(
55
- join(io.cwd, 'manifest.json'),
56
- `${JSON.stringify(detail.manifest, null, 2)}\n`,
57
- 'utf8'
105
+ const { written, removed } = await pullArtifacts(io.cwd, {
106
+ stored,
107
+ base,
108
+ kind: 'integration',
109
+ });
110
+ written.push(
111
+ ...(await writeChangedFiles(
112
+ io.cwd,
113
+ Object.fromEntries(
114
+ Object.entries(types).map(([name, content]) => [
115
+ `types/${name}`,
116
+ content,
117
+ ])
118
+ )
119
+ ))
58
120
  );
59
- for (const [rel, content] of Object.entries(detail.source_files ?? {})) {
60
- const path = join(io.cwd, rel);
61
- await mkdir(dirname(path), { recursive: true });
62
- await writeFile(path, content, 'utf8');
63
- written.push(rel);
64
- }
121
+ const wrote = await updateIntegrationPackage(
122
+ io.cwd,
123
+ (pkg) => {
124
+ if (Object.keys(detail.manifest.dependencies ?? {}).length) {
125
+ setDependencies(pkg, detail.manifest);
126
+ }
127
+ pkg.juno.base_version = detail.version;
128
+ },
129
+ io
130
+ );
131
+ if (wrote) written.push('package.json');
65
132
  io.log(
66
- `pulled ${name} v${detail.version} from ${target} (${written.sort().join(', ')})`
133
+ `pulled ${id} v${detail.version} from ${target} (${written.sort().join(', ')})`
67
134
  );
68
- io.log('run `juno types` to regenerate types/ against the pulled manifest');
135
+ if (removed.length) io.log(`removed ${removed.join(', ')}`);
69
136
  return 0;
70
137
  }
71
-
72
- /**
73
- * Returns the paths git reports as changed, [] for a clean tree, or null when
74
- * this is not a git repository at all — three states the caller treats
75
- * differently, so they must not collapse into a boolean.
76
- */
77
- async function dirtyPaths(cwd) {
78
- try {
79
- const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
80
- cwd,
81
- });
82
- return stdout
83
- .split('\n')
84
- .map((line) => line.trim())
85
- .filter(Boolean);
86
- } catch {
87
- return null;
88
- }
89
- }
@@ -0,0 +1,73 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import { connect } from '../api.js';
4
+ import { UsageError, UserError } from '../errors.js';
5
+ import { converse, finish } from '../prompt.js';
6
+ import { waitSeconds } from './ask.js';
7
+
8
+ export const SESSION_APPROVE_USAGE =
9
+ 'juno session approve <session-id> [--wait <seconds>]';
10
+
11
+ /**
12
+ * `juno session approve <id>` lets through the tool call an assistant session
13
+ * is waiting on. It is its own command, not `juno ask … approve`, so that a
14
+ * permission rule allowing `juno ask` does not also let a coding agent
15
+ * approve the assistant's call_service on the user's behalf.
16
+ *
17
+ * It checks first that the session is awaiting an approval — anything else
18
+ * sent to a question would be taken as the answer — then sends `approve` as
19
+ * `juno ask` would, and prints and exits the same way.
20
+ */
21
+ export async function sessionApprove(args, io) {
22
+ const [id, ...extra] = args._;
23
+ if (id === undefined || extra.length) {
24
+ throw new UsageError(`usage: ${SESSION_APPROVE_USAGE}`);
25
+ }
26
+ const wait = waitSeconds(args.wait);
27
+
28
+ const { client } = await connect(args, io);
29
+ const session = await client.getAgentSession(id);
30
+ if (!session) throw new UserError(`no assistant session ${id}`);
31
+ const refusal = notAwaitingApproval(session, id);
32
+ if (refusal) throw new UserError(refusal);
33
+
34
+ const body = {
35
+ session_id: id,
36
+ message: 'approve',
37
+ message_id: randomUUID(),
38
+ wait_timeout_seconds: wait,
39
+ };
40
+ // Never resent after a busy 409: whatever is pending by then is not what
41
+ // was checked above.
42
+ const outcome = await converse(client, body, {
43
+ stream: false,
44
+ io,
45
+ resendWhenBusy: false,
46
+ });
47
+ return finish(outcome, id, io);
48
+ }
49
+
50
+ /** What the session is doing instead of waiting on an approval, if it is. */
51
+ function notAwaitingApproval(session, id) {
52
+ const pending = session.pending_input;
53
+ if (session.status === 'awaiting_input' && pending?.kind === 'approval') {
54
+ return null;
55
+ }
56
+ if (session.status === 'awaiting_input') {
57
+ return (
58
+ `session ${id} is asking a question, not waiting for an approval: ` +
59
+ `${JSON.stringify(pending?.question ?? '')}\n` +
60
+ `answer it with: juno ask --session ${id} "<answer>"`
61
+ );
62
+ }
63
+ if (session.status === 'active') {
64
+ return (
65
+ `session ${id} is running a turn and has nothing to approve yet; ` +
66
+ `wait for it with: juno ask --session ${id}`
67
+ );
68
+ }
69
+ if (session.status === 'idle') {
70
+ return `session ${id} is idle and has nothing to approve`;
71
+ }
72
+ return `session ${id} has ended (status: ${session.status}); there is nothing to approve`;
73
+ }
@@ -0,0 +1,73 @@
1
+ import { connect } from '../api.js';
2
+ import { UsageError } from '../errors.js';
3
+ import { warnIfStale } from '../installed-skill.js';
4
+ import { levenshtein } from '../suggest.js';
5
+
6
+ export const SKILL_USAGE = 'juno skill [<name>] [--json]';
7
+
8
+ /**
9
+ * `juno skill` prints the skills the instance serves: with no name, one line
10
+ * per skill; with a name, that skill's body exactly as served. `--json`
11
+ * prints the route's JSON instead. The server renders each tool reference as
12
+ * the `juno` command in front of it; the CLI does not rewrite them. The index
13
+ * also says when an installed agent skill is older than what the instance
14
+ * serves (installed-skill.js).
15
+ */
16
+ export async function skill(args, io) {
17
+ const [name, ...extra] = args._;
18
+ if (extra.length) throw new UsageError(`usage: ${SKILL_USAGE}`);
19
+
20
+ const { target, client } = await connect(args, io);
21
+
22
+ if (name === undefined) {
23
+ const index = await client.listSkills();
24
+ await warnIfStale(index, target, io);
25
+ if (args.json) return printJSON(index, io);
26
+ for (const s of index?.skills ?? []) {
27
+ io.log(`${s.name} — ${s.description}`);
28
+ }
29
+ return 0;
30
+ }
31
+
32
+ const detail = await client.getSkill(name);
33
+ if (!detail) {
34
+ const { skills = [] } = (await client.listSkills()) ?? {};
35
+ throw unknownSkill(
36
+ name,
37
+ skills.map((s) => s.name)
38
+ );
39
+ }
40
+ if (args.json) return printJSON(detail, io);
41
+ // Verbatim: the body's own trailing newline is the last byte written.
42
+ io.write(detail.body);
43
+ return 0;
44
+ }
45
+
46
+ /** Compact off a TTY, indented on one, as every JSON the CLI prints. */
47
+ function printJSON(value, io) {
48
+ io.log(io.isTTY ? JSON.stringify(value, null, 2) : JSON.stringify(value));
49
+ return 0;
50
+ }
51
+
52
+ /**
53
+ * unknownSkill names up to three served skills near what was typed. Skill
54
+ * names are short, so the distance allowed grows with the name, and a name
55
+ * that contains what was typed (`integration` → `integration-states`) counts
56
+ * as near.
57
+ */
58
+ function unknownSkill(name, names) {
59
+ const max = Math.max(3, Math.floor(name.length / 2));
60
+ const near = names
61
+ .map((n) => ({
62
+ n,
63
+ d: n.includes(name) ? 0 : levenshtein(name, n),
64
+ }))
65
+ .filter(({ d }) => d <= max)
66
+ .sort((a, b) => a.d - b.d)
67
+ .slice(0, 3)
68
+ .map(({ n }) => `"${n}"`);
69
+ const hint = near.length
70
+ ? `did you mean ${near.join(', ')}?`
71
+ : 'run `juno skill` for the list';
72
+ return new UsageError(`unknown skill "${name}"; ${hint}`);
73
+ }
@@ -0,0 +1,249 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { basename, resolve } from 'node:path';
4
+ import { isDeepStrictEqual } from 'node:util';
5
+
6
+ import { connect } from '../api.js';
7
+ import { UsageError, UserError } from '../errors.js';
8
+ import { EXIT_FAILURE, EXIT_OK } from '../exit.js';
9
+
10
+ /**
11
+ * runTool runs one tool row of verbs.json: build the tool's input from the
12
+ * command line, POST it to the gateway, print the answer. It holds no tool
13
+ * semantics — the gateway validates the input against the tool's schema and
14
+ * answers 422 naming the field — so nothing here knows what any field means
15
+ * beyond which one the positional fills.
16
+ */
17
+ export async function runTool(row, args, io) {
18
+ const input = await buildInput(row, args, io);
19
+
20
+ const { client } = await connect(args, io);
21
+ const { result, text } = await client.callTool(row.tool, input);
22
+
23
+ if (args.jq !== undefined) {
24
+ io.log(await runJq(args.jq, text, io));
25
+ } else {
26
+ io.log(io.isTTY ? JSON.stringify(result, null, 2) : text.trimEnd());
27
+ }
28
+ // A tool reports a domain failure as a 200 with a top-level string error:
29
+ // it is printed like any result, and only the exit code says it failed.
30
+ return typeof result?.error === 'string' ? EXIT_FAILURE : EXIT_OK;
31
+ }
32
+
33
+ /**
34
+ * buildInput merges in Decision 8's order: the --input object, then the
35
+ * positional into its field, then the row's flags, then fixed_input. A later
36
+ * source may fill a field but never silently overwrite one: a disagreement is
37
+ * a usage error naming both, because either could be the one the person meant.
38
+ */
39
+ async function buildInput(row, args, io) {
40
+ const input = await readInput(args.input, io);
41
+ const positionals = args._;
42
+
43
+ // `<kind> <id>` is what discover's {kind, id} reads as, so a kind:id row
44
+ // takes it as two words as well as one.
45
+ if (row.positional === 'kind:id' && positionals.length === 2) {
46
+ put(input, 'kind', positionals[0], 'the positional argument');
47
+ put(input, 'id', positionals[1], 'the positional argument');
48
+ return finishInput(row, args, input);
49
+ }
50
+ if (positionals.length > 1) {
51
+ throw new UsageError(
52
+ `juno ${row.command} takes at most one positional argument, got ${positionals.length}: ${positionals.join(' ')}`
53
+ );
54
+ }
55
+ const [positional] = positionals;
56
+ if (positional !== undefined) {
57
+ if (row.id_field) {
58
+ put(input, row.id_field, positional, 'the positional argument');
59
+ } else if (row.positional === 'kind:id') {
60
+ putKindID(input, positional);
61
+ } else if (row.positional === 'file') {
62
+ await putFile(input, positional, io);
63
+ } else {
64
+ throw new UsageError(
65
+ `juno ${row.command} takes no positional argument; pass its input with --input`
66
+ );
67
+ }
68
+ }
69
+ return finishInput(row, args, input);
70
+ }
71
+
72
+ /** finishInput adds the row's flags, then its fixed_input. */
73
+ function finishInput(row, args, input) {
74
+ for (const [flag, spec] of Object.entries(row.flags ?? {})) {
75
+ if (args[flag] === undefined) continue;
76
+ const values = spec.multiple ? args[flag] : [args[flag]];
77
+ const typed = values.map((v) => flagValue(flag, spec, v));
78
+ put(input, spec.field, spec.multiple ? typed : typed[0], `--${flag}`);
79
+ }
80
+
81
+ for (const [field, value] of Object.entries(row.fixed_input ?? {})) {
82
+ if (
83
+ Object.hasOwn(input, field) &&
84
+ !isDeepStrictEqual(input[field], value)
85
+ ) {
86
+ throw new UsageError(
87
+ `juno ${row.command} always sends ${JSON.stringify(field)}: ${JSON.stringify(value)}, ` +
88
+ `but --input has ${JSON.stringify(input[field])}`
89
+ );
90
+ }
91
+ input[field] = value;
92
+ }
93
+ return input;
94
+ }
95
+
96
+ /** put sets input[field] unless --input already holds a different value. */
97
+ function put(input, field, value, source) {
98
+ if (Object.hasOwn(input, field) && !isDeepStrictEqual(input[field], value)) {
99
+ throw new UsageError(
100
+ `--input has ${JSON.stringify(field)}: ${JSON.stringify(input[field])}, ` +
101
+ `but ${source} says ${JSON.stringify(value)}; give it once`
102
+ );
103
+ }
104
+ input[field] = value;
105
+ }
106
+
107
+ /**
108
+ * putKindID splits `<kind>:<id>` on the first colon only: an endpoint id such
109
+ * as `ha:light.hallway` or `mqtt:zigbee2mqtt/porch/set` carries its own. An
110
+ * endpoint id on its own is an endpoint — `ha:` and `mqtt:` are what the tool
111
+ * calls the address, so `juno related ha:light.hallway` means what it says.
112
+ */
113
+ function putKindID(input, arg) {
114
+ const colon = arg.indexOf(':');
115
+ if (colon <= 0 || colon === arg.length - 1) {
116
+ throw new UsageError(
117
+ `expected <kind>:<id> or <kind> <id>, e.g. state:porch-dark or ha:light.hallway; got "${arg}"`
118
+ );
119
+ }
120
+ const kind = arg.slice(0, colon);
121
+ if (ENDPOINT_SCHEMES.has(kind)) {
122
+ put(input, 'kind', 'endpoint', `the positional argument`);
123
+ put(input, 'id', arg, `the positional argument`);
124
+ return;
125
+ }
126
+ put(input, 'kind', kind, `the positional argument`);
127
+ put(input, 'id', arg.slice(colon + 1), `the positional argument`);
128
+ }
129
+
130
+ /** ENDPOINT_SCHEMES are the prefixes of an endpoint id, as get_related spells it. */
131
+ const ENDPOINT_SCHEMES = new Set(['ha', 'mqtt']);
132
+
133
+ /**
134
+ * putFile reads a script into `files` under its basename and makes it the
135
+ * `entry`. Other files from --input ride along; one with the same name is a
136
+ * usage error rather than silently replaced.
137
+ */
138
+ async function putFile(input, arg, io) {
139
+ const path = resolve(io.cwd, arg);
140
+ let content;
141
+ try {
142
+ content = await readFile(path, 'utf8');
143
+ } catch (err) {
144
+ throw new UsageError(`cannot read ${path}: ${err.message}`);
145
+ }
146
+ const name = basename(path);
147
+ const files = input.files ?? {};
148
+ if (files === null || typeof files !== 'object' || Array.isArray(files)) {
149
+ throw new UsageError('--input has "files" that is not an object');
150
+ }
151
+ if (Object.hasOwn(files, name)) {
152
+ throw new UsageError(
153
+ `--input already has a file named "${name}" under "files", and so does the positional ${arg}; give it once`
154
+ );
155
+ }
156
+ input.files = { ...files, [name]: content };
157
+ put(input, 'entry', name, `the positional argument ${arg}`);
158
+ }
159
+
160
+ /** flagValue types one flag value as verbs.json declares it. */
161
+ function flagValue(flag, spec, value) {
162
+ if (spec.type !== 'integer') return value;
163
+ if (!/^-?\d+$/.test(value)) {
164
+ throw new UsageError(`--${flag} takes an integer, got "${value}"`);
165
+ }
166
+ return Number(value);
167
+ }
168
+
169
+ /**
170
+ * readInput parses --input: `-` is stdin, `@path` a file relative to the
171
+ * working directory, anything else the JSON itself. Stdin is read only when
172
+ * asked for — an open, empty stdin would hang an agent — and an absent
173
+ * --input is `{}`.
174
+ */
175
+ async function readInput(spec, io) {
176
+ if (spec === undefined) return {};
177
+
178
+ let source;
179
+ let raw;
180
+ if (spec === '-') {
181
+ source = 'stdin (--input -)';
182
+ raw = await io.readStdin();
183
+ } else if (spec.startsWith('@')) {
184
+ const path = resolve(io.cwd, spec.slice(1));
185
+ source = `--input ${spec}`;
186
+ try {
187
+ raw = await readFile(path, 'utf8');
188
+ } catch (err) {
189
+ throw new UsageError(`cannot read ${path} (${source}): ${err.message}`);
190
+ }
191
+ } else {
192
+ source = '--input';
193
+ raw = spec;
194
+ }
195
+
196
+ let value;
197
+ try {
198
+ value = JSON.parse(raw);
199
+ } catch (err) {
200
+ throw new UsageError(`${source} is not JSON: ${err.message}`);
201
+ }
202
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
203
+ throw new UsageError(`${source} must be a JSON object`);
204
+ }
205
+ return value;
206
+ }
207
+
208
+ /**
209
+ * runJq pipes the result through the jq on PATH. It is a subprocess, not a
210
+ * library: `--jq` exists so a filter stays inside the `juno <kind> <verb>`
211
+ * permission rule, and the CLI takes on no jq implementation of its own.
212
+ */
213
+ function runJq(expr, text, io) {
214
+ const argv = io.isTTY ? [expr] : ['-c', expr];
215
+ return new Promise((resolvePromise, reject) => {
216
+ let child;
217
+ try {
218
+ child = spawn('jq', argv, {
219
+ env: io.env,
220
+ stdio: ['pipe', 'pipe', 'pipe'],
221
+ });
222
+ } catch (err) {
223
+ reject(jqSpawnError(err));
224
+ return;
225
+ }
226
+ const out = [];
227
+ const err = [];
228
+ child.stdout.on('data', (c) => out.push(c));
229
+ child.stderr.on('data', (c) => err.push(c));
230
+ child.on('error', (e) => reject(jqSpawnError(e)));
231
+ child.on('close', (code) => {
232
+ if (code === 0) {
233
+ resolvePromise(Buffer.concat(out).toString('utf8').replace(/\n$/, ''));
234
+ return;
235
+ }
236
+ const stderr = Buffer.concat(err).toString('utf8').trim();
237
+ reject(new UsageError(stderr || `jq exited ${code}`));
238
+ });
239
+ // jq may exit before reading everything (a bad expression); the EPIPE
240
+ // that follows is not the failure worth reporting.
241
+ child.stdin.on('error', () => {});
242
+ child.stdin.end(text);
243
+ });
244
+ }
245
+
246
+ function jqSpawnError(err) {
247
+ if (err.code === 'ENOENT') return new UsageError('jq is not installed');
248
+ return new UserError(`cannot run jq: ${err.message}`);
249
+ }