@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
package/src/index.js CHANGED
@@ -1,123 +1,615 @@
1
1
  import { parseArgs } from 'node:util';
2
2
 
3
- import { check } from './commands/check.js';
4
- import { deploy } from './commands/deploy.js';
5
- import { init } from './commands/init.js';
6
- import { pull } from './commands/pull.js';
7
- import { types } from './commands/types.js';
8
- import { verifyTypes } from './commands/verify-types.js';
9
- import { DiagnosticsError, UserError } from './errors.js';
3
+ import {
4
+ AGENT_SKILL_INSTALL_USAGE,
5
+ agentSkillInstall,
6
+ } from './commands/agent-skill-install.js';
7
+ import { APP_CHECK_USAGE, appCheck } from './commands/app-check.js';
8
+ import { APP_DEPLOY_USAGE, appDeploy } from './commands/app-deploy.js';
9
+ import { APP_DEV_USAGE, appDev } from './commands/app-dev.js';
10
+ import { APP_GEN_TYPES_USAGE, appGenTypes } from './commands/app-gen-types.js';
11
+ import { APP_INIT_USAGE, appInit } from './commands/app-init.js';
12
+ import { APP_PULL_USAGE, appPull } from './commands/app-pull.js';
13
+ import { ASK_USAGE, ask } from './commands/ask.js';
14
+ import { CHECK_USAGE, check } from './commands/check.js';
15
+ import { CONFIG_SET_USAGE, configSet } from './commands/config.js';
16
+ import {
17
+ CREDENTIAL_SET_USAGE,
18
+ credentialSet,
19
+ } from './commands/credential-set.js';
20
+ import { DEPLOY_USAGE, deploy } from './commands/deploy.js';
21
+ import { FLOW_CHECK_USAGE, flowCheck } from './commands/flow-check.js';
22
+ import { FLOW_DEPLOY_USAGE, flowDeploy } from './commands/flow-deploy.js';
23
+ import {
24
+ FLOW_GEN_TYPES_USAGE,
25
+ flowGenTypes,
26
+ } from './commands/flow-gen-types.js';
27
+ import { FLOW_FIXTURE_USAGE, flowFixture } from './commands/flow-fixture.js';
28
+ import { FLOW_INIT_USAGE, flowInit } from './commands/flow-init.js';
29
+ import { FLOW_PULL_USAGE, flowPull } from './commands/flow-pull.js';
30
+ import { GEN_TYPES_USAGE, genTypes } from './commands/gen-types.js';
31
+ import { INIT_USAGE, init } from './commands/init.js';
32
+ import { PULL_USAGE, pull } from './commands/pull.js';
33
+ import { SKILL_USAGE, skill } from './commands/skill.js';
34
+ import {
35
+ SESSION_APPROVE_USAGE,
36
+ sessionApprove,
37
+ } from './commands/session-approve.js';
38
+ import { runTool } from './commands/tool.js';
39
+ import { VERIFY_TYPES_USAGE, verifyTypes } from './commands/verify-types.js';
40
+ import {
41
+ ConflictError,
42
+ DiagnosticsError,
43
+ UnreachableError,
44
+ UsageError,
45
+ UserError,
46
+ } from './errors.js';
10
47
  import { formatDiagnostics } from './diagnostics.js';
48
+ import {
49
+ EXIT_CONFLICT,
50
+ EXIT_FAILURE,
51
+ EXIT_OK,
52
+ EXIT_UNREACHABLE,
53
+ EXIT_USAGE,
54
+ } from './exit.js';
55
+ import {
56
+ groupHelp,
57
+ nonToolHelp,
58
+ verbHelp,
59
+ WORKING_COPY_HEADING,
60
+ workingCopyListing,
61
+ } from './help.js';
62
+ import { closest, closestByVerb, levenshtein } from './suggest.js';
63
+ import { groupOf, normalizeVerb, rowFor, rows } from './verbs.js';
11
64
 
