@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 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.13.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.13.0",
43
+ "@junoflow/eslint-plugin": "0.14.0",
44
44
  "@typescript-eslint/parser": "^8.0.0"
45
45
  },
46
46
  "devDependencies": {
@@ -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(parsed, env, readFile, io.cwd);
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(parsed, env, readFile, cwd = '.') {
144
- const [positional] = parsed.positionals;
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(`juno job run: --url-file ${file}: ${err.message}`);
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 job run: no ping URL — pass it, --url-file or JUNO_JOB_URL\nusage: ${JOB_RUN_USAGE}`
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 query = q === -1 ? '' : `${url.slice(q + 1)}&`;
171
- return `${path}/${suffix}?${query}rid=${encodeURIComponent(rid)}`;
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 { JOB_RUN_USAGE, jobRun } from './commands/job-run.js';
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",