@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.
- package/LICENSE +21 -0
- package/README.md +297 -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 +120 -0
- package/dist/cli.js +344 -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 +11 -0
- package/dist/commands/doctor.js +90 -0
- package/dist/commands/init.d.ts +24 -0
- package/dist/commands/init.js +136 -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 +85 -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/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 +48 -0
- package/dist/setup-files.js +91 -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,35 @@
|
|
|
1
|
+
/** A command of the setup or the teardown of a worktree. */
|
|
2
|
+
export interface Step {
|
|
3
|
+
name: string;
|
|
4
|
+
run: string;
|
|
5
|
+
trusted: boolean;
|
|
6
|
+
once: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** The range of ports split in blocks, one block per worktree. */
|
|
9
|
+
export interface PortsConfig {
|
|
10
|
+
start: number;
|
|
11
|
+
blockSize: number;
|
|
12
|
+
blocks: number;
|
|
13
|
+
}
|
|
14
|
+
/** The `worktrees` section, with its defaults. */
|
|
15
|
+
export interface WorktreesConfig {
|
|
16
|
+
dir: string;
|
|
17
|
+
setup: Step[];
|
|
18
|
+
teardown: Step[];
|
|
19
|
+
ports: PortsConfig | null;
|
|
20
|
+
dockerProjects: string[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The ports with `"ports": true`: 1000 blocks of 10 from 20000, far from the usual ones
|
|
24
|
+
* (3000, 9000), from Docker's ephemeral ones (from 32768) and the same as Abastio's.
|
|
25
|
+
*/
|
|
26
|
+
export declare const DEFAULT_PORTS: PortsConfig;
|
|
27
|
+
/**
|
|
28
|
+
* The `worktrees` section, with its defaults.
|
|
29
|
+
* @param value - The section, or undefined.
|
|
30
|
+
* @param fallback - The defaults.
|
|
31
|
+
* @returns The section.
|
|
32
|
+
* @throws ConfigError if something is invalid.
|
|
33
|
+
* @example worktreesSection({ ports: true }, defaults).ports // { start: 20000, blockSize: 10, blocks: 1000 }
|
|
34
|
+
*/
|
|
35
|
+
export declare function worktreesSection(value: unknown, fallback: WorktreesConfig): WorktreesConfig;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The `worktrees` section of `.agents/flow.json`: where worktrees go, the steps that
|
|
3
|
+
* prepare one and clean it before deleting it, its block of ports, and the prefixes of its
|
|
4
|
+
* Docker Compose projects. Everything that belongs to one project (installing, a database,
|
|
5
|
+
* Docker) is a step here; `issue-flow` only runs them.
|
|
6
|
+
*/
|
|
7
|
+
import { CONFIG_PATH, ConfigError, isObject, onlyKeys, text } from "./config-base.js";
|
|
8
|
+
/**
|
|
9
|
+
* The ports with `"ports": true`: 1000 blocks of 10 from 20000, far from the usual ones
|
|
10
|
+
* (3000, 9000), from Docker's ephemeral ones (from 32768) and the same as Abastio's.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_PORTS = { start: 20000, blockSize: 10, blocks: 1000 };
|
|
13
|
+
/** The first port a block can use: below it, ports need root. */
|
|
14
|
+
const MIN_PORT = 1024;
|
|
15
|
+
/** One past the last port. */
|
|
16
|
+
const PORT_LIMIT = 65536;
|
|
17
|
+
/** A step's name: lowercase, for `--only` and the marker of `once`. */
|
|
18
|
+
const STEP_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
19
|
+
/** A Docker Compose project prefix, as Compose accepts project names. */
|
|
20
|
+
const PROJECT = /^[a-z0-9][a-z0-9_-]*$/;
|
|
21
|
+
/**
|
|
22
|
+
* An optional boolean of a step.
|
|
23
|
+
* @param value - The value, or undefined.
|
|
24
|
+
* @param where - Its path, for the error.
|
|
25
|
+
* @returns The value, false if missing.
|
|
26
|
+
* @throws ConfigError if it is not a boolean.
|
|
27
|
+
*/
|
|
28
|
+
function flag(value, where) {
|
|
29
|
+
if (value === undefined)
|
|
30
|
+
return false;
|
|
31
|
+
if (typeof value !== "boolean")
|
|
32
|
+
throw new ConfigError(`${CONFIG_PATH}: "${where}" must be true or false.`);
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A list of steps. A teardown step has no `trusted` nor `once`: it cleans up, whatever the
|
|
37
|
+
* code.
|
|
38
|
+
* @param value - The list, or undefined.
|
|
39
|
+
* @param where - Its path (`worktrees.setup`).
|
|
40
|
+
* @param teardown - Whether these are teardown steps.
|
|
41
|
+
* @returns The steps.
|
|
42
|
+
* @throws ConfigError if a step is invalid or two share a name.
|
|
43
|
+
*/
|
|
44
|
+
function steps(value, where, teardown) {
|
|
45
|
+
if (value === undefined)
|
|
46
|
+
return [];
|
|
47
|
+
if (!Array.isArray(value))
|
|
48
|
+
throw new ConfigError(`${CONFIG_PATH}: "${where}" must be a list of steps.`);
|
|
49
|
+
const names = new Set();
|
|
50
|
+
return value.map((raw, index) => {
|
|
51
|
+
const at = `${where}[${index}]`;
|
|
52
|
+
if (!isObject(raw))
|
|
53
|
+
throw new ConfigError(`${CONFIG_PATH}: "${at}" must be an object with "name" and "run".`);
|
|
54
|
+
onlyKeys(raw, teardown ? ["name", "run"] : ["name", "run", "trusted", "once"], at);
|
|
55
|
+
const name = text(raw.name, `${at}.name`, null);
|
|
56
|
+
if (name === null || !STEP_NAME.test(name))
|
|
57
|
+
throw new ConfigError(`${CONFIG_PATH}: "${at}.name" must be lowercase letters, digits and dashes.`);
|
|
58
|
+
if (names.has(name))
|
|
59
|
+
throw new ConfigError(`${CONFIG_PATH}: "${where}" has two steps named "${name}".`);
|
|
60
|
+
names.add(name);
|
|
61
|
+
const run = text(raw.run, `${at}.run`, null);
|
|
62
|
+
if (run === null)
|
|
63
|
+
throw new ConfigError(`${CONFIG_PATH}: "${at}.run" must be a non-empty string.`);
|
|
64
|
+
return { name, run, trusted: flag(raw.trusted, `${at}.trusted`), once: flag(raw.once, `${at}.once`) };
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A whole number of the ports.
|
|
69
|
+
* @param value - The value, or undefined.
|
|
70
|
+
* @param key - Its key.
|
|
71
|
+
* @returns The number, or the default's.
|
|
72
|
+
* @throws ConfigError if it is not a positive whole number.
|
|
73
|
+
*/
|
|
74
|
+
function count(value, key) {
|
|
75
|
+
if (value === undefined)
|
|
76
|
+
return DEFAULT_PORTS[key];
|
|
77
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1)
|
|
78
|
+
throw new ConfigError(`${CONFIG_PATH}: "worktrees.ports.${key}" must be a positive whole number.`);
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The ports: off (absent or false), the defaults (true), or a range. Its blocks are split
|
|
83
|
+
* in two halves, one for macOS and one for the rest, so they must be even.
|
|
84
|
+
* @param value - The value, or undefined.
|
|
85
|
+
* @returns The ports, or null if off.
|
|
86
|
+
* @throws ConfigError if the range does not fit.
|
|
87
|
+
*/
|
|
88
|
+
function ports(value) {
|
|
89
|
+
if (value === undefined || value === false)
|
|
90
|
+
return null;
|
|
91
|
+
if (value === true)
|
|
92
|
+
return DEFAULT_PORTS;
|
|
93
|
+
if (!isObject(value))
|
|
94
|
+
throw new ConfigError(`${CONFIG_PATH}: "worktrees.ports" must be true, false or an object.`);
|
|
95
|
+
onlyKeys(value, ["start", "blockSize", "blocks"], "worktrees.ports");
|
|
96
|
+
const range = { start: count(value.start, "start"), blockSize: count(value.blockSize, "blockSize"), blocks: count(value.blocks, "blocks") };
|
|
97
|
+
if (range.blocks % 2 !== 0)
|
|
98
|
+
throw new ConfigError(`${CONFIG_PATH}: "worktrees.ports.blocks" must be even.`);
|
|
99
|
+
if (range.start < MIN_PORT || range.start + range.blockSize * range.blocks > PORT_LIMIT)
|
|
100
|
+
throw new ConfigError(`${CONFIG_PATH}: "worktrees.ports" must stay between ${MIN_PORT} and ${PORT_LIMIT - 1}.`);
|
|
101
|
+
return range;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The Docker Compose project prefixes of a worktree (each worktree's project is
|
|
105
|
+
* `<prefix>-<folder>-<hash>`); the cleanup only ever deletes projects that start with one.
|
|
106
|
+
* @param value - The list, or undefined.
|
|
107
|
+
* @returns The prefixes.
|
|
108
|
+
* @throws ConfigError if one is not a valid project name.
|
|
109
|
+
*/
|
|
110
|
+
function dockerProjects(value) {
|
|
111
|
+
if (value === undefined)
|
|
112
|
+
return [];
|
|
113
|
+
const valid = Array.isArray(value) && value.every((item) => typeof item === "string" && PROJECT.test(item));
|
|
114
|
+
if (!valid)
|
|
115
|
+
throw new ConfigError(`${CONFIG_PATH}: "worktrees.dockerProjects" must be a list of Compose project prefixes (lowercase letters, digits, - and _).`);
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The `worktrees` section, with its defaults.
|
|
120
|
+
* @param value - The section, or undefined.
|
|
121
|
+
* @param fallback - The defaults.
|
|
122
|
+
* @returns The section.
|
|
123
|
+
* @throws ConfigError if something is invalid.
|
|
124
|
+
* @example worktreesSection({ ports: true }, defaults).ports // { start: 20000, blockSize: 10, blocks: 1000 }
|
|
125
|
+
*/
|
|
126
|
+
export function worktreesSection(value, fallback) {
|
|
127
|
+
if (value === undefined)
|
|
128
|
+
return fallback;
|
|
129
|
+
if (!isObject(value))
|
|
130
|
+
throw new ConfigError(`${CONFIG_PATH}: "worktrees" must be an object.`);
|
|
131
|
+
onlyKeys(value, ["dir", "setup", "teardown", "ports", "dockerProjects"], "worktrees");
|
|
132
|
+
return {
|
|
133
|
+
dir: text(value.dir, "worktrees.dir", fallback.dir),
|
|
134
|
+
setup: steps(value.setup, "worktrees.setup", false),
|
|
135
|
+
teardown: steps(value.teardown, "worktrees.teardown", true),
|
|
136
|
+
ports: ports(value.ports),
|
|
137
|
+
dockerProjects: dockerProjects(value.dockerProjects),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { FlowConfig } from "./config.js";
|
|
2
|
+
import { type Output } from "./output.js";
|
|
3
|
+
/** Folder, in the worktree's git dir, with a mark per `once` step that worked. */
|
|
4
|
+
export declare const SETUP_MARKS_DIR = "issue-flow-setup";
|
|
5
|
+
/** Where the steps run, and with what. */
|
|
6
|
+
export interface StepPlace {
|
|
7
|
+
root: string;
|
|
8
|
+
mainRoot: string;
|
|
9
|
+
base: string;
|
|
10
|
+
config: FlowConfig;
|
|
11
|
+
out: Output;
|
|
12
|
+
remote: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Whether the worktree's code can run an install: its HEAD is in `origin/<base>` and it has
|
|
16
|
+
* no changes to tracked files. A branch of someone else could put anything in the scripts
|
|
17
|
+
* an install runs.
|
|
18
|
+
* @param root - The worktree.
|
|
19
|
+
* @param base - The base branch.
|
|
20
|
+
* @returns True if it is trusted.
|
|
21
|
+
* @example trustedCode(wt, "main")
|
|
22
|
+
*/
|
|
23
|
+
export declare function trustedCode(root: string, base: string): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* The environment of a step: the worktree, the main checkout, the first port of its block
|
|
26
|
+
* (if the config has ports) and the suffix of its Docker projects (`<prefix>-<suffix>`).
|
|
27
|
+
* @param place - Where.
|
|
28
|
+
* @param port - The block's first port, or null.
|
|
29
|
+
* @returns The variables.
|
|
30
|
+
* @example stepEnv(place, 21430).ISSUE_FLOW_PORT_BASE // "21430"
|
|
31
|
+
*/
|
|
32
|
+
export declare function stepEnv(place: StepPlace, port: number | null): Record<string, string>;
|
|
33
|
+
/**
|
|
34
|
+
* Prepares a worktree with the setup steps: one (`only`) or all, in order. In the main
|
|
35
|
+
* checkout it does nothing, except in a cloud session, whose checkout is not a worktree. It
|
|
36
|
+
* fixes the block of ports first, so the steps (and the apps after them) agree on it.
|
|
37
|
+
* Never stops on a failed step: it says what to run and goes on.
|
|
38
|
+
* @param place - Where.
|
|
39
|
+
* @param only - The step to run, or null for all.
|
|
40
|
+
* @returns True if every step that ran worked.
|
|
41
|
+
* @example await runSetup(place, null)
|
|
42
|
+
*/
|
|
43
|
+
export declare function runSetup(place: StepPlace, only: string | null): Promise<boolean>;
|
|
44
|
+
/**
|
|
45
|
+
* Cleans a worktree before it is deleted: its apps and then its teardown steps (if the
|
|
46
|
+
* folder still exists), so a step that drops a database finds nobody connected; then its
|
|
47
|
+
* block of ports and its Docker. Never stops: a step or a Docker that
|
|
48
|
+
* fails gets a warning, because a cleanup cannot block the deletion.
|
|
49
|
+
* @param place - Where; the folder may be gone already.
|
|
50
|
+
* @returns True if nothing failed.
|
|
51
|
+
* @example await teardownWorktree(place)
|
|
52
|
+
*/
|
|
53
|
+
export declare function teardownWorktree(place: StepPlace): Promise<boolean>;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Runs the steps of `worktrees.setup` and `worktrees.teardown` in a worktree. The
|
|
3
|
+
* steps come from the main checkout's config, not the worktree's, so a branch cannot add
|
|
4
|
+
* commands to its own setup. A `trusted` step (an install, which runs the lifecycle scripts
|
|
5
|
+
* of `package.json`) runs only if the worktree's code is in `origin/<base>` and has no
|
|
6
|
+
* changes; a `once` step leaves a mark when it works and is not repeated. Two setups of one
|
|
7
|
+
* worktree at a time (the tool's and the session hook's) take turns with a lock. Each step
|
|
8
|
+
* gets the worktree, the main checkout, its block of ports and its Docker suffix in the
|
|
9
|
+
* environment. Ported from Abastio's `scripts/worktree-setup.sh` and `worktree-cleanup.sh`.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { basename, join } from "node:path";
|
|
14
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
15
|
+
import { dockerCleanup, sanitizeLabel } from "./docker.js";
|
|
16
|
+
import { git, isClean } from "./git.js";
|
|
17
|
+
import { warn } from "./output.js";
|
|
18
|
+
import { claimPortBase, portBase, releasePortClaims, sha256 } from "./ports.js";
|
|
19
|
+
import { stopApps } from "./stop-apps.js";
|
|
20
|
+
/** Folder, in the worktree's git dir, with a mark per `once` step that worked. */
|
|
21
|
+
export const SETUP_MARKS_DIR = "issue-flow-setup";
|
|
22
|
+
/** The lock of the setup, in the worktree's git dir. */
|
|
23
|
+
const LOCK_NAME = "issue-flow-setup.lock";
|
|
24
|
+
/** How old a lock without a pid must be to count as left by a process that died at once. */
|
|
25
|
+
const LOCK_NO_PID_MS = 10_000;
|
|
26
|
+
/** Seconds a setup waits for another one of the same worktree. */
|
|
27
|
+
const LOCK_WAIT_SECONDS = 600;
|
|
28
|
+
/** Hex digits of the hash in the Docker suffix, as in `dockerProject`. */
|
|
29
|
+
const SUFFIX_HASH = 6;
|
|
30
|
+
/**
|
|
31
|
+
* The worktree's own git dir.
|
|
32
|
+
* @param root - The worktree.
|
|
33
|
+
* @returns The path.
|
|
34
|
+
*/
|
|
35
|
+
function gitDir(root) {
|
|
36
|
+
return git(root, ["rev-parse", "--path-format=absolute", "--git-dir"]).out;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Whether the worktree's code can run an install: its HEAD is in `origin/<base>` and it has
|
|
40
|
+
* no changes to tracked files. A branch of someone else could put anything in the scripts
|
|
41
|
+
* an install runs.
|
|
42
|
+
* @param root - The worktree.
|
|
43
|
+
* @param base - The base branch.
|
|
44
|
+
* @returns True if it is trusted.
|
|
45
|
+
* @example trustedCode(wt, "main")
|
|
46
|
+
*/
|
|
47
|
+
export function trustedCode(root, base) {
|
|
48
|
+
return git(root, ["merge-base", "--is-ancestor", "HEAD", `origin/${base}`]).ok && isClean(root, false);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The environment of a step: the worktree, the main checkout, the first port of its block
|
|
52
|
+
* (if the config has ports) and the suffix of its Docker projects (`<prefix>-<suffix>`).
|
|
53
|
+
* @param place - Where.
|
|
54
|
+
* @param port - The block's first port, or null.
|
|
55
|
+
* @returns The variables.
|
|
56
|
+
* @example stepEnv(place, 21430).ISSUE_FLOW_PORT_BASE // "21430"
|
|
57
|
+
*/
|
|
58
|
+
export function stepEnv(place, port) {
|
|
59
|
+
return {
|
|
60
|
+
ISSUE_FLOW_WORKTREE: place.root,
|
|
61
|
+
ISSUE_FLOW_MAIN_ROOT: place.mainRoot,
|
|
62
|
+
ISSUE_FLOW_DOCKER_SUFFIX: `${sanitizeLabel(basename(place.root))}-${sha256(place.root).slice(0, SUFFIX_HASH)}`,
|
|
63
|
+
...(port === null ? {} : { ISSUE_FLOW_PORT_BASE: String(port) }),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Runs one step with `sh -c` in the worktree. Its output goes to stderr, so the stdout of
|
|
68
|
+
* the session hook stays for what Claude must read.
|
|
69
|
+
* @param place - Where.
|
|
70
|
+
* @param step - The step.
|
|
71
|
+
* @param env - The step's variables.
|
|
72
|
+
* @returns True if it worked.
|
|
73
|
+
*/
|
|
74
|
+
function runStep(place, step, env) {
|
|
75
|
+
const result = spawnSync("sh", ["-c", step.run], { cwd: place.root, env: { ...process.env, ...env }, stdio: ["ignore", 2, 2] });
|
|
76
|
+
if (result.status === 0)
|
|
77
|
+
return true;
|
|
78
|
+
warn(place.out, `step "${step.name}" failed${result.status === null ? "" : ` (exit ${result.status})`}; run '${step.run}' in ${place.root}.`);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Takes the setup's lock: a folder with the pid. A lock of a process that is gone is taken
|
|
83
|
+
* over, and so is one without a pid that is some seconds old (its process died before
|
|
84
|
+
* writing it); another one is waited for.
|
|
85
|
+
* @param lock - The folder.
|
|
86
|
+
* @param out - Where the wait is said.
|
|
87
|
+
* @returns True if it was taken, false if it waited too long.
|
|
88
|
+
*/
|
|
89
|
+
async function takeLock(lock, out) {
|
|
90
|
+
for (let waited = 0; waited < LOCK_WAIT_SECONDS; waited += 1) {
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(lock);
|
|
93
|
+
writeFileSync(join(lock, "pid"), `${process.pid}\n`);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
const pidFile = join(lock, "pid");
|
|
98
|
+
const owner = Number(existsSync(pidFile) ? readFileSync(pidFile, "utf8").trim() : "");
|
|
99
|
+
const orphan = !existsSync(pidFile) && existsSync(lock) && Date.now() - statSync(lock).mtimeMs > LOCK_NO_PID_MS;
|
|
100
|
+
if (orphan || (Number.isInteger(owner) && owner > 0 && !alive(owner))) {
|
|
101
|
+
rmSync(lock, { recursive: true, force: true });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (waited === 0)
|
|
105
|
+
warn(out, "another setup is preparing this worktree; waiting for it…");
|
|
106
|
+
await sleep(1000);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Whether a process is alive.
|
|
113
|
+
* @param pid - The pid.
|
|
114
|
+
* @returns True if it is (or it is another user's).
|
|
115
|
+
*/
|
|
116
|
+
function alive(pid) {
|
|
117
|
+
try {
|
|
118
|
+
process.kill(pid, 0);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
return error.code === "EPERM";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Prepares a worktree with the setup steps: one (`only`) or all, in order. In the main
|
|
127
|
+
* checkout it does nothing, except in a cloud session, whose checkout is not a worktree. It
|
|
128
|
+
* fixes the block of ports first, so the steps (and the apps after them) agree on it.
|
|
129
|
+
* Never stops on a failed step: it says what to run and goes on.
|
|
130
|
+
* @param place - Where.
|
|
131
|
+
* @param only - The step to run, or null for all.
|
|
132
|
+
* @returns True if every step that ran worked.
|
|
133
|
+
* @example await runSetup(place, null)
|
|
134
|
+
*/
|
|
135
|
+
export async function runSetup(place, only) {
|
|
136
|
+
const { root, mainRoot, config, out } = place;
|
|
137
|
+
if (root === mainRoot && !place.remote)
|
|
138
|
+
return true;
|
|
139
|
+
const chosen = config.worktrees.setup.filter((step) => only === null || step.name === only);
|
|
140
|
+
const ports = config.worktrees.ports;
|
|
141
|
+
const port = ports === null ? null : await claimPortBase(ports, root, null);
|
|
142
|
+
if (ports !== null && port === null)
|
|
143
|
+
warn(out, "no block of ports is free on this machine; delete worktrees you no longer use.");
|
|
144
|
+
if (chosen.length === 0)
|
|
145
|
+
return true;
|
|
146
|
+
const lock = join(gitDir(root), LOCK_NAME);
|
|
147
|
+
if (!(await takeLock(lock, out))) {
|
|
148
|
+
warn(out, `another setup has been running for over ${LOCK_WAIT_SECONDS} s; nothing ran. Run 'issue-flow worktree setup' in ${root} when it ends.`);
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
return chosen.map((step) => setupStep(place, step, stepEnv(place, port))).every(Boolean);
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
rmSync(lock, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* One setup step: skipped if it is `once` and already worked, or `trusted` and the code is
|
|
160
|
+
* not; run otherwise, leaving its mark if it is `once`.
|
|
161
|
+
* @param place - Where.
|
|
162
|
+
* @param step - The step.
|
|
163
|
+
* @param env - Its variables.
|
|
164
|
+
* @returns True unless it ran and failed.
|
|
165
|
+
*/
|
|
166
|
+
function setupStep(place, step, env) {
|
|
167
|
+
const mark = join(gitDir(place.root), SETUP_MARKS_DIR, step.name);
|
|
168
|
+
if (step.once && existsSync(mark))
|
|
169
|
+
return true;
|
|
170
|
+
if (step.trusted && !place.remote && !trustedCode(place.root, place.base)) {
|
|
171
|
+
warn(place.out, `skipped "${step.name}": this worktree has code that is not in origin/${place.base}. Check it and run '${step.run}' in ${place.root} if you trust it.`);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
if (!runStep(place, step, env))
|
|
175
|
+
return false;
|
|
176
|
+
if (step.once) {
|
|
177
|
+
mkdirSync(join(gitDir(place.root), SETUP_MARKS_DIR), { recursive: true });
|
|
178
|
+
writeFileSync(mark, "");
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Cleans a worktree before it is deleted: its apps and then its teardown steps (if the
|
|
184
|
+
* folder still exists), so a step that drops a database finds nobody connected; then its
|
|
185
|
+
* block of ports and its Docker. Never stops: a step or a Docker that
|
|
186
|
+
* fails gets a warning, because a cleanup cannot block the deletion.
|
|
187
|
+
* @param place - Where; the folder may be gone already.
|
|
188
|
+
* @returns True if nothing failed.
|
|
189
|
+
* @example await teardownWorktree(place)
|
|
190
|
+
*/
|
|
191
|
+
export async function teardownWorktree(place) {
|
|
192
|
+
const { root, config, out } = place;
|
|
193
|
+
const ports = config.worktrees.ports;
|
|
194
|
+
let ok = true;
|
|
195
|
+
if (existsSync(root)) {
|
|
196
|
+
const base = ports === null ? null : portBase(ports, root);
|
|
197
|
+
if (ports !== null && base !== null)
|
|
198
|
+
await stopApps(ports, base, root, out);
|
|
199
|
+
const env = stepEnv(place, base);
|
|
200
|
+
for (const step of config.worktrees.teardown)
|
|
201
|
+
ok = runStep(place, step, env) && ok;
|
|
202
|
+
}
|
|
203
|
+
releasePortClaims(root, null);
|
|
204
|
+
const docker = dockerCleanup(config.worktrees.dockerProjects, root);
|
|
205
|
+
if (!docker.dockerRunning)
|
|
206
|
+
warn(out, "Docker is not running; the worktree's containers stay. Start Docker and run 'issue-flow worktree cleanup'.");
|
|
207
|
+
if (docker.failed.length > 0)
|
|
208
|
+
warn(out, `could not delete the Docker projects ${docker.failed.join(", ")}; see what holds them with 'docker ps -a --filter label=com.docker.compose.project=<project>'.`);
|
|
209
|
+
return ok && docker.dockerRunning && docker.failed.length === 0;
|
|
210
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ramon-sg/issue-flow",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Issue-to-PR workflow for any repo: names each issue's branch, creates its worktree, opens the draft PR and launches Claude Code, locally and in Claude's cloud.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": { "issue-flow": "dist/cli.js" },
|
|
8
|
+
"files": ["dist", "schema.json", "recipes"],
|
|
9
|
+
"engines": { "node": ">=20" },
|
|
10
|
+
"publishConfig": { "access": "public", "provenance": true },
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/ramon-sg/issue-flow.git"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"test": "tsc && node --test test/*.test.mjs",
|
|
18
|
+
"prepublishOnly": "npm test"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": { "@ramon-sg/github-rest": "0.2.1" },
|
|
21
|
+
"devDependencies": { "@ramon-sg/skills-manager": "1.1.0", "@types/node": "^24.0.0", "typescript": "^5.9.3" }
|
|
22
|
+
}
|
package/recipes/bun.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Installs the dependencies with bun in each worktree, only when its code is in the base branch.",
|
|
3
|
+
"detect": ["bun.lock", "bun.lockb"],
|
|
4
|
+
"config": { "worktrees": { "setup": [{ "name": "deps", "run": "bun install --frozen-lockfile", "trusted": true }] } }
|
|
5
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Allows the .envrc of each worktree whose code is in the base branch, so direnv (and the secrets it loads, Infisical for example) works there.",
|
|
3
|
+
"detect": [
|
|
4
|
+
".envrc"
|
|
5
|
+
],
|
|
6
|
+
"config": {
|
|
7
|
+
"worktrees": {
|
|
8
|
+
"setup": [
|
|
9
|
+
{
|
|
10
|
+
"name": "direnv",
|
|
11
|
+
"run": "command -v direnv >/dev/null || { echo 'direnv is not installed: .envrc not allowed'; exit 0; }; direnv allow",
|
|
12
|
+
"trusted": true
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Gives each worktree its own Docker Compose project and block of ports: up on setup (only when its code is in the base branch), down with its volumes on teardown. The compose file takes its ports from ISSUE_FLOW_PORT_BASE.",
|
|
3
|
+
"detect": [
|
|
4
|
+
"compose.yaml",
|
|
5
|
+
"compose.yml",
|
|
6
|
+
"docker-compose.yml",
|
|
7
|
+
"docker-compose.yaml"
|
|
8
|
+
],
|
|
9
|
+
"config": {
|
|
10
|
+
"worktrees": {
|
|
11
|
+
"setup": [
|
|
12
|
+
{
|
|
13
|
+
"name": "docker",
|
|
14
|
+
"run": "docker compose -p {project}-$ISSUE_FLOW_DOCKER_SUFFIX up -d",
|
|
15
|
+
"trusted": true
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"teardown": [
|
|
19
|
+
{
|
|
20
|
+
"name": "docker",
|
|
21
|
+
"run": "docker compose -p {project}-$ISSUE_FLOW_DOCKER_SUFFIX down -v"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"ports": true,
|
|
25
|
+
"dockerProjects": [
|
|
26
|
+
"{project}"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
package/recipes/npm.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Installs the dependencies with pnpm in each worktree, only when its code is in the base branch.",
|
|
3
|
+
"detect": ["pnpm-lock.yaml"],
|
|
4
|
+
"config": { "worktrees": { "setup": [{ "name": "deps", "run": "pnpm install --frozen-lockfile", "trusted": true }] } }
|
|
5
|
+
}
|