@ramon-sg/issue-flow 0.0.0-stage → 0.2.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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +297 -2
  3. package/dist/ask.d.ts +28 -0
  4. package/dist/ask.js +73 -0
  5. package/dist/branch-name.d.ts +113 -0
  6. package/dist/branch-name.js +226 -0
  7. package/dist/claude-namer.d.ts +51 -0
  8. package/dist/claude-namer.js +140 -0
  9. package/dist/cli-setup.d.ts +22 -0
  10. package/dist/cli-setup.js +26 -0
  11. package/dist/cli-worktree.d.ts +74 -0
  12. package/dist/cli-worktree.js +123 -0
  13. package/dist/cli.d.ts +120 -0
  14. package/dist/cli.js +344 -0
  15. package/dist/commands/adapter.d.ts +49 -0
  16. package/dist/commands/adapter.js +231 -0
  17. package/dist/commands/claude-hooks.d.ts +37 -0
  18. package/dist/commands/claude-hooks.js +162 -0
  19. package/dist/commands/doctor.d.ts +11 -0
  20. package/dist/commands/doctor.js +90 -0
  21. package/dist/commands/init.d.ts +24 -0
  22. package/dist/commands/init.js +136 -0
  23. package/dist/commands/issue-branch.d.ts +36 -0
  24. package/dist/commands/issue-branch.js +182 -0
  25. package/dist/commands/issue-start.d.ts +11 -0
  26. package/dist/commands/issue-start.js +361 -0
  27. package/dist/commands/pr-worktree.d.ts +31 -0
  28. package/dist/commands/pr-worktree.js +122 -0
  29. package/dist/commands/session.d.ts +15 -0
  30. package/dist/commands/session.js +102 -0
  31. package/dist/commands/upgrade.d.ts +16 -0
  32. package/dist/commands/upgrade.js +85 -0
  33. package/dist/commands/worktree.d.ts +82 -0
  34. package/dist/commands/worktree.js +244 -0
  35. package/dist/config-base.d.ts +32 -0
  36. package/dist/config-base.js +47 -0
  37. package/dist/config.d.ts +63 -0
  38. package/dist/config.js +230 -0
  39. package/dist/docker.d.ts +53 -0
  40. package/dist/docker.js +153 -0
  41. package/dist/git.d.ts +130 -0
  42. package/dist/git.js +192 -0
  43. package/dist/issue-text.d.ts +128 -0
  44. package/dist/issue-text.js +210 -0
  45. package/dist/launch.d.ts +90 -0
  46. package/dist/launch.js +158 -0
  47. package/dist/output.d.ts +33 -0
  48. package/dist/output.js +37 -0
  49. package/dist/package-edits.d.ts +35 -0
  50. package/dist/package-edits.js +78 -0
  51. package/dist/plan-doc.d.ts +26 -0
  52. package/dist/plan-doc.js +93 -0
  53. package/dist/ports.d.ts +90 -0
  54. package/dist/ports.js +257 -0
  55. package/dist/propose.d.ts +42 -0
  56. package/dist/propose.js +111 -0
  57. package/dist/recipes.d.ts +43 -0
  58. package/dist/recipes.js +123 -0
  59. package/dist/session-lock.d.ts +32 -0
  60. package/dist/session-lock.js +69 -0
  61. package/dist/settings-hooks.d.ts +31 -0
  62. package/dist/settings-hooks.js +133 -0
  63. package/dist/setup-files.d.ts +48 -0
  64. package/dist/setup-files.js +91 -0
  65. package/dist/start-modes.d.ts +162 -0
  66. package/dist/start-modes.js +277 -0
  67. package/dist/start-place.d.ts +61 -0
  68. package/dist/start-place.js +301 -0
  69. package/dist/start-state.d.ts +72 -0
  70. package/dist/start-state.js +13 -0
  71. package/dist/start-summary.d.ts +42 -0
  72. package/dist/start-summary.js +217 -0
  73. package/dist/stop-apps.d.ts +14 -0
  74. package/dist/stop-apps.js +127 -0
  75. package/dist/usage.d.ts +5 -0
  76. package/dist/usage.js +54 -0
  77. package/dist/work-state.d.ts +51 -0
  78. package/dist/work-state.js +58 -0
  79. package/dist/worktree-config.d.ts +35 -0
  80. package/dist/worktree-config.js +139 -0
  81. package/dist/worktree-steps.d.ts +53 -0
  82. package/dist/worktree-steps.js +210 -0
  83. package/package.json +20 -4
  84. package/recipes/bun.json +5 -0
  85. package/recipes/direnv.json +17 -0
  86. package/recipes/docker-compose.json +30 -0
  87. package/recipes/npm.json +5 -0
  88. package/recipes/pnpm.json +5 -0
  89. package/recipes/staging-main.json +5 -0
  90. package/schema.json +281 -0
