@junoflow/cli 0.13.0 → 0.14.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 +21 -0
- package/package.json +2 -2
- package/src/commands/job-run.js +97 -10
- package/src/index.js +20 -1
- package/verbs.json +6 -0
package/README.md
CHANGED
|
@@ -185,7 +185,9 @@ The group's working-copy commands (`integration init`, `gen-types`,
|
|
|
185
185
|
| `juno job list` | `list_jobs` | — | read |
|
|
186
186
|
| `juno job get-runs` | `get_job_runs` | `id` | read |
|
|
187
187
|
| `juno job write` | `write_job` | `id` | change |
|
|
188
|
+
| `juno job delete` | `delete_job` | `id` | act |
|
|
188
189
|
| `juno job run [<url>] -- <command>…` | none | ping URL | act |
|
|
190
|
+
| `juno job ping [<url>]` | none | ping URL | act |
|
|
189
191
|
|
|
190
192
|
**`credential`**
|
|
191
193
|
|
|
@@ -359,6 +361,25 @@ no API key. A report that fails is retried once and then dropped silently
|
|
|
359
361
|
overdue finding by itself. `SIGINT`, `SIGTERM` and `SIGHUP` are passed on to the
|
|
360
362
|
command.
|
|
361
363
|
|
|
364
|
+
The wrapper never times a command out: a run that hangs stays open, and the job
|
|
365
|
+
goes down once its start plus grace has passed. A wrapper killed outright leaves
|
|
366
|
+
the run open the same way.
|
|
367
|
+
|
|
368
|
+
### `juno job ping`
|
|
369
|
+
|
|
370
|
+
Sends one report, for a task with no command to wrap:
|
|
371
|
+
|
|
372
|
+
```bash
|
|
373
|
+
juno job ping --url-file .secrets/juno-jobs/digest # success
|
|
374
|
+
juno job ping --url-file .secrets/juno-jobs/digest --start --rid r1
|
|
375
|
+
juno job ping --url-file .secrets/juno-jobs/digest --exit 3 --rid r1
|
|
376
|
+
juno job ping --url-file .secrets/juno-jobs/digest --fail
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
The URL comes from the same three places as `juno job run`'s. `--log` sends a
|
|
380
|
+
log report, and `--rid` pairs a start with its finish. A report that still fails
|
|
381
|
+
after one retry exits 1 with the reason.
|
|
382
|
+
|
|
362
383
|
See [External jobs](https://boivie.github.io/juno/external-jobs/) for creating
|
|
363
384
|
the job, the ping protocol, and systemd and crontab examples.
|
|
364
385
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junoflow/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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.14.0",
|
|
44
44
|
"@typescript-eslint/parser": "^8.0.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
package/src/commands/job-run.js
CHANGED
|
@@ -6,6 +6,7 @@ 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
11
|
export const JOB_RUN_USAGE =
|
|
11
12
|
'juno job run [<url>] [--url-file <path>] [--verbose] -- <command> [args…]';
|
|
@@ -77,7 +78,13 @@ export async function jobRun(argv, io, deps = {}) {
|
|
|
77
78
|
`juno job run: one URL before --, got ${parsed.positionals.length} arguments\nusage: ${JOB_RUN_USAGE}`
|
|
78
79
|
);
|
|
79
80
|
}
|
|
80
|
-
const url = await pingURL(
|
|
81
|
+
const url = await pingURL('job run', JOB_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();
|
|
@@ -138,37 +145,46 @@ export async function jobRun(argv, io, deps = {}) {
|
|
|
138
145
|
* pingURL is the URL from the first source that yields one: the positional,
|
|
139
146
|
* then --url-file (its contents, trimmed), then $JUNO_JOB_URL. The two
|
|
140
147
|
* indirect sources keep the token off the command line, where `ps` and the
|
|
141
|
-
* scheduler's job definition would show it.
|
|
148
|
+
* scheduler's job definition would show it. command and usage name the verb
|
|
149
|
+
* in its refusals.
|
|
142
150
|
*/
|
|
143
|
-
async function pingURL(
|
|
144
|
-
|
|
151
|
+
async function pingURL(
|
|
152
|
+
command,
|
|
153
|
+
usage,
|
|
154
|
+
{ positional, file, env, readFile, cwd = '.' }
|
|
155
|
+
) {
|
|
145
156
|
if (positional?.trim()) return positional.trim();
|
|
146
|
-
const file = parsed.values['url-file'];
|
|
147
157
|
if (file !== undefined) {
|
|
148
158
|
let text;
|
|
149
159
|
try {
|
|
150
160
|
text = await readFile(resolve(cwd, file), 'utf8');
|
|
151
161
|
} catch (err) {
|
|
152
|
-
throw new UsageError(
|
|
162
|
+
throw new UsageError(
|
|
163
|
+
`juno ${command}: --url-file ${file}: ${err.message}`
|
|
164
|
+
);
|
|
153
165
|
}
|
|
154
166
|
if (text.trim()) return text.trim();
|
|
155
167
|
}
|
|
156
168
|
if (env.JUNO_JOB_URL?.trim()) return env.JUNO_JOB_URL.trim();
|
|
157
169
|
throw new UsageError(
|
|
158
|
-
`juno
|
|
170
|
+
`juno ${command}: no ping URL — pass it, --url-file or JUNO_JOB_URL\nusage: ${usage}`
|
|
159
171
|
);
|
|
160
172
|
}
|
|
161
173
|
|
|
162
174
|
/**
|
|
163
175
|
* endpoint is `<url>/<suffix>?rid=<rid>`, used as given otherwise: no /ping/
|
|
164
176
|
* check, so a Healthchecks URL works too. A query the URL already carries is
|
|
165
|
-
* kept.
|
|
177
|
+
* kept; without a rid there is no `rid` parameter.
|
|
166
178
|
*/
|
|
167
179
|
export function endpoint(url, suffix, rid) {
|
|
168
180
|
const q = url.indexOf('?');
|
|
169
181
|
const path = (q === -1 ? url : url.slice(0, q)).replace(/\/+$/, '');
|
|
170
|
-
const
|
|
171
|
-
|
|
182
|
+
const params = [];
|
|
183
|
+
if (q !== -1 && q < url.length - 1) params.push(url.slice(q + 1));
|
|
184
|
+
if (rid) params.push(`rid=${encodeURIComponent(rid)}`);
|
|
185
|
+
return params.length
|
|
186
|
+
? `${path}/${suffix}?${params.join('&')}`
|
|
187
|
+
: `${path}/${suffix}`;
|
|
172
188
|
}
|
|
173
189
|
|
|
174
190
|
/**
|
|
@@ -274,3 +290,74 @@ export class Tail {
|
|
|
274
290
|
return Buffer.concat(this.#chunks, this.#size);
|
|
275
291
|
}
|
|
276
292
|
}
|
|
293
|
+
|
|
294
|
+
export const JOB_PING_USAGE =
|
|
295
|
+
'juno job ping [<url>] [--url-file <path>] [--start | --fail | --exit <0-255> | --log] [--rid <id>]';
|
|
296
|
+
|
|
297
|
+
const PING_KINDS = ['start', 'fail', 'exit', 'log'];
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* `juno job ping` sends one report to an external job's ping URL, for a task
|
|
301
|
+
* `job run` cannot wrap — one whose scheduler runs no command, or whose start
|
|
302
|
+
* and finish happen in different places. Without a flag it reports success;
|
|
303
|
+
* `--start`, `--fail`, `--exit <n>` and `--log` send the other reports, and
|
|
304
|
+
* `--rid` pairs a start with its finish. It sends no output.
|
|
305
|
+
*
|
|
306
|
+
* It finds the URL as `job run` does and, like it, reads no Juno config and no
|
|
307
|
+
* API key. Unlike `job run` it has nothing else to do, so a report that still
|
|
308
|
+
* fails after one retry is exit 1 with the reason, not silence: the caller
|
|
309
|
+
* asked for exactly this and can `|| true` it.
|
|
310
|
+
*/
|
|
311
|
+
export async function jobPing(args, io, deps = {}) {
|
|
312
|
+
const {
|
|
313
|
+
fetch = globalThis.fetch,
|
|
314
|
+
env = process.env,
|
|
315
|
+
readFile = nodeReadFile,
|
|
316
|
+
timeouts = DEFAULT_TIMEOUTS,
|
|
317
|
+
} = deps;
|
|
318
|
+
const usage = (msg) =>
|
|
319
|
+
new UsageError(`juno job ping: ${msg}\nusage: ${JOB_PING_USAGE}`);
|
|
320
|
+
|
|
321
|
+
if (args._.length > 1) {
|
|
322
|
+
throw usage(`one URL, got ${args._.length} arguments`);
|
|
323
|
+
}
|
|
324
|
+
const kinds = PING_KINDS.filter(
|
|
325
|
+
(k) => args[k] !== undefined && args[k] !== false
|
|
326
|
+
);
|
|
327
|
+
if (kinds.length > 1) {
|
|
328
|
+
throw usage(`--${kinds.join(' and --')} are different reports; pick one`);
|
|
329
|
+
}
|
|
330
|
+
let suffix = 'success';
|
|
331
|
+
if (kinds[0] === 'exit') {
|
|
332
|
+
const n = args.exit.trim();
|
|
333
|
+
if (!/^\d{1,3}$/.test(n) || Number(n) > 255) {
|
|
334
|
+
throw usage(`--exit takes a status from 0 to 255, got "${args.exit}"`);
|
|
335
|
+
}
|
|
336
|
+
suffix = String(Number(n));
|
|
337
|
+
} else if (kinds[0]) {
|
|
338
|
+
suffix = kinds[0];
|
|
339
|
+
}
|
|
340
|
+
const url = await pingURL('job ping', JOB_PING_USAGE, {
|
|
341
|
+
positional: args._[0],
|
|
342
|
+
file: args['url-file'],
|
|
343
|
+
env,
|
|
344
|
+
readFile,
|
|
345
|
+
cwd: io.cwd,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const send = () =>
|
|
349
|
+
report(fetch, endpoint(url, suffix, args.rid), {
|
|
350
|
+
method: 'GET',
|
|
351
|
+
timeout: timeouts.finish,
|
|
352
|
+
});
|
|
353
|
+
let failure = await send();
|
|
354
|
+
if (failure) {
|
|
355
|
+
await new Promise((resolve) => setTimeout(resolve, timeouts.retryDelay));
|
|
356
|
+
failure = await send();
|
|
357
|
+
}
|
|
358
|
+
if (failure) {
|
|
359
|
+
io.error(`juno job ping: could not report to Juno: ${failure}`);
|
|
360
|
+
return EXIT_FAILURE;
|
|
361
|
+
}
|
|
362
|
+
return EXIT_OK;
|
|
363
|
+
}
|
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
|
+
JOB_PING_USAGE,
|
|
34
|
+
JOB_RUN_USAGE,
|
|
35
|
+
jobPing,
|
|
36
|
+
jobRun,
|
|
37
|
+
} from './commands/job-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 {
|
|
@@ -115,6 +120,20 @@ const NON_TOOL = {
|
|
|
115
120
|
usage: JOB_RUN_USAGE,
|
|
116
121
|
raw: true,
|
|
117
122
|
},
|
|
123
|
+
// No --url or --token either: one report to the ping URL, found as job run
|
|
124
|
+
// finds it.
|
|
125
|
+
'job ping': {
|
|
126
|
+
run: (args, io) => jobPing(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: JOB_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
|
@@ -224,6 +224,7 @@
|
|
|
224
224
|
{ "command": "job list", "tool": "list_jobs" },
|
|
225
225
|
{ "command": "job get-runs", "tool": "get_job_runs", "id_field": "id" },
|
|
226
226
|
{ "command": "job write", "tool": "write_job", "id_field": "id" },
|
|
227
|
+
{ "command": "job delete", "tool": "delete_job", "id_field": "id" },
|
|
227
228
|
{ "command": "credential list", "tool": "list_credentials" },
|
|
228
229
|
{ "command": "person list", "tool": "list_people" },
|
|
229
230
|
{ "command": "person get", "tool": "get_person", "id_field": "id" },
|
|
@@ -279,6 +280,11 @@
|
|
|
279
280
|
"effect": "act",
|
|
280
281
|
"summary": "Run a command and report it to an external job's ping URL"
|
|
281
282
|
},
|
|
283
|
+
{
|
|
284
|
+
"command": "job ping",
|
|
285
|
+
"effect": "act",
|
|
286
|
+
"summary": "Send one report (success, start, fail, an exit status or a log) to an external job's ping URL"
|
|
287
|
+
},
|
|
282
288
|
{
|
|
283
289
|
"command": "credential set",
|
|
284
290
|
"effect": "act",
|