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