@junoflow/cli 0.12.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 CHANGED
@@ -178,6 +178,17 @@ The group's working-copy commands (`integration init`, `gen-types`,
178
178
  | `juno webhook write` | `write_webhook` | `id` | change |
179
179
  | `juno webhook fire` | `fire_webhook` | `id` | act |
180
180
 
181
+ **`job`** (external jobs)
182
+
183
+ | Command | Tool | Positional | Effect |
184
+ | ------------------------------------ | -------------- | ---------- | ------ |
185
+ | `juno job list` | `list_jobs` | — | read |
186
+ | `juno job get-runs` | `get_job_runs` | `id` | read |
187
+ | `juno job write` | `write_job` | `id` | change |
188
+ | `juno job delete` | `delete_job` | `id` | act |
189
+ | `juno job run [<url>] -- <command>…` | none | ping URL | act |
190
+ | `juno job ping [<url>]` | none | ping URL | act |
191
+
181
192
  **`credential`**
182
193
 
183
194
  | Command | Tool | Positional | Effect |
@@ -327,6 +338,51 @@ exits 1 without sending anything otherwise. Any other message sent to a session
327
338
  waiting for an approval denies it, and the assistant sees the message as the
328
339
  reason.
329
340
 
341
+ ### `juno job run`
342
+
343
+ Wraps a scheduled command so its runs are reported to an external job's ping URL
344
+ (`juno job write` answers it as `url`):
345
+
346
+ ```bash
347
+ juno job run https://juno.example/ping/<token> -- ./backup.sh --full
348
+ juno job run --url-file .secrets/juno-jobs/backup -- ./backup.sh --full
349
+ JUNO_JOB_URL=https://juno.example/ping/<token> juno job run -- python3 check.py
350
+ ```
351
+
352
+ The command runs exactly as it would unwrapped: same stdin, the same stdout and
353
+ stderr bytes as they arrive, the same exit status (`128 + N` when killed by
354
+ signal `N`; 127, or 126 for a file that is not executable, when it cannot be
355
+ started). Everything after `--` is the command's, flags included. Juno gets a
356
+ `start`, then the exit status with the last 64 KiB of stdout and stderr, paired
357
+ by a run id. The URL comes from the argument, else `--url-file` (keeps the token
358
+ out of `ps`), else `$JUNO_JOB_URL`; nothing else is read — no instance config,
359
+ no API key. A report that fails is retried once and then dropped silently
360
+ (`--verbose` prints one line): Juno turns a run it never heard about into an
361
+ overdue finding by itself. `SIGINT`, `SIGTERM` and `SIGHUP` are passed on to the
362
+ command.
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
+
383
+ See [External jobs](https://boivie.github.io/juno/external-jobs/) for creating
384
+ the job, the ping protocol, and systemd and crontab examples.
385
+
330
386
  ### `juno credential set`
331
387
 
332
388
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junoflow/cli",
3
- "version": "0.12.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.12.0",
43
+ "@junoflow/eslint-plugin": "0.14.0",
44
44
  "@typescript-eslint/parser": "^8.0.0"
45
45
  },
