@ramon-sg/issue-flow 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +304 -2
- package/dist/ask.d.ts +28 -0
- package/dist/ask.js +73 -0
- package/dist/branch-name.d.ts +113 -0
- package/dist/branch-name.js +226 -0
- package/dist/claude-namer.d.ts +51 -0
- package/dist/claude-namer.js +140 -0
- package/dist/cli-setup.d.ts +22 -0
- package/dist/cli-setup.js +26 -0
- package/dist/cli-worktree.d.ts +74 -0
- package/dist/cli-worktree.js +123 -0
- package/dist/cli.d.ts +132 -0
- package/dist/cli.js +348 -0
- package/dist/commands/adapter.d.ts +49 -0
- package/dist/commands/adapter.js +231 -0
- package/dist/commands/claude-hooks.d.ts +37 -0
- package/dist/commands/claude-hooks.js +162 -0
- package/dist/commands/doctor.d.ts +10 -0
- package/dist/commands/doctor.js +124 -0
- package/dist/commands/init.d.ts +27 -0
- package/dist/commands/init.js +213 -0
- package/dist/commands/issue-branch.d.ts +36 -0
- package/dist/commands/issue-branch.js +182 -0
- package/dist/commands/issue-start.d.ts +11 -0
- package/dist/commands/issue-start.js +361 -0
- package/dist/commands/pr-worktree.d.ts +31 -0
- package/dist/commands/pr-worktree.js +122 -0
- package/dist/commands/session.d.ts +15 -0
- package/dist/commands/session.js +102 -0
- package/dist/commands/upgrade.d.ts +16 -0
- package/dist/commands/upgrade.js +115 -0
- package/dist/commands/worktree.d.ts +82 -0
- package/dist/commands/worktree.js +244 -0
- package/dist/config-base.d.ts +32 -0
- package/dist/config-base.js +47 -0
- package/dist/config.d.ts +63 -0
- package/dist/config.js +230 -0
- package/dist/docker.d.ts +53 -0
- package/dist/docker.js +153 -0
- package/dist/git.d.ts +130 -0
- package/dist/git.js +192 -0
- package/dist/guard-config.d.ts +16 -0
- package/dist/guard-config.js +38 -0
- package/dist/issue-text.d.ts +128 -0
- package/dist/issue-text.js +210 -0
- package/dist/launch.d.ts +90 -0
- package/dist/launch.js +158 -0
- package/dist/output.d.ts +33 -0
- package/dist/output.js +37 -0
- package/dist/package-edits.d.ts +35 -0
- package/dist/package-edits.js +78 -0
- package/dist/plan-doc.d.ts +26 -0
- package/dist/plan-doc.js +93 -0
- package/dist/ports.d.ts +90 -0
- package/dist/ports.js +257 -0
- package/dist/propose.d.ts +42 -0
- package/dist/propose.js +111 -0
- package/dist/recipes.d.ts +43 -0
- package/dist/recipes.js +123 -0
- package/dist/session-lock.d.ts +32 -0
- package/dist/session-lock.js +69 -0
- package/dist/settings-hooks.d.ts +31 -0
- package/dist/settings-hooks.js +133 -0
- package/dist/setup-files.d.ts +89 -0
- package/dist/setup-files.js +138 -0
- package/dist/start-modes.d.ts +162 -0
- package/dist/start-modes.js +277 -0
- package/dist/start-place.d.ts +61 -0
- package/dist/start-place.js +301 -0
- package/dist/start-state.d.ts +72 -0
- package/dist/start-state.js +13 -0
- package/dist/start-summary.d.ts +42 -0
- package/dist/start-summary.js +217 -0
- package/dist/stop-apps.d.ts +14 -0
- package/dist/stop-apps.js +127 -0
- package/dist/usage.d.ts +5 -0
- package/dist/usage.js +54 -0
- package/dist/work-state.d.ts +51 -0
- package/dist/work-state.js +58 -0
- package/dist/worktree-config.d.ts +35 -0
- package/dist/worktree-config.js +139 -0
- package/dist/worktree-steps.d.ts +53 -0
- package/dist/worktree-steps.js +210 -0
- package/package.json +20 -4
- package/recipes/bun.json +5 -0
- package/recipes/direnv.json +17 -0
- package/recipes/docker-compose.json +30 -0
- package/recipes/npm.json +5 -0
- package/recipes/pnpm.json +5 -0
- package/recipes/staging-main.json +5 -0
- package/schema.json +281 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `issue-flow adapter …`: what the worktree tools (Orca, https://www.onorca.dev, and
|
|
3
|
+
* Paseo, https://paseo.sh) run when they make or delete a worktree, so the worktree gets
|
|
4
|
+
* the same as one of `issue start`. `orca.yaml` and `paseo.json` only call it:
|
|
5
|
+
*
|
|
6
|
+
* - `setup [--only <step>]`, when the worktree is made: copies `.worktreeinclude` (Paseo
|
|
7
|
+
* does not), runs the setup steps and, without `--only`, starts the issue the workspace
|
|
8
|
+
* came from in it, as `issue start <n> --adopt`. Always exits 0: a failed setup leaves the
|
|
9
|
+
* worktree usable and says what to run.
|
|
10
|
+
* - `issue [<url>]`: only the start of the issue. Without a URL it is a setup step and
|
|
11
|
+
* always exits 0; with one it is run by hand and exits with the code of `issue start`.
|
|
12
|
+
* - `archive`, before the worktree goes: its teardown (steps, apps, ports, Docker). Always
|
|
13
|
+
* exits 0, because a failed archive cannot block the deletion.
|
|
14
|
+
*
|
|
15
|
+
* The issue comes from the branch under Paseo (`<n>-` at the start or after a `/`) and
|
|
16
|
+
* from Orca's CLI otherwise. Ported from Abastio's `scripts/orca-hooks.sh`.
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { DEFAULT_CONFIG, loadConfig } from "../config.js";
|
|
21
|
+
import { canonicalPath, dockerCleanup } from "../docker.js";
|
|
22
|
+
import { checkoutRoot, currentBranch, locateRepo } from "../git.js";
|
|
23
|
+
import { issueOfBranch } from "../issue-text.js";
|
|
24
|
+
import { inPath } from "../launch.js";
|
|
25
|
+
import { UsageError, warn } from "../output.js";
|
|
26
|
+
import { releasePortClaims } from "../ports.js";
|
|
27
|
+
import { copyWorktreeIncludes } from "../start-place.js";
|
|
28
|
+
import { StartError } from "../start-state.js";
|
|
29
|
+
import { runSetup, teardownWorktree } from "../worktree-steps.js";
|
|
30
|
+
import { issueStart } from "./issue-start.js";
|
|
31
|
+
import { stepPlace } from "./worktree.js";
|
|
32
|
+
/** Orca's CLI, which knows which issue a workspace came from; `ISSUE_FLOW_ORCA_CLI` changes it. */
|
|
33
|
+
export const ORCA_CLI = "orca";
|
|
34
|
+
/**
|
|
35
|
+
* The number of an issue of the repo by its URL; a slash, an anchor or a query may follow.
|
|
36
|
+
* @param url - The URL.
|
|
37
|
+
* @param repo - `owner/repo`.
|
|
38
|
+
* @returns The number, or null if it is not an issue of the repo.
|
|
39
|
+
* @example issueOfUrl("https://github.com/acme/widgets/issues/214#top", "acme/widgets") // 214
|
|
40
|
+
*/
|
|
41
|
+
export function issueOfUrl(url, repo) {
|
|
42
|
+
const found = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)(?:[/?#].*)?$/.exec(url);
|
|
43
|
+
if (found?.[1] === undefined || found[2] === undefined)
|
|
44
|
+
return null;
|
|
45
|
+
return found[1].toLowerCase() === repo.toLowerCase() ? Number(found[2]) : null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The issue Orca says the workspace came from: `linkedWorkItem` (with its URL) when it was
|
|
49
|
+
* made on the desktop, or the number alone in `linkedIssue` when it was made from Orca
|
|
50
|
+
* Mobile, always of the project's repo. A work item of something else (Linear, a PR) has
|
|
51
|
+
* no issue URL of the repo.
|
|
52
|
+
* @param root - The worktree.
|
|
53
|
+
* @param repo - `owner/repo`.
|
|
54
|
+
* @param out - Where a failure is said.
|
|
55
|
+
* @returns The number, null if it came from no issue, or undefined if Orca could not be asked.
|
|
56
|
+
*/
|
|
57
|
+
function orcaIssue(root, repo, out) {
|
|
58
|
+
const bin = process.env.ISSUE_FLOW_ORCA_CLI ?? ORCA_CLI;
|
|
59
|
+
const result = bin.includes("/") ? (existsSync(bin) ? run(bin, root) : null) : inPath(bin) ? run(bin, root) : null;
|
|
60
|
+
let worktree;
|
|
61
|
+
try {
|
|
62
|
+
worktree = result === null ? undefined : JSON.parse(result).result?.worktree;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
worktree = undefined;
|
|
66
|
+
}
|
|
67
|
+
if (worktree === undefined) {
|
|
68
|
+
warn(out, `could not ask Orca which issue this workspace came from. If it came from one, run 'issue-flow adapter issue <issue-url>' in ${root}.`);
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
const item = worktree.linkedWorkItem;
|
|
72
|
+
if (item !== undefined && item !== null)
|
|
73
|
+
return typeof item.url === "string" ? issueOfUrl(item.url, repo) : null;
|
|
74
|
+
return typeof worktree.linkedIssue === "number" && Number.isInteger(worktree.linkedIssue) ? worktree.linkedIssue : null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Runs `<orca> worktree current --json` in the worktree.
|
|
78
|
+
* @param bin - Orca's CLI.
|
|
79
|
+
* @param root - The worktree.
|
|
80
|
+
* @returns Its output, or null if it failed.
|
|
81
|
+
*/
|
|
82
|
+
function run(bin, root) {
|
|
83
|
+
const result = spawnSync(bin, ["worktree", "current", "--json"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
84
|
+
return result.status === 0 ? result.stdout : null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Starts an issue in this worktree, as `issue start <n> --adopt`: the branch of the
|
|
88
|
+
* convention, the draft PR and the assignment, without asking nor opening Claude.
|
|
89
|
+
* @param options - The options.
|
|
90
|
+
* @param number - The issue.
|
|
91
|
+
* @param out - Where to write.
|
|
92
|
+
* @returns The exit code of `issue start`.
|
|
93
|
+
* @throws What `issue start` throws.
|
|
94
|
+
*/
|
|
95
|
+
function adopt(options, number, out) {
|
|
96
|
+
return issueStart({
|
|
97
|
+
repo: options.repo(),
|
|
98
|
+
config: options.config,
|
|
99
|
+
cwd: options.cwd,
|
|
100
|
+
number,
|
|
101
|
+
dryRun: false,
|
|
102
|
+
yes: false,
|
|
103
|
+
resume: null,
|
|
104
|
+
forceNew: false,
|
|
105
|
+
branchMode: false,
|
|
106
|
+
launch: false,
|
|
107
|
+
permissions: "default",
|
|
108
|
+
plan: false,
|
|
109
|
+
cloud: false,
|
|
110
|
+
here: false,
|
|
111
|
+
teleport: false,
|
|
112
|
+
adopt: true,
|
|
113
|
+
remoteSession: null,
|
|
114
|
+
}, out);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The start of the issue the workspace came from, as a setup step: it never fails, it
|
|
118
|
+
* says how to retry.
|
|
119
|
+
* @param options - The options.
|
|
120
|
+
* @param root - The worktree.
|
|
121
|
+
* @param out - Where to write.
|
|
122
|
+
*/
|
|
123
|
+
async function linkedIssue(options, root, out) {
|
|
124
|
+
const paseo = (process.env.PASEO_WORKTREE_PATH ?? "") !== "";
|
|
125
|
+
let repo;
|
|
126
|
+
try {
|
|
127
|
+
repo = options.repo();
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
warn(out, `could not start the workspace's issue: ${error instanceof Error ? error.message : String(error)}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const number = paseo ? issueOfBranch(currentBranch(root) ?? "") : orcaIssue(root, repo, out);
|
|
134
|
+
if (number === null || number === undefined)
|
|
135
|
+
return;
|
|
136
|
+
const retry = `to retry, run 'issue-flow adapter issue https://github.com/${repo}/issues/${number}' in ${root}`;
|
|
137
|
+
try {
|
|
138
|
+
await adopt(options, number, out);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
warn(out, `issue #${number} was not started (${error instanceof Error ? error.message : String(error)}); ${retry}.`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The worktree the command runs in, or null in the main checkout, where there is nothing
|
|
146
|
+
* to do (Orca never sets up nor archives it).
|
|
147
|
+
* @param options - The options.
|
|
148
|
+
* @param out - Where to write.
|
|
149
|
+
* @returns The worktree's place, or null.
|
|
150
|
+
* @throws StartError outside git.
|
|
151
|
+
*/
|
|
152
|
+
function linkedPlace(options, out) {
|
|
153
|
+
const place = stepPlace(options, out);
|
|
154
|
+
return place.root === place.mainRoot ? null : place;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* `adapter setup [--only <step>]`.
|
|
158
|
+
* @param options - The options.
|
|
159
|
+
* @param out - Where to write.
|
|
160
|
+
* @returns Always 0.
|
|
161
|
+
* @throws StartError outside git; UsageError if `--only` names no step.
|
|
162
|
+
* @example await adapterSetup(options, out)
|
|
163
|
+
*/
|
|
164
|
+
export async function adapterSetup(options, out) {
|
|
165
|
+
const names = options.config.worktrees.setup.map((step) => step.name);
|
|
166
|
+
if (options.only !== null && !names.includes(options.only))
|
|
167
|
+
throw new UsageError(`--only expects a setup step of .agents/flow.json (${names.join(", ") || "it has none"}): ${options.only}`);
|
|
168
|
+
const place = linkedPlace(options, out);
|
|
169
|
+
if (place === null)
|
|
170
|
+
return 0;
|
|
171
|
+
copyWorktreeIncludes(place.mainRoot, place.root, out);
|
|
172
|
+
await runSetup(place, options.only);
|
|
173
|
+
if (options.only === null)
|
|
174
|
+
await linkedIssue(options, place.root, out);
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* `adapter issue [<url>]`.
|
|
179
|
+
* @param options - The options.
|
|
180
|
+
* @param out - Where to write.
|
|
181
|
+
* @returns The code of `issue start` with a URL, 0 without one.
|
|
182
|
+
* @throws StartError outside git or if the URL is not an issue of the repo.
|
|
183
|
+
* @example await adapterIssue({ ...options, url: "https://github.com/acme/widgets/issues/214" }, out)
|
|
184
|
+
*/
|
|
185
|
+
export async function adapterIssue(options, out) {
|
|
186
|
+
const place = linkedPlace(options, out);
|
|
187
|
+
if (options.url === null) {
|
|
188
|
+
if (place !== null)
|
|
189
|
+
await linkedIssue(options, place.root, out);
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
const number = issueOfUrl(options.url, options.repo());
|
|
193
|
+
if (number === null)
|
|
194
|
+
throw new StartError(`${options.url} is not an issue of ${options.repo()}; nothing done.`);
|
|
195
|
+
if (place === null)
|
|
196
|
+
throw new StartError("an issue starts in a worktree, not in the main checkout.");
|
|
197
|
+
return adopt(options, number, out);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* `adapter archive`. With git's link broken (the main checkout moved, or its record of the
|
|
201
|
+
* worktree was deleted) it cannot tell a worktree, but it still frees the ports and the
|
|
202
|
+
* Docker of the folder, with the config of the main checkout Orca or Paseo name: without
|
|
203
|
+
* git it cannot be the main checkout, which they never archive.
|
|
204
|
+
* @param options - The options.
|
|
205
|
+
* @param out - Where to write.
|
|
206
|
+
* @returns Always 0.
|
|
207
|
+
* @example await adapterArchive(options, out)
|
|
208
|
+
*/
|
|
209
|
+
export async function adapterArchive(options, out) {
|
|
210
|
+
if (checkoutRoot(options.cwd) !== null) {
|
|
211
|
+
const place = linkedPlace(options, out);
|
|
212
|
+
if (place !== null)
|
|
213
|
+
await teardownWorktree(place);
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
const path = canonicalPath(process.env.ORCA_WORKTREE_PATH || process.env.PASEO_WORKTREE_PATH || options.cwd);
|
|
217
|
+
const main = process.env.ORCA_ROOT_PATH || process.env.PASEO_SOURCE_CHECKOUT_PATH || null;
|
|
218
|
+
let config = DEFAULT_CONFIG;
|
|
219
|
+
try {
|
|
220
|
+
if (main !== null && locateRepo(main) !== null)
|
|
221
|
+
config = loadConfig(main);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
warn(out, "the main checkout's .agents/flow.json is not valid; cleaning the Docker without its prefixes.");
|
|
225
|
+
}
|
|
226
|
+
releasePortClaims(path, null);
|
|
227
|
+
const docker = dockerCleanup(config.worktrees.dockerProjects, path);
|
|
228
|
+
if (docker.failed.length > 0)
|
|
229
|
+
warn(out, `could not delete the Docker of ${path}; run 'issue-flow worktree cleanup' later.`);
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { FlowConfig } from "../config.js";
|
|
2
|
+
import { type Output } from "../output.js";
|
|
3
|
+
/**
|
|
4
|
+
* The JSON Claude Code sends a hook on stdin, or an empty object if there is none (a
|
|
5
|
+
* terminal, or not JSON).
|
|
6
|
+
* @returns The fields.
|
|
7
|
+
* @example hookInput().cwd
|
|
8
|
+
*/
|
|
9
|
+
export declare function hookInput(): Record<string, unknown>;
|
|
10
|
+
/**
|
|
11
|
+
* `hook worktree-create`: makes `<worktrees.dir>/<name>` on a branch `worktree-<name>`
|
|
12
|
+
* from `origin/<base>` (or reuses it), copies `.worktreeinclude`, runs the setup and
|
|
13
|
+
* prints the path, which is all Claude reads from stdout. A `pr-<n>` name is refused:
|
|
14
|
+
* that is `issue-flow pr worktree <n>`, on the PR's own branch.
|
|
15
|
+
* @param input - The hook's input (`name`, `cwd`).
|
|
16
|
+
* @param config - The config.
|
|
17
|
+
* @param cwd - Where the hook runs, if the input has no `cwd`.
|
|
18
|
+
* @param out - Where to write; only the path goes to stdout.
|
|
19
|
+
* @returns 0.
|
|
20
|
+
* @throws StartError if the worktree cannot be made, which makes Claude not create it.
|
|
21
|
+
* @example await worktreeCreate({ name: "bold-oak" }, config, cwd, out)
|
|
22
|
+
*/
|
|
23
|
+
export declare function worktreeCreate(input: Record<string, unknown>, config: FlowConfig, cwd: string, out: Output): Promise<number>;
|
|
24
|
+
/**
|
|
25
|
+
* `hook worktree-remove`: cleans the worktree Claude is deleting (steps, apps, ports,
|
|
26
|
+
* Docker) and deletes its folder, and its branch if every commit of it is in origin. A
|
|
27
|
+
* worktree with uncommitted changes is kept. A folder already gone only gets its ports and
|
|
28
|
+
* Docker freed.
|
|
29
|
+
* @param input - The hook's input (`worktree_path`).
|
|
30
|
+
* @param config - The config.
|
|
31
|
+
* @param cwd - Where the hook runs.
|
|
32
|
+
* @param out - Where to write.
|
|
33
|
+
* @returns 0 if the worktree is gone.
|
|
34
|
+
* @throws StartError if it is kept, or is not a worktree of the repo.
|
|
35
|
+
* @example await worktreeRemove({ worktree_path: wt }, config, cwd, out)
|
|
36
|
+
*/
|
|
37
|
+
export declare function worktreeRemove(input: Record<string, unknown>, config: FlowConfig, cwd: string, out: Output): Promise<number>;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `issue-flow hook worktree-create` and `hook worktree-remove`: the `WorktreeCreate`
|
|
3
|
+
* and `WorktreeRemove` hooks of Claude Code, for `claude --worktree <name>` and the
|
|
4
|
+
* worktrees Claude makes during a session. With them those worktrees get what one of
|
|
5
|
+
* `issue start` gets: made from `origin/<base>` (the config's base branch, not only the
|
|
6
|
+
* default one), with `.worktreeinclude` and the setup steps, and cleaned when Claude
|
|
7
|
+
* deletes them (steps, apps, ports, Docker). Claude only calls `WorktreeRemove` for the
|
|
8
|
+
* worktrees a `WorktreeCreate` hook made, and then the hook deletes the folder itself.
|
|
9
|
+
* Ported from Clean Queen's `.claude/hooks/worktree-create.sh` and `worktree-remove.sh`.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { isObject } from "../config-base.js";
|
|
15
|
+
import { canonicalPath } from "../docker.js";
|
|
16
|
+
import { git, hasLocal, isClean, locateRepo, safeToDelete, worktrees } from "../git.js";
|
|
17
|
+
import { warn } from "../output.js";
|
|
18
|
+
import { copyWorktreeIncludes } from "../start-place.js";
|
|
19
|
+
import { StartError } from "../start-state.js";
|
|
20
|
+
import { runSetup, teardownWorktree } from "../worktree-steps.js";
|
|
21
|
+
import { baseBranch, stepPlace } from "./worktree.js";
|
|
22
|
+
/** Milliseconds the fetch of the base branch gets before the local ref is used. */
|
|
23
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
24
|
+
/** The name Claude gives a worktree: a slug, without `.` or `..` as a whole. */
|
|
25
|
+
const WORKTREE_NAME = /^[A-Za-z0-9._+/-]+$/;
|
|
26
|
+
/** A worktree name that asks for a PR, which `pr worktree` checks out instead. */
|
|
27
|
+
const PR_NAME = /^pr-(\d+)$/;
|
|
28
|
+
/**
|
|
29
|
+
* The JSON Claude Code sends a hook on stdin, or an empty object if there is none (a
|
|
30
|
+
* terminal, or not JSON).
|
|
31
|
+
* @returns The fields.
|
|
32
|
+
* @example hookInput().cwd
|
|
33
|
+
*/
|
|
34
|
+
export function hookInput() {
|
|
35
|
+
if (process.stdin.isTTY)
|
|
36
|
+
return {};
|
|
37
|
+
try {
|
|
38
|
+
const value = JSON.parse(readFileSync(0, "utf8"));
|
|
39
|
+
return isObject(value) ? value : {};
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A text field of the hook's input.
|
|
47
|
+
* @param input - The input.
|
|
48
|
+
* @param key - The field.
|
|
49
|
+
* @returns Its value, or null if it is missing or empty.
|
|
50
|
+
*/
|
|
51
|
+
function field(input, key) {
|
|
52
|
+
const value = input[key];
|
|
53
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Fetches the base branch, giving up after `FETCH_TIMEOUT_MS` and never asking for a
|
|
57
|
+
* password: a hook has no terminal.
|
|
58
|
+
* @param mainRoot - The main checkout.
|
|
59
|
+
* @param base - The base branch.
|
|
60
|
+
* @returns True if it was fetched.
|
|
61
|
+
*/
|
|
62
|
+
function fetchBase(mainRoot, base) {
|
|
63
|
+
const result = spawnSync("git", ["fetch", "-q", "origin", base], {
|
|
64
|
+
cwd: mainRoot,
|
|
65
|
+
stdio: "ignore",
|
|
66
|
+
timeout: FETCH_TIMEOUT_MS,
|
|
67
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
68
|
+
});
|
|
69
|
+
return result.status === 0;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* `hook worktree-create`: makes `<worktrees.dir>/<name>` on a branch `worktree-<name>`
|
|
73
|
+
* from `origin/<base>` (or reuses it), copies `.worktreeinclude`, runs the setup and
|
|
74
|
+
* prints the path, which is all Claude reads from stdout. A `pr-<n>` name is refused:
|
|
75
|
+
* that is `issue-flow pr worktree <n>`, on the PR's own branch.
|
|
76
|
+
* @param input - The hook's input (`name`, `cwd`).
|
|
77
|
+
* @param config - The config.
|
|
78
|
+
* @param cwd - Where the hook runs, if the input has no `cwd`.
|
|
79
|
+
* @param out - Where to write; only the path goes to stdout.
|
|
80
|
+
* @returns 0.
|
|
81
|
+
* @throws StartError if the worktree cannot be made, which makes Claude not create it.
|
|
82
|
+
* @example await worktreeCreate({ name: "bold-oak" }, config, cwd, out)
|
|
83
|
+
*/
|
|
84
|
+
export async function worktreeCreate(input, config, cwd, out) {
|
|
85
|
+
const name = field(input, "name");
|
|
86
|
+
if (name === null)
|
|
87
|
+
throw new StartError("the hook got no worktree name.");
|
|
88
|
+
const pr = PR_NAME.exec(name);
|
|
89
|
+
if (pr !== null)
|
|
90
|
+
throw new StartError(`to review a PR, run 'issue-flow pr worktree ${pr[1]}', not 'claude --worktree ${name}'.`);
|
|
91
|
+
const slug = name.replaceAll("/", "+");
|
|
92
|
+
if (!WORKTREE_NAME.test(name) || slug === "." || slug === ".." || slug.startsWith("-"))
|
|
93
|
+
throw new StartError(`the worktree name cannot be '${name}'.`);
|
|
94
|
+
const located = locateRepo(field(input, "cwd") ?? cwd);
|
|
95
|
+
if (located === null)
|
|
96
|
+
throw new StartError(`${field(input, "cwd") ?? cwd} is not a git checkout.`);
|
|
97
|
+
const { mainRoot } = located;
|
|
98
|
+
const path = canonicalPath(join(mainRoot, config.worktrees.dir, slug));
|
|
99
|
+
const branch = `worktree-${slug}`;
|
|
100
|
+
const base = baseBranch(config, mainRoot);
|
|
101
|
+
const stderrOnly = { ...out, stdout: (text) => out.stderr(text) };
|
|
102
|
+
if (!worktrees(mainRoot).some((entry) => entry.path === path)) {
|
|
103
|
+
if (!fetchBase(mainRoot, base))
|
|
104
|
+
warn(out, `could not fetch ${base}; using the local origin/${base}.`);
|
|
105
|
+
if (!git(mainRoot, ["rev-parse", "-q", "--verify", `origin/${base}^{commit}`]).ok)
|
|
106
|
+
throw new StartError(`there is no origin/${base}, and the worktree is not made on another base.`);
|
|
107
|
+
const add = hasLocal(mainRoot, branch)
|
|
108
|
+
? git(mainRoot, ["worktree", "add", "-q", path, branch])
|
|
109
|
+
: git(mainRoot, ["worktree", "add", "-q", "--no-track", "-b", branch, path, `origin/${base}`]);
|
|
110
|
+
if (!add.ok)
|
|
111
|
+
throw new StartError(`could not make the worktree ${path}: ${add.err}`);
|
|
112
|
+
copyWorktreeIncludes(mainRoot, path, stderrOnly);
|
|
113
|
+
}
|
|
114
|
+
await runSetup({ root: path, mainRoot, base, config, out: stderrOnly, remote: false }, null);
|
|
115
|
+
out.stdout(`${path}\n`);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* `hook worktree-remove`: cleans the worktree Claude is deleting (steps, apps, ports,
|
|
120
|
+
* Docker) and deletes its folder, and its branch if every commit of it is in origin. A
|
|
121
|
+
* worktree with uncommitted changes is kept. A folder already gone only gets its ports and
|
|
122
|
+
* Docker freed.
|
|
123
|
+
* @param input - The hook's input (`worktree_path`).
|
|
124
|
+
* @param config - The config.
|
|
125
|
+
* @param cwd - Where the hook runs.
|
|
126
|
+
* @param out - Where to write.
|
|
127
|
+
* @returns 0 if the worktree is gone.
|
|
128
|
+
* @throws StartError if it is kept, or is not a worktree of the repo.
|
|
129
|
+
* @example await worktreeRemove({ worktree_path: wt }, config, cwd, out)
|
|
130
|
+
*/
|
|
131
|
+
export async function worktreeRemove(input, config, cwd, out) {
|
|
132
|
+
const given = field(input, "worktree_path");
|
|
133
|
+
if (given === null)
|
|
134
|
+
throw new StartError("the hook got no worktree_path; deleting nothing.");
|
|
135
|
+
const path = canonicalPath(given);
|
|
136
|
+
const place = stepPlace({ config, cwd: existsSync(path) ? path : (field(input, "cwd") ?? cwd) }, out);
|
|
137
|
+
const { mainRoot } = place;
|
|
138
|
+
if (path === mainRoot)
|
|
139
|
+
throw new StartError("the main checkout is never deleted.");
|
|
140
|
+
const entry = worktrees(mainRoot).find((item) => item.path === path);
|
|
141
|
+
if (entry === undefined && existsSync(path))
|
|
142
|
+
throw new StartError(`${path} is not a worktree of this repo; deleting nothing.`);
|
|
143
|
+
if (entry === undefined) {
|
|
144
|
+
await teardownWorktree({ ...place, root: path });
|
|
145
|
+
git(mainRoot, ["worktree", "prune"]);
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
if (!isClean(path))
|
|
149
|
+
throw new StartError(`${path} has uncommitted changes; kept. See them with 'git -C ${path} status'.`);
|
|
150
|
+
await teardownWorktree({ ...place, root: path });
|
|
151
|
+
const removed = git(mainRoot, ["worktree", "remove", "--force", path]);
|
|
152
|
+
if (!removed.ok)
|
|
153
|
+
throw new StartError(`could not delete ${path} (its teardown already ran): ${removed.err}`);
|
|
154
|
+
const { branch } = entry;
|
|
155
|
+
if (branch === null)
|
|
156
|
+
return 0;
|
|
157
|
+
if (safeToDelete(mainRoot, branch, ""))
|
|
158
|
+
git(mainRoot, ["branch", "-q", "-D", branch]);
|
|
159
|
+
else
|
|
160
|
+
warn(out, `kept the branch ${branch}: it has commits that are not in origin.`);
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Output } from "../output.js";
|
|
2
|
+
/**
|
|
3
|
+
* Checks the repo; see the file's header.
|
|
4
|
+
* @param cwd - Where it runs.
|
|
5
|
+
* @param out - Where to write.
|
|
6
|
+
* @returns 0 if everything passes, 1 if not.
|
|
7
|
+
* @throws SetupError outside a checkout.
|
|
8
|
+
* @example doctor(process.cwd(), out)
|
|
9
|
+
*/
|
|
10
|
+
export declare function doctor(cwd: string, out: Output): number;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `issue-flow doctor`: whether the repo is set up as `init` leaves it, at this
|
|
3
|
+
* version. It checks the config, the dev dependencies, the hooks, the tag of the flow's
|
|
4
|
+
* skills, and `claude-repo-guard` (its own doctor and, on macOS, `weakerNetworkIsolation`,
|
|
5
|
+
* which `gh` needs inside the sandbox). It changes nothing; each problem says what fixes it.
|
|
6
|
+
*/
|
|
7
|
+
import { ConfigError, isObject } from "../config-base.js";
|
|
8
|
+
import { loadConfig } from "../config.js";
|
|
9
|
+
import { locateRepo } from "../git.js";
|
|
10
|
+
import { WEAKER_NETWORK } from "../guard-config.js";
|
|
11
|
+
import { dependencyVersion } from "../package-edits.js";
|
|
12
|
+
import { hookVersions } from "../settings-hooks.js";
|
|
13
|
+
import { captureNpx, flowSkills, GUARD_CONFIG, GUARD_SPEC, ownVersions, PACKAGE, readJson, SETTINGS_FILE, SetupError, SKILLS_LOCK, } from "../setup-files.js";
|
|
14
|
+
/** What fixes most problems. */
|
|
15
|
+
const RUN_INIT = "run issue-flow init";
|
|
16
|
+
/**
|
|
17
|
+
* Checks the config.
|
|
18
|
+
* @param root - The repo.
|
|
19
|
+
* @returns The check.
|
|
20
|
+
*/
|
|
21
|
+
function configCheck(root) {
|
|
22
|
+
try {
|
|
23
|
+
loadConfig(root);
|
|
24
|
+
return { name: ".agents/flow.json", ok: true, detail: "valid" };
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (!(error instanceof ConfigError))
|
|
28
|
+
throw error;
|
|
29
|
+
return { name: ".agents/flow.json", ok: false, detail: error.message };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Checks the dev dependencies: `issue-flow` at this version and `github-rest`, which the
|
|
34
|
+
* skills call.
|
|
35
|
+
* @param root - The repo.
|
|
36
|
+
* @param version - This package's version.
|
|
37
|
+
* @returns The checks.
|
|
38
|
+
*/
|
|
39
|
+
function packageChecks(root, version) {
|
|
40
|
+
const pkg = readJson(root, "package.json");
|
|
41
|
+
const flow = dependencyVersion(pkg, PACKAGE);
|
|
42
|
+
const rest = dependencyVersion(pkg, "@ramon-sg/github-rest");
|
|
43
|
+
return [
|
|
44
|
+
{ name: PACKAGE, ok: flow === version, detail: flow === null ? `not in package.json: ${RUN_INIT}` : flow === version ? version : `package.json has ${flow}, this is ${version}: run issue-flow upgrade` },
|
|
45
|
+
{ name: "@ramon-sg/github-rest", ok: rest !== null, detail: rest ?? `not in package.json, and the skills call it: ${RUN_INIT}` },
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Checks that each hook is there at this version.
|
|
50
|
+
* @param root - The repo.
|
|
51
|
+
* @param version - This package's version.
|
|
52
|
+
* @returns The checks.
|
|
53
|
+
*/
|
|
54
|
+
function hookChecks(root, version) {
|
|
55
|
+
return Object.entries(hookVersions(readJson(root, SETTINGS_FILE))).map(([event, found]) => ({
|
|
56
|
+
name: `${event} hook`,
|
|
57
|
+
ok: found === version,
|
|
58
|
+
detail: found === null ? `not in ${SETTINGS_FILE}: ${RUN_INIT}` : found === version ? version : `at ${found}, this is ${version}: run issue-flow upgrade`,
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Checks that the flow's skills are at this version's tag: they call this CLI, so they go
|
|
63
|
+
* together.
|
|
64
|
+
* @param root - The repo.
|
|
65
|
+
* @param version - This package's version.
|
|
66
|
+
* @returns The check.
|
|
67
|
+
*/
|
|
68
|
+
function skillsCheck(root, version) {
|
|
69
|
+
const skills = flowSkills(readJson(root, SKILLS_LOCK));
|
|
70
|
+
const tag = `v${version}`;
|
|
71
|
+
const off = skills.filter((skill) => skill.tag !== tag);
|
|
72
|
+
if (skills.length === 0)
|
|
73
|
+
return { name: "flow skills", ok: false, detail: `none in ${SKILLS_LOCK}: ${RUN_INIT}` };
|
|
74
|
+
if (off.length === 0)
|
|
75
|
+
return { name: "flow skills", ok: true, detail: `${skills.length} at ${tag}` };
|
|
76
|
+
return { name: "flow skills", ok: false, detail: `${off.map((skill) => `${skill.name} at ${skill.tag ?? "no tag"}`).join(", ")}, this is ${tag}: run issue-flow upgrade` };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Checks `claude-repo-guard`, if the repo has it: its own doctor and, on macOS, the option
|
|
80
|
+
* `gh` needs.
|
|
81
|
+
* @param root - The repo.
|
|
82
|
+
* @returns The checks.
|
|
83
|
+
*/
|
|
84
|
+
function guardChecks(root) {
|
|
85
|
+
const options = readJson(root, GUARD_CONFIG);
|
|
86
|
+
if (options === null)
|
|
87
|
+
return [{ name: "claude-repo-guard", ok: true, detail: "not installed (Claude opens with your own permissions)" }];
|
|
88
|
+
const checks = [];
|
|
89
|
+
if (process.platform === "darwin" && options[WEAKER_NETWORK] !== true)
|
|
90
|
+
checks.push({ name: WEAKER_NETWORK, ok: false, detail: `not true in ${GUARD_CONFIG}, so gh fails inside the macOS sandbox: ${RUN_INIT}` });
|
|
91
|
+
const result = captureNpx(root, [GUARD_SPEC, "doctor", "--json"]);
|
|
92
|
+
let found;
|
|
93
|
+
try {
|
|
94
|
+
found = JSON.parse(result?.out ?? "");
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
found = null;
|
|
98
|
+
}
|
|
99
|
+
if (!Array.isArray(found))
|
|
100
|
+
return [...checks, { name: "claude-repo-guard", ok: false, detail: `could not run its doctor; run 'npx ${GUARD_SPEC} doctor'` }];
|
|
101
|
+
const failed = found.filter((check) => !isObject(check) || check.ok !== true).map((check) => (isObject(check) ? check : { name: "?", detail: String(check) }));
|
|
102
|
+
if (failed.length === 0)
|
|
103
|
+
return [...checks, { name: "claude-repo-guard", ok: true, detail: "its doctor passes" }];
|
|
104
|
+
return [...checks, ...failed.map((check) => ({ name: `claude-repo-guard: ${String(check.name)}`, ok: false, detail: String(check.detail) }))];
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Checks the repo; see the file's header.
|
|
108
|
+
* @param cwd - Where it runs.
|
|
109
|
+
* @param out - Where to write.
|
|
110
|
+
* @returns 0 if everything passes, 1 if not.
|
|
111
|
+
* @throws SetupError outside a checkout.
|
|
112
|
+
* @example doctor(process.cwd(), out)
|
|
113
|
+
*/
|
|
114
|
+
export function doctor(cwd, out) {
|
|
115
|
+
const located = locateRepo(cwd);
|
|
116
|
+
if (located === null)
|
|
117
|
+
throw new SetupError("run doctor inside a checkout of the repo.");
|
|
118
|
+
const root = located.mainRoot;
|
|
119
|
+
const { issueFlow } = ownVersions();
|
|
120
|
+
const checks = [configCheck(root), ...packageChecks(root, issueFlow), ...hookChecks(root, issueFlow), skillsCheck(root, issueFlow), ...guardChecks(root)];
|
|
121
|
+
for (const check of checks)
|
|
122
|
+
out.stdout(`${check.ok ? "✓" : "✗"} ${check.name}: ${check.detail}\n`);
|
|
123
|
+
return checks.every((check) => check.ok) ? 0 : 1;
|
|
124
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Output } from "../output.js";
|
|
2
|
+
/** Options of `issue-flow init`. */
|
|
3
|
+
export interface InitOptions {
|
|
4
|
+
cwd: string;
|
|
5
|
+
yes: boolean;
|
|
6
|
+
recipes: string[] | null;
|
|
7
|
+
skills: boolean;
|
|
8
|
+
guard: boolean;
|
|
9
|
+
cloud: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The URL of the config's schema at a version.
|
|
13
|
+
* @param version - This package's version.
|
|
14
|
+
* @returns The URL.
|
|
15
|
+
* @example schemaUrl("0.1.0") // "https://raw.githubusercontent.com/ramon-sg/issue-flow/v0.1.0/schema.json"
|
|
16
|
+
*/
|
|
17
|
+
export declare function schemaUrl(version: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* Sets the repo up for the flow; see the file's header.
|
|
20
|
+
* @param options - The options.
|
|
21
|
+
* @param out - Where to write and ask.
|
|
22
|
+
* @returns 0, or 1 if a file was not written or another tool failed.
|
|
23
|
+
* @throws SetupError outside the main checkout or with a broken file; ConfigError if the
|
|
24
|
+
* config would be invalid; UsageError for an unknown recipe.
|
|
25
|
+
* @example await init({ cwd, yes: true, recipes: null, skills: true, guard: true, cloud: true }, out)
|
|
26
|
+
*/
|
|
27
|
+
export declare function init(options: InitOptions, out: Output): Promise<number>;
|