@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/README.md +310 -117
- package/app-client.js +95 -9
- package/package.json +2 -2
- package/src/api.js +154 -15
- package/src/authoring-skills.js +2 -6
- package/src/commands/app-init.js +1 -1
- package/src/commands/approval-wait.js +215 -0
- package/src/commands/flow-init.js +1 -1
- package/src/commands/init.js +1 -1
- package/src/commands/permissions.js +47 -0
- package/src/commands/run-follow.js +68 -0
- package/src/commands/session-approve.js +22 -3
- package/src/commands/session.js +103 -0
- package/src/commands/skill.js +28 -19
- package/src/commands/{job-run.js → task-run.js} +36 -31
- package/src/commands/tool.js +23 -5
- package/src/commands/whoami.js +38 -0
- package/src/exit.js +6 -2
- package/src/help.js +143 -22
- package/src/index.js +127 -60
- package/src/prompt.js +9 -0
- package/src/verbs.js +6 -1
- package/verbs.json +435 -13
- package/src/commands/agent-skill-install.js +0 -64
- package/src/installed-skill.js +0 -55
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
|
|
18
|
-
* {states: {<b>: value}, controls: {<b>: {value, active, expires_at}}
|
|
19
|
-
*
|
|
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>";
|
|
24
|
-
* snapshot). Registered after a snapshot, cb is called at
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
310
|
-
*
|
|
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-
|
|
322
|
-
*
|
|
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
|
|
326
|
-
return this.#json('GET', '/api/agent-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/authoring-skills.js
CHANGED
|
@@ -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.
|
|
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,
|
|
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
|
}
|
package/src/commands/app-init.js
CHANGED
|
@@ -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,
|
|
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,
|
|
48
|
+
await printAuthoringIndex('flow', client, io);
|
|
49
49
|
return 0;
|
|
50
50
|
}
|
|
51
51
|
|