@junoflow/cli 0.14.0 → 0.17.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/app-client.js CHANGED
@@ -14,27 +14,36 @@
14
14
  * rejects with an Error whose message is the handler's (or the host's).
15
15
  *
16
16
  * window.juno.values
17
- * The app's bound states and controls, by binding name:
18
- * {states: {<b>: value}, controls: {<b>: {value, active, expires_at}}}, or
19
- * null until the first snapshot has arrived.
17
+ * The app's bound states, controls and series, by binding name:
18
+ * {states: {<b>: value}, controls: {<b>: {value, active, expires_at}},
19
+ * series: {<b>: {published_at, issued_at, covered_until, intervals}}}, or
20
+ * null until the first snapshot has arrived. A series moves on every
21
+ * publish, changed or not.
20
22
  *
21
23
  * window.juno.watch(cb) → unwatch()
22
24
  * Calls cb(values, changed) on every snapshot and update, where changed
23
- * lists the keys that moved ("states.<b>", "controls.<b>"; every key on a
24
- * snapshot). Registered after a snapshot, cb is called at once with every
25
- * key.
25
+ * lists the keys that moved ("states.<b>", "controls.<b>", "series.<b>";
26
+ * every key on a snapshot). Registered after a snapshot, cb is called at
27
+ * once with every key.
28
+ *
29
+ * window.juno.series.<binding>.get(range?) → Promise<read>
30
+ * window.juno.series.<binding>.view(spec) → Promise<result>
31
+ * What a handler's series.<binding>.get / .view answer, with no handler:
32
+ * GET _juno/series/<binding>?from=&to= and POST _juno/series/<binding>/view.
33
+ * A Date bound is sent as its ISO string.
26
34
  *
27
35
  * window.juno.controls.<binding>.onPress(cb) → unwatch()
28
36
  * Calls cb(at) on every press of that bound button control, at an RFC 3339
29
37
  * timestamp.
30
38
  *
31
- * handlers and controls are proxies, not maps of what the manifest declares:
39
+ * handlers, controls and series are proxies, not maps of what the manifest declares:
32
40
  * the client is never told the names, so any name reaches the server, and an
33
41
  * unknown handler is a rejected call carrying the host's 404 — the same answer
34
42
  * a hand-typed name got before. A name some JavaScript protocol looks up on
35
43
  * every object (then, toJSON, toString, …) answers undefined instead, so
36
44
  * `await juno.handlers` or JSON.stringify(juno) never fires a call;
37
- * manifest.ValidateApp refuses those names for a handler or a control binding.
45
+ * manifest.ValidateApp refuses those names for a handler, a control binding or
46
+ * a series binding.
38
47
  *
39
48
  * The values arrive over one EventSource on _juno/stream, opened by the first
40
49
  * watch or onPress and never on page load: a page that does not watch holds no
@@ -147,6 +156,67 @@
147
156
  return body.result;
148
157
  }
149
158
 
159
+ // readJSON answers a series route: the body on success, its error thrown
160
+ // otherwise. Like a call, every answer names the version that served it.
161
+ async function readJSON(res) {
162
+ checkVersion(res);
163
+ var body;
164
+ try {
165
+ body = await res.json();
166
+ } catch (e) {
167
+ throw new Error('HTTP ' + res.status);
168
+ }
169
+ if (!res.ok) {
170
+ throw new Error(
171
+ body && typeof body.error === 'string'
172
+ ? body.error
173
+ : 'HTTP ' + res.status
174
+ );
175
+ }
176
+ return body;
177
+ }
178
+
179
+ function instant(v) {
180
+ // Duck-typed rather than instanceof, so a Date from another realm (an
181
+ // iframe) is still sent as its ISO string.
182
+ return v && typeof v.toISOString === 'function'
183
+ ? v.toISOString()
184
+ : String(v);
185
+ }
186
+
187
+ function seriesURL(binding) {
188
+ return base + '_juno/series/' + encodeURIComponent(binding);
189
+ }
190
+
191
+ async function seriesGet(binding, range) {
192
+ var q = [];
193
+ if (range && range.from !== undefined && range.from !== null) {
194
+ q.push('from=' + encodeURIComponent(instant(range.from)));
195
+ }
196
+ if (range && range.to !== undefined && range.to !== null) {
197
+ q.push('to=' + encodeURIComponent(instant(range.to)));
198
+ }
199
+ var res = await fetch(
200
+ seriesURL(binding) + (q.length ? '?' + q.join('&') : ''),
201
+ {
202
+ headers: { 'X-Juno-App-Version': String(version) },
203
+ }
204
+ );
205
+ return readJSON(res);
206
+ }
207
+
208
+ async function seriesView(binding, spec) {
209
+ var res = await fetch(seriesURL(binding) + '/view', {
210
+ method: 'POST',
211
+ headers: {
212
+ 'Content-Type': 'application/json',
213
+ 'X-Juno-App-Version': String(version),
214
+ },
215
+ body: JSON.stringify(spec === undefined ? {} : spec),
216
+ });
217
+ return readJSON(res);
218
+ }
219
+
150
220
  // Live values. The stream is opened lazily and at most one is open.
