@ohzw/worktree-command-tui 0.1.1 → 0.1.2

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 (39) hide show
  1. package/README.md +5 -0
  2. package/dist/app.d.ts +1 -1
  3. package/dist/app.js +235 -113
  4. package/dist/components/ActionPanel.d.ts +4 -2
  5. package/dist/components/ActionPanel.js +87 -135
  6. package/dist/components/ContextBar.d.ts +4 -1
  7. package/dist/components/ContextBar.js +27 -3
  8. package/dist/components/HelpWindow.d.ts +7 -0
  9. package/dist/components/HelpWindow.js +29 -0
  10. package/dist/components/LogPanel.d.ts +10 -3
  11. package/dist/components/LogPanel.js +239 -33
  12. package/dist/components/WorktreeList.js +20 -40
  13. package/dist/core/command-runner.d.ts +11 -0
  14. package/dist/core/command-runner.js +44 -0
  15. package/dist/core/config-lifecycle.d.ts +25 -0
  16. package/dist/core/config-lifecycle.js +143 -0
  17. package/dist/core/config.d.ts +2 -3
  18. package/dist/core/config.js +0 -48
  19. package/dist/core/git-metadata.d.ts +25 -0
  20. package/dist/core/git-metadata.js +84 -0
  21. package/dist/core/git-worktrees.d.ts +2 -1
  22. package/dist/core/git-worktrees.js +30 -11
  23. package/dist/core/github-metadata.d.ts +14 -0
  24. package/dist/core/github-metadata.js +137 -0
  25. package/dist/core/init.d.ts +3 -2
  26. package/dist/core/init.js +9 -57
  27. package/dist/core/log-reader.d.ts +7 -0
  28. package/dist/core/log-reader.js +43 -0
  29. package/dist/core/runtime-state.d.ts +42 -0
  30. package/dist/core/runtime-state.js +125 -0
  31. package/dist/core/runtime.d.ts +19 -39
  32. package/dist/core/runtime.js +112 -216
  33. package/dist/core/tui-interaction.d.ts +31 -0
  34. package/dist/core/tui-interaction.js +59 -0
  35. package/dist/core/worktree-projection.d.ts +76 -0
  36. package/dist/core/worktree-projection.js +124 -0
  37. package/dist/terminal/viewport.d.ts +15 -0
  38. package/dist/terminal/viewport.js +49 -0
  39. package/package.json +1 -1