12
- const COMMANDS = {
13
- init,
14
- types,
15
- 'verify-types': verifyTypes,
16
- check,
17
- deploy,
18
- pull,
65
+ const URL_OPTION = { url: { type: 'string' } };
66
+
67
+ /**
68
+ * The verbs.json rows that call no tool, by command. A row is dispatchable
69
+ * only once it has a handler here; until then the CLI treats it as unknown
70
+ * rather than advertising a command it cannot run.
71
+ */
72
+ const NON_TOOL = {
73
+ skill: {
74
+ run: skill,
75
+ options: { ...URL_OPTION, json: { type: 'boolean', default: false } },
76
+ usage: SKILL_USAGE,
77
+ },
78
+ ask: {
79
+ run: ask,
80
+ options: {
81
+ ...URL_OPTION,
82
+ session: { type: 'string' },
83
+ wait: { type: 'string' },
84
+ },
85
+ usage: ASK_USAGE,
86
+ },
87
+ 'session approve': {
88
+ run: sessionApprove,
89
+ options: { ...URL_OPTION, wait: { type: 'string' } },
90
+ usage: SESSION_APPROVE_USAGE,
91
+ },
92
+ 'credential set': {
93
+ run: credentialSet,
94
+ options: {
95
+ ...URL_OPTION,
96
+ name: { type: 'string' },
97
+ description: { type: 'string' },
98
+ },
99
+ usage: CREDENTIAL_SET_USAGE,
100
+ },
101
+ 'config set': { run: configSet, options: {}, usage: CONFIG_SET_USAGE },
102
+ 'agent-skill install': {
103
+ run: agentSkillInstall,
104
+ options: {
105
+ ...URL_OPTION,
106
+ dir: { type: 'string' },
107
+ 'allow-change': { type: 'boolean', default: false },
108
+ 'working-copy': { type: 'boolean', default: false },
109
+ 'agents-md': { type: 'boolean', default: false },
110
+ json: { type: 'boolean', default: false },
111
+ },
112
+ usage: AGENT_SKILL_INSTALL_USAGE,
113
+ },
114
+ };
115
+
116
+ /**
117
+ * The working-copy commands: verbs.json rows with `working_copy: true`, which
118
+ * act on the project in the current directory rather than on the instance
119
+ * (plan-external-agents-flows-as-files.md, Decision 11). Like NON_TOOL, a row
120
+ * is dispatchable only once it has a handler here. `positional` is true only
121
+ * for `init`, which names what to make; every other one refuses a positional
122
+ * (see runRow), because the working copy, not an argument, says what it acts
123
+ * on.
124
+ */
125
+ const WORKING_COPY = {
126
+ 'flow init': {
127
+ run: flowInit,
128
+ options: { ...URL_OPTION, here: { type: 'boolean', default: false } },
129
+ usage: FLOW_INIT_USAGE,
130
+ positional: true,
131
+ },
132
+ 'flow gen-types': {
133
+ run: flowGenTypes,
134
+ options: URL_OPTION,
135
+ usage: FLOW_GEN_TYPES_USAGE,
136
+ },
137
+ 'flow check': {
138
+ run: flowCheck,
139
+ options: {
140
+ ...URL_OPTION,
141
+ 'skip-local': { type: 'boolean', default: false },
142
+ json: { type: 'boolean', default: false },
143
+ },
144
+ usage: FLOW_CHECK_USAGE,
145
+ },
146
+ 'flow deploy': {
147
+ run: flowDeploy,
148
+ options: {
149
+ ...URL_OPTION,
150
+ watch: { type: 'boolean', default: false },
151
+ force: { type: 'boolean', default: false },
152
+ json: { type: 'boolean', default: false },
153
+ },
154
+ usage: FLOW_DEPLOY_USAGE,
155
+ },
156
+ 'flow pull': {
157
+ run: flowPull,
158
+ options: { ...URL_OPTION, force: { type: 'boolean', default: false } },
159
+ usage: FLOW_PULL_USAGE,
160
+ },
161
+ 'flow fixture': {
162
+ run: flowFixture,
163
+ options: {
164
+ ...URL_OPTION,
165
+ run: { type: 'string' },
166
+ force: { type: 'boolean', default: false },
167
+ },
168
+ usage: FLOW_FIXTURE_USAGE,
169
+ },
170
+ 'integration init': {
171
+ run: init,
172
+ options: {
173
+ ...URL_OPTION,
174
+ name: { type: 'string' },
175
+ here: { type: 'boolean', default: false },
176
+ },
177
+ usage: INIT_USAGE,
178
+ positional: true,
179
+ },
180
+ 'integration gen-types': {
181
+ run: genTypes,
182
+ options: URL_OPTION,
183
+ usage: GEN_TYPES_USAGE,
184
+ },
185
+ 'integration verify-types': {
186
+ run: verifyTypes,
187
+ options: {},
188
+ usage: VERIFY_TYPES_USAGE,
189
+ },
190
+ // check and deploy keep --id: deploy under a different install id than the
191
+ // manifest's. pull keeps it too, to pull such an install back.
192
+ 'integration check': {
193
+ run: check,
194
+ options: {
195
+ ...URL_OPTION,
196
+ id: { type: 'string' },
197
+ 'skip-local': { type: 'boolean', default: false },
198
+ json: { type: 'boolean', default: false },
199
+ },
200
+ usage: CHECK_USAGE,
201
+ },
202
+ 'integration deploy': {
203
+ run: deploy,
204
+ options: {
205
+ ...URL_OPTION,
206
+ id: { type: 'string' },
207
+ watch: { type: 'boolean', default: false },
208
+ force: { type: 'boolean', default: false },
209
+ json: { type: 'boolean', default: false },
210
+ },
211
+ usage: DEPLOY_USAGE,
212
+ },
213
+ 'integration pull': {
214
+ run: pull,
215
+ options: {
216
+ ...URL_OPTION,
217
+ id: { type: 'string' },
218
+ force: { type: 'boolean', default: false },
219
+ },
220
+ usage: PULL_USAGE,
221
+ },
222
+ 'app init': {
223
+ run: appInit,
224
+ options: { ...URL_OPTION, here: { type: 'boolean', default: false } },
225
+ usage: APP_INIT_USAGE,
226
+ positional: true,
227
+ },
228
+ 'app gen-types': {
229
+ run: appGenTypes,
230
+ options: URL_OPTION,
231
+ usage: APP_GEN_TYPES_USAGE,
232
+ },
233
+ 'app check': {
234
+ run: appCheck,
235
+ options: {
236
+ ...URL_OPTION,
237
+ 'skip-local': { type: 'boolean', default: false },
238
+ json: { type: 'boolean', default: false },
239
+ },
240
+ usage: APP_CHECK_USAGE,
241
+ },
242
+ 'app deploy': {
243
+ run: appDeploy,
244
+ options: {
245
+ ...URL_OPTION,
246
+ watch: { type: 'boolean', default: false },
247
+ force: { type: 'boolean', default: false },
248
+ json: { type: 'boolean', default: false },
249
+ },
250
+ usage: APP_DEPLOY_USAGE,
251
+ },
252
+ 'app pull': {
253
+ run: appPull,
254
+ options: { ...URL_OPTION, force: { type: 'boolean', default: false } },
255
+ usage: APP_PULL_USAGE,
256
+ },
257
+ // No --url: the dev server is offline and never reads juno.target.
258
+ 'app dev': {
259
+ run: appDev,
260
+ options: {
261
+ port: { type: 'string' },
262
+ scenario: { type: 'string' },
263
+ },
264
+ usage: APP_DEV_USAGE,
265
+ },
19
266
  };
20
267
 
21
- const USAGE = `juno — develop Juno integrations
268
+ /** The handler of a non-tool row, working-copy or not, or undefined. */
269
+ function handlerFor(row) {
270
+ if (row.tool) return undefined;
271
+ const table = row.working_copy ? WORKING_COPY : NON_TOOL;
272
+ return Object.hasOwn(table, row.command) ? table[row.command] : undefined;
273
+ }
274
+
275
+ /**
276
+ * The rows the CLI can run: every tool row, and the non-tool and
277
+ * working-copy rows above.
278
+ */
279
+ const DISPATCHABLE = rows.filter((row) => row.tool || handlerFor(row));
280
+ const GROUPS = new Set(DISPATCHABLE.map(groupOf).filter(Boolean));
281
+
282
+ function dispatchable(command) {
283
+ const row = rowFor(command);
284
+ return row && DISPATCHABLE.includes(row) ? row : undefined;
285
+ }
286
+
287
+ const USAGE = `juno — operate a Juno instance, and develop flows, integrations and apps for it
22
288
 
23
289
  Usage: juno <command> [options]
290
+ juno <group> <verb> [id] [--input <json>|-|@file] [--jq <expr>]
291
+
292
+ Groups: ${[...GROUPS].join(', ')}
293
+ Commands: ${DISPATCHABLE.filter((r) => !groupOf(r))
294
+ .map((r) => r.command)
295
+ .join(', ')}
296
+ Run \`juno <group> --help\` for a group's commands.
24
297
 
25
- Commands:
26
- init <name> scaffold a new integration project from a Juno
27
- types generate types/ from manifest.json
28
- verify-types check types/ still matches manifest.json (offline)
29
- check typecheck and lint locally, then validate against a Juno
30
- deploy [--watch] upload, verify, and activate a new version
31
- pull write back the manifest and sources Juno holds
32
-
33
- Options:
34
- --url <url> the Juno instance; else $JUNO_URL, else package.json juno.target
35
- --name <name> address an install under a different name than the manifest
36
- --watch deploy on every save (deploy only)
37
- --force overwrite a dirty working tree (pull only)
38
- --here scaffold into the current directory (init only)
39
- --skip-local skip the local tsc/eslint pass (check only)
40
- -h, --help this
298
+ ${WORKING_COPY_HEADING}:
299
+ ${workingCopyListing(DISPATCHABLE, handlerFor)}
300
+
301
+ --url <url> picks the instance; else $JUNO_URL, package.json juno.target,
302
+ then the user config (juno config set url <url>).
41
303
  `;
42
304
 
43
305
  /**
44
306
  * run is the whole CLI, injectable so the tests drive it without spawning a
45
- * process: io carries cwd and the three output streams, and a command may be
46
- * handed a client instead of building one from a URL.
307
+ * process: io carries cwd, env, the TTY bit, stdin and the three output
308
+ * streams, and a command may be handed a client instead of building one from
309
+ * a URL. It returns the exit code (exit.js).
47
310
  */
48
311
  export async function run(argv, io = defaultIO()) {
49
- const [command, ...rest] = argv;
50
-
51
- if (
52
- !command ||
53
- command === '--help' ||
54
- command === '-h' ||
55
- command === 'help'
56
- ) {
312
+ try {
313
+ return await dispatch(argv, io);
314
+ } catch (err) {
315
+ // Diagnostics print as file:line:col so an editor can click through; a
316
+ // UserError is a sentence about their input, and neither deserves a stack.
317
+ if (err instanceof DiagnosticsError) {
318
+ io.error(formatDiagnostics(err.diagnostics));
319
+ io.error(`\n${err.diagnostics.length} problem(s): ${err.message}`);
320
+ return EXIT_FAILURE;
321
+ }
322
+ if (err instanceof ConflictError) {
323
+ io.error(err.message);
324
+ return EXIT_CONFLICT;
325
+ }
326
+ if (err instanceof UsageError) {
327
+ io.error(err.message);
328
+ return EXIT_USAGE;
329
+ }
330
+ if (err instanceof UnreachableError) {
331
+ io.error(err.message);
332
+ return EXIT_UNREACHABLE;
333
+ }
334
+ if (err instanceof UserError) {
335
+ io.error(err.message);
336
+ return EXIT_FAILURE;
337
+ }
338
+ throw err;
339
+ }
340
+ }
341
+
342
+ /**
343
+ * dispatch is two-level: the first word is a top-level row (`discover`) or a
344
+ * group, and a group's second word is the verb. A
345
+ * leading `--url` is accepted before the first word, so
346
+ * `juno --url <u> state get x` reads the way an alias would write it.
347
+ */
348
+ async function dispatch(argv, io) {
349
+ const { url, rest } = leadingURL(argv);
350
+ const [first, ...tail] = rest;
351
+
352
+ if (!first || first === '--help' || first === '-h' || first === 'help') {
57
353
  io.log(USAGE);
58
- return command ? 0 : 1;
354
+ return first ? EXIT_OK : EXIT_USAGE;
59
355
  }
60
- const handler = COMMANDS[command];
61
- if (!handler) {
62
- io.error(`unknown command: ${command}\n`);
63
- io.error(USAGE);
64
- return 1;
356
+
357
+ const top = dispatchable(first);
358
+ if (top) return runRow(top, tail, url, io);
359
+
360
+ if (GROUPS.has(first)) {
361
+ const [verb, ...args] = tail;
362
+ // `juno state` alone is its help, as a usage error: the person has not
363
+ // said what to do yet.
364
+ if (!verb || verb.startsWith('-')) {
365
+ return runCommand(
366
+ { options: URL_OPTION },
367
+ tail,
368
+ url,
369
+ io,
370
+ (parsed) => showGroupHelp(first, parsed, io, EXIT_OK),
371
+ (parsed) => showGroupHelp(first, parsed, io, EXIT_USAGE)
372
+ );
373
+ }
374
+ const row = dispatchable(`${first} ${normalizeVerb(verb)}`);
375
+ if (!row) {
376
+ throw unknownCommand(`${first} ${verb}`, [
377
+ `${first} ${normalizeVerb(verb)}`,
378
+ ]);
379
+ }
380
+ return runRow(row, args, url, io);
65
381
  }
66
382
 
383
+ const second = tail[0] && !tail[0].startsWith('-') ? tail[0] : null;
384
+ if (second) {
385
+ throw unknownTopLevel(`${first} ${second}`, first, [
386
+ `${first} ${normalizeVerb(second)}`,
387
+ first,
388
+ ]);
389
+ }
390
+ throw unknownTopLevel(first, first, [first]);
391
+ }
392
+
393
+ async function showGroupHelp(group, args, io, code) {
394
+ io.log(await groupHelp(group, DISPATCHABLE, handlerFor, args, io));
395
+ return code;
396
+ }
397
+
398
+ /** runRow runs one verbs.json row, a tool's or a non-tool command's. */
399
+ function runRow(row, argv, url, io) {
400
+ if (!row.tool) {
401
+ const handler = handlerFor(row);
402
+ const run =
403
+ row.working_copy && !handler.positional
404
+ ? (args, io) => {
405
+ refusePositional(row, args);
406
+ return handler.run(args, io);
407
+ }
408
+ : handler.run;
409
+ return runCommand(
410
+ handler,
411
+ argv,
412
+ url,
413
+ io,
414
+ () => {
415
+ io.log(nonToolHelp(row, handler));
416
+ return EXIT_OK;
417
+ },
418
+ run
419
+ );
420
+ }
421
+
422
+ const options = {
423
+ ...URL_OPTION,
424
+ input: { type: 'string' },
425
+ jq: { type: 'string' },
426
+ };
427
+ for (const [flag, spec] of Object.entries(row.flags ?? {})) {
428
+ options[flag] = { type: 'string', multiple: spec.multiple === true };
429
+ }
430
+ return runCommand(
431
+ { run: (args) => runTool(row, args, io), options },
432
+ argv,
433
+ url,
434
+ io,
435
+ async (args) => {
436
+ io.log(await verbHelp(row, args, io));
437
+ return EXIT_OK;
438
+ }
439
+ );
440
+ }
441
+
442
+ /**
443
+ * runCommand parses a command's own flags (plus --help) and runs it, or its
444
+ * 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
446
+ * passes its help instead.
447
+ */
448
+ async function runCommand(command, argv, url, io, help, run = command.run) {
67
449
  let parsed;
68
450
  try {
69
451
  parsed = parseArgs({
70
- args: rest,
452
+ args: argv,
71
453
  allowPositionals: true,
72
454
  options: {
73
- url: { type: 'string' },
74
- name: { type: 'string' },
75
- watch: { type: 'boolean', default: false },
76
- force: { type: 'boolean', default: false },
77
- here: { type: 'boolean', default: false },
78
- 'skip-local': { type: 'boolean', default: false },
455
+ ...command.options,
79
456
  help: { type: 'boolean', short: 'h', default: false },
80
457
  },
81
458
  });
82
459
  } catch (err) {
83
- io.error(err.message);
84
- return 1;
85
- }
86
- if (parsed.values.help) {
87
- io.log(USAGE);
88
- return 0;
460
+ throw new UsageError(err.message);
89
461
  }
90
462
 
91
463
  const args = { ...parsed.values, _: parsed.positionals, client: io.client };
92
- try {
93
- return (await handler(args, io)) ?? 0;
94
- } catch (err) {
95
- // Diagnostics print as file:line:col so an editor can click through; a
96
- // UserError is a sentence about their input, and neither deserves a stack.
97
- if (err instanceof DiagnosticsError) {
98
- io.error(formatDiagnostics(err.diagnostics));
99
- io.error(`\n${err.diagnostics.length} problem(s): ${err.message}`);
100
- return 1;
101
- }
102
- if (err instanceof UserError) {
103
- io.error(err.message);
104
- return 1;
464
+ if (url !== undefined && args.url === undefined) args.url = url;
465
+ if (parsed.values.help) return help(args);
466
+ return (await run(args, io)) ?? EXIT_OK;
467
+ }
468
+
469
+ /**
470
+ * refusePositional: a working-copy command acts on the project in the current
471
+ * directory, so `juno flow deploy porch-light` is a usage error that says so
472
+ * and points at the command that makes a working copy of porch-light —
473
+ * rather than deploying whatever directory it happens to run in.
474
+ */
475
+ function refusePositional(row, args) {
476
+ if (args._.length === 0) return;
477
+ const group = groupOf(row);
478
+ throw new UsageError(
479
+ `juno ${row.command} takes no argument: it acts on the working copy in ` +
480
+ 'the current directory. ' +
481
+ `To get a working copy of "${args._[0]}", run \`juno ${group} init ${args._[0]}\` ` +
482
+ 'and run this from inside it.'
483
+ );
484
+ }
485
+
486
+ /** leadingURL takes `--url <u>` / `--url=<u>` off the front of argv. */
487
+ function leadingURL(argv) {
488
+ let url;
489
+ let i = 0;
490
+ while (i < argv.length) {
491
+ if (argv[i] === '--url' && i + 1 < argv.length) {
492
+ url = argv[i + 1];
493
+ i += 2;
494
+ } else if (argv[i].startsWith('--url=')) {
495
+ url = argv[i].slice('--url='.length);
496
+ i += 1;
497
+ } else {
498
+ break;
105
499
  }
106
- throw err;
107
500
  }
501
+ return { url, rest: argv.slice(i) };
502
+ }
503
+
504
+ /**
505
+ * unknownCommand names what was typed and, when one is close, the command
506
+ * that was probably meant — rather than the whole help, which buries the fix.
507
+ */
508
+ function unknownCommand(words, attempts) {
509
+ const candidates = [...DISPATCHABLE.map((row) => row.command), ...GROUPS];
510
+ const guess = closest(attempts, candidates);
511
+ const hint = guess ? `did you mean "juno ${guess}"?` : 'see juno --help';
512
+ return new UsageError(`unknown command "juno ${words}"; ${hint}`);
513
+ }
514
+
515
+ /**
516
+ * unknownTopLevel is unknownCommand for a first word that is neither a
517
+ * command nor a group. It may be a verb typed without its group: a bare
518
+ * `deploy` is nearer `juno integration deploy` (and `juno flow deploy`) than
519
+ * any top-level command, and the integration commands lived at the top level until
520
+ * 0.5.0. A verb match wins only when it is strictly nearer than every whole
521
+ * command, so a mistyped group (`juno stat get`) still finds its command.
522
+ */
523
+ function unknownTopLevel(words, first, attempts) {
524
+ const commands = DISPATCHABLE.map((row) => row.command);
525
+ const whole = closest(attempts, [...commands, ...GROUPS]);
526
+ const wholeDistance = whole
527
+ ? Math.min(...attempts.map((a) => levenshtein(a, whole)))
528
+ : Infinity;
529
+ const byVerb = closestByVerb(normalizeVerb(first), commands);
530
+ if (byVerb.commands.length === 0 || byVerb.distance >= wholeDistance) {
531
+ return unknownCommand(words, attempts);
532
+ }
533
+ const quoted = byVerb.commands.map((c) => `"juno ${c}"`);
534
+ const list =
535
+ quoted.length === 1
536
+ ? quoted[0]
537
+ : `${quoted.slice(0, -1).join(', ')} or ${quoted.at(-1)}`;
538
+ return new UsageError(
539
+ `unknown command "juno ${words}"; did you mean ${list}?`
540
+ );
108
541
  }
109
542
 
110
543
  export function defaultIO() {
111
544
  return {
112
545
  cwd: process.cwd(),
546
+ env: process.env,
113
547
  client: null,
548
+ isTTY: process.stdout.isTTY === true,
549
+ stdinIsTTY: process.stdin.isTTY === true,
550
+ readStdin,
551
+ readSecret,
114
552
  log: (msg) => process.stdout.write(`${msg}\n`),
553
+ write: (text) => process.stdout.write(text),
115
554
  warn: (msg) => process.stderr.write(`${msg}\n`),
116
555
  error: (msg) => process.stderr.write(`${msg}\n`),
117
556
  };
118
557
  }
119
558
 
120
559
  export { JunoClient } from './api.js';
121
- export { DiagnosticsError, UserError } from './errors.js';
560
+ export {
561
+ DiagnosticsError,
562
+ UnreachableError,
563
+ UsageError,
564
+ UserError,
565
+ } from './errors.js';
122
566
  export { resolveTarget } from './target.js';
123
567
  export { readProject } from './project.js';
568
+
569
+ /** readStdin reads the whole of stdin; only `--input -` ever calls it. */
570
+ async function readStdin() {
571
+ const chunks = [];
572
+ for await (const chunk of process.stdin) chunks.push(chunk);
573
+ return Buffer.concat(chunks).toString('utf8');
574
+ }
575
+
576
+ /**
577
+ * readSecret prompts on stderr and reads one line from the terminal with echo
578
+ * off: raw mode, so no keystroke is echoed, restored before it returns.
579
+ * Backspace edits; Enter or Ctrl-D ends the line; Ctrl-C restores the mode
580
+ * and exits 130, as an interrupted command does. Only `credential set` calls
581
+ * it, and only once stdin is known to be a TTY.
582
+ */
583
+ function readSecret(prompt) {
584
+ const stdin = process.stdin;
585
+ return new Promise((resolve) => {
586
+ let value = [];
587
+ const wasRaw = stdin.isRaw;
588
+ const done = () => {
589
+ stdin.off('data', onData);
590
+ stdin.setRawMode(wasRaw);
591
+ stdin.pause();
592
+ process.stderr.write('\n');
593
+ };
594
+ const onData = (chunk) => {
595
+ for (const ch of chunk) {
596
+ if (ch === '\u0003') {
597
+ done();
598
+ process.exit(130);
599
+ }
600
+ if (ch === '\r' || ch === '\n' || ch === '\u0004') {
601
+ done();
602
+ resolve(value.join(''));
603
+ return;
604
+ }
605
+ if (ch === '\u007f' || ch === '\b') value = value.slice(0, -1);
606
+ else value.push(ch);
607
+ }
608
+ };
609
+ process.stderr.write(prompt);
610
+ stdin.setEncoding('utf8');
611
+ stdin.setRawMode(true);
612
+ stdin.on('data', onData);
613
+ stdin.resume();
614
+ });
615
+ }