151
221
  var values = null;
152
222
  var watchers = [];
@@ -190,6 +260,9 @@
190
260
  Object.keys(vals.controls || {}).forEach(function (b) {
191
261
  out.push('controls.' + b);
192
262
  });
263
+ Object.keys(vals.series || {}).forEach(function (b) {
264
+ out.push('series.' + b);
265
+ });
193
266
  return out;
194
267
  }
195
268
 
@@ -207,6 +280,7 @@
207
280
  values = {
208
281
  states: data.values.states || {},
209
282
  controls: data.values.controls || {},
283
+ series: data.values.series || {},
210
284
  };
211
285
  var changed = keys(values);
212
286
  watchers.slice().forEach(function (cb) {
@@ -214,7 +288,7 @@
214
288
  });
215
289
  }
216
290
 
217
- // onUpdate merges per binding. An update carries both maps, the one that did
291
+ // onUpdate merges per binding. An update carries every map, the ones that did
218
292
  // not move empty, so the changed keys are exactly the bindings present.
219
293
  function onUpdate(ev) {
220
294
  var data = parse(ev);
@@ -222,10 +296,12 @@
222
296
  var moved = {
223
297
  states: data.values.states || {},
224
298
  controls: data.values.controls || {},
299
+ series: data.values.series || {},
225
300
  };
226
301
  values = {
227
302
  states: Object.assign({}, values.states, moved.states),
228
303
  controls: Object.assign({}, values.controls, moved.controls),
304
+ series: Object.assign({}, values.series, moved.series),
229
305
  };
230
306
  var changed = keys(moved);
231
307
  if (!changed.length) return;
@@ -298,6 +374,16 @@
298
374
  },
299
375
  });
300
376
  }),
377
+ series: namespace(function (binding) {
378
+ return Object.freeze({
379
+ get: function (range) {
380
+ return seriesGet(binding, range);
381
+ },
382
+ view: function (spec) {
383
+ return seriesView(binding, spec);
384
+ },
385
+ });
386
+ }),
301
387
  get values() {
302
388
  return values;
303
389
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junoflow/cli",
3
- "version": "0.14.0",
3
+ "version": "0.17.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.14.0",
43
+ "@junoflow/eslint-plugin": "0.17.0",
44
44
  "@typescript-eslint/parser": "^8.0.0"
45
45
  },
