@junoflow/cli 0.13.0 → 0.15.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 +55 -19
- package/app-client.js +95 -9
- package/package.json +2 -2
- package/src/commands/{job-run.js → task-run.js} +116 -24
- package/src/exit.js +1 -1
- package/src/index.js +23 -4
- package/verbs.json +48 -5
package/README.md
CHANGED
|
@@ -165,9 +165,18 @@ The group's working-copy commands (`integration init`, `gen-types`,
|
|
|
165
165
|
|
|
166
166
|
**`finding`**
|
|
167
167
|
|
|
168
|
-
| Command
|
|
169
|
-
|
|
|
170
|
-
| `juno finding
|
|
168
|
+
| Command | Tool | Positional | Effect |
|
|
169
|
+
| -------------------------- | --------------------- | ---------- | ------ |
|
|
170
|
+
| `juno finding list` | `list_findings` | — | read |
|
|
171
|
+
| `juno finding get` | `get_finding` | `slug` | read |
|
|
172
|
+
| `juno finding get-history` | `get_finding_history` | `slug` | read |
|
|
173
|
+
| `juno finding dismiss` | `dismiss_finding` | `slug` | act |
|
|
174
|
+
|
|
175
|
+
`finding list` shows open findings of every owner — flows, integrations,
|
|
176
|
+
devices, states and tasks — each naming its source as `owner_kind`, `owner` and
|
|
177
|
+
`owner_name`; `--status all` adds cleared ones, and `--owner-kind`, `--owner`
|
|
178
|
+
and `--severity` (a floor) narrow it. `get-history` and `dismiss` take `--key`
|
|
179
|
+
for a keyed finding; `get-history` also takes `--limit`.
|
|
171
180
|
|
|
172
181
|
**`webhook`**
|
|
173
182
|
|
|
@@ -178,14 +187,22 @@ The group's working-copy commands (`integration init`, `gen-types`,
|
|
|
178
187
|
| `juno webhook write` | `write_webhook` | `id` | change |
|
|
179
188
|
| `juno webhook fire` | `fire_webhook` | `id` | act |
|
|
180
189
|
|
|
181
|
-
**`
|
|
190
|
+
**`task`** (tasks)
|
|
182
191
|
|
|
183
|
-
| Command
|
|
184
|
-
|
|
|
185
|
-
| `juno
|
|
186
|
-
| `juno
|
|
187
|
-
| `juno
|
|
188
|
-
| `juno
|
|
192
|
+
| Command | Tool | Positional | Effect |
|
|
193
|
+
| ------------------------------------- | --------------- | ---------- | ------ |
|
|
194
|
+
| `juno task list` | `list_tasks` | — | read |
|
|
195
|
+
| `juno task get-runs` | `get_task_runs` | `id` | read |
|
|
196
|
+
| `juno task write` | `write_task` | `id` | change |
|
|
197
|
+
| `juno task delete` | `delete_task` | `id` | act |
|
|
198
|
+
| `juno task run [<url>] -- <command>…` | none | ping URL | act |
|
|
199
|
+
| `juno task ping [<url>]` | none | ping URL | act |
|
|
200
|
+
|
|
201
|
+
**`series`**
|
|
202
|
+
|
|
203
|
+
| Command | Tool | Positional | Effect |
|
|
204
|
+
| ----------------- | ------------ | ---------- | ------ |
|
|
205
|
+
| `juno series get` | `get_series` | `id` | read |
|
|
189
206
|
|
|
190
207
|
**`credential`**
|
|
191
208
|
|
|
@@ -336,15 +353,15 @@ exits 1 without sending anything otherwise. Any other message sent to a session
|
|
|
336
353
|
waiting for an approval denies it, and the assistant sees the message as the
|
|
337
354
|
reason.
|
|
338
355
|
|
|
339
|
-
### `juno
|
|
356
|
+
### `juno task run`
|
|
340
357
|
|
|
341
|
-
Wraps a scheduled command so its runs are reported to
|
|
342
|
-
(`juno
|
|
358
|
+
Wraps a scheduled command so its runs are reported to a task's ping URL
|
|
359
|
+
(`juno task write` answers it as `url`):
|
|
343
360
|
|
|
344
361
|
```bash
|
|
345
|
-
juno
|
|
346
|
-
juno
|
|
347
|
-
|
|
362
|
+
juno task run https://juno.example/ping/<token> -- ./backup.sh --full
|
|
363
|
+
juno task run --url-file .secrets/juno-tasks/backup -- ./backup.sh --full
|
|
364
|
+
JUNO_TASK_URL=https://juno.example/ping/<token> juno task run -- python3 check.py
|
|
348
365
|
```
|
|
349
366
|
|
|
350
367
|
The command runs exactly as it would unwrapped: same stdin, the same stdout and
|
|
@@ -353,14 +370,33 @@ signal `N`; 127, or 126 for a file that is not executable, when it cannot be
|
|
|
353
370
|
started). Everything after `--` is the command's, flags included. Juno gets a
|
|
354
371
|
`start`, then the exit status with the last 64 KiB of stdout and stderr, paired
|
|
355
372
|
by a run id. The URL comes from the argument, else `--url-file` (keeps the token
|
|
356
|
-
out of `ps`), else `$
|
|
373
|
+
out of `ps`), else `$JUNO_TASK_URL`; nothing else is read — no instance config,
|
|
357
374
|
no API key. A report that fails is retried once and then dropped silently
|
|
358
375
|
(`--verbose` prints one line): Juno turns a run it never heard about into an
|
|
359
376
|
overdue finding by itself. `SIGINT`, `SIGTERM` and `SIGHUP` are passed on to the
|
|
360
377
|
command.
|
|
361
378
|
|
|
362
|
-
|
|
363
|
-
|
|
379
|
+
The wrapper never times a command out: a run that hangs stays open, and the task
|
|
380
|
+
goes down once its start plus grace has passed. A wrapper killed outright leaves
|
|
381
|
+
the run open the same way.
|
|
382
|
+
|
|
383
|
+
### `juno task ping`
|
|
384
|
+
|
|
385
|
+
Sends one report, for a task with no command to wrap:
|
|
386
|
+
|
|
387
|
+
```bash
|
|
388
|
+
juno task ping --url-file .secrets/juno-tasks/digest # success
|
|
389
|
+
juno task ping --url-file .secrets/juno-tasks/digest --start --rid r1
|
|
390
|
+
juno task ping --url-file .secrets/juno-tasks/digest --exit 3 --rid r1
|
|
391
|
+
juno task ping --url-file .secrets/juno-tasks/digest --fail
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
The URL comes from the same three places as `juno task run`'s. `--log` sends a
|
|
395
|
+
log report, and `--rid` pairs a start with its finish. A report that still fails
|
|
396
|
+
after one retry exits 1 with the reason.
|
|
397
|
+
|
|
398
|
+
See [Tasks](https://boivie.github.io/juno/tasks/) for creating the task, the
|
|
399
|
+
ping protocol, and systemd and crontab examples.
|
|
364
400
|
|
|
365
401
|
### `juno credential set`
|
|
366
402
|
|
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.15.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.15.0",
|
|
44
44
|
"@typescript-eslint/parser": "^8.0.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
@@ -6,9 +6,10 @@ import { resolve } from 'node:path';
|
|
|
6
6
|
import { parseArgs } from 'node:util';
|
|
7
7
|
|
|
8
8
|
import { UsageError } from '../errors.js';
|
|
9
|
+
import { EXIT_FAILURE, EXIT_OK } from '../exit.js';
|
|
9
10
|
|
|
10
|
-
export const
|
|
11
|
-
'juno
|
|
11
|
+
export const TASK_RUN_USAGE =
|
|
12
|
+
'juno task run [<url>] [--url-file <path>] [--verbose] -- <command> [args…]';
|
|
12
13
|
|
|
13
14
|
/** The tail of each stream a run reports; Juno keeps the same. */
|
|
14
15
|
export const TAIL_BYTES = 64 << 10;
|
|
@@ -23,7 +24,7 @@ const DEFAULT_TIMEOUTS = {
|
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
|
-
* `juno
|
|
27
|
+
* `juno task run` runs a command and reports it to a task's ping URL
|
|
27
28
|
* (plan-external-jobs-2-wrapper-and-tools.md, Decisions 3–5). Its first rule
|
|
28
29
|
* is transparency: the command gets the same stdin, and the wrapper's stdout,
|
|
29
30
|
* stderr and exit status are the command's, byte for byte — a scheduler such
|
|
@@ -33,11 +34,11 @@ const DEFAULT_TIMEOUTS = {
|
|
|
33
34
|
* on its own, so the wrapper never queues and never fails the command.
|
|
34
35
|
*
|
|
35
36
|
* It reads no Juno config and no API key; the URL is all it needs. argv is
|
|
36
|
-
* everything after `
|
|
37
|
+
* everything after `task run`, `--` included. The exit code is returned, not
|
|
37
38
|
* passed to process.exit, so stdout is flushed before the process ends.
|
|
38
39
|
* deps is injectable for the tests; the default binds the real ones.
|
|
39
40
|
*/
|
|
40
|
-
export async function
|
|
41
|
+
export async function taskRun(argv, io, deps = {}) {
|
|
41
42
|
const {
|
|
42
43
|
spawn = nodeSpawn,
|
|
43
44
|
fetch = globalThis.fetch,
|
|
@@ -64,20 +65,26 @@ export async function jobRun(argv, io, deps = {}) {
|
|
|
64
65
|
});
|
|
65
66
|
} catch (err) {
|
|
66
67
|
throw new UsageError(
|
|
67
|
-
`juno
|
|
68
|
+
`juno task run: ${err.message}\nusage: ${TASK_RUN_USAGE}`
|
|
68
69
|
);
|
|
69
70
|
}
|
|
70
71
|
if (command.length === 0) {
|
|
71
72
|
throw new UsageError(
|
|
72
|
-
`juno
|
|
73
|
+
`juno task run: missing -- <command>\nusage: ${TASK_RUN_USAGE}`
|
|
73
74
|
);
|
|
74
75
|
}
|
|
75
76
|
if (parsed.positionals.length > 1) {
|
|
76
77
|
throw new UsageError(
|
|
77
|
-
`juno
|
|
78
|
+
`juno task run: one URL before --, got ${parsed.positionals.length} arguments\nusage: ${TASK_RUN_USAGE}`
|
|
78
79
|
);
|
|
79
80
|
}
|
|
80
|
-
const url = await pingURL(
|
|
81
|
+
const url = await pingURL('task run', TASK_RUN_USAGE, {
|
|
82
|
+
positional: parsed.positionals[0],
|
|
83
|
+
file: parsed.values['url-file'],
|
|
84
|
+
env,
|
|
85
|
+
readFile,
|
|
86
|
+
cwd: io.cwd,
|
|
87
|
+
});
|
|
81
88
|
const verbose = parsed.values.verbose;
|
|
82
89
|
|
|
83
90
|
const rid = randomUUID();
|
|
@@ -100,7 +107,11 @@ export async function jobRun(argv, io, deps = {}) {
|
|
|
100
107
|
let suffix;
|
|
101
108
|
let exitCode;
|
|
102
109
|
if (outcome.startError) {
|
|
103
|
-
|
|
110
|
+
// With no shell, `FOO=bar cmd` names an executable called "FOO=bar".
|
|
111
|
+
const line =
|
|
112
|
+
outcome.startError.code === 'ENOENT' && command[0].includes('=')
|
|
113
|
+
? `juno task run: "${command[0]}" looks like an env assignment; write -- env ${command.join(' ')}`
|
|
114
|
+
: `juno task run: ${command[0]}: ${outcome.startError.message}`;
|
|
104
115
|
io.error(line);
|
|
105
116
|
err.push(Buffer.from(`${line}\n`));
|
|
106
117
|
suffix = 'fail';
|
|
@@ -129,46 +140,55 @@ export async function jobRun(argv, io, deps = {}) {
|
|
|
129
140
|
failure = await finish();
|
|
130
141
|
}
|
|
131
142
|
if (failure && verbose) {
|
|
132
|
-
io.error(`juno
|
|
143
|
+
io.error(`juno task run: could not report to Juno: ${failure}`);
|
|
133
144
|
}
|
|
134
145
|
return exitCode;
|
|
135
146
|
}
|
|
136
147
|
|
|
137
148
|
/**
|
|
138
149
|
* pingURL is the URL from the first source that yields one: the positional,
|
|
139
|
-
* then --url-file (its contents, trimmed), then $
|
|
150
|
+
* then --url-file (its contents, trimmed), then $JUNO_TASK_URL. The two
|
|
140
151
|
* indirect sources keep the token off the command line, where `ps` and the
|
|
141
|
-
* scheduler's job definition would show it.
|
|
152
|
+
* scheduler's job definition would show it. command and usage name the verb
|
|
153
|
+
* in its refusals.
|
|
142
154
|
*/
|
|
143
|
-
async function pingURL(
|
|
144
|
-
|
|
155
|
+
async function pingURL(
|
|
156
|
+
command,
|
|
157
|
+
usage,
|
|
158
|
+
{ positional, file, env, readFile, cwd = '.' }
|
|
159
|
+
) {
|
|
145
160
|
if (positional?.trim()) return positional.trim();
|
|
146
|
-
const file = parsed.values['url-file'];
|
|
147
161
|
if (file !== undefined) {
|
|
148
162
|
let text;
|
|
149
163
|
try {
|
|
150
164
|
text = await readFile(resolve(cwd, file), 'utf8');
|
|
151
165
|
} catch (err) {
|
|
152
|
-
throw new UsageError(
|
|
166
|
+
throw new UsageError(
|
|
167
|
+
`juno ${command}: --url-file ${file}: ${err.message}`
|
|
168
|
+
);
|
|
153
169
|
}
|
|
154
170
|
if (text.trim()) return text.trim();
|
|
155
171
|
}
|
|
156
|
-
if (env.
|
|
172
|
+
if (env.JUNO_TASK_URL?.trim()) return env.JUNO_TASK_URL.trim();
|
|
157
173
|
throw new UsageError(
|
|
158
|
-
`juno
|
|
174
|
+
`juno ${command}: no ping URL — pass it, --url-file or JUNO_TASK_URL\nusage: ${usage}`
|
|
159
175
|
);
|
|
160
176
|
}
|
|
161
177
|
|
|
162
178
|
/**
|
|
163
179
|
* endpoint is `<url>/<suffix>?rid=<rid>`, used as given otherwise: no /ping/
|
|
164
|
-
* check, so a Healthchecks URL works too.
|
|
165
|
-
* kept
|
|
180
|
+
* check, so a Healthchecks URL works too. An empty suffix is a success, which
|
|
181
|
+
* is the bare URL. A query the URL already carries is kept; without a rid
|
|
182
|
+
* there is no `rid` parameter.
|
|
166
183
|
*/
|
|
167
184
|
export function endpoint(url, suffix, rid) {
|
|
168
185
|
const q = url.indexOf('?');
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
|
|
186
|
+
const base = (q === -1 ? url : url.slice(0, q)).replace(/\/+$/, '');
|
|
187
|
+
const path = suffix ? `${base}/${suffix}` : base;
|
|
188
|
+
const params = [];
|
|
189
|
+
if (q !== -1 && q < url.length - 1) params.push(url.slice(q + 1));
|
|
190
|
+
if (rid) params.push(`rid=${encodeURIComponent(rid)}`);
|
|
191
|
+
return params.length ? `${path}?${params.join('&')}` : path;
|
|
172
192
|
}
|
|
173
193
|
|
|
174
194
|
/**
|
|
@@ -274,3 +294,75 @@ export class Tail {
|
|
|
274
294
|
return Buffer.concat(this.#chunks, this.#size);
|
|
275
295
|
}
|
|
276
296
|
}
|
|
297
|
+
|
|
298
|
+
export const TASK_PING_USAGE =
|
|
299
|
+
'juno task ping [<url>] [--url-file <path>] [--start | --fail | --exit <0-255> | --log] [--rid <id>]';
|
|
300
|
+
|
|
301
|
+
const PING_KINDS = ['start', 'fail', 'exit', 'log'];
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* `juno task ping` sends one report to a task's ping URL, for a task
|
|
305
|
+
* `task run` cannot wrap — one whose scheduler runs no command, or whose start
|
|
306
|
+
* and finish happen in different places. Without a flag it reports success;
|
|
307
|
+
* `--start`, `--fail`, `--exit <n>` and `--log` send the other reports, and
|
|
308
|
+
* `--rid` pairs a start with its finish. It sends no output.
|
|
309
|
+
*
|
|
310
|
+
* It finds the URL as `task run` does and, like it, reads no Juno config and no
|
|
311
|
+
* API key. Unlike `task run` it has nothing else to do, so a report that still
|
|
312
|
+
* fails after one retry is exit 1 with the reason, not silence: the caller
|
|
313
|
+
* asked for exactly this and can `|| true` it.
|
|
314
|
+
*/
|
|
315
|
+
export async function taskPing(args, io, deps = {}) {
|
|
316
|
+
const {
|
|
317
|
+
fetch = globalThis.fetch,
|
|
318
|
+
env = process.env,
|
|
319
|
+
readFile = nodeReadFile,
|
|
320
|
+
timeouts = DEFAULT_TIMEOUTS,
|
|
321
|
+
} = deps;
|
|
322
|
+
const usage = (msg) =>
|
|
323
|
+
new UsageError(`juno task ping: ${msg}\nusage: ${TASK_PING_USAGE}`);
|
|
324
|
+
|
|
325
|
+
if (args._.length > 1) {
|
|
326
|
+
throw usage(`one URL, got ${args._.length} arguments`);
|
|
327
|
+
}
|
|
328
|
+
const kinds = PING_KINDS.filter(
|
|
329
|
+
(k) => args[k] !== undefined && args[k] !== false
|
|
330
|
+
);
|
|
331
|
+
if (kinds.length > 1) {
|
|
332
|
+
throw usage(`--${kinds.join(' and --')} are different reports; pick one`);
|
|
333
|
+
}
|
|
334
|
+
// A success is the bare URL: the protocol has no /success suffix.
|
|
335
|
+
let suffix = '';
|
|
336
|
+
if (kinds[0] === 'exit') {
|
|
337
|
+
const n = args.exit.trim();
|
|
338
|
+
if (!/^\d{1,3}$/.test(n) || Number(n) > 255) {
|
|
339
|
+
throw usage(`--exit takes a status from 0 to 255, got "${args.exit}"`);
|
|
340
|
+
}
|
|
341
|
+
suffix = String(Number(n));
|
|
342
|
+
} else if (kinds[0]) {
|
|
343
|
+
suffix = kinds[0];
|
|
344
|
+
}
|
|
345
|
+
const url = await pingURL('task ping', TASK_PING_USAGE, {
|
|
346
|
+
positional: args._[0],
|
|
347
|
+
file: args['url-file'],
|
|
348
|
+
env,
|
|
349
|
+
readFile,
|
|
350
|
+
cwd: io.cwd,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const send = () =>
|
|
354
|
+
report(fetch, endpoint(url, suffix, args.rid), {
|
|
355
|
+
method: 'GET',
|
|
356
|
+
timeout: timeouts.finish,
|
|
357
|
+
});
|
|
358
|
+
let failure = await send();
|
|
359
|
+
if (failure) {
|
|
360
|
+
await new Promise((resolve) => setTimeout(resolve, timeouts.retryDelay));
|
|
361
|
+
failure = await send();
|
|
362
|
+
}
|
|
363
|
+
if (failure) {
|
|
364
|
+
io.error(`juno task ping: could not report to Juno: ${failure}`);
|
|
365
|
+
return EXIT_FAILURE;
|
|
366
|
+
}
|
|
367
|
+
return EXIT_OK;
|
|
368
|
+
}
|
package/src/exit.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* 5 `juno ask` / `session approve`: the turn is still running (pending)
|
|
14
14
|
* 6 `juno ask` / `session approve`: the assistant is awaiting input
|
|
15
15
|
*
|
|
16
|
-
* `juno
|
|
16
|
+
* `juno task run` is the exception once its own arguments are accepted: it
|
|
17
17
|
* exits with the wrapped command's status, as a shell would report it.
|
|
18
18
|
*/
|
|
19
19
|
export const EXIT_OK = 0;
|
package/src/index.js
CHANGED
|
@@ -29,7 +29,12 @@ import { FLOW_INIT_USAGE, flowInit } from './commands/flow-init.js';
|
|
|
29
29
|
import { FLOW_PULL_USAGE, flowPull } from './commands/flow-pull.js';
|
|
30
30
|
import { GEN_TYPES_USAGE, genTypes } from './commands/gen-types.js';
|
|
31
31
|
import { INIT_USAGE, init } from './commands/init.js';
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
TASK_PING_USAGE,
|
|
34
|
+
TASK_RUN_USAGE,
|
|
35
|
+
taskPing,
|
|
36
|
+
taskRun,
|
|
37
|
+
} from './commands/task-run.js';
|
|
33
38
|
import { PULL_USAGE, pull } from './commands/pull.js';
|
|
34
39
|
import { SKILL_USAGE, skill } from './commands/skill.js';
|
|
35
40
|
import {
|
|
@@ -106,15 +111,29 @@ const NON_TOOL = {
|
|
|
106
111
|
// raw: everything after `--` is the wrapped command's, verbatim, so the
|
|
107
112
|
// argv goes to the handler unparsed (see runRow). No --url or --token: the
|
|
108
113
|
// wrapper reads no Juno config, only the ping URL.
|
|
109
|
-
'
|
|
110
|
-
run: (argv, io) =>
|
|
114
|
+
'task run': {
|
|
115
|
+
run: (argv, io) => taskRun(argv, io, { env: io.env }),
|
|
111
116
|
options: {
|
|
112
117
|
'url-file': { type: 'string' },
|
|
113
118
|
verbose: { type: 'boolean', default: false },
|
|
114
119
|
},
|
|
115
|
-
usage:
|
|
120
|
+
usage: TASK_RUN_USAGE,
|
|
116
121
|
raw: true,
|
|
117
122
|
},
|
|
123
|
+
// No --url or --token either: one report to the ping URL, found as task run
|
|
124
|
+
// finds it.
|
|
125
|
+
'task ping': {
|
|
126
|
+
run: (args, io) => taskPing(args, io, { env: io.env }),
|
|
127
|
+
options: {
|
|
128
|
+
'url-file': { type: 'string' },
|
|
129
|
+
start: { type: 'boolean' },
|
|
130
|
+
fail: { type: 'boolean' },
|
|
131
|
+
exit: { type: 'string' },
|
|
132
|
+
log: { type: 'boolean' },
|
|
133
|
+
rid: { type: 'string' },
|
|
134
|
+
},
|
|
135
|
+
usage: TASK_PING_USAGE,
|
|
136
|
+
},
|
|
118
137
|
'config set': { run: configSet, options: {}, usage: CONFIG_SET_USAGE },
|
|
119
138
|
'agent-skill install': {
|
|
120
139
|
run: agentSkillInstall,
|
package/verbs.json
CHANGED
|
@@ -212,7 +212,32 @@
|
|
|
212
212
|
"working_copy": true,
|
|
213
213
|
"summary": "Serve this app locally on mocked data"
|
|
214
214
|
},
|
|
215
|
+
{
|
|
216
|
+
"command": "finding list",
|
|
217
|
+
"tool": "list_findings",
|
|
218
|
+
"flags": {
|
|
219
|
+
"status": { "field": "status", "type": "string" },
|
|
220
|
+
"owner-kind": { "field": "owner_kind", "type": "string" },
|
|
221
|
+
"owner": { "field": "owner", "type": "string" },
|
|
222
|
+
"severity": { "field": "severity", "type": "string" }
|
|
223
|
+
}
|
|
224
|
+
},
|
|
215
225
|
{ "command": "finding get", "tool": "get_finding", "id_field": "slug" },
|
|
226
|
+
{
|
|
227
|
+
"command": "finding get-history",
|
|
228
|
+
"tool": "get_finding_history",
|
|
229
|
+
"id_field": "slug",
|
|
230
|
+
"flags": {
|
|
231
|
+
"key": { "field": "key", "type": "string" },
|
|
232
|
+
"limit": { "field": "limit", "type": "integer" }
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
"command": "finding dismiss",
|
|
237
|
+
"tool": "dismiss_finding",
|
|
238
|
+
"id_field": "slug",
|
|
239
|
+
"flags": { "key": { "field": "key", "type": "string" } }
|
|
240
|
+
},
|
|
216
241
|
{ "command": "webhook list", "tool": "list_webhooks" },
|
|
217
242
|
{
|
|
218
243
|
"command": "webhook get-deliveries",
|
|
@@ -221,9 +246,12 @@
|
|
|
221
246
|
},
|
|
222
247
|
{ "command": "webhook write", "tool": "write_webhook", "id_field": "id" },
|
|
223
248
|
{ "command": "webhook fire", "tool": "fire_webhook", "id_field": "id" },
|
|
224
|
-
{ "command": "
|
|
225
|
-
{ "command": "
|
|
226
|
-
{ "command": "
|
|
249
|
+
{ "command": "task list", "tool": "list_tasks" },
|
|
250
|
+
{ "command": "task get-runs", "tool": "get_task_runs", "id_field": "id" },
|
|
251
|
+
{ "command": "task write", "tool": "write_task", "id_field": "id" },
|
|
252
|
+
{ "command": "task delete", "tool": "delete_task", "id_field": "id" },
|
|
253
|
+
{ "command": "task command", "tool": "command_task", "id_field": "id" },
|
|
254
|
+
{ "command": "series get", "tool": "get_series", "id_field": "id" },
|
|
227
255
|
{ "command": "credential list", "tool": "list_credentials" },
|
|
228
256
|
{ "command": "person list", "tool": "list_people" },
|
|
229
257
|
{ "command": "person get", "tool": "get_person", "id_field": "id" },
|
|
@@ -239,6 +267,16 @@
|
|
|
239
267
|
"id_field": "run_id",
|
|
240
268
|
"flags": { "handle": { "field": "handle_id", "type": "string" } }
|
|
241
269
|
},
|
|
270
|
+
{
|
|
271
|
+
"command": "question answer",
|
|
272
|
+
"tool": "answer_question",
|
|
273
|
+
"id_field": "run_id",
|
|
274
|
+
"flags": {
|
|
275
|
+
"handle": { "field": "handle_id", "type": "string" },
|
|
276
|
+
"answer": { "field": "answer", "type": "string" },
|
|
277
|
+
"for": { "field": "for", "type": "string" }
|
|
278
|
+
}
|
|
279
|
+
},
|
|
242
280
|
{
|
|
243
281
|
"command": "ha get-entity",
|
|
244
282
|
"tool": "get_entity",
|
|
@@ -275,9 +313,14 @@
|
|
|
275
313
|
"summary": "Approve the tool call an assistant session is waiting on"
|
|
276
314
|
},
|
|
277
315
|
{
|
|
278
|
-
"command": "
|
|
316
|
+
"command": "task run",
|
|
317
|
+
"effect": "act",
|
|
318
|
+
"summary": "Run a command and report it to a task's ping URL"
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
"command": "task ping",
|
|
279
322
|
"effect": "act",
|
|
280
|
-
"summary": "
|
|
323
|
+
"summary": "Send one report (success, start, fail, an exit status or a log) to a task's ping URL"
|
|
281
324
|
},
|
|
282
325
|
{
|
|
283
326
|
"command": "credential set",
|