@@ -1,54 +1,260 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- const ANSI_ESCAPE_PATTERN = /(?:\u001B|\u009B)\[[0-?]*[ -/]*[@-~]/gu;
4
- function sanitizeLogLine(value) {
3
+ import { getScrollbarThumbRows, sliceTailViewport } from '../terminal/viewport.js';
4
+ const ESCAPE = '\u001B';
5
+ const CSI = '\u009B';
6
+ const BEL = '\u0007';
7
+ const STRING_TERMINATOR_PREFIX = '\u001B';
8
+ const STRING_TERMINATOR_SUFFIX = '\\';
9
+ const STRING_TERMINATOR = '\u009C';
10
+ const DEVICE_CONTROL_STRING = '\u0090';
11
+ const START_OF_STRING = '\u0098';
12
+ const OPERATING_SYSTEM_COMMAND = '\u009D';
13
+ const PRIVACY_MESSAGE = '\u009E';
14
+ const APPLICATION_PROGRAM_COMMAND = '\u009F';
15
+ const ANSI_FOREGROUND_COLORS = new Map([
16
+ [30, 'black'],
17
+ [31, 'red'],
18
+ [32, 'green'],
19
+ [33, 'yellow'],
20
+ [34, 'blue'],
21
+ [35, 'magenta'],
22
+ [36, 'cyan'],
23
+ [37, 'white'],
24
+ [90, 'gray'],
25
+ [91, 'red'],
26
+ [92, 'green'],
27
+ [93, 'yellow'],
28
+ [94, 'blue'],
29
+ [95, 'magenta'],
30
+ [96, 'cyan'],
31
+ [97, 'white'],
32
+ ]);
33
+ function sanitizeTextChunk(value) {
5
34
  return value
6
- .replace(ANSI_ESCAPE_PATTERN, '')
7
35
  .replace(/[\r\t\u2028\u2029]+/g, ' ')
8
36
  .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '')
9
- .replace(/\p{Cf}/gu, '')
10
- .trimEnd();
37
+ .replace(/\p{Cf}/gu, '');
38
+ }
39
+ function sameStyle(a, b) {
40
+ return a.color === b.color && a.dimColor === b.dimColor && a.bold === b.bold;
41
+ }
42
+ function appendSegment(segments, text, style) {
43
+ if (text.length === 0) {
44
+ return;
45
+ }
46
+ const last = segments.at(-1);
47
+ if (last && sameStyle(last, style)) {
48
+ last.text += text;
49
+ return;
50
+ }
51
+ segments.push({ ...style, text });
52
+ }
53
+ function skipExtendedColor(codes, index) {
54
+ const mode = Number(codes[index + 1] ?? Number.NaN);
55
+ if (mode === 5) {
56
+ return index + 2;
57
+ }
58
+ if (mode === 2) {
59
+ return index + 4;
60
+ }
61
+ return index;
62
+ }
63
+ function applyAnsiSgr(style, parameters) {
64
+ const codes = parameters.length === 0 ? ['0'] : parameters.split(';');
65
+ for (let index = 0; index < codes.length; index += 1) {
66
+ const rawCode = codes[index] ?? '0';
67
+ if (rawCode.includes(':')) {
68
+ if (Number(rawCode.split(':', 1)[0]) === 38) {
69
+ delete style.color;
70
+ }
71
+ continue;
72
+ }
73
+ const code = Number(rawCode || 0);
74
+ if (code === 0) {
75
+ delete style.color;
76
+ delete style.dimColor;
77
+ delete style.bold;
78
+ continue;
79
+ }
80
+ if (code === 1) {
81
+ style.bold = true;
82
+ continue;
83
+ }
84
+ if (code === 2) {
85
+ style.dimColor = true;
86
+ continue;
87
+ }
88
+ if (code === 22) {
89
+ delete style.bold;
90
+ delete style.dimColor;
91
+ continue;
92
+ }
93
+ if (code === 38) {
94
+ delete style.color;
95
+ index = skipExtendedColor(codes, index);
96
+ continue;
97
+ }
98
+ if (code === 48 || code === 58) {
99
+ index = skipExtendedColor(codes, index);
100
+ continue;
101
+ }
102
+ if (code === 39) {
103
+ delete style.color;
104
+ continue;
105
+ }
106
+ const color = ANSI_FOREGROUND_COLORS.get(code);
107
+ if (color) {
108
+ style.color = color;
109
+ }
110
+ }
111
+ }
112
+ function trimLineEnd(segments) {
113
+ const result = [...segments];
114
+ while (result.length > 0) {
115
+ const last = result.at(-1);
116
+ const trimmed = last.text.trimEnd();
117
+ if (trimmed.length > 0) {
118
+ if (trimmed.length !== last.text.length) {
119
+ result[result.length - 1] = { ...last, text: trimmed };
120
+ }
121
+ break;
122
+ }
123
+ result.pop();
124
+ }
125
+ return result;
126
+ }
127
+ function pushLine(lines, segments) {
128
+ const trimmedSegments = trimLineEnd(segments);
129
+ lines.push({ segments: trimmedSegments.length > 0 ? trimmedSegments : [{ text: ' ' }] });
130
+ }
131
+ function isAnsiFinalByte(value) {
132
+ const code = value.charCodeAt(0);
133
+ return code >= 0x40 && code <= 0x7E;
134
+ }
135
+ function isAnsiIntermediateByte(value) {
136
+ const code = value.charCodeAt(0);
137
+ return code >= 0x20 && code <= 0x2F;
138
+ }
139
+ function consumeCsi(value, index, style) {
140
+ let cursor = index;
141
+ while (cursor < value.length && !isAnsiFinalByte(value[cursor])) {
142
+ cursor += 1;
143
+ }
144
+ if (cursor >= value.length) {
145
+ return value.length;
146
+ }
147
+ const finalByte = value[cursor];
148
+ const sequence = value.slice(index, cursor);
149
+ const hasIntermediate = Array.from(sequence).some(isAnsiIntermediateByte);
150
+ if (!hasIntermediate && finalByte === 'm') {
151
+ applyAnsiSgr(style, sequence);
152
+ }
153
+ return cursor + 1;
154
+ }
155
+ function consumeStringControl(value, index, allowBelTerminator) {
156
+ let cursor = index;
157
+ while (cursor < value.length) {
158
+ if ((allowBelTerminator && value[cursor] === BEL) || value[cursor] === STRING_TERMINATOR) {
159
+ return cursor + 1;
160
+ }
161
+ if (value[cursor] === STRING_TERMINATOR_PREFIX && value[cursor + 1] === STRING_TERMINATOR_SUFFIX) {
162
+ return cursor + 2;
163
+ }
164
+ cursor += 1;
165
+ }
166
+ return value.length;
167
+ }
168
+ function isStringControl(value) {
169
+ return value === DEVICE_CONTROL_STRING
170
+ || value === START_OF_STRING
171
+ || value === OPERATING_SYSTEM_COMMAND
172
+ || value === PRIVACY_MESSAGE
173
+ || value === APPLICATION_PROGRAM_COMMAND;
174
+ }
175
+ function consumeEscape(value, index, style) {
176
+ if (value[index] === CSI) {
177
+ return consumeCsi(value, index + 1, style);
178
+ }
179
+ if (isStringControl(value[index])) {
180
+ return consumeStringControl(value, index + 1, value[index] === OPERATING_SYSTEM_COMMAND);
181
+ }
182
+ const next = value[index + 1];
183
+ if (next === undefined) {
184
+ return index + 1;
185
+ }
186
+ if (next === '[') {
187
+ return consumeCsi(value, index + 2, style);
188
+ }
189
+ if (next === ']') {
190
+ return consumeStringControl(value, index + 2, true);
191
+ }
192
+ if (next === 'P' || next === '^' || next === '_' || next === 'X') {
193
+ return consumeStringControl(value, index + 2, false);
194
+ }
195
+ let cursor = index + 1;
196
+ while (cursor < value.length && isAnsiIntermediateByte(value[cursor])) {
197
+ cursor += 1;
198
+ }
199
+ return Math.min(cursor + 1, value.length);
200
+ }
201
+ function parseLogContent(value) {
202
+ const lines = [];
203
+ let segments = [];
204
+ const style = {};
205
+ let textStart = 0;
206
+ let index = 0;
207
+ while (index < value.length) {
208
+ const character = value[index];
209
+ if (character === '\n') {
210
+ appendSegment(segments, sanitizeTextChunk(value.slice(textStart, index)), style);
211
+ pushLine(lines, segments);
212
+ segments = [];
213
+ index += 1;
214
+ textStart = index;
215
+ continue;
216
+ }
217
+ if (character === ESCAPE || character === CSI || isStringControl(character)) {
218
+ appendSegment(segments, sanitizeTextChunk(value.slice(textStart, index)), style);
219
+ index = consumeEscape(value, index, style);
220
+ textStart = index;
221
+ continue;
222
+ }
223
+ index += 1;
224
+ }
225
+ appendSegment(segments, sanitizeTextChunk(value.slice(textStart)), style);
226
+ pushLine(lines, segments);
227
+ return lines;
228
+ }
229
+ function plainLine(text, style = {}) {
230
+ return { segments: [{ ...style, text }] };
231
+ }
232
+ function getLineText(line) {
233
+ return line.segments.map(segment => segment.text).join('');
11
234
  }
12
235
  export function buildLogLines(logs) {
13
236
  if (logs.length === 0) {
14
- return [{ text: 'No *.log files yet.', dimColor: true }];
237
+ return [plainLine('No *.log files yet.', { dimColor: true })];
15
238
  }
16
239
  const lines = [];
17
240
  for (const [index, log] of logs.entries()) {
18
241
  if (index > 0) {
19
- lines.push({ text: ' ', dimColor: true });
20
- }
21
- lines.push({ text: `[${log.name}]`, color: 'cyan' });
22
- const contentLines = log.content.length > 0 ? log.content.split('\n') : ['(empty)'];
23
- for (const line of contentLines) {
24
- lines.push({ text: sanitizeLogLine(line) || ' ' });
242
+ lines.push(plainLine(' ', { dimColor: true }));
25
243
  }
244
+ lines.push(plainLine(`[${log.name}]`, { color: 'cyan' }));
245
+ lines.push(...parseLogContent(log.content.length > 0 ? log.content : '(empty)'));
26
246
  }
27
247
  return lines;
28
248
  }
29
- function getScrollbarThumbRows(totalLines, viewportHeight, scrollOffset) {
30
- if (totalLines <= viewportHeight) {
31
- return new Set();
32
- }
33
- const thumbSize = Math.max(1, Math.floor((viewportHeight / totalLines) * viewportHeight));
34
- const maxScrollOffset = Math.max(1, totalLines - viewportHeight);
35
- const thumbStart = Math.round((scrollOffset / maxScrollOffset) * (viewportHeight - thumbSize));
36
- return new Set(Array.from({ length: thumbSize }, (_, index) => thumbStart + index));
37
- }
38
249
  export function LogPanel({ logs, width, height, scrollOffset = 0, title = 'Logs (*.log · tail 120)', }) {
39
250
  const lines = buildLogLines(logs);
40
- const contentViewportHeight = height === undefined ? lines.length : Math.max(1, height - 3);
41
- const maxScrollOffset = contentViewportHeight === undefined ? 0 : Math.max(0, lines.length - contentViewportHeight);
42
- const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset);
43
- const startIndex = contentViewportHeight === undefined
44
- ? 0
45
- : Math.max(0, lines.length - contentViewportHeight - effectiveScrollOffset);
46
- const visibleLines = contentViewportHeight === undefined
47
- ? lines
48
- : lines.slice(startIndex, startIndex + contentViewportHeight);
49
- const showScrollbar = contentViewportHeight !== undefined && lines.length > contentViewportHeight;
251
+ const viewport = sliceTailViewport(lines, height === undefined ? lines.length : height - 3, scrollOffset);
252
+ const contentViewportHeight = viewport.viewportHeight;
253
+ const startIndex = viewport.startIndex;
254
+ const visibleLines = viewport.visibleItems;
255
+ const showScrollbar = height !== undefined && lines.length > contentViewportHeight;
50
256
  const scrollbarThumbRows = showScrollbar
51
- ? getScrollbarThumbRows(lines.length, contentViewportHeight, maxScrollOffset - effectiveScrollOffset)
257
+ ? getScrollbarThumbRows(lines.length, contentViewportHeight, viewport.topScrollOffset)
52
258
  : new Set();
53
- return (_jsxs(Box, { width: width, height: height, borderStyle: "round", borderColor: "yellow", flexDirection: "column", paddingX: 1, overflow: "hidden", children: [_jsx(Text, { bold: true, color: "yellow", wrap: "truncate-end", children: title }), _jsx(Box, { height: contentViewportHeight, flexDirection: "column", overflow: "hidden", children: visibleLines.map((line, index) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsx(Text, { color: line.color, dimColor: line.dimColor, wrap: "truncate-end", children: line.text }) }), showScrollbar ? (_jsx(Text, { color: scrollbarThumbRows.has(index) ? 'yellow' : 'gray', dimColor: !scrollbarThumbRows.has(index), children: scrollbarThumbRows.has(index) ? '█' : '│' })) : null] }, `${startIndex + index}-${line.text}`))) })] }));
259
+ return (_jsxs(Box, { width: width, height: height, borderStyle: "round", borderColor: "yellow", flexDirection: "column", paddingX: 1, overflow: "hidden", children: [_jsx(Text, { bold: true, color: "yellow", wrap: "truncate-end", children: title }), _jsx(Box, { height: contentViewportHeight, flexDirection: "column", overflow: "hidden", children: visibleLines.map((line, index) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsx(Text, { wrap: "truncate-end", children: line.segments.map((segment, segmentIndex) => (_jsx(Text, { color: segment.color, dimColor: segment.dimColor, bold: segment.bold, children: segment.text }, `${segmentIndex}-${segment.text}`))) }) }), showScrollbar ? (_jsx(Text, { color: scrollbarThumbRows.has(index) ? 'yellow' : 'gray', dimColor: !scrollbarThumbRows.has(index), children: scrollbarThumbRows.has(index) ? '█' : '│' })) : null] }, `${startIndex + index}-${getLineText(line)}`))) })] }));
54
260
  }
