@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 +66 -0
- package/package.json +2 -2
- package/src/api.js +15 -7
- package/src/commands/app-check.js +8 -1
- package/src/commands/app-deploy.js +8 -1
- package/src/commands/check.js +8 -1
- package/src/commands/deploy.js +8 -1
- package/src/commands/flow-check.js +8 -2
- package/src/commands/flow-deploy.js +12 -6
- package/src/commands/job-run.js +276 -0
- package/src/diagnostics.js +10 -0
- package/src/exit.js +3 -0
- package/src/index.js +24 -0
- package/src/working-copy.js +60 -0
- package/tsconfig.base.json +1 -0
- package/verbs.json +22 -0
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.
|
|
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.
|
|
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).
|
|
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(
|
|
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 {
|
|
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 {
|
|
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}`);
|
package/src/commands/check.js
CHANGED
|
@@ -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 {
|
|
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
|
|
package/src/commands/deploy.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
|
|
3
3
|
import { connect } from '../api.js';
|
|
4
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
+
}
|
package/src/diagnostics.js
CHANGED
|
@@ -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) => {
|
package/src/working-copy.js
CHANGED
|
@@ -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');
|
package/tsconfig.base.json
CHANGED
|
@@ -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",
|