@junoflow/cli 0.11.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 |
@@ -185,6 +194,14 @@ The group's working-copy commands (`integration init`, `gen-types`,
185
194
  | `juno credential list` | `list_credentials` | — | read |
186
195
  | `juno credential set <id>` | none | credential id | act |
187
196
 
197
+ **`person`**
198
+
199
+ | Command | Tool | Positional | Effect |
200
+ | ------------------- | -------------- | ---------- | ------ |
201
+ | `juno person list` | `list_people` | — | read |
202
+ | `juno person get` | `get_person` | `id` | read |
203
+ | `juno person write` | `write_person` | `id` | change |
204
+
188
205
  **`ha`** (Home Assistant)
189
206
 
190
207
  | Command | Tool | Positional | Effect |
@@ -319,6 +336,32 @@ exits 1 without sending anything otherwise. Any other message sent to a session
319
336
  waiting for an approval denies it, and the assistant sees the message as the
320
337
  reason.
321
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
+
322
365
  ### `juno credential set`
323
366
 
324
367
  ```bash
@@ -518,6 +561,29 @@ keeps them in `package.json` alone, so there `pull` leaves `dependencies` as it
518
561
  is. It refuses to overwrite uncommitted changes in a git working tree unless you
519
562
  pass `--force`.
520
563
 
564
+ ### The source, and the warning
565
+
566
+ Every kind's `check` and `deploy` send where the working copy sits in git: the
567
+ `origin` remote (the first remote without one; any user, password, query or
568
+ fragment stripped), the `HEAD` sha, and `dirty` when `git status` reports a
569
+ change or an untracked file under the working copy's directory. Outside git, or
570
+ before the first commit, they send nothing and deploy as usual. Juno records it
571
+ on the version, which is from then on **remote**: its source of truth is this
572
+ working copy, not Juno.
573
+
574
+ When a deploy moves the artifact's source, or replaces a version written in Juno
575
+ since your last pull, the instance answers one sentence and the CLI prints it on
576
+ stderr:
577
+
578
+ ```text
579
+ warning: flow "porch-light" was local; v8 is remote from now on — change it in the working copy and deploy.
580
+ ```
581
+
582
+ It is never a failure: the deploy has happened, and the exit code is what it
583
+ would have been. `check` prints the warning the deploy would. With `--json` the
584
+ sentence is the body's `source_warning`. See
585
+ [Where the Source Lives](https://boivie.github.io/juno/assistant/source/).
586
+
521
587
  ## Flows as files
522
588
 
523
589
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junoflow/cli",
3
- "version": "0.11.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.11.0",
43
+ "@junoflow/eslint-plugin": "0.13.0",
44
44
  "@typescript-eslint/parser": "^8.0.0"
45
45
  },
46
46
  "devDependencies": {
package/src/api.js CHANGED
@@ -87,9 +87,10 @@ export class JunoClient {
87
87
  *
88
88
  * It does not go through #workingCopyJSON, whose 422-without-diagnostics
89
89
  * wording differs from what this route has always printed; only the 409 is
90
- * shared.
90
+ * shared. `origin` (gitOrigin) is sent when given, dry runs included: they
91
+ * stamp nothing, but their source_warning says what a deploy would.
91
92
  */
92
- async ingest(project, { dryRun = false, id, baseVersion } = {}) {
93
+ async ingest(project, { dryRun = false, id, baseVersion, origin } = {}) {
93
94
  const path = `/api/integrations/ingest${dryRun ? '?dry_run=true' : ''}`;
94
95
  const res = await this.#request('POST', path, {
95
96
  manifest: project.manifest,
@@ -98,6 +99,7 @@ export class JunoClient {
98
99
  lockfile: project.lockfile ?? undefined,
99
100
  id: id ?? undefined,
100
101
  base_version: baseVersion ?? undefined,
102
+ origin: origin ?? undefined,
101
103
  });
102
104
  if (res.status === 409) throw new ConflictError(problemDetail(res));
103
105
  return this.#answer('POST', path, res);
@@ -187,16 +189,20 @@ export class JunoClient {
187
189
  * POST /api/flows/ingest — verify a flow working copy and, unless dryRun,
188
190
  * commit and activate it. `files` is the working copy's src/ tree, path →
189
191
  * content. `baseVersion` undefined sends none (a new flow,
190
- * or --force). A 409 is a ConflictError; a 422 with diagnostics is a
191
- * DiagnosticsError.
192
+ * or --force). `origin` (gitOrigin) is sent when given. A 409 is a
193
+ * ConflictError; a 422 with diagnostics is a DiagnosticsError.
192
194
  */
193
- async ingestFlow({ flowId, manifest, files, baseVersion }, { dryRun } = {}) {
195
+ async ingestFlow(
196
+ { flowId, manifest, files, baseVersion },
197
+ { dryRun, origin } = {}
198
+ ) {
194
199
  const query = dryRun ? '?dry_run=true' : '';
195
200
  return this.#workingCopyJSON(`/api/flows/ingest${query}`, {
196
201
  flow_id: flowId,
197
202
  manifest,
198
203
  files,
199
204
  base_version: baseVersion ?? undefined,
205
+ origin: origin ?? undefined,
200
206
  });
201
207
  }
202
208
 
@@ -227,11 +233,12 @@ export class JunoClient {
227
233
  * `baseVersion` undefined sends none (a copy that records no base, or
228
234
  * --force). When activating, a 409 is a ConflictError: the app's active
229
235
  * version is not the copy's base. A draft is never refused; the answer's
230
- * `stale_base` carries the sentence instead.
236
+ * `stale_base` carries the sentence instead. `origin` (gitOrigin) is sent
237
+ * when given.
231
238
  */
232
239
  async ingestApp(
233
240
  { manifest, files, lockfile },
234
- { activate = true, baseVersion } = {}
241
+ { activate = true, baseVersion, origin } = {}
235
242
  ) {
236
243
  return this.#workingCopyJSON('/api/apps/ingest', {
237
244
  manifest,
@@ -239,6 +246,7 @@ export class JunoClient {
239
246
  lockfile: lockfile ?? undefined,
240
247
  activate,
241
248
  base_version: baseVersion ?? undefined,
249
+ origin: origin ?? undefined,
242
250
  });
243
251
  }
244
252
 
@@ -1,10 +1,15 @@
1
1
  import { connect } from '../api.js';
2
2
  import { readAppProject } from '../app-project.js';
3
- import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
3
+ import {
4
+ printDiagnosticsJSON,
5
+ printJSON,
6
+ printSourceWarning,
7
+ } from '../diagnostics.js';
4
8
  import { DiagnosticsError } from '../errors.js';
5
9
  import { EXIT_CONFLICT } from '../exit.js';
6
10
  import { runLocalChecks } from './check.js';
7
11
  import { warnOnDependencyDrift } from './flow-check.js';
12
+ import { gitOrigin } from '../working-copy.js';
8
13
 
9
14
  export const APP_CHECK_USAGE = 'juno app check [--skip-local] [--json]';
10
15
 
@@ -35,6 +40,7 @@ export async function appCheck(argv, io) {
35
40
  result = await client.ingestApp(project, {
36
41
  activate: false,
37
42
  baseVersion: project.baseVersion,
43
+ origin: await gitOrigin(io.cwd, io.env),
38
44
  });
39
45
  } catch (err) {
40
46
  if (argv.json && err instanceof DiagnosticsError) {
@@ -47,6 +53,7 @@ export async function appCheck(argv, io) {
47
53
  printJSON(result, io);
48
54
  } else {
49
55
  for (const w of result.warnings ?? []) io.warn(`warning: ${w}`);
56
+ printSourceWarning(result, io);
50
57
  io.log(
51
58
  `${result.id}: verified and stored as draft v${result.version} on ${target}`
52
59
  );
@@ -8,10 +8,15 @@ import {
8
8
  SRC_DIR,
9
9
  updateAppPackage,
10
10
  } from '../app-project.js';
11
- import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
11
+ import {
12
+ printDiagnosticsJSON,
13
+ printJSON,
14
+ printSourceWarning,
15
+ } from '../diagnostics.js';
12
16
  import { DiagnosticsError } from '../errors.js';
13
17
  import { deployOnChange } from '../watch.js';
14
18
  import {
19
+ gitOrigin,
15
20
  MANIFEST,
16
21
  manifestText,
17
22
  typeFiles,
@@ -79,6 +84,7 @@ export async function appDeploy(argv, io) {
79
84
  async function deployProject(client, project, argv, io, target) {
80
85
  const result = await client.ingestApp(project, {
81
86
  baseVersion: argv.force ? undefined : project.baseVersion,
87
+ origin: await gitOrigin(io.cwd, io.env),
82
88
  });
83
89
 
84
90
  const manifestRaw = manifestText(result.manifest);
@@ -97,6 +103,7 @@ async function deployProject(client, project, argv, io, target) {
97
103
  printJSON(result, io);
98
104
  } else {
99
105
  for (const w of result.warnings ?? []) io.warn(`warning: ${w}`);
106
+ printSourceWarning(result, io);
100
107
  const files = written.length ? ` (${written.sort().join(', ')})` : '';
101
108
  io.log(`deployed ${result.id} v${result.version} to ${target}${files}`);
102
109
  if (result.url) io.log(`open: ${result.url}`);
@@ -2,11 +2,16 @@ import { spawn } from 'node:child_process';
2
2
  import { join } from 'node:path';
3
3
 
4
4
  import { connect } from '../api.js';
5
- import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
5
+ import {
6
+ printDiagnosticsJSON,
7
+ printJSON,
8
+ printSourceWarning,
9
+ } from '../diagnostics.js';
6
10
  import { DiagnosticsError, UserError } from '../errors.js';
7
11
  import { readProject } from '../project.js';
8
12
  import { baseFor } from './deploy.js';
9
13
  import { verifyTypes } from './verify-types.js';
14
+ import { gitOrigin } from '../working-copy.js';
10
15
 
11
16
  export const CHECK_USAGE =
12
17
  'juno integration check [--skip-local] [--id <id>] [--json]';
@@ -48,6 +53,7 @@ export async function check(argv, io) {
48
53
  dryRun: true,
49
54
  id: argv.id,
50
55
  baseVersion: baseFor(project, argv, io),
56
+ origin: await gitOrigin(io.cwd, io.env),
51
57
  });
52
58
  } catch (err) {
53
59
  if (argv.json && err instanceof DiagnosticsError) {
@@ -60,6 +66,7 @@ export async function check(argv, io) {
60
66
  printJSON(result, io);
61
67
  } else {
62
68
  for (const w of result.warnings ?? []) io.warn(`warning: ${w}`);
69
+ printSourceWarning(result, io);
63
70
  io.log(`${result.id}: manifest and sources verified against ${target}`);
64
71
  }
65
72
 
@@ -1,7 +1,11 @@
1
1
  import { join } from 'node:path';
2
2
 
3
3
  import { connect } from '../api.js';
4
- import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
4
+ import {
5
+ printDiagnosticsJSON,
6
+ printJSON,
7
+ printSourceWarning,
8
+ } from '../diagnostics.js';
5
9
  import { DiagnosticsError } from '../errors.js';
6
10
  import {
7
11
  readManifestRaw,
@@ -12,6 +16,7 @@ import {
12
16
  import { stampTypeFiles } from '../stamp.js';
13
17
  import { pickTypeFiles } from './gen-types.js';
14
18
  import { deployOnChange } from '../watch.js';
19
+ import { gitOrigin } from '../working-copy.js';
15
20
 
16
21
  export const DEPLOY_USAGE =
17
22
  'juno integration deploy [--watch] [--force] [--id <id>] [--json]';
@@ -57,6 +62,7 @@ async function deployOnce(client, argv, io, target) {
57
62
  const result = await client.ingest(project, {
58
63
  id: argv.id,
59
64
  baseVersion: argv.force ? undefined : baseFor(project, argv, io),
65
+ origin: await gitOrigin(io.cwd, io.env),
60
66
  });
61
67
  // Stamped against the manifest this deploy was built from, so the watch loop
62
68
  // leaves a tree that `juno integration verify-types` accepts.
@@ -82,6 +88,7 @@ async function deployOnce(client, argv, io, target) {
82
88
  return result;
83
89
  }
84
90
  for (const warning of result.warnings ?? []) io.warn(`warning: ${warning}`);
91
+ printSourceWarning(result, io);
85
92
  io.log(
86
93
  `deployed ${result.id} v${result.version} to ${target} (${written.sort().join(', ')})`
87
94
  );
@@ -2,7 +2,12 @@ import { connect } from '../api.js';
2
2
  import { DiagnosticsError } from '../errors.js';
3
3
  import { readFlowProject } from '../flow-project.js';
4
4
  import { runLocalChecks } from './check.js';
5
- import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
5
+ import {
6
+ printDiagnosticsJSON,
7
+ printJSON,
8
+ printSourceWarning,
9
+ } from '../diagnostics.js';
10
+ import { gitOrigin } from '../working-copy.js';
6
11
  import { printWarnings } from './flow-deploy.js';
7
12
 
8
13
  export const FLOW_CHECK_USAGE = 'juno flow check [--skip-local] [--json]';
@@ -28,7 +33,7 @@ export async function flowCheck(argv, io) {
28
33
  files: project.files,
29
34
  baseVersion: project.baseVersion,
30
35
  },
31
- { dryRun: true }
36
+ { dryRun: true, origin: await gitOrigin(io.cwd, io.env) }
32
37
  );
33
38
  } catch (err) {
34
39
  if (argv.json && err instanceof DiagnosticsError) {
@@ -41,6 +46,7 @@ export async function flowCheck(argv, io) {
41
46
  printJSON(result, io);
42
47
  } else {
43
48
  printWarnings(result.warnings, io);
49
+ printSourceWarning(result, io);
44
50
  io.log(`${result.flow_id}: manifest and src/ verified against ${target}`);
45
51
  }
46
52
  return failed ? 1 : 0;
@@ -6,6 +6,7 @@ import {
6
6
  formatDiagnostic,
7
7
  printDiagnosticsJSON,
8
8
  printJSON,
9
+ printSourceWarning,
9
10
  } from '../diagnostics.js';
10
11
  import {
11
12
  MANIFEST,
@@ -17,6 +18,7 @@ import {
17
18
  writeFlowFiles,
18
19
  } from '../flow-project.js';
19
20
  import { deployOnChange } from '../watch.js';
21
+ import { gitOrigin } from '../working-copy.js';
20
22
 
21
23
  export const FLOW_DEPLOY_USAGE =
22
24
  'juno flow deploy [--watch] [--force] [--json]';
@@ -73,12 +75,15 @@ async function deployOnce(client, argv, io, target) {
73
75
  }
74
76
 
75
77
  async function deployProject(client, project, argv, io, target) {
76
- const result = await client.ingestFlow({
77
- flowId: project.flowId,
78
- manifest: project.manifest,
79
- files: project.files,
80
- baseVersion: argv.force ? undefined : project.baseVersion,
81
- });
78
+ const result = await client.ingestFlow(
79
+ {
80
+ flowId: project.flowId,
81
+ manifest: project.manifest,
82
+ files: project.files,
83
+ baseVersion: argv.force ? undefined : project.baseVersion,
84
+ },
85
+ { origin: await gitOrigin(io.cwd, io.env) }
86
+ );
82
87
 
83
88
  const manifestRaw = manifestText(result.manifest);
84
89
  const written = await writeFlowFiles(io.cwd, {
@@ -96,6 +101,7 @@ async function deployProject(client, project, argv, io, target) {
96
101
  printJSON(result, io);
97
102
  } else {
98
103
  printWarnings(result.warnings, io);
104
+ printSourceWarning(result, io);
99
105
  const files = written.length ? ` (${written.sort().join(', ')})` : '';
100
106
  io.log(
101
107
  `deployed ${result.flow_id} v${result.version} to ${target}${files}`
@@ -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
+ }
@@ -28,3 +28,13 @@ export function printDiagnosticsJSON(err, io) {
28
28
  printJSON({ detail: err.message, diagnostics: err.diagnostics }, io);
29
29
  return 1;
30
30
  }
31
+
32
+ /**
33
+ * A route's `source_warning` — the server's sentence for a deploy that moves
34
+ * an artifact between sources (local, installed, remote) or supersedes a
35
+ * version written in Juno — on stderr as `warning: …`. Never a failure: the
36
+ * write already happened, or a check says what a deploy would do.
37
+ */
38
+ export function printSourceWarning(result, io) {
39
+ if (result?.source_warning) io.warn(`warning: ${result.source_warning}`);
40
+ }
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) => {
@@ -249,6 +249,66 @@ export async function dirtyPaths(cwd) {
249
249
  }
250
250
  }
251
251
 
252
+ /**
253
+ * gitOrigin is where this working copy sits in git, as the `origin` every
254
+ * ingest route accepts: `{git: {remote, sha, dirty}}` — the origin remote's
255
+ * URL (the first remote when there is no `origin`, none when there is no
256
+ * remote), HEAD's sha, and whether `git status --porcelain` reports anything
257
+ * under this directory, untracked files included. Scoped to the working copy,
258
+ * so changes elsewhere in a monorepo do not make it dirty.
259
+ *
260
+ * Best effort and never fatal: not a repository, no commit yet, no git on
261
+ * PATH or any other failure answers undefined, and the deploy goes ahead
262
+ * without it. The remote loses any credentials here; the server strips them
263
+ * again rather than trusting this.
264
+ */
265
+ export async function gitOrigin(cwd, env) {
266
+ const git = async (...args) =>
267
+ (await execFileAsync('git', args, { cwd, env })).stdout;
268
+ try {
269
+ const sha = (await git('rev-parse', 'HEAD')).trim();
270
+ const dirty = (await git('status', '--porcelain', '--', '.')).trim() !== '';
271
+ let remote = '';
272
+ try {
273
+ remote = (await git('remote', 'get-url', 'origin')).trim();
274
+ } catch {
275
+ const first = (await git('remote')).split('\n')[0].trim();
276
+ if (first) remote = (await git('remote', 'get-url', first)).trim();
277
+ }
278
+ remote = stripCredentials(remote);
279
+ return {
280
+ git: {
281
+ ...(remote ? { remote } : {}),
282
+ sha,
283
+ ...(dirty ? { dirty } : {}),
284
+ },
285
+ };
286
+ } catch {
287
+ return undefined;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * stripCredentials drops what can carry a secret from a URL remote — its
293
+ * userinfo, query and fragment (`https://user:token@host/o/r` →
294
+ * `https://host/o/r`). An scp-like remote (`git@host:o/r.git`) names a user
295
+ * and no secret, and is kept. A URL that does not parse is dropped rather
296
+ * than guessed at.
297
+ */
298
+ export function stripCredentials(remote) {
299
+ if (!remote.includes('://')) return remote;
300
+ try {
301
+ const u = new URL(remote);
302
+ u.username = '';
303
+ u.password = '';
304
+ u.search = '';
305
+ u.hash = '';
306
+ return u.toString();
307
+ } catch {
308
+ return '';
309
+ }
310
+ }
311
+
252
312
  export async function readText(cwd, name) {
253
313
  try {
254
314
  return await readFile(join(cwd, name), 'utf8');
@@ -2,6 +2,7 @@
2
2
  "//": "The compiler options Juno type-checks with server-side. internal/verify's tsconfig_preset_test.go asserts this file and verify.CompilerOptions() are identical, so `tsc --noEmit` passing here means the deploy will not 422 on a type error. Extend it; do not fork it.",
3
3
  "compilerOptions": {
4
4
  "target": "ES2022",
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable", "ESNext.Disposable"],
5
6
  "module": "CommonJS",
6
7
  "moduleResolution": "node",
7
8
  "strict": true,
package/verbs.json CHANGED
@@ -221,7 +221,24 @@
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" },
228
+ { "command": "person list", "tool": "list_people" },
229
+ { "command": "person get", "tool": "get_person", "id_field": "id" },
230
+ { "command": "person write", "tool": "write_person", "id_field": "id" },
231
+ {
232
+ "command": "question list",
233
+ "tool": "list_questions",
234
+ "flags": { "closed": { "field": "closed", "type": "string" } }
235
+ },
236
+ {
237
+ "command": "question get",
238
+ "tool": "get_question",
239
+ "id_field": "run_id",
240
+ "flags": { "handle": { "field": "handle_id", "type": "string" } }
241
+ },
225
242
  {
226
243
  "command": "ha get-entity",
227
244
  "tool": "get_entity",
@@ -257,6 +274,11 @@
257
274
  "effect": "act",
258
275
  "summary": "Approve the tool call an assistant session is waiting on"
259
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
+ },
260
282
  {
261
283
  "command": "credential set",
262
284
  "effect": "act",