46
46
  "devDependencies": {
@@ -0,0 +1,363 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { readFile as nodeReadFile } from 'node:fs/promises';
4
+ import { constants } from 'node:os';
5
+ import { resolve } from 'node:path';
6
+ import { parseArgs } from 'node:util';
7
+
8
+ import { UsageError } from '../errors.js';
9
+ import { EXIT_FAILURE, EXIT_OK } from '../exit.js';
10
+
11
+ export const JOB_RUN_USAGE =
12
+ 'juno job run [<url>] [--url-file <path>] [--verbose] -- <command> [args…]';
13
+
14
+ /** The tail of each stream a run reports; Juno keeps the same. */
15
+ export const TAIL_BYTES = 64 << 10;
16
+
17
+ /** The signals the wrapper passes on to the command instead of obeying. */
18
+ const FORWARDED = ['SIGINT', 'SIGTERM', 'SIGHUP'];
19
+
20
+ const DEFAULT_TIMEOUTS = {
21
+ start: 5000,
22
+ finish: 10000,
23
+ retryDelay: 1000,
24
+ };
25
+
26
+ /**
27
+ * `juno job run` runs a command and reports it to an external job's ping URL
28
+ * (plan-external-jobs-2-wrapper-and-tools.md, Decisions 3–5). Its first rule
29
+ * is transparency: the command gets the same stdin, and the wrapper's stdout,
30
+ * stderr and exit status are the command's, byte for byte — a scheduler such
31
+ * as OpenClaw delivers a task's stdout as its message. Juno gets a `start`,
32
+ * then the exit status with the last 64 KiB of each stream. A report that
33
+ * fails is dropped: a run Juno never hears about becomes an overdue finding
34
+ * on its own, so the wrapper never queues and never fails the command.
35
+ *
36
+ * It reads no Juno config and no API key; the URL is all it needs. argv is
37
+ * everything after `job run`, `--` included. The exit code is returned, not
38
+ * passed to process.exit, so stdout is flushed before the process ends.
39
+ * deps is injectable for the tests; the default binds the real ones.
40
+ */
41
+ export async function jobRun(argv, io, deps = {}) {
42
+ const {
43
+ spawn = nodeSpawn,
44
+ fetch = globalThis.fetch,
45
+ env = process.env,
46
+ readFile = nodeReadFile,
47
+ stdout = process.stdout,
48
+ stderr = process.stderr,
49
+ signals = process,
50
+ timeouts = DEFAULT_TIMEOUTS,
51
+ } = deps;
52
+
53
+ const dash = argv.indexOf('--');
54
+ const command = dash === -1 ? [] : argv.slice(dash + 1);
55
+ const own = dash === -1 ? argv : argv.slice(0, dash);
56
+ let parsed;
57
+ try {
58
+ parsed = parseArgs({
59
+ args: own,
60
+ allowPositionals: true,
61
+ options: {
62
+ 'url-file': { type: 'string' },
63
+ verbose: { type: 'boolean', default: false },
64
+ },
65
+ });
66
+ } catch (err) {
67
+ throw new UsageError(
68
+ `juno job run: ${err.message}\nusage: ${JOB_RUN_USAGE}`
69
+ );
70
+ }
71
+ if (command.length === 0) {
72
+ throw new UsageError(
73
+ `juno job run: missing -- <command>\nusage: ${JOB_RUN_USAGE}`
74
+ );
75
+ }
76
+ if (parsed.positionals.length > 1) {
77
+ throw new UsageError(
78
+ `juno job run: one URL before --, got ${parsed.positionals.length} arguments\nusage: ${JOB_RUN_USAGE}`
79
+ );
80
+ }
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
+ });
88
+ const verbose = parsed.values.verbose;
89
+
90
+ const rid = randomUUID();
91
+ await report(fetch, endpoint(url, 'start', rid), {
92
+ method: 'GET',
93
+ timeout: timeouts.start,
94
+ });
95
+
96
+ const out = new Tail();
97
+ const err = new Tail();
98
+ const outcome = await runCommand(command, {
99
+ spawn,
100
+ signals,
101
+ onStdout: (chunk) => out.push(chunk),
102
+ onStderr: (chunk) => err.push(chunk),
103
+ stdout,
104
+ stderr,
105
+ });
106
+
107
+ let suffix;
108
+ let exitCode;
109
+ if (outcome.startError) {
110
+ const line = `juno job run: ${command[0]}: ${outcome.startError.message}`;
111
+ io.error(line);
112
+ err.push(Buffer.from(`${line}\n`));
113
+ suffix = 'fail';
114
+ exitCode = outcome.startError.code === 'EACCES' ? 126 : 127;
115
+ } else if (outcome.signal) {
116
+ suffix = 'fail';
117
+ exitCode = 128 + (constants.signals[outcome.signal] ?? 0);
118
+ } else {
119
+ suffix = String(outcome.code);
120
+ exitCode = outcome.code;
121
+ }
122
+
123
+ const finish = () => {
124
+ const body = new FormData();
125
+ body.append('stdout', new Blob([out.bytes()]), 'stdout');
126
+ body.append('stderr', new Blob([err.bytes()]), 'stderr');
127
+ return report(fetch, endpoint(url, suffix, rid), {
128
+ method: 'POST',
129
+ body,
130
+ timeout: timeouts.finish,
131
+ });
132
+ };
133
+ let failure = await finish();
134
+ if (failure) {
135
+ await new Promise((resolve) => setTimeout(resolve, timeouts.retryDelay));
136
+ failure = await finish();
137
+ }
138
+ if (failure && verbose) {
139
+ io.error(`juno job run: could not report to Juno: ${failure}`);
140
+ }
141
+ return exitCode;
142
+ }
143
+
144
+ /**
145
+ * pingURL is the URL from the first source that yields one: the positional,
146
+ * then --url-file (its contents, trimmed), then $JUNO_JOB_URL. The two
147
+ * indirect sources keep the token off the command line, where `ps` and the
148
+ * scheduler's job definition would show it. command and usage name the verb
149
+ * in its refusals.
150
+ */
151
+ async function pingURL(
152
+ command,
153
+ usage,
154
+ { positional, file, env, readFile, cwd = '.' }
155
+ ) {
156
+ if (positional?.trim()) return positional.trim();
157
+ if (file !== undefined) {
158
+ let text;
159
+ try {
160
+ text = await readFile(resolve(cwd, file), 'utf8');
161
+ } catch (err) {
162
+ throw new UsageError(
163
+ `juno ${command}: --url-file ${file}: ${err.message}`
164
+ );
165
+ }
166
+ if (text.trim()) return text.trim();
167
+ }
168
+ if (env.JUNO_JOB_URL?.trim()) return env.JUNO_JOB_URL.trim();
169
+ throw new UsageError(
170
+ `juno ${command}: no ping URL — pass it, --url-file or JUNO_JOB_URL\nusage: ${usage}`
171
+ );
172
+ }
173
+
174
+ /**
175
+ * endpoint is `<url>/<suffix>?rid=<rid>`, used as given otherwise: no /ping/
176
+ * check, so a Healthchecks URL works too. A query the URL already carries is
177
+ * kept; without a rid there is no `rid` parameter.
178
+ */
179
+ export function endpoint(url, suffix, rid) {
180
+ const q = url.indexOf('?');
181
+ const path = (q === -1 ? url : url.slice(0, q)).replace(/\/+$/, '');
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}`;
188
+ }
189
+
190
+ /**
191
+ * report sends one ping and answers why it failed, or undefined when it did
192
+ * not. It never throws: a ping that fails changes nothing about the command.
193
+ */
194
+ async function report(fetch, url, { method, body, timeout }) {
195
+ try {
196
+ const res = await fetch(url, {
197
+ method,
198
+ body,
199
+ signal: AbortSignal.timeout(timeout),
200
+ });
201
+ await res.arrayBuffer().catch(() => {});
202
+ return res.ok ? undefined : `HTTP ${res.status}`;
203
+ } catch (err) {
204
+ return err?.cause?.message ?? err?.message ?? String(err);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * runCommand spawns argv with stdin inherited and stdout and stderr piped,
210
+ * with no shell, and copies each chunk to the wrapper's own stream as it
211
+ * arrives. It resolves with `{code}`, `{signal}` or, when the command could
212
+ * not be started, `{startError}`. While it runs, SIGINT, SIGTERM and SIGHUP
213
+ * are passed on to the command, and do not end the wrapper.
214
+ */
215
+ function runCommand(
216
+ argv,
217
+ { spawn, signals, onStdout, onStderr, stdout, stderr }
218
+ ) {
219
+ return new Promise((resolve) => {
220
+ let child;
221
+ try {
222
+ child = spawn(argv[0], argv.slice(1), {
223
+ stdio: ['inherit', 'pipe', 'pipe'],
224
+ });
225
+ } catch (err) {
226
+ resolve({ startError: err });
227
+ return;
228
+ }
229
+
230
+ const forward = Object.fromEntries(
231
+ FORWARDED.map((sig) => [
232
+ sig,
233
+ () => {
234
+ try {
235
+ child.kill(sig);
236
+ } catch {
237
+ // Already gone: its exit is on its way.
238
+ }
239
+ },
240
+ ])
241
+ );
242
+ for (const [sig, fn] of Object.entries(forward)) signals.on(sig, fn);
243
+ let settled = false;
244
+ const settle = (result) => {
245
+ if (settled) return;
246
+ settled = true;
247
+ for (const [sig, fn] of Object.entries(forward)) signals.off(sig, fn);
248
+ resolve(result);
249
+ };
250
+
251
+ child.stdout.on('data', onStdout);
252
+ child.stderr.on('data', onStderr);
253
+ child.stdout.pipe(stdout, { end: false });
254
+ child.stderr.pipe(stderr, { end: false });
255
+
256
+ child.on('error', (err) => {
257
+ // A spawn failure leaves no process behind; any later error is about
258
+ // one that did start, whose close still comes.
259
+ if (child.pid === undefined) settle({ startError: err });
260
+ });
261
+ child.on('close', (code, signal) => {
262
+ settle(signal ? { signal } : { code });
263
+ });
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Tail keeps the last TAIL_BYTES of a stream as the chunks that arrived,
269
+ * dropping whole chunks from the front once over the limit and cutting the
270
+ * first one kept, so a long run costs at most a little over 64 KiB.
271
+ */
272
+ export class Tail {
273
+ #chunks = [];
274
+ #size = 0;
275
+
276
+ push(chunk) {
277
+ this.#chunks.push(chunk);
278
+ this.#size += chunk.length;
279
+ while (this.#size - this.#chunks[0].length >= TAIL_BYTES) {
280
+ this.#size -= this.#chunks.shift().length;
281
+ }
282
+ if (this.#size > TAIL_BYTES) {
283
+ const cut = this.#size - TAIL_BYTES;
284
+ this.#chunks[0] = this.#chunks[0].subarray(cut);
285
+ this.#size -= cut;
286
+ }
287
+ }
288
+
289
+ bytes() {
290
+ return Buffer.concat(this.#chunks, this.#size);
291
+ }
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/exit.js CHANGED
@@ -12,6 +12,9 @@
12
12
  * 409 because the active version is not the copy's base_version
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
+ *
16
+ * `juno job run` is the exception once its own arguments are accepted: it
17
+ * exits with the wrapped command's status, as a shell would report it.
15
18
  */
16
19
  export const EXIT_OK = 0;
17
20
  export const EXIT_FAILURE = 1;
package/src/index.js CHANGED
@@ -29,6 +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 {
33
+ JOB_PING_USAGE,
34
+ JOB_RUN_USAGE,
35
+ jobPing,
36
+ jobRun,
37
+ } from './commands/job-run.js';
32
38
  import { PULL_USAGE, pull } from './commands/pull.js';
33
39
  import { SKILL_USAGE, skill } from './commands/skill.js';
34
40
  import {
@@ -102,6 +108,32 @@ const NON_TOOL = {
102
108
  },
103
109
  usage: CREDENTIAL_SET_USAGE,
104
110
  },
111
+ // raw: everything after `--` is the wrapped command's, verbatim, so the
112
+ // argv goes to the handler unparsed (see runRow). No --url or --token: the
113
+ // wrapper reads no Juno config, only the ping URL.
114
+ 'job run': {
115
+ run: (argv, io) => jobRun(argv, io, { env: io.env }),
116
+ options: {
117
+ 'url-file': { type: 'string' },
118
+ verbose: { type: 'boolean', default: false },
119
+ },
120
+ usage: JOB_RUN_USAGE,
121
+ raw: true,
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
+ },
105
137
  'config set': { run: configSet, options: {}, usage: CONFIG_SET_USAGE },
106
138
  'agent-skill install': {
107
139
  run: agentSkillInstall,
@@ -405,6 +437,17 @@ async function showGroupHelp(group, args, io, code) {
405
437
  function runRow(row, argv, leading, io) {
406
438
  if (!row.tool) {
407
439
  const handler = handlerFor(row);
440
+ if (handler.raw) {
441
+ // Only the words before `--` are the command's own; a --help after it
442
+ // belongs to the wrapped program.
443
+ const dash = argv.indexOf('--');
444
+ const own = dash === -1 ? argv : argv.slice(0, dash);
445
+ if (own.includes('--help') || own.includes('-h')) {
446
+ io.log(nonToolHelp(row, handler));
447
+ return EXIT_OK;
448
+ }
449
+ return handler.run(argv, io);
450
+ }
408
451
  const run =
409
452
  row.working_copy && !handler.positional
410
453
  ? (args, io) => {
package/verbs.json CHANGED
@@ -221,6 +221,10 @@
221
221
  },
222
222
  { "command": "webhook write", "tool": "write_webhook", "id_field": "id" },
223
223
  { "command": "webhook fire", "tool": "fire_webhook", "id_field": "id" },
224
+ { "command": "job list", "tool": "list_jobs" },
225
+ { "command": "job get-runs", "tool": "get_job_runs", "id_field": "id" },
226
+ { "command": "job write", "tool": "write_job", "id_field": "id" },
227
+ { "command": "job delete", "tool": "delete_job", "id_field": "id" },
224
228
  { "command": "credential list", "tool": "list_credentials" },
225
229
  { "command": "person list", "tool": "list_people" },
226
230
  { "command": "person get", "tool": "get_person", "id_field": "id" },
@@ -271,6 +275,16 @@
271
275
  "effect": "act",
272
276
  "summary": "Approve the tool call an assistant session is waiting on"
273
277
  },
278
+ {
279
+ "command": "job run",
280
+ "effect": "act",
281
+ "summary": "Run a command and report it to an external job's ping URL"
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
+ },
274
288
  {
275
289
  "command": "credential set",
276
290
  "effect": "act",