jackherd 1.21.0 → 1.23.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/CHANGELOG.md +8 -0
- package/README.md +18 -18
- package/dist/config.d.ts +1 -0
- package/dist/config.js +3 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/read-only-shell.ts +310 -0
- package/kit/hooks/hooks/approval-gate/run.ts +7 -0
- package/kit/workflow/skills/orchestrate/references/start-lane.md +4 -5
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +4 -0
- package/src/config/schema.ts +2 -0
- package/src/lane/command.ts +3 -1
- package/src/lane/dev-command.ts +40 -0
- package/src/lane/dev-herdr.ts +41 -0
- package/src/lane/dev.ts +129 -0
- package/src/plans/settings.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
What changed for people who use jackherd, newest first.
|
|
4
4
|
|
|
5
|
+
## 1.23.0
|
|
6
|
+
|
|
7
|
+
- **`jh lane dev <lane>` starts a lane's dev server in one call.** It splits a pane beside the lane's agent, labels it `<lane> dev :<port>`, runs `PORT=<port> <command>` there and prints one line (pane, label, port, command). The port is `--port` or the port column of the lane's board row (`--board <board.md>` names the board), and a port something listens on is refused. A pane with that label in the lane's tab is reported and left alone. The command is the new `lanes.devCommand` in `.jackherd/config.json`, with `{port}` and `{filter}` (`--filter <package>`) filled in; without it the call refuses and names the key. The coordinator no longer chains `herdr pane split`, `rename` and `run`, which the merge guard asked about; `jh lane dev` passes it like any other `jh` call. Run `jh sync` to get the skill text.
|
|
8
|
+
|
|
9
|
+
## 1.22.0
|
|
10
|
+
|
|
11
|
+
- **The approval gate no longer asks about a Bash call that only reads.** In a lane, and for a subagent of the coordinator, a Bash command passes at once, with no request and no question, when every simple command in it (across `;`, `&&`, `||`, `|` and newlines) is a reading command: `cd` inside the working directory, `echo`, `printf`, `cat`, `head`, `tail` (not `-f`), `wc`, `ls`, `grep`, `rg`, `sed -n` with a print script, `git grep`/`show`/`log`/`diff`/`status`/`ls-files`/`ls-tree`/`rev-parse`/`merge-base`/`blame`, `find` without `-exec`/`-delete`/`-fprint*`, `sort`, `uniq`, `cut` and `true`. A plain `NAME=value` assignment and `$NAME` as a command word are followed. Anything the check does not model (command substitution, a redirection to a file, a heredoc, a subshell, an unknown command or variable) keeps today's question. A word with an unquoted `*`, `?`, `[`, `]`, `{` or `}` is not passed either, since the shell would expand it after the check, and an abbreviated long option (`sort --outp=out`) counts as the option it abbreviates. Run `jh sync` to get the hook.
|
|
12
|
+
|
|
5
13
|
## 1.21.0
|
|
6
14
|
|
|
7
15
|
- **`jh init` and `jh sync` prepare the main checkout for the herdr plugin `zerodice0.worktree-bootstrap`.** When herdr lists the plugin as enabled, they write `.herdr/worktree-copy.list` from `lanes.copy`, `.herdr/worktree-setup.json` with `commands.install` (run with the Node version of `.nvmrc` when the checkout has one) and `/.herdr/` into the git exclude file. A second run rewrites nothing. A `lanes.copy` path git does not ignore is named in a warning, since the plugin skips it. Without herdr or the plugin nothing is written. The orchestrate lane setup also names the plugin's `sync` for a worktree that existed before. Run `jh sync` to get the skill text.
|
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<p>
|
|
2
|
-
<img alt="jackherd: a black and white Shih Tzu in a turquoise bandana, three lanes running from him to three agent nodes" src="https://unpkg.com/jackherd@1.
|
|
2
|
+
<img alt="jackherd: a black and white Shih Tzu in a turquoise bandana, three lanes running from him to three agent nodes" src="https://unpkg.com/jackherd@1.23.0/assets/branding/jackherd-logo-outline.svg" width="340">
|
|
3
3
|
<br>
|
|
4
4
|
</p>
|
|
5
5
|
|
|
6
|
-
[](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd?activeTab=dependencies) [](https://unpkg.com/jackherd/INSTALL.md) [](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.
|
|
6
|
+
[](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd?activeTab=dependencies) [](https://unpkg.com/jackherd/INSTALL.md) [](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.0/assets/icons/space.svg">
|
|
7
7
|
|
|
8
8
|
# Jack herds your coding agents.
|
|
9
9
|
|
|
@@ -15,29 +15,29 @@ For developers who'd rather decide than supervise.
|
|
|
15
15
|
curl -fsSL https://unpkg.com/jackherd/install.sh | sh
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Then `/jh-start` in a repository, and Jack offers your first plan. → [Quickstart](#quickstart)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.
|
|
18
|
+
Then `/jh-start` in a repository, and Jack offers your first plan. → [Quickstart](#quickstart)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.0/assets/icons/space.svg">
|
|
19
19
|
|
|
20
20
|
<p>
|
|
21
|
-
<img src="https://unpkg.com/jackherd@1.
|
|
21
|
+
<img src="https://unpkg.com/jackherd@1.23.0/docs/workflow-dark.svg" width="720" alt="jackherd in herdr: a lane hits a question, Jack asks you as a choice, you pick, and the lane works on while the herd view counts its progress.">
|
|
22
22
|
<br>
|
|
23
|
-
<sub>Jack and his lanes in <a href="https://herdr.dev/">herdr</a>, a terminal for coding agents (recommended). Without it, one Claude Code session builds with subagents.</sub><br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.
|
|
23
|
+
<sub>Jack and his lanes in <a href="https://herdr.dev/">herdr</a>, a terminal for coding agents (recommended). Without it, one Claude Code session builds with subagents.</sub><br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.0/assets/icons/space.svg">
|
|
24
24
|
</p>
|
|
25
25
|
|
|
26
|
-
<p><img src="https://unpkg.com/jackherd@1.
|
|
26
|
+
<p><img src="https://unpkg.com/jackherd@1.23.0/assets/icons/lanes.svg" width="24" height="34" align="middle" alt=""> <b>Parallel where it pays.</b><br>Phases that don't collide run side by side, each lane on its own branch and worktree. Jack keeps the merge order and has the others rebase.</p>
|
|
27
27
|
|
|
28
|
-
<p><img src="https://unpkg.com/jackherd@1.
|
|
28
|
+
<p><img src="https://unpkg.com/jackherd@1.23.0/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Commits, pushes and merges wait for your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours. The guards catch mistakes, not intent; a hard boundary takes branch protection.</p>
|
|
29
29
|
|
|
30
|
-
<p><img src="https://unpkg.com/jackherd@1.
|
|
30
|
+
<p><img src="https://unpkg.com/jackherd@1.23.0/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A lane gets stuck, a check goes red, a decision needs you: Jack steps in and brings you only what's yours to decide. The herd view shows where every lane stands.</p>
|
|
31
31
|
|
|
32
|
-
<p><img src="https://unpkg.com/jackherd@1.
|
|
32
|
+
<p><img src="https://unpkg.com/jackherd@1.23.0/assets/icons/skills.svg" width="24" height="34" align="middle" alt=""> <b>Works with your skills.</b><br>Keep your own skills: a lane builds with whichever one you pick. Plans written elsewhere run too, once Jack fills in what they're missing.</p>
|
|
33
33
|
|
|
34
|
-
<p><img src="https://unpkg.com/jackherd@1.
|
|
34
|
+
<p><img src="https://unpkg.com/jackherd@1.23.0/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments on a lane's pull request go back to that lane, which fixes them with a test. <code>/jh-follow</code> brings in anything a CLI or an API can read as JSON: tickets, error logs, status pages.</p>
|
|
35
35
|
|
|
36
|
-
<p><img src="https://unpkg.com/jackherd@1.
|
|
36
|
+
<p><img src="https://unpkg.com/jackherd@1.23.0/assets/icons/model.svg" width="24" height="34" align="middle" alt=""> <b>The right model for each phase.</b><br>Every phase names its model: the stronger one where a mistake is expensive (data, security, a migration), the cheaper one for the rest. Lanes start on it, and a hook refuses to start a phase's subagent on a weaker one.</p>
|
|
37
37
|
|
|
38
|
-
<p><img src="https://unpkg.com/jackherd@1.
|
|
38
|
+
<p><img src="https://unpkg.com/jackherd@1.23.0/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>A crash or a full context doesn't cost you the work.</b><br>Plan, board and a hand-over file hold the state, not the chat: the next session picks up where the last one stopped.</p>
|
|
39
39
|
|
|
40
|
-
<p><img src="https://unpkg.com/jackherd@1.
|
|
40
|
+
<p><img src="https://unpkg.com/jackherd@1.23.0/assets/icons/plan.svg" width="24" height="34" align="middle" alt=""> <b>Lives in your repository.</b><br>Plans, board and every decision are Markdown, reviewed like code. <code>jh init</code> gives your team the same setup.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.0/assets/icons/space.svg"></p>
|
|
41
41
|
|
|
42
42
|
## Quickstart
|
|
43
43
|
|
|
@@ -68,22 +68,22 @@ Then `/jh-start` in a repository, and Jack offers your first plan. → [Quicksta
|
|
|
68
68
|
/jh-orchestrate docs/plans/board-<name>/board.md
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
Linux and macOS are supported (Windows through WSL); the requirements are in [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md). By hand (Bun 1.3+): `bun add -g jackherd` or `npm install -g jackherd`, then `jh init --global` for yourself, or `jh init` in a repository to give your team the same skills and guard hooks.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.
|
|
71
|
+
Linux and macOS are supported (Windows through WSL); the requirements are in [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md). By hand (Bun 1.3+): `bun add -g jackherd` or `npm install -g jackherd`, then `jh init --global` for yourself, or `jh init` in a repository to give your team the same skills and guard hooks.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.0/assets/icons/space.svg">
|
|
72
72
|
|
|
73
73
|
## Follow anything you can query
|
|
74
74
|
|
|
75
75
|
Tickets, error logs, status pages: if a command or an API call can read it as JSON with your login, Jack can watch it. Recipes cover Jira, Linear, GitHub issues and Projects, Sentry, Google Calendar and status pages; for anything else, `/jh-follow` writes the command with you. One yes, and every change shows up like this:
|
|
76
76
|
|
|
77
|
-
<img src="https://unpkg.com/jackherd@1.
|
|
77
|
+
<img src="https://unpkg.com/jackherd@1.23.0/docs/follow-dark.svg" width="720" alt="A source (ticket, error log or status page) has a new or changed entry; jh watch reports it to Jack; Jack puts a row into Your turn, prefixed with the source's label; its [ask] button makes Jack ask you, and then a lane works it.">
|
|
78
78
|
|
|
79
|
-
The herd sums up your sources in one `Sources` line: new entries per source, or its last run, red while it fails; opened, a click opens an entry. One that needs you lands in Your turn under the source's label, [ask] has Jack look at it, and a ticket shows on the lane that works it.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.
|
|
79
|
+
The herd sums up your sources in one `Sources` line: new entries per source, or its last run, red while it fails; opened, a click opens an entry. One that needs you lands in Your turn under the source's label, [ask] has Jack look at it, and a ticket shows on the lane that works it.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.0/assets/icons/space.svg">
|
|
80
80
|
|
|
81
81
|
## In detail
|
|
82
82
|
|
|
83
83
|
<details>
|
|
84
84
|
<summary><b>What Jack does on his own, and what waits for you</b></summary>
|
|
85
85
|
|
|
86
|
-
<img src="https://unpkg.com/jackherd@1.
|
|
86
|
+
<img src="https://unpkg.com/jackherd@1.23.0/docs/lanes-dark.svg" alt="Jack prompts two lanes and asks you for decisions and approvals; the lanes send questions back as text; jh watch reads lane state, pull requests and checks and wakes Jack.">
|
|
87
87
|
|
|
88
88
|
| On his own | After your approval | Never |
|
|
89
89
|
| --- | --- | --- |
|
|
@@ -118,7 +118,7 @@ The guards catch mistakes, not intent; for that, use branch protection. The hook
|
|
|
118
118
|
|
|
119
119
|
A **plan** is Markdown with phases, checkbox tasks, a model and a review gate per phase. A **board** lists the lanes of one or more plans: which phases each lane builds, on which model, after what. A **lane** is one agent session in its own git worktree (a second checkout of your repository), and **Jack** is the session in the main checkout that runs `/jh-orchestrate` on a board.
|
|
120
120
|
|
|
121
|
-
<img src="https://unpkg.com/jackherd@1.
|
|
121
|
+
<img src="https://unpkg.com/jackherd@1.23.0/docs/skills-dark.svg" alt="Your own plan or an issue is adopted (Jack adds what is missing, you see the diff) and goes to /jh-orchestrate; /jh-grill hands decisions to /jh-plan; /jh-plan feeds /jh-orchestrate or a single /jh-implement, which your own skill can replace; /jh-implement ends in /jh-pre-commit, which runs the reviewers; /jh-diagnose hands a red command to /jh-implement.">
|
|
122
122
|
|
|
123
123
|
| Skill | Does |
|
|
124
124
|
| --- | --- |
|
package/dist/config.d.ts
CHANGED
|
@@ -121,6 +121,7 @@ declare const jackherdConfigSchema: z.ZodObject<{
|
|
|
121
121
|
lockFile: z.ZodOptional<z.ZodString>;
|
|
122
122
|
minFreeGb: z.ZodOptional<z.ZodNumber>;
|
|
123
123
|
basePort: z.ZodOptional<z.ZodNumber>;
|
|
124
|
+
devCommand: z.ZodOptional<z.ZodString>;
|
|
124
125
|
}, z.core.$strict>>;
|
|
125
126
|
models: z.ZodDefault<z.ZodRecord<z.ZodEnum<{
|
|
126
127
|
review: "review";
|
package/dist/config.js
CHANGED
|
@@ -708,7 +708,9 @@ var jackherdConfigSchema = z2.strictObject({
|
|
|
708
708
|
/** Free gigabytes the disk must hold before `jh lane space` lets a new lane install its dependencies; 5 when left out. */
|
|
709
709
|
minFreeGb: z2.number().positive().optional(),
|
|
710
710
|
/** The port `jh lane port` starts counting from when it picks a lane's dev-server port; 3100 when left out. */
|
|
711
|
-
basePort: z2.number().int().min(1).max(65535).optional()
|
|
711
|
+
basePort: z2.number().int().min(1).max(65535).optional(),
|
|
712
|
+
/** The command `jh lane dev` runs as `PORT=<port> <command>` in a pane beside the lane's agent; `{port}` and `{filter}` (`--filter <package>`) are filled in. Without it `jh lane dev` refuses. */
|
|
713
|
+
devCommand: z2.string().min(1).optional()
|
|
712
714
|
}).prefault({}),
|
|
713
715
|
models: z2.record(z2.enum(Object.values(MODEL_ROLE)), modelId).default(DEFAULT_MODELS),
|
|
714
716
|
terminal: z2.strictObject({ adapter: z2.enum(Object.values(TERMINAL_ADAPTER)).default(TERMINAL_ADAPTER.none) }).prefault({}),
|
package/install.sh
CHANGED
|
@@ -9,7 +9,7 @@ set -eu
|
|
|
9
9
|
|
|
10
10
|
# The default is the version this file ships with. The release bump changes it together with
|
|
11
11
|
# package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
|
|
12
|
-
JACKHERD_VERSION="${JACKHERD_VERSION:-1.
|
|
12
|
+
JACKHERD_VERSION="${JACKHERD_VERSION:-1.23.0}"
|
|
13
13
|
|
|
14
14
|
JACKHERD_PACKAGE='jackherd'
|
|
15
15
|
# Test seam: the terminal the wizard asks its questions on.
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs'
|
|
2
|
+
import { isAbsolute, relative, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
type Token = { kind: 'word'; value: string } | { kind: 'variable'; name: string } | { kind: 'separator' }
|
|
5
|
+
|
|
6
|
+
type PartialWord = { value: string; quoted: boolean }
|
|
7
|
+
|
|
8
|
+
type Step =
|
|
9
|
+
| { kind: 'text'; value: string; quoted: boolean; next: number }
|
|
10
|
+
| { kind: 'blank'; next: number }
|
|
11
|
+
| { kind: 'separator'; next: number }
|
|
12
|
+
| { kind: 'variable'; name: string; next: number }
|
|
13
|
+
| { kind: 'redirect'; next: number }
|
|
14
|
+
|
|
15
|
+
type Quoted = { value: string; next: number }
|
|
16
|
+
|
|
17
|
+
/** The directory the check starts in, and the one `cd` has reached since. */
|
|
18
|
+
type Position = { root: string; current: string }
|
|
19
|
+
|
|
20
|
+
type Rule = (args: string[], position: Position) => boolean
|
|
21
|
+
|
|
22
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_]*/
|
|
23
|
+
const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/
|
|
24
|
+
const BOUNDARY = /^(?:$|[ \t\n;&|])/
|
|
25
|
+
const ALLOWED_REDIRECT = /^>(?:&1|[ \t]*\/dev\/null(?=$|[ \t\n;&|]))/
|
|
26
|
+
const SAFE_EXPANSION = /^[\w./:=,@%+-]+(?: +[\w./:=,@%+-]+)*$/
|
|
27
|
+
const SECRET_NAME = /^\.env|\.(?:pem|key)$|^id_(?:rsa|ed25519)|^Pulumi\..*\.ya?ml$|credentials|secret/i
|
|
28
|
+
const SECRET_DIRECTORY = /(?:^|\/)(?:\.ssh|\.aws|\.gnupg|\.pulumi|\.config\/gh)(?:\/|$)/
|
|
29
|
+
const FORBIDDEN_NAME =
|
|
30
|
+
/^(?:PATH|IFS|HOME|CDPATH|ENV|BASH.*|SHELLOPTS|PS4|PROMPT_COMMAND|GIT_.*|LD_.*|.*PAGER|EDITOR|LESS.*)$/
|
|
31
|
+
const UNQUOTED_FORBIDDEN = new Set(['`', '(', ')', '<', '\\', '\r', '*', '?', '[', ']', '{', '}'])
|
|
32
|
+
|
|
33
|
+
const GIT_READ_SUBCOMMANDS = new Set([
|
|
34
|
+
'grep',
|
|
35
|
+
'show',
|
|
36
|
+
'log',
|
|
37
|
+
'diff',
|
|
38
|
+
'status',
|
|
39
|
+
'ls-files',
|
|
40
|
+
'ls-tree',
|
|
41
|
+
'rev-parse',
|
|
42
|
+
'merge-base',
|
|
43
|
+
'blame',
|
|
44
|
+
])
|
|
45
|
+
const FIND_WRITING_PRIMARIES = new Set([
|
|
46
|
+
'-exec',
|
|
47
|
+
'-execdir',
|
|
48
|
+
'-ok',
|
|
49
|
+
'-okdir',
|
|
50
|
+
'-delete',
|
|
51
|
+
'-fprint',
|
|
52
|
+
'-fprint0',
|
|
53
|
+
'-fprintf',
|
|
54
|
+
'-fls',
|
|
55
|
+
])
|
|
56
|
+
const SED_SCRIPT = /^(?:(?:\d+|\$|\/[^/\\]*\/)(?:,(?:\d+|\$|\+\d+|\/[^/\\]*\/))?)[p=]$/
|
|
57
|
+
const SED_FLAGS = /^-[nEr]+$/
|
|
58
|
+
const SED_FLAGS_WITH_SCRIPT = /^-[nEr]*e$/
|
|
59
|
+
|
|
60
|
+
const singleQuoted = (text: string, start: number): Quoted | null => {
|
|
61
|
+
const end = text.indexOf("'", start + 1)
|
|
62
|
+
return end === -1 ? null : { value: text.slice(start + 1, end), next: end + 1 }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const doubleQuoted = (text: string, start: number): Quoted | null => {
|
|
66
|
+
const end = text.indexOf('"', start + 1)
|
|
67
|
+
if (end === -1) return null
|
|
68
|
+
const value = text.slice(start + 1, end)
|
|
69
|
+
return /[$`\\]/.test(value) ? null : { value, next: end + 1 }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `$NAME` or `${NAME}`, only as a whole word; anything else after a dollar sign is refused. */
|
|
73
|
+
const expansionAt = (text: string, start: number, atWordStart: boolean): Step | null => {
|
|
74
|
+
if (!atWordStart) return null
|
|
75
|
+
const braced = text[start + 1] === '{'
|
|
76
|
+
const nameStart = start + (braced ? 2 : 1)
|
|
77
|
+
const name = NAME.exec(text.slice(nameStart))?.[0]
|
|
78
|
+
if (name === undefined) return null
|
|
79
|
+
const end = nameStart + name.length
|
|
80
|
+
if (braced && text[end] !== '}') return null
|
|
81
|
+
const next = braced ? end + 1 : end
|
|
82
|
+
return BOUNDARY.test(text.slice(next)) ? { kind: 'variable', name, next } : null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A redirection to the null device or `2>&1`; the word before it may only be a file descriptor. */
|
|
86
|
+
const redirectAt = (text: string, start: number, word: PartialWord | null): Step | null => {
|
|
87
|
+
if (word !== null && (word.quoted || (word.value !== '1' && word.value !== '2'))) return null
|
|
88
|
+
const matched = ALLOWED_REDIRECT.exec(text.slice(start))
|
|
89
|
+
if (matched === null) return null
|
|
90
|
+
return { kind: 'redirect', next: start + matched[0].length }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const separatorAt = (text: string, start: number): Step | null => {
|
|
94
|
+
const char = text[start]
|
|
95
|
+
const double = text[start + 1] === char
|
|
96
|
+
if (char === '&' && !double) return null
|
|
97
|
+
return { kind: 'separator', next: start + (double ? 2 : 1) }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const quotedStep = (text: string, start: number): Step | null => {
|
|
101
|
+
const quoted = text[start] === "'" ? singleQuoted(text, start) : doubleQuoted(text, start)
|
|
102
|
+
return quoted === null ? null : { kind: 'text', value: quoted.value, quoted: true, next: quoted.next }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const stepAt = (text: string, start: number, word: PartialWord | null): Step | null => {
|
|
106
|
+
const char = text[start] as string
|
|
107
|
+
if (char === ' ' || char === '\t') return { kind: 'blank', next: start + 1 }
|
|
108
|
+
if (char === '\n' || char === ';') return { kind: 'separator', next: start + 1 }
|
|
109
|
+
if (char === '&' || char === '|') return separatorAt(text, start)
|
|
110
|
+
if (char === "'" || char === '"') return quotedStep(text, start)
|
|
111
|
+
if (char === '$') return expansionAt(text, start, word === null)
|
|
112
|
+
if (char === '>') return redirectAt(text, start, word)
|
|
113
|
+
if (UNQUOTED_FORBIDDEN.has(char) || (char === '#' && word === null)) return null
|
|
114
|
+
return { kind: 'text', value: char, quoted: false, next: start + 1 }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Splits the command into words, variable words and separators; null for any construct the check does not model. */
|
|
118
|
+
const tokenize = (text: string): Token[] | null => {
|
|
119
|
+
const tokens: Token[] = []
|
|
120
|
+
let word = null as PartialWord | null
|
|
121
|
+
const flush = () => {
|
|
122
|
+
if (word !== null) tokens.push({ kind: 'word', value: word.value })
|
|
123
|
+
word = null
|
|
124
|
+
}
|
|
125
|
+
let position = 0
|
|
126
|
+
while (position < text.length) {
|
|
127
|
+
const step = stepAt(text, position, word)
|
|
128
|
+
if (step === null) return null
|
|
129
|
+
position = step.next
|
|
130
|
+
if (step.kind === 'text') {
|
|
131
|
+
word = { value: (word?.value ?? '') + step.value, quoted: (word?.quoted ?? false) || step.quoted }
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
if (step.kind === 'redirect') {
|
|
135
|
+
word = null
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
flush()
|
|
139
|
+
if (step.kind === 'separator') tokens.push({ kind: 'separator' })
|
|
140
|
+
if (step.kind === 'variable') tokens.push({ kind: 'variable', name: step.name })
|
|
141
|
+
}
|
|
142
|
+
flush()
|
|
143
|
+
return tokens
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const splitCommands = (tokens: Token[]): Token[][] => {
|
|
147
|
+
const commands: Token[][] = [[]]
|
|
148
|
+
for (const token of tokens) {
|
|
149
|
+
if (token.kind === 'separator') commands.push([])
|
|
150
|
+
else commands[commands.length - 1]?.push(token)
|
|
151
|
+
}
|
|
152
|
+
return commands.filter((command) => command.length > 0)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Whether an option is one of the long options, or an abbreviation of one: GNU option parsing takes any unambiguous prefix. */
|
|
156
|
+
const hasLong = (args: string[], names: string[]): boolean =>
|
|
157
|
+
args.some((arg) => {
|
|
158
|
+
const option = arg.split('=')[0] as string
|
|
159
|
+
return option.length > 2 && names.some((name) => name.startsWith(option))
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
/** Whether a single-dash option carries one of the letters; a cluster such as `-nO` counts, a value starting with a dash too. */
|
|
163
|
+
const hasShort = (args: string[], letters: string): boolean =>
|
|
164
|
+
args.some((arg) => /^-[^-]/.test(arg) && [...letters].some((letter) => arg.includes(letter)))
|
|
165
|
+
|
|
166
|
+
const isInside = (root: string, target: string): boolean => {
|
|
167
|
+
const path = relative(root, target)
|
|
168
|
+
return path !== '..' && !path.startsWith('../') && !isAbsolute(path)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const realPathOrNull = (path: string): string | null => {
|
|
172
|
+
try {
|
|
173
|
+
return realpathSync(path)
|
|
174
|
+
} catch {
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** `cd` into an existing directory that, once symbolic links are followed, still lies inside the root. */
|
|
180
|
+
const cdRule: Rule = (args, position) => {
|
|
181
|
+
const [target] = args
|
|
182
|
+
if (args.length !== 1 || target === undefined || target.startsWith('-') || target.startsWith('~')) return false
|
|
183
|
+
const root = realPathOrNull(position.root)
|
|
184
|
+
const next = realPathOrNull(resolve(position.current, target))
|
|
185
|
+
if (root === null || next === null || !isInside(root, next)) return false
|
|
186
|
+
position.current = next
|
|
187
|
+
return true
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const isSedFlag = (arg: string): boolean => arg === '--quiet' || arg === '--silent' || SED_FLAGS.test(arg)
|
|
191
|
+
|
|
192
|
+
/** The scripts of a `sed` call, from `-e` or the first plain argument; null for an option the check does not know. */
|
|
193
|
+
const sedScripts = (args: string[]): string[] | null => {
|
|
194
|
+
const scripts: string[] = []
|
|
195
|
+
let index = 0
|
|
196
|
+
while (index < args.length) {
|
|
197
|
+
const arg = args[index++] as string
|
|
198
|
+
if (SED_FLAGS_WITH_SCRIPT.test(arg)) {
|
|
199
|
+
const script = args[index++]
|
|
200
|
+
if (script === undefined) return null
|
|
201
|
+
scripts.push(script)
|
|
202
|
+
} else if (arg.startsWith('-') && !isSedFlag(arg)) return null
|
|
203
|
+
else if (!arg.startsWith('-') && scripts.length === 0) scripts.push(arg)
|
|
204
|
+
}
|
|
205
|
+
return scripts
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const isSedQuiet = (args: string[]): boolean =>
|
|
209
|
+
args.some((arg) => (isSedFlag(arg) || SED_FLAGS_WITH_SCRIPT.test(arg)) && (arg.startsWith('--') || arg.includes('n')))
|
|
210
|
+
|
|
211
|
+
const sedRule: Rule = (args) => {
|
|
212
|
+
const scripts = sedScripts(args)
|
|
213
|
+
return (
|
|
214
|
+
scripts !== null && isSedQuiet(args) && scripts.length > 0 && scripts.every((script) => SED_SCRIPT.test(script))
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const gitRule: Rule = ([subcommand = '', ...rest]) =>
|
|
219
|
+
GIT_READ_SUBCOMMANDS.has(subcommand) &&
|
|
220
|
+
!hasLong(rest, ['--output', '--ext-diff', '--open-files-in-pager']) &&
|
|
221
|
+
!hasShort(rest, 'O')
|
|
222
|
+
|
|
223
|
+
const RULES: Record<string, Rule> = {
|
|
224
|
+
cd: cdRule,
|
|
225
|
+
true: () => true,
|
|
226
|
+
echo: () => true,
|
|
227
|
+
printf: () => true,
|
|
228
|
+
cat: () => true,
|
|
229
|
+
head: () => true,
|
|
230
|
+
wc: () => true,
|
|
231
|
+
ls: () => true,
|
|
232
|
+
grep: () => true,
|
|
233
|
+
cut: () => true,
|
|
234
|
+
tail: (args) => !hasShort(args, 'fF') && !hasLong(args, ['--follow']),
|
|
235
|
+
sort: (args) => !hasShort(args, 'o') && !hasLong(args, ['--output', '--compress-program']),
|
|
236
|
+
uniq: (args) => args.filter((arg) => !arg.startsWith('-')).length <= 1,
|
|
237
|
+
rg: (args) => !hasShort(args, 'z') && !hasLong(args, ['--pre', '--hostname-bin', '--search-zip']),
|
|
238
|
+
find: (args) => !args.some((arg) => FIND_WRITING_PRIMARIES.has(arg)),
|
|
239
|
+
sed: sedRule,
|
|
240
|
+
git: gitRule,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The assignment `NAME=value` as a whole command: records it, or null when the name could change how commands run. */
|
|
244
|
+
const assign = (word: string, variables: Map<string, string>): boolean => {
|
|
245
|
+
const matched = ASSIGNMENT.exec(word)
|
|
246
|
+
if (matched === null || FORBIDDEN_NAME.test(matched[1] as string)) return false
|
|
247
|
+
variables.set(matched[1] as string, matched[2] as string)
|
|
248
|
+
return true
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The words of the command with a leading `$NAME` replaced by its recorded value; null when anything else expands. */
|
|
252
|
+
const wordsOf = (command: Token[], variables: Map<string, string>): string[] | null => {
|
|
253
|
+
const [first, ...rest] = command
|
|
254
|
+
if (rest.some((token) => token.kind !== 'word')) return null
|
|
255
|
+
const words = rest.map((token) => (token as { value: string }).value)
|
|
256
|
+
if (first?.kind === 'word') return [first.value, ...words]
|
|
257
|
+
const value = first?.kind === 'variable' ? variables.get(first.name) : undefined
|
|
258
|
+
return value !== undefined && SAFE_EXPANSION.test(value) ? [...value.split(' ').filter(Boolean), ...words] : null
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const isAssignmentCommand = (command: Token[]): boolean => {
|
|
262
|
+
const [first] = command
|
|
263
|
+
return command.length === 1 && first?.kind === 'word' && ASSIGNMENT.test(first.value)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The arguments that name a place to read, without the search pattern of `grep`, `rg` and `git grep`. */
|
|
267
|
+
const pathArguments = (name: string, args: string[]): string[] => {
|
|
268
|
+
const isGitGrep = name === 'git' && args[0] === 'grep'
|
|
269
|
+
if (name !== 'grep' && name !== 'rg' && !isGitGrep) return args
|
|
270
|
+
const search = isGitGrep ? args.slice(1) : args
|
|
271
|
+
const kept: string[] = []
|
|
272
|
+
let patternGiven = search.some((arg) => arg === '-e' || arg === '--regexp')
|
|
273
|
+
let skipNext = false
|
|
274
|
+
for (const arg of search) {
|
|
275
|
+
const isPatternFlag = arg === '-e' || arg === '--regexp'
|
|
276
|
+
const isPositionalPattern = !patternGiven && !arg.startsWith('-')
|
|
277
|
+
patternGiven = patternGiven || isPositionalPattern
|
|
278
|
+
if (!skipNext && !isPatternFlag && !isPositionalPattern) kept.push(arg)
|
|
279
|
+
skipNext = isPatternFlag
|
|
280
|
+
}
|
|
281
|
+
return isGitGrep ? [...args.slice(0, 1), ...kept] : kept
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Whether an argument names a file that tends to hold secrets, also behind `--option=`, `rev:` or a glob. */
|
|
285
|
+
const namesSecret = (argument: string): boolean => {
|
|
286
|
+
const path = argument.replace(/[:=]/g, '/')
|
|
287
|
+
return SECRET_DIRECTORY.test(path) || path.split('/').some((part) => SECRET_NAME.test(part))
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const isReadOnlyCommand = (command: Token[], variables: Map<string, string>, position: Position): boolean => {
|
|
291
|
+
if (isAssignmentCommand(command)) return assign((command[0] as { value: string }).value, variables)
|
|
292
|
+
const words = wordsOf(command, variables)
|
|
293
|
+
const [name = '', ...args] = words ?? []
|
|
294
|
+
const rule = Object.hasOwn(RULES, name) ? RULES[name] : undefined
|
|
295
|
+
return rule !== undefined && !pathArguments(name, args).some(namesSecret) && rule(args, position)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Whether a Bash command only reads: every simple command, across `;`, `&&`, `||`, `|` and newlines, is an allowlisted
|
|
300
|
+
* reading command with arguments that cannot write or run something else. Conservative: a construct this check does not
|
|
301
|
+
* model (substitution, redirection to a file, heredoc, subshell, unknown variable or command) answers false. `cd` may
|
|
302
|
+
* only reach a directory inside `cwd`; symbolic links are not followed, so a link inside `cwd` can still lead out.
|
|
303
|
+
*/
|
|
304
|
+
export const isReadOnlyShellCommand = (command: string, cwd: string): boolean => {
|
|
305
|
+
const tokens = tokenize(command)
|
|
306
|
+
if (tokens === null) return false
|
|
307
|
+
const variables = new Map<string, string>()
|
|
308
|
+
const position: Position = { root: resolve(cwd), current: resolve(cwd) }
|
|
309
|
+
return splitCommands(tokens).every((simple) => isReadOnlyCommand(simple, variables, position))
|
|
310
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { ASK_USER_QUESTION_TOOL } from '../shared/dialog-answers'
|
|
2
2
|
import { isFresh, MS_PER_MINUTE } from '../shared/fresh'
|
|
3
|
+
import { isRecord } from '../shared/json'
|
|
3
4
|
import { isCoordinatorAgent, laneOfAgent } from '../shared/lane'
|
|
4
5
|
import type { PaneAgent } from '../shared/pane-agent'
|
|
5
6
|
import { type StandingLabels, standingGrants } from '../shared/standing-mark'
|
|
6
7
|
import { gateDecision, type GateLabels } from './decision'
|
|
7
8
|
import { type ApprovalRequest, callHash, type GateCall, isStorableInput, newRequest, requestId } from './request'
|
|
9
|
+
import { isReadOnlyShellCommand } from './read-only-shell'
|
|
8
10
|
import { type GateSettings, gateSettingsOf } from './settings'
|
|
9
11
|
import { queryOf, type StandingHistoryEntry } from './standing-history'
|
|
10
12
|
import { ENVIRONMENT_KIND, standingSqlDecision } from './standing-sql'
|
|
@@ -154,6 +156,10 @@ const awaitDecision = async (
|
|
|
154
156
|
/** A question to the user asks for an answer, not for a permission: the lane asks the coordinator in its text instead. */
|
|
155
157
|
const isHeld = ({ tool, input }: ClientCall): boolean => tool !== ASK_USER_QUESTION_TOOL && isStorableInput(input)
|
|
156
158
|
|
|
159
|
+
/** A Bash call whose command provably only reads passes at once; nobody is asked about it. */
|
|
160
|
+
const isReadOnlyBash = ({ tool, input, cwd }: ClientCall): boolean =>
|
|
161
|
+
tool === 'Bash' && isRecord(input) && typeof input.command === 'string' && isReadOnlyShellCommand(input.command, cwd)
|
|
162
|
+
|
|
157
163
|
/**
|
|
158
164
|
* What holds the call: the lane of the pane's agent, else, for a subagent of the coordinator, its own name. The
|
|
159
165
|
* coordinator's session is the one the hook was called for, and the agent of the pane started in the main checkout
|
|
@@ -193,6 +199,7 @@ export const runGate = async (
|
|
|
193
199
|
const settings = gateSettingsOf(dependencies.config(main))
|
|
194
200
|
const lane = holderOf(clientCall, agent, main, settings.worktreeDirectory, dependencies)
|
|
195
201
|
if (!lane) return null
|
|
202
|
+
if (isReadOnlyBash(clientCall)) return { kind: 'allow' }
|
|
196
203
|
const { cwd, tool, input } = clientCall
|
|
197
204
|
const request = openRequest(main, { lane, cwd, tool, input }, dependencies)
|
|
198
205
|
return await awaitDecision(main, request, texts, settings, dependencies)
|
|
@@ -49,12 +49,11 @@ column and the plan's dependency table are met and step 1 finds nothing.
|
|
|
49
49
|
## § 3 Dev server
|
|
50
50
|
|
|
51
51
|
A lane whose phase changes something a user sees (`/implement` Step 4b) gets its server **when the
|
|
52
|
-
lane starts**, not when it asks: the lane looks
|
|
53
|
-
at it before the PR question (§ 5). The command is the project's dev server on the lane's port
|
|
54
|
-
(`PORT=<port> {{kit.command.dev}}`, unless the project supplement names another).
|
|
52
|
+
lane starts**, not when it asks: the lane looks before the commit question, you before the PR question (§ 5).
|
|
55
53
|
|
|
56
|
-
Never in the lane's shell: the agent would block on it.
|
|
57
|
-
|
|
54
|
+
Never in the lane's shell: the agent would block on it. Start it with `jh lane dev <lane>` (`--port` where the row has
|
|
55
|
+
none, `--filter <package>` for `{filter}` in `lanes.devCommand`): it splits a pane beside the agent, labels it
|
|
56
|
+
`<lane> dev :<port>`, runs the command there; an existing pane stays. Without `lanes.devCommand`, by hand, **beside the agent**:
|
|
58
57
|
|
|
59
58
|
```bash
|
|
60
59
|
herdr pane split <lane-pane> --direction right --no-focus
|
package/package.json
CHANGED
package/src/config/schema.ts
CHANGED
|
@@ -605,6 +605,8 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
605
605
|
minFreeGb: z.number().positive().optional(),
|
|
606
606
|
/** The port `jh lane port` starts counting from when it picks a lane's dev-server port; 3100 when left out. */
|
|
607
607
|
basePort: z.number().int().min(1).max(65535).optional(),
|
|
608
|
+
/** The command `jh lane dev` runs as `PORT=<port> <command>` in a pane beside the lane's agent; `{port}` and `{filter}` (`--filter <package>`) are filled in. Without it `jh lane dev` refuses. */
|
|
609
|
+
devCommand: z.string().min(1).optional(),
|
|
608
610
|
})
|
|
609
611
|
.prefault({}),
|
|
610
612
|
models: z.record(z.enum(Object.values(MODEL_ROLE)), modelId).default(DEFAULT_MODELS),
|
package/src/lane/command.ts
CHANGED
|
@@ -7,6 +7,7 @@ const SPACE = 'space'
|
|
|
7
7
|
const PORT = 'port'
|
|
8
8
|
const SEND = 'send'
|
|
9
9
|
const BASE = 'base'
|
|
10
|
+
const DEV = 'dev'
|
|
10
11
|
|
|
11
12
|
/** `code` after the call; a call from the main checkout that succeeded adds the hint to the start-lane reference on stderr, so the output a caller captures stays the one value. */
|
|
12
13
|
const withStartLaneHint = (code: number, args: readonly string[], cwd: string): number => {
|
|
@@ -16,13 +17,14 @@ const withStartLaneHint = (code: number, args: readonly string[], cwd: string):
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
|
-
* `jh lane close …` closes a lane, `jh lane space …` checks the disk for a new one, `jh lane port …` names its dev-server port, `jh lane send …` sends its prompt
|
|
20
|
+
* `jh lane close …` closes a lane, `jh lane space …` checks the disk for a new one, `jh lane port …` names its dev-server port, `jh lane dev …` starts that server beside its agent, `jh lane send …` sends its prompt
|
|
20
21
|
* file, `jh lane base …` names the ref its worktree starts from, everything else is `jh lane prompt …`. `close` must
|
|
21
22
|
* come first; `jh lane --board b.md close x` reaches the prompt parser and is refused there.
|
|
22
23
|
*/
|
|
23
24
|
export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
|
|
24
25
|
if (args[0] === SPACE) return (await import('./space-command')).run(args.slice(1), cwd)
|
|
25
26
|
if (args[0] === PORT) return (await import('./port-command')).run(args.slice(1), cwd)
|
|
27
|
+
if (args[0] === DEV) return (await import('./dev-command')).run(args.slice(1), cwd)
|
|
26
28
|
if (args[0] === SEND) return (await import('./send-command')).run(args.slice(1), cwd)
|
|
27
29
|
if (args[0] === BASE)
|
|
28
30
|
return withStartLaneHint(await (await import('./base-command')).run(args.slice(1), cwd), args.slice(1), cwd)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { parseSprintLanes } from '../plan-format'
|
|
4
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
5
|
+
import { boardFor } from '../plans/board-pick'
|
|
6
|
+
import { mainCheckout, worktreeRoot } from '../plans/git'
|
|
7
|
+
import { readSettings } from '../plans/settings'
|
|
8
|
+
import { asksForHelp } from '../preview/options'
|
|
9
|
+
import { SAFE_LANE_NAME } from '../watch/lane-name'
|
|
10
|
+
import { devCommandOf, devPortOf, LANE_DEV_USAGE, parseDevOptions, startLaneDev } from './dev'
|
|
11
|
+
import { herdrDevPorts } from './dev-herdr'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `jh lane dev <lane> [--board <board.md>] [--port <port>] [--filter <package>]`: starts the lane's dev server in a pane
|
|
15
|
+
* beside its agent (orchestrate § 3) and prints one line. Exit 0 started or already there, 1 refused.
|
|
16
|
+
*/
|
|
17
|
+
export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
|
|
18
|
+
if (asksForHelp(args)) {
|
|
19
|
+
console.log(LANE_DEV_USAGE)
|
|
20
|
+
return 0
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const { lane, boardPath, port: portOption, filter } = parseDevOptions(args)
|
|
24
|
+
if (!SAFE_LANE_NAME.test(lane)) throw new Error(`${lane} is no lane name (letters, digits, . _ -)`)
|
|
25
|
+
const root = mainCheckout(cwd)
|
|
26
|
+
const settings = readSettings([root, worktreeRoot(cwd)])
|
|
27
|
+
const board = boardFor(boardPath === null ? null : resolve(cwd, boardPath), [root], settings, () => [])
|
|
28
|
+
if (board.notice) console.error(terminalSafe(board.notice))
|
|
29
|
+
const row = parseSprintLanes(readFileSync(board.file, 'utf8'), settings.planLanguage).find(
|
|
30
|
+
({ name, closed }) => name === lane && !closed,
|
|
31
|
+
)
|
|
32
|
+
const port = devPortOf(portOption, row?.port ?? null)
|
|
33
|
+
const outcome = await startLaneDev(herdrDevPorts(), lane, port, devCommandOf(settings.devCommand, port, filter))
|
|
34
|
+
console.log(terminalSafe(outcome.line))
|
|
35
|
+
return outcome.exitCode
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error(terminalSafe(error instanceof Error ? error.message : String(error)))
|
|
38
|
+
return 1
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { paneListOf, splitPaneOf } from '../plans/plan-panes'
|
|
2
|
+
import { isPortListening } from '../plans/port-listening'
|
|
3
|
+
import { herdrBinary } from '../sync/tools'
|
|
4
|
+
import { agentPlaceOf, type DevPorts } from './dev'
|
|
5
|
+
|
|
6
|
+
type Run = (args: string[]) => { exitCode: number; stdout: string }
|
|
7
|
+
|
|
8
|
+
const spawnHerdr =
|
|
9
|
+
(binary: string): Run =>
|
|
10
|
+
(args) => {
|
|
11
|
+
const result = Bun.spawnSync([binary, ...args], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
|
|
12
|
+
return { exitCode: result.exitCode, stdout: result.stdout.toString() }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The stdout of a herdr call that must succeed; throws with the call's name otherwise. */
|
|
16
|
+
const succeeded = (run: Run, args: string[]): string => {
|
|
17
|
+
const { exitCode, stdout } = run(args)
|
|
18
|
+
if (exitCode !== 0) throw new Error(`herdr ${args.slice(0, 2).join(' ')} failed (exit ${exitCode})`)
|
|
19
|
+
return stdout
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The start's calls, made with the `herdr` binary; the command line is one argument, no shell reads it before the pane's own. */
|
|
23
|
+
export const herdrDevPorts = (run: Run = spawnHerdr(herdrBinary(process.env))): DevPorts => ({
|
|
24
|
+
place: (lane) => {
|
|
25
|
+
const { exitCode, stdout } = run(['agent', 'get', lane])
|
|
26
|
+
return exitCode === 0 ? agentPlaceOf(stdout) : null
|
|
27
|
+
},
|
|
28
|
+
labelsInTab: (tab) =>
|
|
29
|
+
paneListOf(succeeded(run, ['pane', 'list']))
|
|
30
|
+
.filter((pane) => pane.tab === tab)
|
|
31
|
+
.flatMap((pane) => (pane.label === null ? [] : [pane.label])),
|
|
32
|
+
isListening: isPortListening,
|
|
33
|
+
split: (pane, cwd) =>
|
|
34
|
+
splitPaneOf(succeeded(run, ['pane', 'split', pane, '--direction', 'right', '--no-focus', '--cwd', cwd])),
|
|
35
|
+
rename: (pane, label) => {
|
|
36
|
+
succeeded(run, ['pane', 'rename', pane, label])
|
|
37
|
+
},
|
|
38
|
+
run: (pane, command) => {
|
|
39
|
+
succeeded(run, ['pane', 'run', pane, command])
|
|
40
|
+
},
|
|
41
|
+
})
|
package/src/lane/dev.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { isRecord } from '../shared/is-record'
|
|
2
|
+
import { CLI_NAME } from '../version/release'
|
|
3
|
+
import { MAX_PORT } from './port'
|
|
4
|
+
|
|
5
|
+
/** What `jh lane dev` was asked for; a null stands for an option left out. */
|
|
6
|
+
export type DevOptions = { lane: string; boardPath: string | null; port: number | null; filter: string | null }
|
|
7
|
+
|
|
8
|
+
/** Where a lane's agent sits: its pane, the tab of that pane and the directory it works in (the worktree). */
|
|
9
|
+
export type AgentPlace = { pane: string; tab: string; cwd: string }
|
|
10
|
+
|
|
11
|
+
type Awaitable<T> = T | Promise<T>
|
|
12
|
+
|
|
13
|
+
/** The calls the start makes; the command wires them to `herdr` and to a connection probe. */
|
|
14
|
+
export type DevPorts = {
|
|
15
|
+
/** Where the lane's agent is, null when herdr knows no such agent. */
|
|
16
|
+
place: (lane: string) => Awaitable<AgentPlace | null>
|
|
17
|
+
/** The labels of the panes in the tab. */
|
|
18
|
+
labelsInTab: (tab: string) => Awaitable<string[]>
|
|
19
|
+
isListening: (port: number) => Awaitable<boolean>
|
|
20
|
+
/** Splits a pane beside `pane`, working in `cwd`, and answers the new pane's id. */
|
|
21
|
+
split: (pane: string, cwd: string) => Awaitable<string>
|
|
22
|
+
rename: (pane: string, label: string) => Awaitable<void>
|
|
23
|
+
run: (pane: string, command: string) => Awaitable<void>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type DevOutcome = { exitCode: 0 | 1; line: string }
|
|
27
|
+
|
|
28
|
+
const BOARD = '--board'
|
|
29
|
+
const PORT = '--port'
|
|
30
|
+
const FILTER = '--filter'
|
|
31
|
+
|
|
32
|
+
export const LANE_DEV_USAGE = `usage: ${CLI_NAME} lane dev <lane> [${BOARD} <board.md>] [${PORT} <port>] [${FILTER} <package>]
|
|
33
|
+
|
|
34
|
+
Starts the lane's dev server in a pane beside its agent: the port is ${PORT} or the port column of the lane's row on the
|
|
35
|
+
board, the command is lanes.devCommand of the config ({port} and {filter} stand for the port and ${FILTER}), run as
|
|
36
|
+
PORT=<port> <command> in a pane labelled "<lane> dev :<port>". A port something listens on is refused; a pane with
|
|
37
|
+
that label in the lane's tab is reported and left alone.`
|
|
38
|
+
|
|
39
|
+
const FILTER_VALUE = /^[\w@./:-]+$/
|
|
40
|
+
const PORT_PLACEHOLDER = '{port}'
|
|
41
|
+
const FILTER_PLACEHOLDER = '{filter}'
|
|
42
|
+
|
|
43
|
+
const optionValue = (next: string | undefined): string => {
|
|
44
|
+
if (next === undefined || next.startsWith('--')) throw new Error(LANE_DEV_USAGE)
|
|
45
|
+
return next
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const portOf = (text: string): number => {
|
|
49
|
+
const port = Number(text)
|
|
50
|
+
if (!Number.isInteger(port) || port < 1 || port > MAX_PORT) throw new Error(LANE_DEV_USAGE)
|
|
51
|
+
return port
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reads `<lane> [--board <file>] [--port <port>] [--filter <package>]`; throws with the usage on anything else. */
|
|
55
|
+
export const parseDevOptions = (args: readonly string[]): DevOptions => {
|
|
56
|
+
const [lane, ...rest] = args
|
|
57
|
+
if (lane === undefined || lane.startsWith('-')) throw new Error(LANE_DEV_USAGE)
|
|
58
|
+
const options: DevOptions = { lane, boardPath: null, port: null, filter: null }
|
|
59
|
+
for (let index = 0; index < rest.length; index += 2) {
|
|
60
|
+
const option = rest[index]
|
|
61
|
+
const value = optionValue(rest[index + 1])
|
|
62
|
+
if (option === BOARD && options.boardPath === null) options.boardPath = value
|
|
63
|
+
else if (option === PORT && options.port === null) options.port = portOf(value)
|
|
64
|
+
else if (option === FILTER && options.filter === null) options.filter = value
|
|
65
|
+
else throw new Error(LANE_DEV_USAGE)
|
|
66
|
+
}
|
|
67
|
+
return options
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The port of the call, else the one in the lane's board row; throws when neither names one. */
|
|
71
|
+
export const devPortOf = (option: number | null, boardPort: number | null): number => {
|
|
72
|
+
const port = option ?? boardPort
|
|
73
|
+
if (port === null) throw new Error(`the board row names no port for the lane; pass ${PORT} <port>`)
|
|
74
|
+
return port
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The pane label of a lane's dev server. */
|
|
78
|
+
export const devLabelOf = (lane: string, port: number): string => `${lane} dev :${port}`
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The shell line for the pane: `PORT=<port>` in front of the configured command with `{port}` and `{filter}` filled.
|
|
82
|
+
* A filter is a package name, one shell word; anything else is refused.
|
|
83
|
+
*/
|
|
84
|
+
export const devCommandOf = (template: string | null | undefined, port: number, filter: string | null): string => {
|
|
85
|
+
if (!template) throw new Error('no dev command: set lanes.devCommand in .jackherd/config.json')
|
|
86
|
+
if (filter !== null && !FILTER_VALUE.test(filter)) throw new Error(`${FILTER} ${filter} is no package name`)
|
|
87
|
+
if (filter === null && template.includes(FILTER_PLACEHOLDER))
|
|
88
|
+
throw new Error(`lanes.devCommand names {filter}: pass ${FILTER} <package>`)
|
|
89
|
+
const command = template.replaceAll(PORT_PLACEHOLDER, String(port)).replaceAll(FILTER_PLACEHOLDER, filter ?? '')
|
|
90
|
+
return `PORT=${port} ${command}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Pane, tab and directory of the agent in a `herdr agent get` answer; null for an answer of another shape. */
|
|
94
|
+
export const agentPlaceOf = (json: string): AgentPlace | null => {
|
|
95
|
+
try {
|
|
96
|
+
const answer: unknown = JSON.parse(json)
|
|
97
|
+
const result = isRecord(answer) ? answer.result : undefined
|
|
98
|
+
const agent = isRecord(result) ? result.agent : undefined
|
|
99
|
+
if (!isRecord(agent)) return null
|
|
100
|
+
const { pane_id: pane, tab_id: tab, cwd } = agent
|
|
101
|
+
return typeof pane === 'string' && typeof tab === 'string' && typeof cwd === 'string' ? { pane, tab, cwd } : null
|
|
102
|
+
} catch {
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const failed = (line: string): DevOutcome => ({ exitCode: 1, line })
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Starts the dev server beside the lane's agent: refuses a lane without an agent and a port something listens on,
|
|
111
|
+
* leaves an existing pane of that label alone, else splits, labels and runs. Prints one line.
|
|
112
|
+
*/
|
|
113
|
+
export const startLaneDev = async (
|
|
114
|
+
ports: DevPorts,
|
|
115
|
+
lane: string,
|
|
116
|
+
port: number,
|
|
117
|
+
command: string,
|
|
118
|
+
): Promise<DevOutcome> => {
|
|
119
|
+
const place = await ports.place(lane)
|
|
120
|
+
if (place === null) return failed(`${lane}: herdr knows no agent of that name`)
|
|
121
|
+
const label = devLabelOf(lane, port)
|
|
122
|
+
if ((await ports.labelsInTab(place.tab)).includes(label))
|
|
123
|
+
return { exitCode: 0, line: `${lane}: a pane "${label}" already exists in the tab, nothing started.` }
|
|
124
|
+
if (await ports.isListening(port)) return failed(`${lane}: something already listens on port ${port}.`)
|
|
125
|
+
const pane = await ports.split(place.pane, place.cwd)
|
|
126
|
+
await ports.rename(pane, label)
|
|
127
|
+
await ports.run(pane, command)
|
|
128
|
+
return { exitCode: 0, line: `${pane} "${label}" :${port} ${command}` }
|
|
129
|
+
}
|
package/src/plans/settings.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type PlansSettings = Pick<
|
|
|
21
21
|
minFreeGb: number
|
|
22
22
|
/** The port `jh lane port` starts from (`lanes.basePort`). */
|
|
23
23
|
basePort: number
|
|
24
|
+
/** The command `jh lane dev` starts a lane's dev server with (`lanes.devCommand`); undefined when none is set. */
|
|
25
|
+
devCommand: string | undefined
|
|
24
26
|
fetchIntervalMinutes: number
|
|
25
27
|
/** Whether the running task of a folded lane shimmers; `NO_COLOR` keeps it still whatever this says. */
|
|
26
28
|
shimmer: boolean
|
|
@@ -66,6 +68,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
|
|
|
66
68
|
adapter: config.terminal.adapter,
|
|
67
69
|
minFreeGb: config.lanes.minFreeGb ?? DEFAULT_MIN_FREE_GB,
|
|
68
70
|
basePort: config.lanes.basePort ?? DEFAULT_BASE_PORT,
|
|
71
|
+
devCommand: config.lanes.devCommand,
|
|
69
72
|
fetchIntervalMinutes: config.plans.fetchIntervalMinutes,
|
|
70
73
|
shimmer: config.plans.shimmer,
|
|
71
74
|
linkKinds: config.plans.linkKinds,
|