package/dist/ports.js ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * @file The block of ports of each worktree, so two worktrees never take the same ports.
3
+ * A block starts from a hash of the machine and the folder (stable: the same worktree
4
+ * always gets the same block), moves on if something listens in it or another worktree of
5
+ * the machine registered it, and once chosen is saved in the worktree's git dir. macOS uses
6
+ * the first half of the range and the rest the second, so a laptop worktree and a server's
7
+ * never share a block when Orca forwards the server's ports with the same numbers. Ported
8
+ * from `wt_claim_port_base` and its helpers in Abastio's `scripts/lib/worktree.sh`.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { spawnSync } from "node:child_process";
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
13
+ import { connect } from "node:net";
14
+ import { homedir, hostname, platform } from "node:os";
15
+ import { join } from "node:path";
16
+ import { checkoutRoot, git } from "./git.js";
17
+ /** File, in the worktree's own git dir, with the first port of its block. */
18
+ export const PORT_BASE_FILE = "issue-flow-port-base";
19
+ /** Hex digits of the hash that pick the block: seven fit in a safe integer. */
20
+ const HASH_DIGITS = 7;
21
+ /** How long a port gets to answer before it counts as free. */
22
+ const CONNECT_TIMEOUT_MS = 300;
23
+ /**
24
+ * The sha256 of a text, in hex.
25
+ * @param value - The text.
26
+ * @returns The hash.
27
+ * @example sha256("a").slice(0, 6) // "ca9781"
28
+ */
29
+ export function sha256(value) {
30
+ return createHash("sha256").update(value).digest("hex");
31
+ }
32
+ /**
33
+ * Where `issue-flow` keeps what belongs to the whole machine (which worktree took each
34
+ * block): `ISSUE_FLOW_STATE_DIR`, or `~/.issue-flow`.
35
+ * @returns The folder.
36
+ * @example stateDir() // "/Users/me/.issue-flow"
37
+ */
38
+ export function stateDir() {
39
+ return process.env.ISSUE_FLOW_STATE_DIR ?? join(homedir(), ".issue-flow");
40
+ }
41
+ /**
42
+ * A stable name of the machine, without its domain. On macOS the local host name, not
43
+ * `hostname()`: that one changes with the network, and every block would move.
44
+ * @returns The name.
45
+ * @example machineName() // "ramon-mbp"
46
+ */
47
+ export function machineName() {
48
+ const local = platform() === "darwin" ? spawnSync("scutil", ["--get", "LocalHostName"], { encoding: "utf8" }) : null;
49
+ const name = local?.status === 0 && local.stdout.trim() !== "" ? local.stdout.trim() : hostname();
50
+ return name.split(".")[0] ?? name;
51
+ }
52
+ /**
53
+ * The first block of the half of the range that belongs to a system.
54
+ * @param ports - The range.
55
+ * @param system - The system, as `os.platform()` names it.
56
+ * @returns The block's index.
57
+ */
58
+ function firstBlock(ports, system) {
59
+ return system === "darwin" ? 0 : ports.blocks / 2;
60
+ }
61
+ /**
62
+ * The first port of the block a folder starts from on a machine. It is only the start:
63
+ * `claimPortBase` moves on if it is taken.
64
+ * @param ports - The range.
65
+ * @param machine - The machine's name.
66
+ * @param root - The worktree.
67
+ * @param system - The system (`os.platform()`).
68
+ * @returns The port.
69
+ * @example defaultPortBase(DEFAULT_PORTS, "mbp", "/repo/.claude/worktrees/feat+42-x", "darwin") // 20000 to 24990
70
+ */
71
+ export function defaultPortBase(ports, machine, root, system) {
72
+ const hash = Number.parseInt(sha256(`${machine}:${root}`).slice(0, HASH_DIGITS), 16);
73
+ const half = ports.blocks / 2;
74
+ return ports.start + (firstBlock(ports, system) + (hash % half)) * ports.blockSize;
75
+ }
76
+ /**
77
+ * The block after one, without leaving its half: after the half's last it goes back to
78
+ * the half's first.
79
+ * @param ports - The range.
80
+ * @param base - The first port of a block.
81
+ * @returns The next block's first port.
82
+ * @example nextPortBase(DEFAULT_PORTS, 24990) // 20000
83
+ */
84
+ export function nextPortBase(ports, base) {
85
+ const half = ports.blocks / 2;
86
+ const block = (base - ports.start) / ports.blockSize;
87
+ const first = Math.floor(block / half) * half;
88
+ const next = block + 1 < first + half ? block + 1 : first;
89
+ return ports.start + next * ports.blockSize;
90
+ }
91
+ /**
92
+ * Whether a value is the first port of a block of the range.
93
+ * @param ports - The range.
94
+ * @param value - The value.
95
+ * @returns True if it is.
96
+ * @example isPortBase(DEFAULT_PORTS, 20010) // true
97
+ */
98
+ export function isPortBase(ports, value) {
99
+ const end = ports.start + ports.blocks * ports.blockSize;
100
+ return Number.isInteger(value) && value >= ports.start && value < end && (value - ports.start) % ports.blockSize === 0;
101
+ }
102
+ /**
103
+ * The file with a worktree's block.
104
+ * @param root - The worktree.
105
+ * @returns The path, or null outside git.
106
+ */
107
+ function baseFile(root) {
108
+ const dir = git(root, ["rev-parse", "--path-format=absolute", "--git-dir"]);
109
+ return dir.ok ? join(dir.out, PORT_BASE_FILE) : null;
110
+ }
111
+ /**
112
+ * The block saved for a worktree, if it is valid for the range.
113
+ * @param ports - The range.
114
+ * @param root - The worktree.
115
+ * @returns The first port, or null.
116
+ */
117
+ function savedBase(ports, root) {
118
+ const file = baseFile(root);
119
+ if (file === null || !existsSync(file))
120
+ return null;
121
+ const value = Number(readFileSync(file, "utf8").trim());
122
+ return isPortBase(ports, value) ? value : null;
123
+ }
124
+ /**
125
+ * The first port of a worktree's block: the saved one, or the one it starts from. It only
126
+ * reads: `claimPortBase` writes it once, before the apps start.
127
+ * @param ports - The range.
128
+ * @param root - The worktree.
129
+ * @returns The port.
130
+ * @example portBase(DEFAULT_PORTS, wt) // 21430
131
+ */
132
+ export function portBase(ports, root) {
133
+ return savedBase(ports, root) ?? defaultPortBase(ports, machineName(), root, platform());
134
+ }
135
+ /**
136
+ * Whether a folder is still the root of a checkout, comparing resolved paths. An empty
137
+ * folder left by a deleted worktree inside the main checkout finds the main repo, so being
138
+ * inside git is not enough.
139
+ * @param path - The folder.
140
+ * @returns True if it is a root.
141
+ * @example isCheckoutRoot("/repo/.claude/worktrees/feat+42-x")
142
+ */
143
+ export function isCheckoutRoot(path) {
144
+ return path !== "" && existsSync(path) && checkoutRoot(path) === realpathSync(path);
145
+ }
146
+ /**
147
+ * The folder that records which worktree of the machine took a block.
148
+ * @param base - The block's first port.
149
+ * @returns The folder.
150
+ */
151
+ function claimDir(base) {
152
+ return join(stateDir(), "ports", String(base));
153
+ }
154
+ /**
155
+ * The worktree that registered a block, if it still exists and is not this one.
156
+ * @param root - This worktree.
157
+ * @param base - The block.
158
+ * @returns True if another live worktree has it.
159
+ */
160
+ function takenByOther(root, base) {
161
+ const file = join(claimDir(base), "path");
162
+ if (!existsSync(file))
163
+ return false;
164
+ const owner = readFileSync(file, "utf8").trim();
165
+ return owner !== "" && owner !== root && isCheckoutRoot(owner);
166
+ }
167
+ /**
168
+ * Whether something listens on a port, over IPv4 or IPv6.
169
+ * @param port - The port.
170
+ * @returns True if a connection opens.
171
+ */
172
+ async function listening(port) {
173
+ const tryHost = (host) => new Promise((resolve) => {
174
+ const socket = connect({ host, port });
175
+ const done = (open) => {
176
+ socket.destroy();
177
+ resolve(open);
178
+ };
179
+ socket.setTimeout(CONNECT_TIMEOUT_MS, () => done(false));
180
+ socket.once("connect", () => done(true));
181
+ socket.once("error", () => done(false));
182
+ });
183
+ return (await tryHost("127.0.0.1")) || (await tryHost("::1"));
184
+ }
185
+ /**
186
+ * Whether something listens on any port of a block.
187
+ * @param ports - The range.
188
+ * @param base - The block.
189
+ * @returns True if one is busy.
190
+ */
191
+ async function blockBusy(ports, base) {
192
+ const checks = Array.from({ length: ports.blockSize }, (_, offset) => listening(base + offset));
193
+ return (await Promise.all(checks)).some(Boolean);
194
+ }
195
+ /**
196
+ * Registers a block for the worktree.
197
+ * @param root - The worktree.
198
+ * @param base - The block.
199
+ * @returns False if another live worktree registered it first.
200
+ */
201
+ function takeClaim(root, base) {
202
+ if (takenByOther(root, base))
203
+ return false;
204
+ mkdirSync(claimDir(base), { recursive: true });
205
+ writeFileSync(join(claimDir(base), "path"), `${root}\n`);
206
+ return true;
207
+ }
208
+ /**
209
+ * Deletes the worktree's registrations, but the one of the block it keeps. Run when the
210
+ * worktree moves to another block or goes away.
211
+ * @param root - The worktree.
212
+ * @param keep - The block it keeps, or null for none.
213
+ * @example releasePortClaims(wt, null)
214
+ */
215
+ export function releasePortClaims(root, keep) {
216
+ const dir = join(stateDir(), "ports");
217
+ if (!existsSync(dir))
218
+ return;
219
+ for (const name of readdirSync(dir)) {
220
+ const file = join(dir, name, "path");
221
+ if (name === String(keep) || !existsSync(file))
222
+ continue;
223
+ if (readFileSync(file, "utf8").trim() === root)
224
+ rmSync(join(dir, name), { recursive: true, force: true });
225
+ }
226
+ }
227
+ /**
228
+ * Fixes the worktree's block and returns it. A valid saved block stays. Otherwise, or with
229
+ * `start` (to move it), it looks from `start` (or the block it starts from) for the first
230
+ * free one of its half: nobody registered it and nothing listens in it. It registers that
231
+ * block, saves it and drops the ones it had.
232
+ * @param ports - The range.
233
+ * @param root - The worktree.
234
+ * @param start - Where to start looking, or null to keep the saved block.
235
+ * @returns The first port, or null if the whole half is taken.
236
+ * @example await claimPortBase(DEFAULT_PORTS, wt, null) // 21430
237
+ */
238
+ export async function claimPortBase(ports, root, start) {
239
+ const saved = savedBase(ports, root);
240
+ if (start === null && saved !== null) {
241
+ takeClaim(root, saved);
242
+ return saved;
243
+ }
244
+ const file = baseFile(root);
245
+ if (file === null)
246
+ return null;
247
+ let candidate = start ?? defaultPortBase(ports, machineName(), root, platform());
248
+ for (let tries = 0; tries < ports.blocks / 2; tries += 1) {
249
+ if (!takenByOther(root, candidate) && !(await blockBusy(ports, candidate)) && takeClaim(root, candidate)) {
250
+ writeFileSync(file, `${candidate}\n`);
251
+ releasePortClaims(root, candidate);
252
+ return candidate;
253
+ }
254
+ candidate = nextPortBase(ports, candidate);
255
+ }
256
+ return null;
257
+ }
@@ -0,0 +1,42 @@
1
+ import type { FlowConfig, Namer } from "./config.js";
2
+ /** What is read of an issue to name its branch. */
3
+ export interface IssueInfo {
4
+ number: number;
5
+ title: string;
6
+ body: string;
7
+ labels: string[];
8
+ }
9
+ /** Where a proposed branch came from. */
10
+ export type BranchSource = "slug" | "suggested" | "claude" | "title";
11
+ /** The branch proposed for an issue, with the slug and type it was made from. */
12
+ export interface Proposal {
13
+ number: number;
14
+ branch: string;
15
+ slug: string;
16
+ type: string | null;
17
+ source: BranchSource;
18
+ warnings: string[];
19
+ }
20
+ /** How to propose: the namer (the config's by default), where to run Claude and whether
21
+ * a type Claude picks for an issue without one is used. */
22
+ export interface ProposeOptions {
23
+ namer?: Namer;
24
+ cwd: string;
25
+ claudeType?: boolean;
26
+ }
27
+ /**
28
+ * Proposes a branch for each issue. Claude is called once, for every issue without a
29
+ * usable block, and only if the namer is `claude`; if it fails or skips an issue, that
30
+ * issue falls back to the title, with a warning.
31
+ * @param issues - The issues.
32
+ * @param config - The repo's config.
33
+ * @param options - The namer, where to run Claude and whether Claude's type is used
34
+ * (true by default; false when only the slug will be written to the issue).
35
+ * @returns One proposal per issue, in the same order, and the warnings of the whole batch
36
+ * (Claude not answering).
37
+ * @example proposeBranches([issue], config, { cwd: process.cwd() }).proposals[0]?.branch
38
+ */
39
+ export declare function proposeBranches(issues: IssueInfo[], config: FlowConfig, options: ProposeOptions): {
40
+ proposals: Proposal[];
41
+ warnings: string[];
42
+ };
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @file Proposes the branch of each issue, in this order: its "Slug de rama" block, its
3
+ * older "Rama sugerida" block if it fits the issue, a slug from Claude (one call for every
4
+ * issue that needs one), or the title's slug. Except for the older block, the branch is
5
+ * always the repo's template. Ported from `propose_branch` in Abastio's
6
+ * `scripts/start-issue.sh`, with the slug block and Claude as new steps.
7
+ */
8
+ import { branchProblem, branchType, isSlug, renderBranch, suggestedBranch, suggestedSlug, titleSlug, typeNames, } from "./branch-name.js";
9
+ import { nameWithClaude } from "./claude-namer.js";
10
+ /**
11
+ * The proposal from the issue's own blocks: the slug block, or else the older block if it
12
+ * fits the issue (used as is, with a warning if its prefix is not the issue's type).
13
+ * @param issue - The issue.
14
+ * @param type - Its type, or null.
15
+ * @param template - The repo's template.
16
+ * @returns The proposal, or the notes on why the blocks were not used.
17
+ */
18
+ function fromBlocks(issue, type, template) {
19
+ const { number } = issue;
20
+ const slug = suggestedSlug(issue.body);
21
+ if (isSlug(slug))
22
+ return { number, branch: renderBranch(template, type, number, slug), slug, type, source: "slug", warnings: [] };
23
+ if (slug !== null)
24
+ return { notes: [`#${number}: "${slug}" in "Slug de rama" is not a valid slug; proposing another.`] };
25
+ const branch = suggestedBranch(issue.body);
26
+ const problem = branchProblem(branch, number);
27
+ if (branch === null)
28
+ return { notes: [] };
29
+ if (problem !== null)
30
+ return { notes: [`#${number}: ${problem}; proposing another.`] };
31
+ const prefix = branch.includes("/") ? branch.slice(0, branch.indexOf("/")) : null;
32
+ const warnings = type !== null && prefix !== null && prefix !== type
33
+ ? [`#${number}: the suggested branch ${branch} does not start with ${type}/, the issue's type; using it anyway.`]
34
+ : [];
35
+ const own = branch.slice(branch.lastIndexOf("/") + 1).slice(`${number}-`.length);
36
+ return { number, branch, slug: own, type, source: "suggested", warnings };
37
+ }
38
+ /**
39
+ * Proposes a branch for each issue. Claude is called once, for every issue without a
40
+ * usable block, and only if the namer is `claude`; if it fails or skips an issue, that
41
+ * issue falls back to the title, with a warning.
42
+ * @param issues - The issues.
43
+ * @param config - The repo's config.
44
+ * @param options - The namer, where to run Claude and whether Claude's type is used
45
+ * (true by default; false when only the slug will be written to the issue).
46
+ * @returns One proposal per issue, in the same order, and the warnings of the whole batch
47
+ * (Claude not answering).
48
+ * @example proposeBranches([issue], config, { cwd: process.cwd() }).proposals[0]?.branch
49
+ */
50
+ export function proposeBranches(issues, config, options) {
51
+ const { template, types: rules } = config.branch;
52
+ const typeOf = new Map(issues.map((issue) => [issue.number, branchType(rules, issue.labels, issue.title)]));
53
+ const proposals = new Map();
54
+ const pending = [];
55
+ const notes = new Map();
56
+ for (const issue of issues) {
57
+ const found = fromBlocks(issue, typeOf.get(issue.number) ?? null, template);
58
+ if ("branch" in found) {
59
+ proposals.set(issue.number, found);
60
+ continue;
61
+ }
62
+ notes.set(issue.number, found.notes);
63
+ pending.push(issue);
64
+ }
65
+ const namer = options.namer ?? config.branch.namer;
66
+ const asked = pending.map((issue) => ({
67
+ number: issue.number,
68
+ title: issue.title,
69
+ body: issue.body,
70
+ type: typeOf.get(issue.number) ?? null,
71
+ }));
72
+ const answer = namer === "claude" && asked.length > 0
73
+ ? nameWithClaude(asked, typeNames(rules), config.branch.claudeModel, options.cwd)
74
+ : null;
75
+ const prefixes = Object.keys(rules.titlePrefixes);
76
+ for (const issue of pending) {
77
+ const { number } = issue;
78
+ const warnings = notes.get(number) ?? [];
79
+ const own = typeOf.get(number) ?? null;
80
+ const named = answer !== null && "names" in answer ? answer.names.get(number) : undefined;
81
+ if (named !== undefined) {
82
+ const type = options.claudeType === false ? own : named.type;
83
+ proposals.set(number, {
84
+ number,
85
+ branch: renderBranch(template, type, number, named.slug),
86
+ slug: named.slug,
87
+ type,
88
+ source: "claude",
89
+ warnings,
90
+ });
91
+ continue;
92
+ }
93
+ if (answer !== null && "names" in answer)
94
+ warnings.push(`#${number}: Claude gave no valid name; using the title.`);
95
+ const slug = titleSlug(issue.title, prefixes);
96
+ proposals.set(number, {
97
+ number,
98
+ branch: renderBranch(template, own, number, slug),
99
+ slug,
100
+ type: own,
101
+ source: "title",
102
+ warnings,
103
+ });
104
+ }
105
+ return {
106
+ proposals: issues.map((issue) => proposals.get(issue.number)),
107
+ warnings: answer !== null && "error" in answer
108
+ ? [`${answer.error}; naming ${pending.length} issue(s) from the title.`]
109
+ : [],
110
+ };
111
+ }
@@ -0,0 +1,43 @@
1
+ /** A recipe. */
2
+ export interface Recipe {
3
+ name: string;
4
+ description: string;
5
+ detect: string[];
6
+ config: Record<string, unknown>;
7
+ }
8
+ /**
9
+ * Every recipe, by name.
10
+ * @returns The recipes.
11
+ * @throws SetupError if a recipe file is broken.
12
+ * @example loadRecipes().map((recipe) => recipe.name) // ["bun", "direnv", …]
13
+ */
14
+ export declare function loadRecipes(): Recipe[];
15
+ /**
16
+ * The recipes the repo's files suggest: a lockfile, a compose file, an `.envrc`.
17
+ * @param root - The repo.
18
+ * @param recipes - The recipes.
19
+ * @returns Their names.
20
+ * @example detectedRecipes(root, loadRecipes()) // ["pnpm", "docker-compose"]
21
+ */
22
+ export declare function detectedRecipes(root: string, recipes: Recipe[]): string[];
23
+ /**
24
+ * The prefix of the repo's Docker projects: its folder name, in lowercase letters, digits
25
+ * and dashes.
26
+ * @param root - The repo.
27
+ * @returns The prefix.
28
+ * @example projectPrefix("/code/Acme Shop") // "acme-shop"
29
+ */
30
+ export declare function projectPrefix(root: string): string;
31
+ /**
32
+ * Applies a recipe to the repo's raw config: adds its missing steps and keys, and keeps
33
+ * every key the repo already set.
34
+ * @param raw - The repo's config, as JSON.
35
+ * @param recipe - The recipe.
36
+ * @param prefix - The repo's Docker project prefix, for `{project}`.
37
+ * @returns The new config and what changed, one line each.
38
+ * @example applyRecipe({}, npmRecipe, "acme").changes // ['worktrees.setup: added "deps"']
39
+ */
40
+ export declare function applyRecipe(raw: Record<string, unknown>, recipe: Recipe, prefix: string): {
41
+ raw: Record<string, unknown>;
42
+ changes: string[];
43
+ };
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @file Recipes: pieces of `.agents/flow.json` for what many repos share (installing with
3
+ * npm, pnpm or bun, a Docker Compose project per worktree, direnv, a staging branch). Each
4
+ * one is a JSON file in `recipes/` with a description, the files that suggest it and its
5
+ * config. Applying one adds what is missing and never overwrites what the repo chose:
6
+ * steps go in by name, and a key already set stays as it is.
7
+ */
8
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
9
+ import { basename, dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { isObject } from "./config-base.js";
12
+ import { SetupError } from "./setup-files.js";
13
+ /** Where the recipes are, next to `dist`. */
14
+ const RECIPES_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "recipes");
15
+ /** What a recipe's strings say instead of the repo's Docker project prefix. */
16
+ const PROJECT_PLACEHOLDER = "{project}";
17
+ /** The worktree lists a recipe adds steps to. */
18
+ const STEP_LISTS = ["setup", "teardown"];
19
+ /**
20
+ * Every recipe, by name.
21
+ * @returns The recipes.
22
+ * @throws SetupError if a recipe file is broken.
23
+ * @example loadRecipes().map((recipe) => recipe.name) // ["bun", "direnv", …]
24
+ */
25
+ export function loadRecipes() {
26
+ return readdirSync(RECIPES_DIR).filter((file) => file.endsWith(".json")).sort().map((file) => {
27
+ const raw = JSON.parse(readFileSync(join(RECIPES_DIR, file), "utf8"));
28
+ if (!isObject(raw) || typeof raw.description !== "string" || !Array.isArray(raw.detect) || !isObject(raw.config))
29
+ throw new SetupError(`recipe ${file} is broken.`);
30
+ return { name: basename(file, ".json"), description: raw.description, detect: raw.detect.map(String), config: raw.config };
31
+ });
32
+ }
33
+ /**
34
+ * The recipes the repo's files suggest: a lockfile, a compose file, an `.envrc`.
35
+ * @param root - The repo.
36
+ * @param recipes - The recipes.
37
+ * @returns Their names.
38
+ * @example detectedRecipes(root, loadRecipes()) // ["pnpm", "docker-compose"]
39
+ */
40
+ export function detectedRecipes(root, recipes) {
41
+ return recipes.filter((recipe) => recipe.detect.some((file) => existsSync(join(root, file)))).map((recipe) => recipe.name);
42
+ }
43
+ /**
44
+ * The prefix of the repo's Docker projects: its folder name, in lowercase letters, digits
45
+ * and dashes.
46
+ * @param root - The repo.
47
+ * @returns The prefix.
48
+ * @example projectPrefix("/code/Acme Shop") // "acme-shop"
49
+ */
50
+ export function projectPrefix(root) {
51
+ const slug = basename(root).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
52
+ return slug === "" ? "app" : slug;
53
+ }
54
+ /**
55
+ * Adds a recipe's steps to a list, those whose name is not there yet.
56
+ * @param current - The repo's steps, or undefined.
57
+ * @param added - The recipe's steps.
58
+ * @returns The list, the names added and the names it already had.
59
+ */
60
+ function mergeSteps(current, added) {
61
+ const steps = Array.isArray(current) ? [...current] : [];
62
+ const names = new Set(steps.filter(isObject).map((step) => step.name));
63
+ const recipeSteps = (Array.isArray(added) ? added : []).filter(isObject);
64
+ const fresh = recipeSteps.filter((step) => !names.has(step.name));
65
+ const taken = recipeSteps.filter((step) => names.has(step.name)).map((step) => String(step.name));
66
+ return { steps: [...steps, ...fresh], names: fresh.map((step) => String(step.name)), taken };
67
+ }
68
+ /**
69
+ * Merges a recipe's `worktrees` into the repo's.
70
+ * @param current - The repo's `worktrees`, or undefined.
71
+ * @param added - The recipe's.
72
+ * @param changes - Where to note what changed.
73
+ * @returns The merged `worktrees`.
74
+ */
75
+ function mergeWorktrees(current, added, changes) {
76
+ const merged = isObject(current) ? { ...current } : {};
77
+ for (const [key, value] of Object.entries(added)) {
78
+ if (STEP_LISTS.includes(key)) {
79
+ const { steps, names, taken } = mergeSteps(merged[key], value);
80
+ merged[key] = steps;
81
+ for (const name of names)
82
+ changes.push(`worktrees.${key}: added "${name}"`);
83
+ for (const name of taken)
84
+ changes.push(`kept the "${name}" step of worktrees.${key}`);
85
+ }
86
+ else if (key === "dockerProjects") {
87
+ const projects = [...new Set([...(Array.isArray(merged[key]) ? merged[key] : []), ...(Array.isArray(value) ? value : [])])];
88
+ if (projects.length !== (Array.isArray(merged[key]) ? merged[key].length : 0))
89
+ changes.push(`worktrees.dockerProjects: ${projects.join(", ")}`);
90
+ merged[key] = projects;
91
+ }
92
+ else if (merged[key] === undefined) {
93
+ merged[key] = value;
94
+ changes.push(`worktrees.${key}: ${JSON.stringify(value)}`);
95
+ }
96
+ }
97
+ return merged;
98
+ }
99
+ /**
100
+ * Applies a recipe to the repo's raw config: adds its missing steps and keys, and keeps
101
+ * every key the repo already set.
102
+ * @param raw - The repo's config, as JSON.
103
+ * @param recipe - The recipe.
104
+ * @param prefix - The repo's Docker project prefix, for `{project}`.
105
+ * @returns The new config and what changed, one line each.
106
+ * @example applyRecipe({}, npmRecipe, "acme").changes // ['worktrees.setup: added "deps"']
107
+ */
108
+ export function applyRecipe(raw, recipe, prefix) {
109
+ const config = JSON.parse(JSON.stringify(recipe.config).split(PROJECT_PLACEHOLDER).join(prefix));
110
+ const next = { ...raw };
111
+ const changes = [];
112
+ for (const [key, value] of Object.entries(config)) {
113
+ if (key === "worktrees" && isObject(value))
114
+ next.worktrees = mergeWorktrees(next.worktrees, value, changes);
115
+ else if (next[key] === undefined) {
116
+ next[key] = value;
117
+ changes.push(`${key}: ${JSON.stringify(value)}`);
118
+ }
119
+ else if (JSON.stringify(next[key]) !== JSON.stringify(value))
120
+ changes.push(`kept ${key} ${JSON.stringify(next[key])} (the recipe says ${JSON.stringify(value)})`);
121
+ }
122
+ return { raw: next, changes };
123
+ }
@@ -0,0 +1,32 @@
1
+ /** Start of the lock reason. */
2
+ export declare const LOCK_REASON = "claude session issue-flow";
3
+ /** How the lock went. */
4
+ export interface Lock {
5
+ mine: boolean;
6
+ shared: boolean;
7
+ }
8
+ /**
9
+ * Whether a lock is from an `issue-flow` whose process is gone (a `kill -9`).
10
+ * @param reason - The lock reason.
11
+ * @returns True if it is stale.
12
+ * @example isStale("claude session issue-flow 99999999") // true
13
+ */
14
+ export declare function isStale(reason: string): boolean;
15
+ /**
16
+ * Locks the worktree. A stale lock of `issue-flow` is taken over; any other lock is left
17
+ * alone, and then the session counts as shared, so nothing offers to delete the worktree.
18
+ * @param mainRoot - The main checkout.
19
+ * @param workDir - The worktree.
20
+ * @param say - Where to say what happened.
21
+ * @returns Whether this process holds the lock and whether another session does.
22
+ * @example lockWorktree(root, wt, console.error)
23
+ */
24
+ export declare function lockWorktree(mainRoot: string, workDir: string, say: (text: string) => void): Lock;
25
+ /**
26
+ * Unlocks the worktree if this process locked it.
27
+ * @param mainRoot - The main checkout.
28
+ * @param workDir - The worktree.
29
+ * @param lock - The lock.
30
+ * @example unlockWorktree(root, wt, lock)
31
+ */
32
+ export declare function unlockWorktree(mainRoot: string, workDir: string, lock: Lock): void;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @file Locks the worktree while Claude runs (`git worktree lock`), so cleaning worktrees
3
+ * from another terminal does not delete one in use. The reason starts like the one of
4
+ * `claude --worktree`, so tools that respect it respect this one too, and ends with the
5
+ * pid: a lock left by a process that died is taken over. Ported from `lock_worktree` in
6
+ * Abastio's `scripts/start-issue.sh`.
7
+ */
8
+ import { git, worktreeLock } from "./git.js";
9
+ /** Start of the lock reason. */
10
+ export const LOCK_REASON = "claude session issue-flow";
11
+ /**
12
+ * Whether a process is alive.
13
+ * @param pid - The pid.
14
+ * @returns True if it is (or it cannot be told: another user's).
15
+ */
16
+ function alive(pid) {
17
+ try {
18
+ process.kill(pid, 0);
19
+ return true;
20
+ }
21
+ catch (error) {
22
+ return error.code === "EPERM";
23
+ }
24
+ }
25
+ /**
26
+ * Whether a lock is from an `issue-flow` whose process is gone (a `kill -9`).
27
+ * @param reason - The lock reason.
28
+ * @returns True if it is stale.
29
+ * @example isStale("claude session issue-flow 99999999") // true
30
+ */
31
+ export function isStale(reason) {
32
+ if (!reason.startsWith(`${LOCK_REASON} `))
33
+ return false;
34
+ const pid = reason.slice(LOCK_REASON.length + 1);
35
+ return /^\d+$/.test(pid) && !alive(Number(pid));
36
+ }
37
+ /**
38
+ * Locks the worktree. A stale lock of `issue-flow` is taken over; any other lock is left
39
+ * alone, and then the session counts as shared, so nothing offers to delete the worktree.
40
+ * @param mainRoot - The main checkout.
41
+ * @param workDir - The worktree.
42
+ * @param say - Where to say what happened.
43
+ * @returns Whether this process holds the lock and whether another session does.
44
+ * @example lockWorktree(root, wt, console.error)
45
+ */
46
+ export function lockWorktree(mainRoot, workDir, say) {
47
+ const reason = worktreeLock(mainRoot, workDir);
48
+ if (reason !== null && isStale(reason)) {
49
+ say(`Taking over the lock of a session that is gone (${reason}).`);
50
+ git(mainRoot, ["worktree", "unlock", workDir]);
51
+ }
52
+ if (git(mainRoot, ["worktree", "lock", "--reason", `${LOCK_REASON} ${process.pid}`, workDir]).ok)
53
+ return { mine: true, shared: false };
54
+ say("the worktree is already locked (is another session open?); going on without locking it.");
55
+ return { mine: false, shared: true };
56
+ }
57
+ /**
58
+ * Unlocks the worktree if this process locked it.
59
+ * @param mainRoot - The main checkout.
60
+ * @param workDir - The worktree.
61
+ * @param lock - The lock.
62
+ * @example unlockWorktree(root, wt, lock)
63
+ */
64
+ export function unlockWorktree(mainRoot, workDir, lock) {
65
+ if (!lock.mine)
66
+ return;
67
+ git(mainRoot, ["worktree", "unlock", workDir]);
68
+ lock.mine = false;
69
+ }