@mcuste/pi-herdr-worktree 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/src/process.ts ADDED
@@ -0,0 +1,144 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ const DEFAULT_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
4
+
5
+ export interface CommandResult {
6
+ readonly command: string;
7
+ readonly args: readonly string[];
8
+ readonly exitCode: number;
9
+ readonly stdout: string;
10
+ readonly stderr: string;
11
+ }
12
+
13
+ export interface RunCommandOptions {
14
+ readonly cwd: string;
15
+ readonly signal?: AbortSignal | undefined;
16
+ readonly maxOutputBytes?: number | undefined;
17
+ }
18
+
19
+ export type CommandRunner = (
20
+ command: string,
21
+ args: readonly string[],
22
+ options: RunCommandOptions,
23
+ ) => Promise<CommandResult>;
24
+
25
+ class CommandExecutionError extends Error {
26
+ readonly result: CommandResult;
27
+
28
+ constructor(message: string, result: CommandResult) {
29
+ super(message);
30
+ this.name = "CommandExecutionError";
31
+ this.result = result;
32
+ }
33
+ }
34
+
35
+ export class CommandInvocationError extends Error {
36
+ readonly command: string;
37
+ readonly code: string | undefined;
38
+
39
+ constructor(command: string, message: string, code: string | undefined, options?: ErrorOptions) {
40
+ super(message, options);
41
+ this.name = "CommandInvocationError";
42
+ this.command = command;
43
+ this.code = code;
44
+ }
45
+ }
46
+
47
+ export class CommandCancelledError extends Error {
48
+ constructor(command: string) {
49
+ super(`${command} was cancelled.`);
50
+ this.name = "CommandCancelledError";
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Node kills the child once its output passes `maxBuffer`. That is a size limit, not a
56
+ * broken executable, so it gets its own type instead of looking like a failed spawn.
57
+ */
58
+ export class CommandOutputLimitError extends Error {
59
+ readonly command: string;
60
+ readonly maxOutputBytes: number;
61
+
62
+ constructor(command: string, maxOutputBytes: number, options?: ErrorOptions) {
63
+ super(`${command} produced more than ${maxOutputBytes} bytes of output and was stopped.`, {
64
+ ...options,
65
+ });
66
+ this.name = "CommandOutputLimitError";
67
+ this.command = command;
68
+ this.maxOutputBytes = maxOutputBytes;
69
+ }
70
+ }
71
+
72
+ export const runCommand: CommandRunner = (command, args, options) => {
73
+ if (options.signal?.aborted) {
74
+ return Promise.reject(new CommandCancelledError(command));
75
+ }
76
+
77
+ const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
78
+ const { promise, resolve, reject } = Promise.withResolvers<CommandResult>();
79
+ execFile(
80
+ command,
81
+ [...args],
82
+ {
83
+ cwd: options.cwd,
84
+ encoding: "utf8",
85
+ maxBuffer: maxOutputBytes,
86
+ signal: options.signal,
87
+ windowsHide: true,
88
+ },
89
+ (error, stdout, stderr) => {
90
+ if (options.signal?.aborted || error?.name === "AbortError") {
91
+ reject(new CommandCancelledError(command));
92
+ return;
93
+ }
94
+
95
+ if (error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
96
+ reject(new CommandOutputLimitError(command, maxOutputBytes, { cause: error }));
97
+ return;
98
+ }
99
+
100
+ if (error && typeof error.code !== "number") {
101
+ const detail = error.message.trim();
102
+ reject(
103
+ new CommandInvocationError(
104
+ command,
105
+ `Unable to execute ${command}: ${detail}`,
106
+ typeof error.code === "string" ? error.code : undefined,
107
+ { cause: error },
108
+ ),
109
+ );
110
+ return;
111
+ }
112
+
113
+ resolve({
114
+ command,
115
+ args: [...args],
116
+ exitCode: typeof error?.code === "number" ? error.code : 0,
117
+ stdout,
118
+ stderr,
119
+ });
120
+ },
121
+ );
122
+ return promise;
123
+ };
124
+
125
+ export async function runChecked(
126
+ runner: CommandRunner,
127
+ command: string,
128
+ args: readonly string[],
129
+ options: RunCommandOptions,
130
+ failureContext: string,
131
+ ): Promise<CommandResult> {
132
+ const result = await runner(command, args, options);
133
+ if (result.exitCode === 0) {
134
+ return result;
135
+ }
136
+
137
+ throw new CommandExecutionError(formatFailure(failureContext, result), result);
138
+ }
139
+
140
+ function formatFailure(context: string, result: CommandResult): string {
141
+ const output = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n");
142
+ const suffix = output ? `\n${output}` : "";
143
+ return `${context} (exit ${result.exitCode}).${suffix}`;
144
+ }
@@ -0,0 +1,189 @@
1
+ import type { CommandResult } from "./process.js";
2
+
3
+ export interface WorktreeInfo {
4
+ readonly path: string;
5
+ readonly label: string;
6
+ readonly branch: string | null;
7
+ readonly openWorkspaceId: string | null;
8
+ readonly isBare: boolean;
9
+ readonly isDetached: boolean;
10
+ readonly isPrunable: boolean;
11
+ readonly isLinkedWorktree: boolean;
12
+ }
13
+
14
+ interface WorktreeSource {
15
+ readonly repoName: string;
16
+ readonly repoRoot: string;
17
+ readonly sourceCheckoutPath: string;
18
+ readonly sourceWorkspaceId: string | null;
19
+ }
20
+
21
+ export interface WorktreeListResult {
22
+ readonly type: "worktree_list";
23
+ readonly source: WorktreeSource;
24
+ readonly worktrees: readonly WorktreeInfo[];
25
+ }
26
+
27
+ export interface WorktreeOpenedResult {
28
+ readonly type: "worktree_created" | "worktree_opened";
29
+ readonly workspaceId: string;
30
+ readonly tabId: string;
31
+ readonly rootPaneId: string;
32
+ readonly worktree: WorktreeInfo;
33
+ readonly alreadyOpen: boolean;
34
+ }
35
+
36
+ export interface WorktreeRemovedResult {
37
+ readonly type: "worktree_removed";
38
+ readonly workspaceId: string;
39
+ readonly path: string;
40
+ readonly forced: boolean;
41
+ }
42
+
43
+ /** A Herdr socket API error, reported as JSON on stderr with a stable machine-readable code. */
44
+ export class HerdrApiError extends Error {
45
+ readonly code: string;
46
+
47
+ constructor(code: string, message: string) {
48
+ super(`Herdr rejected the request (${code}): ${message}`);
49
+ this.name = "HerdrApiError";
50
+ this.code = code;
51
+ }
52
+ }
53
+
54
+ function fail(detail: string): never {
55
+ throw new Error(`Herdr returned an unreadable response: ${detail}.`);
56
+ }
57
+
58
+ function record(value: unknown, field: string): Record<string, unknown> {
59
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
60
+ fail(`${field} is not an object`);
61
+ }
62
+ return value as Record<string, unknown>;
63
+ }
64
+
65
+ function text(value: unknown, field: string): string {
66
+ if (typeof value !== "string" || !value) {
67
+ fail(`${field} is not a non-empty string`);
68
+ }
69
+ return value;
70
+ }
71
+
72
+ function optionalText(value: unknown, field: string): string | null {
73
+ if (value === undefined || value === null) {
74
+ return null;
75
+ }
76
+ return text(value, field);
77
+ }
78
+
79
+ function flag(value: unknown, field: string): boolean {
80
+ if (typeof value !== "boolean") {
81
+ fail(`${field} is not a boolean`);
82
+ }
83
+ return value;
84
+ }
85
+
86
+ /**
87
+ * Herdr prints one JSON document per command: a success envelope carrying `result`, or an
88
+ * error envelope carrying `error`. A non-zero exit with an error envelope is a rejected
89
+ * request, not a broken CLI, so it keeps its own error type.
90
+ */
91
+ export function parseEnvelope(result: CommandResult): Record<string, unknown> {
92
+ const raw = (result.stdout.trim() || result.stderr.trim()).trim();
93
+ if (!raw) {
94
+ fail(`empty output (exit ${result.exitCode})`);
95
+ }
96
+
97
+ let document: unknown;
98
+ try {
99
+ document = JSON.parse(raw);
100
+ } catch (error) {
101
+ throw new Error(`Herdr returned output that is not JSON: ${raw.slice(0, 500)}`, {
102
+ cause: error,
103
+ });
104
+ }
105
+
106
+ const envelope = record(document, "the response");
107
+ const failure = envelope.error;
108
+ if (failure !== undefined) {
109
+ const detail = record(failure, "error");
110
+ throw new HerdrApiError(text(detail.code, "error.code"), text(detail.message, "error.message"));
111
+ }
112
+ // A failed command must say why, so a result with no error field is not read as a success.
113
+ if (result.exitCode !== 0) {
114
+ fail(`a result with no error field and exit ${result.exitCode}`);
115
+ }
116
+ return record(envelope.result, "result");
117
+ }
118
+
119
+ function parseWorktreeInfo(value: unknown, field: string): WorktreeInfo {
120
+ const info = record(value, field);
121
+ return {
122
+ path: text(info.path, `${field}.path`),
123
+ label: text(info.label, `${field}.label`),
124
+ branch: optionalText(info.branch, `${field}.branch`),
125
+ openWorkspaceId: optionalText(info.open_workspace_id, `${field}.open_workspace_id`),
126
+ isBare: flag(info.is_bare, `${field}.is_bare`),
127
+ isDetached: flag(info.is_detached, `${field}.is_detached`),
128
+ isPrunable: flag(info.is_prunable, `${field}.is_prunable`),
129
+ isLinkedWorktree: flag(info.is_linked_worktree, `${field}.is_linked_worktree`),
130
+ };
131
+ }
132
+
133
+ function assertType(payload: Record<string, unknown>, expected: readonly string[]): string {
134
+ const type = text(payload.type, "result.type");
135
+ if (!expected.includes(type)) {
136
+ fail(`result.type is ${JSON.stringify(type)} instead of ${expected.join(" or ")}`);
137
+ }
138
+ return type;
139
+ }
140
+
141
+ export function parseWorktreeList(payload: Record<string, unknown>): WorktreeListResult {
142
+ assertType(payload, ["worktree_list"]);
143
+ const source = record(payload.source, "result.source");
144
+ const worktrees = payload.worktrees;
145
+ if (!Array.isArray(worktrees)) {
146
+ fail("result.worktrees is not an array");
147
+ }
148
+ return {
149
+ type: "worktree_list",
150
+ source: {
151
+ repoName: text(source.repo_name, "result.source.repo_name"),
152
+ repoRoot: text(source.repo_root, "result.source.repo_root"),
153
+ sourceCheckoutPath: text(source.source_checkout_path, "result.source.source_checkout_path"),
154
+ sourceWorkspaceId: optionalText(
155
+ source.source_workspace_id,
156
+ "result.source.source_workspace_id",
157
+ ),
158
+ },
159
+ worktrees: worktrees.map((entry, index) =>
160
+ parseWorktreeInfo(entry, `result.worktrees[${index}]`),
161
+ ),
162
+ };
163
+ }
164
+
165
+ export function parseWorktreeOpened(payload: Record<string, unknown>): WorktreeOpenedResult {
166
+ const type = assertType(payload, ["worktree_created", "worktree_opened"]);
167
+ const workspace = record(payload.workspace, "result.workspace");
168
+ const tab = record(payload.tab, "result.tab");
169
+ const rootPane = record(payload.root_pane, "result.root_pane");
170
+ return {
171
+ type: type as WorktreeOpenedResult["type"],
172
+ workspaceId: text(workspace.workspace_id, "result.workspace.workspace_id"),
173
+ tabId: text(tab.tab_id, "result.tab.tab_id"),
174
+ rootPaneId: text(rootPane.pane_id, "result.root_pane.pane_id"),
175
+ worktree: parseWorktreeInfo(payload.worktree, "result.worktree"),
176
+ alreadyOpen:
177
+ type === "worktree_opened" ? flag(payload.already_open, "result.already_open") : false,
178
+ };
179
+ }
180
+
181
+ export function parseWorktreeRemoved(payload: Record<string, unknown>): WorktreeRemovedResult {
182
+ assertType(payload, ["worktree_removed"]);
183
+ return {
184
+ type: "worktree_removed",
185
+ workspaceId: text(payload.workspace_id, "result.workspace_id"),
186
+ path: text(payload.path, "result.path"),
187
+ forced: flag(payload.forced, "result.forced"),
188
+ };
189
+ }