46
46
  "devDependencies": {
package/src/api.js CHANGED
@@ -281,9 +281,11 @@ export class JunoClient {
281
281
  async callTool(name, input) {
282
282
  const path = `/api/tools/${encodeURIComponent(name)}`;
283
283
  const res = await this.#request('POST', path, input);
284
+ const skills = skillsHeader(res);
284
285
  if (res.status === 422) {
285
286
  throw new UsageError(
286
- `invalid input for ${name}: ${validationDetail(res)}`
287
+ `invalid input for ${name}: ${validationDetail(res)}` +
288
+ (skills.length ? `\n${skillsPointer(skills)}` : '')
287
289
  );
288
290
  }
289
291
  if (res.status === 404) {
@@ -293,7 +295,7 @@ export class JunoClient {
293
295
  );
294
296
  }
295
297
  if (!res.ok) throw failure('POST', path, res);
296
- return { result: jsonBody('POST', path, res), text: res.text };
298
+ return { result: jsonBody('POST', path, res), text: res.text, skills };
297
299
  }
298
300
 
299
301
  /**
@@ -306,8 +308,8 @@ export class JunoClient {
306
308
  }
307
309
 
308
310
  /**
309
- * GET /api/skills — `{skills: [{name, description}], agent_skill_version}`,
310
- * sorted by name. `authoring` (`flow` or `integration`) narrows it to the
311
+ * GET /api/skills — `{skills: [{name, description, applies_to}]}`, sorted
312
+ * by name. `authoring` (`flow` or `integration`) narrows it to the
311
313
  * skills for writing that kind of working copy.
312
314
  */
313
315
  async listSkills({ authoring } = {}) {
@@ -318,12 +320,20 @@ export class JunoClient {
318
320
  }
319
321
 
320
322
  /**
321
- * GET /api/agent-skill — `{version, skill_md, agents_md, permissions,
322
- * working_copy_permissions}`: the skill a coding agent installs and the
323
- * permission rules that go with it, both rendered by the server.
323
+ * GET /api/agent-primer — `{body}`: the markdown `juno --help` prints
324
+ * between its usage lines and the command listing, rendered by the server.
324
325
  */
325
- async getAgentSkill() {
326
- return this.#json('GET', '/api/agent-skill');
326
+ async getAgentPrimer() {
327
+ return this.#json('GET', '/api/agent-primer');
328
+ }
329
+
330
+ /**
331
+ * GET /api/agent-permissions — `{read, change, working_copy: {read,
332
+ * change}}`: Claude Code permission rules for the juno commands and a
333
+ * working copy's npm scripts, by effect, rendered by the server.
334
+ */
335
+ async getAgentPermissions() {
336
+ return this.#json('GET', '/api/agent-permissions');
327
337
  }
328
338
 
329
339
  /**
@@ -456,6 +466,49 @@ export class JunoClient {
456
466
  return jsonBody('GET', path, res);
457
467
  }
458
468
 
469
+ /**
470
+ * GET /api/approvals/{id} — one approval as it stands, or null when there
471
+ * is none. Readable at read scope, so the key that asked can poll it.
472
+ */
473
+ async getApproval(id) {
474
+ const path = `/api/approvals/${encodeURIComponent(id)}`;
475
+ const res = await this.#request('GET', path);
476
+ if (res.status === 404) return null;
477
+ if (!res.ok) throw failure('GET', path, res);
478
+ return jsonBody('GET', path, res);
479
+ }
480
+
481
+ /**
482
+ * rest is one call to a REST route the CLI fronts without a tool (the
483
+ * session, whoami and version commands): the answer's JSON body, or a
484
+ * UserError carrying the server's words — a 404 as `notFound` when given,
485
+ * else just those words, since it is the id the person typed that is
486
+ * unknown.
487
+ */
488
+ async rest(method, path, body, { notFound } = {}) {
489
+ const res = await this.#request(method, path, body);
490
+ if (res.status === 404) {
491
+ throw new UserError(notFound ?? problemDetail(res));
492
+ }
493
+ if (res.status === 422) throw new UsageError(validationDetail(res));
494
+ return this.#answer(method, path, res);
495
+ }
496
+
497
+ /**
498
+ * events reads a server-sent event stream as it arrives, yielding
499
+ * `{event, data}` with data parsed as JSON; it ends when the server closes
500
+ * the stream or `signal` aborts. A non-2xx answer is the route's failure.
501
+ */
502
+ async *events(path, { signal } = {}) {
503
+ const res = await this.#send('GET', path, undefined, signal);
504
+ if (!res.ok) {
505
+ const answer = await this.#read(res);
506
+ if (answer.status === 404) throw new UserError(problemDetail(answer));
507
+ throw failure('GET', path, answer);
508
+ }
509
+ yield* sse(res.body, this.target, signal);
510
+ }
511
+
459
512
  /**
460
513
  * #request sends a request and reads its whole answer. It throws only when
461
514
  * there is no answer at all; every status comes back to the caller, which
@@ -474,7 +527,7 @@ export class JunoClient {
474
527
  * request to its sign-in page. Following it would hand the caller that
475
528
  * page's HTML as a 200.
476
529
  */
477
- async #send(method, path, body) {
530
+ async #send(method, path, body, signal) {
478
531
  const url = `${this.target}${path}`;
479
532
  // Every request carries the header, the existing commands' included:
480
533
  // one client, one header.
@@ -488,6 +541,7 @@ export class JunoClient {
488
541
  headers,
489
542
  body: body ? JSON.stringify(body) : undefined,
490
543
  redirect: 'manual',
544
+ signal,
491
545
  });
492
546
  } catch (err) {
493
547
  throw new UnreachableError(
@@ -527,6 +581,7 @@ export class JunoClient {
527
581
  ok: res.ok,
528
582
  status: res.status,
529
583
  statusText: res.statusText,
584
+ headers: res.headers,
530
585
  text,
531
586
  parsed,
532
587
  };
@@ -574,6 +629,60 @@ async function* ndjson(body, target) {
574
629
  yield* lines(true);
575
630
  }
576
631
 
632
+ /**
633
+ * sse reads a text/event-stream body: blank-line separated events of
634
+ * `event:` and `data:` lines, each data parsed as JSON. An abort ends it
635
+ * quietly; a connection that drops is an UnreachableError.
636
+ */
637
+ async function* sse(body, target, signal) {
638
+ const decoder = new TextDecoder();
639
+ let buffered = '';
640
+ const events = function* (final) {
641
+ buffered = buffered.replace(/\r\n/g, '\n');
642
+ let end;
643
+ while (
644
+ (end = buffered.indexOf('\n\n')) >= 0 ||
645
+ (final && buffered.trim())
646
+ ) {
647
+ const block = end >= 0 ? buffered.slice(0, end) : buffered;
648
+ buffered = end >= 0 ? buffered.slice(end + 2) : '';
649
+ let event = 'message';
650
+ const data = [];
651
+ for (const line of block.split('\n')) {
652
+ if (line.startsWith('event:')) event = line.slice(6).trim();
653
+ else if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
654
+ }
655
+ if (!data.length) continue;
656
+ const text = data.join('\n');
657
+ let parsed;
658
+ try {
659
+ parsed = JSON.parse(text);
660
+ } catch {
661
+ throw new UserError(
662
+ `the stream sent an event that is not JSON: ${text}`
663
+ );
664
+ }
665
+ yield { event, data: parsed };
666
+ }
667
+ };
668
+ try {
669
+ for await (const chunk of body) {
670
+ buffered += decoder.decode(chunk, { stream: true });
671
+ yield* events(false);
672
+ }
673
+ } catch (err) {
674
+ if (err instanceof UserError || signal?.aborted) {
675
+ if (signal?.aborted) return;
676
+ throw err;
677
+ }
678
+ throw new UnreachableError(
679
+ `lost the connection to Juno at ${target}: ${err.message}`
680
+ );
681
+ }
682
+ buffered += decoder.decode();
683
+ yield* events(true);
684
+ }
685
+
577
686
  /**
578
687
  * jsonBody is a 2xx answer's parsed body, which every route this client calls
579
688
  * answers as a JSON object. Anything else came from something other than
@@ -584,17 +693,47 @@ async function* ndjson(body, target) {
584
693
  function jsonBody(method, path, res) {
585
694
  if (res.parsed === null || typeof res.parsed !== 'object') {
586
695
  throw new UserError(
587
- `${method} ${path} answered something that is not JSON: ${res.text.trim()}`
696
+ `${method} ${path} answered something that is not JSON: ${briefText(res)}`
588
697
  );
589
698
  }
590
699
  return res.parsed;
591
700
  }
592
701
 
702
+ /**
703
+ * skillsHeader is X-Juno-Skills, the skills the gateway names beside a call
704
+ * the tool refused (a 422, or a result with a string error), or [] when it
705
+ * sent none. The server decides which skills apply; the CLI only prints them.
706
+ */
707
+ function skillsHeader(res) {
708
+ const value = res.headers?.get?.('x-juno-skills') ?? '';
709
+ return value.split(/\s+/).filter(Boolean);
710
+ }
711
+
712
+ /** The last stderr line after a refused call: what to read before retrying. */
713
+ export function skillsPointer(skills) {
714
+ return `see: juno skill ${skills.join(' ')}`;
715
+ }
716
+
593
717
  /** The server's own words in an error answer: Huma's `detail`, else the body. */
594
718
  function problemDetail(res) {
595
- return (
596
- res.parsed?.detail || res.parsed?.title || res.text.trim() || res.statusText
597
- );
719
+ return res.parsed?.detail || res.parsed?.title || briefText(res);
720
+ }
721
+
722
+ const BRIEF_LIMIT = 200;
723
+
724
+ /**
725
+ * briefText is a body that is not Juno's JSON, cut to one line: an HTML page
726
+ * (a proxy's, or an instance older than the route answering its web app) is
727
+ * named rather than printed, because its markup buries the one line that
728
+ * matters — the status and the route — under a screenful of tags, and other
729
+ * text is collapsed to one line of at most BRIEF_LIMIT characters.
730
+ */
731
+ export function briefText(res) {
732
+ const text = (res.text ?? '').trim();
733
+ if (!text) return res.statusText || '(empty body)';
734
+ if (/^<(!doctype|html|head|body)\b/i.test(text)) return 'an HTML page';
735
+ const flat = text.replace(/\s+/g, ' ');
736
+ return flat.length > BRIEF_LIMIT ? `${flat.slice(0, BRIEF_LIMIT)}…` : flat;
598
737
  }
599
738
 
600
739
  /**
@@ -619,7 +758,7 @@ const UNAUTHORIZED_HINT =
619
758
  * `errors[]` instead, so those are appended.
620
759
  */
621
760
  function validationDetail(res) {
622
- const parts = [res.parsed?.detail || res.text.trim() || res.statusText];
761
+ const parts = [res.parsed?.detail || briefText(res)];
623
762
  for (const e of res.parsed?.errors ?? []) {
624
763
  parts.push(e.location ? `${e.location}: ${e.message}` : e.message);
625
764
  }
@@ -1,5 +1,3 @@
1
- import { warnIfStale } from './installed-skill.js';
2
-
3
1
  /**
4
2
  * The kinds of working copy with an authoring skill index, and the heading
5
3
  * each is printed under. The index itself is the server's
@@ -22,10 +20,9 @@ export function authoringHeading(kind) {
22
20
  * printAuthoringIndex prints a kind's authoring skills after `init` has
23
21
  * written the working copy, as ` <name> — <description>`. Fetching it is a
24
22
  * courtesy: the working copy is already on disk, so a failure is a note on
25
- * stderr, never a failed init. It also says when an installed agent skill is
26
- * stale, since the index carries the version the instance serves.
23
+ * stderr, never a failed init.
27
24
  */
28
- export async function printAuthoringIndex(kind, client, target, io) {
25
+ export async function printAuthoringIndex(kind, client, io) {
29
26
  let index;
30
27
  try {
31
28
  index = await client.listSkills({ authoring: kind });
@@ -39,5 +36,4 @@ export async function printAuthoringIndex(kind, client, target, io) {
39
36
  io.log(authoringHeading(kind));
40
37
  for (const s of skills) io.log(` ${s.name} — ${s.description}`);
41
38
  }
42
- await warnIfStale(index, target, io);
43
39
  }
@@ -41,6 +41,6 @@ export async function appInit(argv, io) {
41
41
  io.log('npm test');
42
42
  io.log('npm run check # stores a draft and prints its preview URL');
43
43
  io.log('npm run deploy');
44
- await printAuthoringIndex('app', client, target, io);
44
+ await printAuthoringIndex('app', client, io);
45
45
  return 0;
46
46
  }
@@ -0,0 +1,215 @@
1
+ import { connect } from '../api.js';
2
+ import { UnreachableError, UsageError, UserError } from '../errors.js';
3
+ import {
4
+ EXIT_FAILURE,
5
+ EXIT_INTERRUPTED,
6
+ EXIT_OK,
7
+ EXIT_PENDING,
8
+ } from '../exit.js';
9
+
10
+ export const APPROVAL_WAIT_USAGE =
11
+ 'juno approval wait <approval-id> [--wait <seconds>]';
12
+
13
+ /** How long a destructive command waits for a person by default. */
14
+ export const DEFAULT_APPROVAL_WAIT_SECONDS = 300;
15
+
16
+ /** How often the approval is read while waiting. */
17
+ const POLL_MS = 2000;
18
+
19
+ /**
20
+ * approvalWaitSeconds reads `--wait`: whole seconds, 0 meaning look once.
21
+ */
22
+ export function approvalWaitSeconds(flag) {
23
+ if (flag === undefined) return DEFAULT_APPROVAL_WAIT_SECONDS;
24
+ if (!/^\d+$/.test(flag)) {
25
+ throw new UsageError(`--wait takes a number of seconds, got "${flag}"`);
26
+ }
27
+ return Number(flag);
28
+ }
29
+
30
+ /**
31
+ * `juno approval wait <id>` picks up a wait a destructive command left: it
32
+ * reads the approval and, while a person has not decided it, waits as the
33
+ * command did, then prints how it ended and exits the same way.
34
+ */
35
+ export async function approvalWait(args, io) {
36
+ const [id, ...extra] = args._;
37
+ if (id === undefined || extra.length) {
38
+ throw new UsageError(`usage: ${APPROVAL_WAIT_USAGE}`);
39
+ }
40
+ const wait = approvalWaitSeconds(args.wait);
41
+ const { client, target } = await connect(args, io);
42
+ const a = await client.getApproval(id);
43
+ if (!a) throw new UserError(`no approval ${id}`);
44
+ const required = {
45
+ id: a.id,
46
+ url: approvalURL(`/approvals/${a.id}`, target),
47
+ summary: a.summary,
48
+ expires_at: a.expires_at,
49
+ };
50
+ return followApproval(required, {
51
+ client,
52
+ target,
53
+ wait,
54
+ io,
55
+ print: json(io),
56
+ });
57
+ }
58
+
59
+ /**
60
+ * followApproval is plan-cli-parity.md decision 11, and the one place the CLI
61
+ * interprets a result: a destructive tool answered `approval_required`, so
62
+ * it says on stderr what needs approving and where, then reads the approval
63
+ * until a person decided it or `wait` seconds ran out.
64
+ *
65
+ * - executed: the stored result, with `approval: {id, approved_by}` beside
66
+ * it, exit 0 — or 1 when the result is a refusal, as the tool's own would be
67
+ * - failed, denied, expired: `{"error": …}`, exit 1
68
+ * - still pending: the `approval_required` answer again, exit 5
69
+ * - Ctrl-C: exit 130, the request left open
70
+ *
71
+ * `print` writes one JSON value to stdout (a `--jq` filter applies to it).
72
+ */
73
+ export async function followApproval(
74
+ required,
75
+ { client, target, wait, io, print }
76
+ ) {
77
+ const id = required.id;
78
+ const url = approvalURL(required.url ?? `/approvals/${id}`, target);
79
+ const resume = `juno approval wait ${id}`;
80
+ io.warn(`Approval needed: ${required.summary}.`);
81
+ io.warn(`Approve or deny in Juno: ${url}`);
82
+
83
+ const interrupt = new AbortController();
84
+ const off = io.onInterrupt?.(() => interrupt.abort()) ?? (() => {});
85
+ try {
86
+ const now = io.now ?? Date.now;
87
+ const deadline = now() + wait * 1000;
88
+ let approval = await read(client, id, io);
89
+ if (approval?.status === 'pending' || approval?.status === 'approved') {
90
+ io.warn(
91
+ wait > 0
92
+ ? `waiting up to ${wait}s… (Ctrl-C leaves the request open: ${resume})`
93
+ : `not waiting (--wait 0); check later with: ${resume}`
94
+ );
95
+ }
96
+ while (!approval || !ended(approval.status)) {
97
+ const left = deadline - now();
98
+ if (left <= 0) break;
99
+ await sleep(Math.min(io.pollMs ?? POLL_MS, left), interrupt.signal);
100
+ if (interrupt.signal.aborted) {
101
+ io.warn(`stopped waiting; the request is still open: ${resume}`);
102
+ return EXIT_INTERRUPTED;
103
+ }
104
+ approval = (await read(client, id, io)) ?? approval;
105
+ }
106
+ if (!approval || !ended(approval.status)) {
107
+ await print({ approval_required: { ...required, url } });
108
+ io.warn(`still waiting for a person; keep waiting with: ${resume}`);
109
+ return EXIT_PENDING;
110
+ }
111
+ return outcome(approval, print);
112
+ } finally {
113
+ off();
114
+ }
115
+ }
116
+
117
+ /** The statuses an approval ends in; `approved` means its call is running. */
118
+ function ended(status) {
119
+ return ['executed', 'failed', 'denied', 'expired'].includes(status);
120
+ }
121
+
122
+ /**
123
+ * read is one GET of the approval. A dropped connection is not the end of the
124
+ * wait — the next read may well get through — so it answers null and warns;
125
+ * an approval that does not exist is.
126
+ */
127
+ async function read(client, id, io) {
128
+ try {
129
+ const a = await client.getApproval(id);
130
+ if (!a) throw new UserError(`no approval ${id}`);
131
+ return a;
132
+ } catch (err) {
133
+ if (!(err instanceof UnreachableError)) throw err;
134
+ io.warn(`${err.message}; still waiting`);
135
+ return null;
136
+ }
137
+ }
138
+
139
+ async function outcome(a, print) {
140
+ switch (a.status) {
141
+ case 'executed': {
142
+ await print(
143
+ withApproval(a.result, { id: a.id, approved_by: a.decided_by })
144
+ );
145
+ return typeof a.result?.error === 'string' ? EXIT_FAILURE : EXIT_OK;
146
+ }
147
+ case 'failed': {
148
+ // The tool refused (it answered an error, stored as the result), or the
149
+ // call could not run at all (the error alone).
150
+ const result =
151
+ a.result && typeof a.result === 'object' && !Array.isArray(a.result)
152
+ ? a.result
153
+ : { error: a.error };
154
+ if (typeof result.error !== 'string') result.error = a.error;
155
+ await print(
156
+ withApproval(result, {
157
+ id: a.id,
158
+ status: 'failed',
159
+ approved_by: a.decided_by,
160
+ })
161
+ );
162
+ return EXIT_FAILURE;
163
+ }
164
+ case 'denied':
165
+ await print({
166
+ error: `denied by ${a.decided_by}`,
167
+ approval: { id: a.id, status: 'denied' },
168
+ });
169
+ return EXIT_FAILURE;
170
+ default:
171
+ await print({
172
+ error: 'expired: nobody approved it within 24 hours',
173
+ approval: { id: a.id, status: 'expired' },
174
+ });
175
+ return EXIT_FAILURE;
176
+ }
177
+ }
178
+
179
+ /** The result with the approval beside it; a non-object result is wrapped. */
180
+ function withApproval(result, approval) {
181
+ if (result && typeof result === 'object' && !Array.isArray(result)) {
182
+ return { ...result, approval };
183
+ }
184
+ return { result: result ?? null, approval };
185
+ }
186
+
187
+ /**
188
+ * The page an approval is decided on: the server's link, which is a bare path
189
+ * while the instance has no public URL — completed against the instance this
190
+ * CLI reached.
191
+ */
192
+ export function approvalURL(url, target) {
193
+ return url.startsWith('/')
194
+ ? `${String(target).replace(/\/+$/, '')}${url}`
195
+ : url;
196
+ }
197
+
198
+ /** json prints a value as the CLI prints any result: indented on a TTY. */
199
+ export function json(io) {
200
+ return async (value) =>
201
+ io.log(io.isTTY ? JSON.stringify(value, null, 2) : JSON.stringify(value));
202
+ }
203
+
204
+ function sleep(ms, signal) {
205
+ return new Promise((resolve) => {
206
+ if (signal.aborted) return resolve();
207
+ const t = setTimeout(done, ms);
208
+ function done() {
209
+ clearTimeout(t);
210
+ signal.removeEventListener('abort', done);
211
+ resolve();
212
+ }
213
+ signal.addEventListener('abort', done);
214
+ });
215
+ }
@@ -45,7 +45,7 @@ export async function flowInit(argv, io) {
45
45
  io.log(argv.here ? 'npm install' : `cd ${id} && npm install`);
46
46
  io.log('npm test');
47
47
  io.log('npm run deploy');
48
- await printAuthoringIndex('flow', client, target, io);
48
+ await printAuthoringIndex('flow', client, io);
49
49
  return 0;
50
50
  }
51
51