@@ -1,45 +1,30 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { projectWorktreeListRow, sanitizeInlineText } from '../core/worktree-projection.js';
4
+ import { getScrollbarThumbRows, sliceListViewport } from '../terminal/viewport.js';
3
5
  const MIN_BRANCH_WIDTH = 24;
4
- function sanitizeInlineText(value) {
5
- return value
6
- .replace(/[\r\n\t\u2028\u2029]+/g, ' ')
7
- .replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
8
- .replace(/\p{Cf}/gu, '')
9
- .replace(/\s+/g, ' ')
10
- .trim();
11
- }
12
- function getIndicator(row) {
13
- if (row.tags.includes('active')) {
6
+ function getIndicator(state) {
7
+ if (state === 'active') {
14
8
  return '*';
15
9
  }
16
- if (row.tags.includes('invalid')) {
10
+ if (state === 'invalid') {
17
11
  return '!';
18
12
  }
19
- if (row.tags.includes('external')) {
13
+ if (state === 'external') {
20
14
  return '^';
21
15
  }
22
- if (row.tags.includes('main')) {
23
- return '#';
24
- }
25
16
  return '-';
26
17
  }
27
- function getRowColor(row, isSelected) {
28
- if (row.tags.includes('active')) {
18
+ function getRowColor(projection) {
19
+ if (projection.state === 'active') {
29
20
  return 'green';
30
21
  }
31
- if (isSelected) {
22
+ if (projection.isSelected) {
32
23
  return 'cyan';
33
24
  }
34
- if (row.tags.includes('invalid')) {
25
+ if (projection.state === 'invalid') {
35
26
  return 'red';
36
27
  }
37
- if (row.tags.includes('external')) {
38
- return 'yellow';
39
- }
40
- if (row.tags.includes('main')) {
41
- return 'blue';
42
- }
43
28
  return undefined;
44
29
  }
45
30
  function truncateLabel(value, width) {
@@ -48,26 +33,21 @@ function truncateLabel(value, width) {
48
33
  }
49
34
  return `${value.slice(0, Math.max(width - 1, 0))}…`;
50
35
  }
51
- function getScrollbarThumbRows(totalLines, viewportHeight, scrollOffset) {
52
- if (totalLines <= viewportHeight) {
53
- return new Set();
54
- }
55
- const thumbSize = Math.max(1, Math.floor((viewportHeight / totalLines) * viewportHeight));
56
- const maxScrollOffset = Math.max(1, totalLines - viewportHeight);
57
- const thumbStart = Math.round((scrollOffset / maxScrollOffset) * (viewportHeight - thumbSize));
58
- return new Set(Array.from({ length: thumbSize }, (_, index) => thumbStart + index));
59
- }
60
36
  export function WorktreeList({ rows, selectedIndex, width, height, stacked, scrollOffset = 0, }) {
61
37
  const branchWidth = Math.max(MIN_BRANCH_WIDTH, (width ?? 34) - 7);
62
- const contentViewportHeight = height === undefined ? rows.length : Math.max(1, height - 3);
63
- const maxScrollOffset = Math.max(0, rows.length - contentViewportHeight);
64
- const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset);
65
- const visibleRows = rows.slice(effectiveScrollOffset, effectiveScrollOffset + contentViewportHeight);
38
+ const viewport = sliceListViewport(rows, height === undefined ? rows.length : height - 3, scrollOffset);
39
+ const contentViewportHeight = viewport.viewportHeight;
40
+ const effectiveScrollOffset = viewport.scrollOffset;
41
+ const visibleRows = viewport.visibleItems;
66
42
  const showScrollbar = height !== undefined && rows.length > contentViewportHeight;
67
43
  const scrollbarThumbRows = showScrollbar ? getScrollbarThumbRows(rows.length, contentViewportHeight, effectiveScrollOffset) : new Set();
68
44
  return (_jsxs(Box, { width: width, height: height, flexGrow: stacked ? 0 : 1, marginRight: stacked ? 0 : 1, borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, overflowY: "hidden", children: [_jsx(Text, { bold: true, color: "cyan", children: "Worktrees" }), visibleRows.map((row, index) => {
69
45
  const isSelected = index + effectiveScrollOffset === selectedIndex;
70
- const line = `${isSelected ? '>' : ' '} ${getIndicator(row)} ${truncateLabel(sanitizeInlineText(row.branch), branchWidth)}`;
71
- return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsx(Text, { color: getRowColor(row, isSelected), dimColor: !isSelected && getRowColor(row, isSelected) === undefined, wrap: "truncate-end", children: line }, row.path) }), showScrollbar ? (_jsx(Text, { color: scrollbarThumbRows.has(index) ? 'cyan' : 'gray', dimColor: !scrollbarThumbRows.has(index), children: scrollbarThumbRows.has(index) ? '' : '│' })) : null] }, row.path));
46
+ const projection = projectWorktreeListRow(row, isSelected);
47
+ const tagSuffix = projection.isMain ? ' [root]' : '';
48
+ const color = getRowColor(projection);
49
+ const branchText = sanitizeInlineText(row.branch);
50
+ const line = `${isSelected ? '>' : ' '} ${getIndicator(projection.state)} ${truncateLabel(branchText, Math.max(1, branchWidth - tagSuffix.length))}${tagSuffix}`;
51
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsx(Text, { color: color, dimColor: !isSelected && color === undefined, bold: projection.state === 'active', wrap: "truncate-end", children: line }, row.path) }), showScrollbar ? (_jsx(Text, { color: scrollbarThumbRows.has(index) ? 'cyan' : 'gray', dimColor: !scrollbarThumbRows.has(index), children: scrollbarThumbRows.has(index) ? '█' : '│' })) : null] }, row.path));
72
52
  })] }));
73
53
  }
@@ -1,3 +1,13 @@
1
+ interface CommandLogOptions {
2
+ command: string[];
3
+ cwd: string;
4
+ logsDir: string;
5
+ logFileBase: string;
6
+ errorLabel?: string;
7
+ }
8
+ export declare function runCommandToLog({ command, cwd, logsDir, logFileBase, errorLabel, }: CommandLogOptions): Promise<{
9
+ logPath: string;
10
+ }>;
1
11
  export declare function startDetachedCommand({ command, cwd, logsDir, logFileBase, }: {
2
12
  command: string[];
3
13
  cwd: string;
@@ -8,3 +18,4 @@ export declare function startDetachedCommand({ command, cwd, logsDir, logFileBas
8
18
  pgid: number;
9
19
  logPath: string;
10
20
  }>;
21
+ export {};
@@ -1,6 +1,49 @@
1
1
  import { closeSync, mkdirSync, openSync } from 'node:fs';
2
2
  import { spawn } from 'node:child_process';
3
3
  import path from 'node:path';
4
+ function getLogEnvironment() {
5
+ return {
6
+ ...process.env,
7
+ FORCE_COLOR: process.env.FORCE_COLOR ?? '1',
8
+ CLICOLOR_FORCE: process.env.CLICOLOR_FORCE ?? '1',
9
+ };
10
+ }
11
+ export function runCommandToLog({ command, cwd, logsDir, logFileBase, errorLabel = 'setup command', }) {
12
+ mkdirSync(logsDir, { recursive: true });
13
+ const logPath = path.join(logsDir, `${logFileBase}.log`);
14
+ const fd = openSync(logPath, 'a');
15
+ const { promise, resolve, reject } = Promise.withResolvers();
16
+ let settled = false;
17
+ const finalize = () => {
18
+ if (settled) {
19
+ return;
20
+ }
21
+ settled = true;
22
+ closeSync(fd);
23
+ };
24
+ const child = spawn(command[0], command.slice(1), {
25
+ cwd,
26
+ env: getLogEnvironment(),
27
+ stdio: ['ignore', fd, fd],
28
+ });
29
+ child.once('error', error => {
30
+ finalize();
31
+ reject(error);
32
+ });
33
+ child.once('exit', (code, signal) => {
34
+ finalize();
35
+ if (code === 0) {
36
+ resolve({ logPath });
37
+ return;
38
+ }
39
+ if (code !== null) {
40
+ reject(new Error(`${errorLabel} exited with code ${code}; see ${logPath}`));
41
+ return;
42
+ }
43
+ reject(new Error(`${errorLabel} exited due to signal ${signal ?? 'unknown'}; see ${logPath}`));
44
+ });
45
+ return promise;
46
+ }
4
47
  export function startDetachedCommand({ command, cwd, logsDir, logFileBase, }) {
5
48
  mkdirSync(logsDir, { recursive: true });
6
49
  const logPath = path.join(logsDir, `${logFileBase}.log`);
@@ -10,6 +53,7 @@ export function startDetachedCommand({ command, cwd, logsDir, logFileBase, }) {
10
53
  const child = spawn(command[0], command.slice(1), {
11
54
  cwd,
12
55
  detached: true,
56
+ env: getLogEnvironment(),
13
57
  stdio: ['ignore', fd, fd],
14
58
  });
15
59
  const finalize = () => {
@@ -0,0 +1,25 @@
1
+ import { type ToolConfig } from './config.js';
2
+ export interface DefaultToolConfigOptions {
3
+ namespaceSeed: string;
4
+ packageManager: 'bun' | 'pnpm' | 'yarn' | 'npm';
5
+ script: string;
6
+ }
7
+ export interface ConfigInitResult {
8
+ path: string;
9
+ config: ToolConfig;
10
+ }
11
+ export interface ConfigInitOptions {
12
+ workspaceRoot: string;
13
+ force: boolean;
14
+ config: ToolConfig;
15
+ }
16
+ export declare function isSafeNamespace(value: unknown): value is string;
17
+ export declare function toSafeNamespace(value: string): string;
18
+ export declare function validateToolConfig(raw: unknown): ToolConfig;
19
+ export declare function createDefaultToolConfig(options: DefaultToolConfigOptions): ToolConfig;
20
+ export declare function loadToolConfig({ repoRoot }: {
21
+ repoRoot: string;
22
+ }): Promise<ToolConfig>;
23
+ export declare function renderConfigJsonc(config: ToolConfig): string;
24
+ export declare function findExistingConfigPath(workspaceRoot: string, fileExists: (filePath: string) => Promise<boolean>): Promise<string | null>;
25
+ export declare function writeToolConfigForRepo({ workspaceRoot, force, config }: ConfigInitOptions, fileExists: (filePath: string) => Promise<boolean>): Promise<ConfigInitResult>;
@@ -0,0 +1,143 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { CONFIG_FILE_NAME, CONFIG_FILE_NAMES, parseJsonc } from './config.js';
4
+ const SAFE_NAMESPACE_PATTERN = /^[A-Za-z0-9._-]+$/u;
5
+ const UNSAFE_NAMESPACE_RUN_PATTERN = /[^A-Za-z0-9._-]+/gu;
6
+ const LEADING_NAMESPACE_HYPHENS_PATTERN = /^-+/u;
7
+ const TRAILING_NAMESPACE_HYPHENS_PATTERN = /-+$/u;
8
+ const SAFE_NAMESPACE_DESCRIPTION = '[A-Za-z0-9._-]+';
9
+ const DEFAULT_NAMESPACE = 'worktree-command-tui';
10
+ function isNonEmptyString(value) {
11
+ return typeof value === 'string' && value.length > 0;
12
+ }
13
+ export function isSafeNamespace(value) {
14
+ return isNonEmptyString(value) && SAFE_NAMESPACE_PATTERN.test(value);
15
+ }
16
+ export function toSafeNamespace(value) {
17
+ const replaced = value.replace(UNSAFE_NAMESPACE_RUN_PATTERN, '-');
18
+ const trimmed = replaced.replace(LEADING_NAMESPACE_HYPHENS_PATTERN, '').replace(TRAILING_NAMESPACE_HYPHENS_PATTERN, '');
19
+ return isSafeNamespace(trimmed) ? trimmed : DEFAULT_NAMESPACE;
20
+ }
21
+ function readStringList(value, fieldName) {
22
+ if (value === undefined) {
23
+ return [];
24
+ }
25
+ if (!Array.isArray(value) || value.some(item => !isNonEmptyString(item))) {
26
+ throw new Error(`${fieldName} must be a string array`);
27
+ }
28
+ return value;
29
+ }
30
+ function readRequiredCommand(value, fieldName) {
31
+ if (!Array.isArray(value) || value.length === 0 || value.some(part => !isNonEmptyString(part))) {
32
+ throw new Error(`${fieldName} must be a non-empty string array`);
33
+ }
34
+ return value;
35
+ }
36
+ function readOptionalCommand(value, fieldName) {
37
+ if (value === undefined) {
38
+ return undefined;
39
+ }
40
+ if (!Array.isArray(value) || value.length === 0 || value.some(part => !isNonEmptyString(part))) {
41
+ throw new Error(`${fieldName} must be a non-empty string array when set`);
42
+ }
43
+ return value;
44
+ }
45
+ function readPort(value) {
46
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) {
47
+ throw new Error('port must be an integer between 1 and 65535');
48
+ }
49
+ return value;
50
+ }
51
+ export function validateToolConfig(raw) {
52
+ const config = (raw ?? {});
53
+ const command = readRequiredCommand(config.command, 'command');
54
+ if (!isSafeNamespace(config.namespace)) {
55
+ throw new Error(`namespace must match ${SAFE_NAMESPACE_DESCRIPTION}`);
56
+ }
57
+ return {
58
+ namespace: config.namespace,
59
+ command,
60
+ setupCommand: readOptionalCommand(config.setupCommand, 'setupCommand'),
61
+ editorCommand: readOptionalCommand(config.editorCommand, 'editorCommand'),
62
+ port: readPort(config.port),
63
+ requiredFiles: readStringList(config.requiredFiles, 'requiredFiles'),
64
+ orphanMatchers: readStringList(config.orphanMatchers, 'orphanMatchers'),
65
+ };
66
+ }
67
+ export function createDefaultToolConfig(options) {
68
+ return validateToolConfig({
69
+ namespace: toSafeNamespace(options.namespaceSeed),
70
+ command: [options.packageManager, 'run', options.script],
71
+ setupCommand: [options.packageManager, 'install'],
72
+ editorCommand: ['code'],
73
+ port: 3000,
74
+ requiredFiles: ['package.json'],
75
+ orphanMatchers: [],
76
+ });
77
+ }
78
+ async function readFirstConfig(repoRoot) {
79
+ let firstError;
80
+ for (const fileName of CONFIG_FILE_NAMES) {
81
+ try {
82
+ return await readFile(path.join(repoRoot, fileName), 'utf8');
83
+ }
84
+ catch (error) {
85
+ firstError ??= error;
86
+ }
87
+ }
88
+ throw firstError;
89
+ }
90
+ export async function loadToolConfig({ repoRoot }) {
91
+ return validateToolConfig(parseJsonc(await readFirstConfig(repoRoot)));
92
+ }
93
+ export function renderConfigJsonc(config) {
94
+ const setupCommandSection = config.setupCommand === undefined ? '' : `
95
+ // Optional command run manually with the setup key in the selected worktree.
96
+ // Useful for first-time dependency installation without doing it on every switch.
97
+ "setupCommand": ${JSON.stringify(config.setupCommand)},
98
+ `;
99
+ const editorCommandSection = config.editorCommand === undefined ? '' : `
100
+ // Optional command that opens the selected worktree path in an editor.
101
+ // The selected worktree path is appended as the final argv entry.
102
+ "editorCommand": ${JSON.stringify(config.editorCommand)},
103
+ `;
104
+ return `{
105
+ // Session namespace used for git-common-dir state files and logs.
106
+ // Keep this filesystem-safe: letters, numbers, dots, underscores, and hyphens only.
107
+ "namespace": ${JSON.stringify(config.namespace)},
108
+
109
+ // Command launched in the selected worktree.
110
+ // Use argv form so spaces and shell metacharacters are passed safely.
111
+ "command": ${JSON.stringify(config.command)},
112
+ ${setupCommandSection}${editorCommandSection}
113
+ // TCP port owned by the command, used when stopping stale/orphaned processes.
114
+ "port": ${JSON.stringify(config.port)},
115
+
116
+ // Files that must exist in a worktree before the command can be started there.
117
+ "requiredFiles": ${JSON.stringify(config.requiredFiles)},
118
+
119
+ // Extra process command-line substrings treated as orphans for cleanup.
120
+ // Example: ["node --watch", "vite --host 0.0.0.0"]
121
+ "orphanMatchers": ${JSON.stringify(config.orphanMatchers)},
122
+ }
123
+ `;
124
+ }
125
+ export async function findExistingConfigPath(workspaceRoot, fileExists) {
126
+ for (const fileName of CONFIG_FILE_NAMES) {
127
+ const configPath = path.join(workspaceRoot, fileName);
128
+ if (await fileExists(configPath)) {
129
+ return configPath;
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+ export async function writeToolConfigForRepo({ workspaceRoot, force, config }, fileExists) {
135
+ const configPath = path.join(workspaceRoot, CONFIG_FILE_NAME);
136
+ const existingConfigPath = await findExistingConfigPath(workspaceRoot, fileExists);
137
+ if (!force && existingConfigPath) {
138
+ throw new Error(`Config file already exists: ${existingConfigPath}`);
139
+ }
140
+ const validatedConfig = validateToolConfig(config);
141
+ await writeFile(configPath, renderConfigJsonc(validatedConfig), 'utf8');
142
+ return { path: configPath, config: validatedConfig };
143
+ }
@@ -4,11 +4,10 @@ export declare const CONFIG_FILE_NAMES: readonly [".worktree-command-tui.jsonc",
4
4
  export interface ToolConfig {
5
5
  namespace: string;
6
6
  command: string[];
7
+ setupCommand?: string[];
8
+ editorCommand?: string[];
7
9
  port: number;
8
10
  requiredFiles: string[];
9
11
  orphanMatchers: string[];
10
12
  }
11
13
  export declare function parseJsonc(source: string): unknown;
12
- export declare function loadToolConfig({ repoRoot }: {
13
- repoRoot: string;
14
- }): Promise<ToolConfig>;