@ohzw/worktree-command-tui 0.1.1 → 0.1.3

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 (46) hide show
  1. package/README.md +91 -26
  2. package/dist/app.d.ts +1 -1
  3. package/dist/app.js +242 -114
  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 +29 -3
  8. package/dist/components/Header.js +5 -1
  9. package/dist/components/HelpWindow.d.ts +7 -0
  10. package/dist/components/HelpWindow.js +29 -0
  11. package/dist/components/LogPanel.d.ts +10 -3
  12. package/dist/components/LogPanel.js +240 -33
  13. package/dist/components/WorktreeList.js +20 -40
  14. package/dist/core/command-runner.d.ts +11 -0
  15. package/dist/core/command-runner.js +59 -7
  16. package/dist/core/config-lifecycle.d.ts +25 -0
  17. package/dist/core/config-lifecycle.js +160 -0
  18. package/dist/core/config.d.ts +2 -3
  19. package/dist/core/config.js +0 -48
  20. package/dist/core/git-metadata.d.ts +25 -0
  21. package/dist/core/git-metadata.js +84 -0
  22. package/dist/core/git-worktrees.d.ts +2 -1
  23. package/dist/core/git-worktrees.js +30 -11
  24. package/dist/core/github-metadata.d.ts +21 -0
  25. package/dist/core/github-metadata.js +153 -0
  26. package/dist/core/init.d.ts +3 -2
  27. package/dist/core/init.js +9 -57
  28. package/dist/core/log-reader.d.ts +7 -0
  29. package/dist/core/log-reader.js +59 -0
  30. package/dist/core/posix-process.d.ts +2 -2
  31. package/dist/core/posix-process.js +19 -4
  32. package/dist/core/process-control.d.ts +2 -2
  33. package/dist/core/process-control.js +5 -2
  34. package/dist/core/runtime-state.d.ts +42 -0
  35. package/dist/core/runtime-state.js +125 -0
  36. package/dist/core/runtime.d.ts +19 -39
  37. package/dist/core/runtime.js +112 -216
  38. package/dist/core/session-store.js +22 -7
  39. package/dist/core/tui-interaction.d.ts +31 -0
  40. package/dist/core/tui-interaction.js +59 -0
  41. package/dist/core/worktree-projection.d.ts +76 -0
  42. package/dist/core/worktree-projection.js +132 -0
  43. package/dist/main.js +6 -5
  44. package/dist/terminal/viewport.d.ts +15 -0
  45. package/dist/terminal/viewport.js +49 -0
  46. package/package.json +1 -1
@@ -1,54 +1,261 @@
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 { sanitizeInlineText } from '../core/worktree-projection.js';
4
+ import { getScrollbarThumbRows, sliceTailViewport } from '../terminal/viewport.js';
5
+ const ESCAPE = '\u001B';
6
+ const CSI = '\u009B';
7
+ const BEL = '\u0007';
8
+ const STRING_TERMINATOR_PREFIX = '\u001B';
9
+ const STRING_TERMINATOR_SUFFIX = '\\';
10
+ const STRING_TERMINATOR = '\u009C';
11
+ const DEVICE_CONTROL_STRING = '\u0090';
12
+ const START_OF_STRING = '\u0098';
13
+ const OPERATING_SYSTEM_COMMAND = '\u009D';
14
+ const PRIVACY_MESSAGE = '\u009E';
15
+ const APPLICATION_PROGRAM_COMMAND = '\u009F';
16
+ const ANSI_FOREGROUND_COLORS = new Map([
17
+ [30, 'black'],
18
+ [31, 'red'],
19
+ [32, 'green'],
20
+ [33, 'yellow'],
21
+ [34, 'blue'],
22
+ [35, 'magenta'],
23
+ [36, 'cyan'],
24
+ [37, 'white'],
25
+ [90, 'gray'],
26
+ [91, 'red'],
27
+ [92, 'green'],
28
+ [93, 'yellow'],
29
+ [94, 'blue'],
30
+ [95, 'magenta'],
31
+ [96, 'cyan'],
32
+ [97, 'white'],
33
+ ]);
34
+ function sanitizeTextChunk(value) {
5
35
  return value
6
- .replace(ANSI_ESCAPE_PATTERN, '')
7
36
  .replace(/[\r\t\u2028\u2029]+/g, ' ')
8
37
  .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '')
9
- .replace(/\p{Cf}/gu, '')
10
- .trimEnd();
38
+ .replace(/\p{Cf}/gu, '');
39
+ }
40
+ function sameStyle(a, b) {
41
+ return a.color === b.color && a.dimColor === b.dimColor && a.bold === b.bold;
42
+ }
43
+ function appendSegment(segments, text, style) {
44
+ if (text.length === 0) {
45
+ return;
46
+ }
47
+ const last = segments.at(-1);
48
+ if (last && sameStyle(last, style)) {
49
+ last.text += text;
50
+ return;
51
+ }
52
+ segments.push({ ...style, text });
53
+ }
54
+ function skipExtendedColor(codes, index) {
55
+ const mode = Number(codes[index + 1] ?? Number.NaN);
56
+ if (mode === 5) {
57
+ return index + 2;
58
+ }
59
+ if (mode === 2) {
60
+ return index + 4;
61
+ }
62
+ return index;
63
+ }
64
+ function applyAnsiSgr(style, parameters) {
65
+ const codes = parameters.length === 0 ? ['0'] : parameters.split(';');
66
+ for (let index = 0; index < codes.length; index += 1) {
67
+ const rawCode = codes[index] ?? '0';
68
+ if (rawCode.includes(':')) {
69
+ if (Number(rawCode.split(':', 1)[0]) === 38) {
70
+ delete style.color;
71
+ }
72
+ continue;
73
+ }
74
+ const code = Number(rawCode || 0);
75
+ if (code === 0) {
76
+ delete style.color;
77
+ delete style.dimColor;
78
+ delete style.bold;
79
+ continue;
80
+ }
81
+ if (code === 1) {
82
+ style.bold = true;
83
+ continue;
84
+ }
85
+ if (code === 2) {
86
+ style.dimColor = true;
87
+ continue;
88
+ }
89
+ if (code === 22) {
90
+ delete style.bold;
91
+ delete style.dimColor;
92
+ continue;
93
+ }
94
+ if (code === 38) {
95
+ delete style.color;
96
+ index = skipExtendedColor(codes, index);
97
+ continue;
98
+ }
99
+ if (code === 48 || code === 58) {
100
+ index = skipExtendedColor(codes, index);
101
+ continue;
102
+ }
103
+ if (code === 39) {
104
+ delete style.color;
105
+ continue;
106
+ }
107
+ const color = ANSI_FOREGROUND_COLORS.get(code);
108
+ if (color) {
109
+ style.color = color;
110
+ }
111
+ }
112
+ }
113
+ function trimLineEnd(segments) {
114
+ const result = [...segments];
115
+ while (result.length > 0) {
116
+ const last = result.at(-1);
117
+ const trimmed = last.text.trimEnd();
118
+ if (trimmed.length > 0) {
119
+ if (trimmed.length !== last.text.length) {
120
+ result[result.length - 1] = { ...last, text: trimmed };
121
+ }
122
+ break;
123
+ }
124
+ result.pop();
125
+ }
126
+ return result;
127
+ }
128
+ function pushLine(lines, segments) {
129
+ const trimmedSegments = trimLineEnd(segments);
130
+ lines.push({ segments: trimmedSegments.length > 0 ? trimmedSegments : [{ text: ' ' }] });
131
+ }
132
+ function isAnsiFinalByte(value) {
133
+ const code = value.charCodeAt(0);
134
+ return code >= 0x40 && code <= 0x7E;
135
+ }
136
+ function isAnsiIntermediateByte(value) {
137
+ const code = value.charCodeAt(0);
138
+ return code >= 0x20 && code <= 0x2F;
139
+ }
140
+ function consumeCsi(value, index, style) {
141
+ let cursor = index;
142
+ while (cursor < value.length && !isAnsiFinalByte(value[cursor])) {
143
+ cursor += 1;
144
+ }
145
+ if (cursor >= value.length) {
146
+ return value.length;
147
+ }
148
+ const finalByte = value[cursor];
149
+ const sequence = value.slice(index, cursor);
150
+ const hasIntermediate = Array.from(sequence).some(isAnsiIntermediateByte);
151
+ if (!hasIntermediate && finalByte === 'm') {
152
+ applyAnsiSgr(style, sequence);
153
+ }
154
+ return cursor + 1;
155
+ }
156
+ function consumeStringControl(value, index, allowBelTerminator) {
157
+ let cursor = index;
158
+ while (cursor < value.length) {
159
+ if ((allowBelTerminator && value[cursor] === BEL) || value[cursor] === STRING_TERMINATOR) {
160
+ return cursor + 1;
161
+ }
162
+ if (value[cursor] === STRING_TERMINATOR_PREFIX && value[cursor + 1] === STRING_TERMINATOR_SUFFIX) {
163
+ return cursor + 2;
164
+ }
165
+ cursor += 1;
166
+ }
167
+ return value.length;
168
+ }
169
+ function isStringControl(value) {
170
+ return value === DEVICE_CONTROL_STRING
171
+ || value === START_OF_STRING
172
+ || value === OPERATING_SYSTEM_COMMAND
173
+ || value === PRIVACY_MESSAGE
174
+ || value === APPLICATION_PROGRAM_COMMAND;
175
+ }
176
+ function consumeEscape(value, index, style) {
177
+ if (value[index] === CSI) {
178
+ return consumeCsi(value, index + 1, style);
179
+ }
180
+ if (isStringControl(value[index])) {
181
+ return consumeStringControl(value, index + 1, value[index] === OPERATING_SYSTEM_COMMAND);
182
+ }
183
+ const next = value[index + 1];
184
+ if (next === undefined) {
185
+ return index + 1;
186
+ }
187
+ if (next === '[') {
188
+ return consumeCsi(value, index + 2, style);
189
+ }
190
+ if (next === ']') {
191
+ return consumeStringControl(value, index + 2, true);
192
+ }
193
+ if (next === 'P' || next === '^' || next === '_' || next === 'X') {
194
+ return consumeStringControl(value, index + 2, false);
195
+ }
196
+ let cursor = index + 1;
197
+ while (cursor < value.length && isAnsiIntermediateByte(value[cursor])) {
198
+ cursor += 1;
199
+ }
200
+ return Math.min(cursor + 1, value.length);
201
+ }
202
+ function parseLogContent(value) {
203
+ const lines = [];
204
+ let segments = [];
205
+ const style = {};
206
+ let textStart = 0;
207
+ let index = 0;
208
+ while (index < value.length) {
209
+ const character = value[index];
210
+ if (character === '\n') {
211
+ appendSegment(segments, sanitizeTextChunk(value.slice(textStart, index)), style);
212
+ pushLine(lines, segments);
213
+ segments = [];
214
+ index += 1;
215
+ textStart = index;
216
+ continue;
217
+ }
218
+ if (character === ESCAPE || character === CSI || isStringControl(character)) {
219
+ appendSegment(segments, sanitizeTextChunk(value.slice(textStart, index)), style);
220
+ index = consumeEscape(value, index, style);
221
+ textStart = index;
222
+ continue;
223
+ }
224
+ index += 1;
225
+ }
226
+ appendSegment(segments, sanitizeTextChunk(value.slice(textStart)), style);
227
+ pushLine(lines, segments);
228
+ return lines;
229
+ }
230
+ function plainLine(text, style = {}) {
231
+ return { segments: [{ ...style, text }] };
232
+ }
233
+ function getLineText(line) {
234
+ return line.segments.map(segment => segment.text).join('');
11
235
  }
12
236
  export function buildLogLines(logs) {
13
237
  if (logs.length === 0) {
14
- return [{ text: 'No *.log files yet.', dimColor: true }];
238
+ return [plainLine('No *.log files yet.', { dimColor: true })];
15
239
  }
16
240
  const lines = [];
17
241
  for (const [index, log] of logs.entries()) {
18
242
  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) || ' ' });
243
+ lines.push(plainLine(' ', { dimColor: true }));
25
244
  }
245
+ lines.push(plainLine(`[${sanitizeInlineText(log.name)}]`, { color: 'cyan' }));
246
+ lines.push(...parseLogContent(log.content.length > 0 ? log.content : '(empty)'));
26
247
  }
27
248
  return lines;
28
249
  }
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
250
  export function LogPanel({ logs, width, height, scrollOffset = 0, title = 'Logs (*.log · tail 120)', }) {
39
251
  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;
252
+ const viewport = sliceTailViewport(lines, height === undefined ? lines.length : height - 3, scrollOffset);
253
+ const contentViewportHeight = viewport.viewportHeight;
254
+ const startIndex = viewport.startIndex;
255
+ const visibleLines = viewport.visibleItems;
256
+ const showScrollbar = height !== undefined && lines.length > contentViewportHeight;
50
257
  const scrollbarThumbRows = showScrollbar
51
- ? getScrollbarThumbRows(lines.length, contentViewportHeight, maxScrollOffset - effectiveScrollOffset)
258
+ ? getScrollbarThumbRows(lines.length, contentViewportHeight, viewport.topScrollOffset)
52
259
  : 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}`))) })] }));
260
+ 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
261
  }
@@ -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,53 @@
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 false;
20
+ }
21
+ settled = true;
22
+ closeSync(fd);
23
+ return true;
24
+ };
25
+ const child = spawn(command[0], command.slice(1), {
26
+ cwd,
27
+ env: getLogEnvironment(),
28
+ stdio: ['ignore', fd, fd],
29
+ });
30
+ child.once('error', error => {
31
+ if (finalize()) {
32
+ reject(error);
33
+ }
34
+ });
35
+ child.once('exit', (code, signal) => {
36
+ if (!finalize()) {
37
+ return;
38
+ }
39
+ if (code === 0) {
40
+ resolve({ logPath });
41
+ return;
42
+ }
43
+ if (code !== null) {
44
+ reject(new Error(`${errorLabel} exited with code ${code}; see ${logPath}`));
45
+ return;
46
+ }
47
+ reject(new Error(`${errorLabel} exited due to signal ${signal ?? 'unknown'}; see ${logPath}`));
48
+ });
49
+ return promise;
50
+ }
4
51
  export function startDetachedCommand({ command, cwd, logsDir, logFileBase, }) {
5
52
  mkdirSync(logsDir, { recursive: true });
6
53
  const logPath = path.join(logsDir, `${logFileBase}.log`);
@@ -10,29 +57,34 @@ export function startDetachedCommand({ command, cwd, logsDir, logFileBase, }) {
10
57
  const child = spawn(command[0], command.slice(1), {
11
58
  cwd,
12
59
  detached: true,
60
+ env: getLogEnvironment(),
13
61
  stdio: ['ignore', fd, fd],
14
62
  });
15
63
  const finalize = () => {
16
64
  if (settled) {
17
- return;
65
+ return false;
18
66
  }
19
67
  settled = true;
20
68
  closeSync(fd);
69
+ return true;
21
70
  };
22
71
  child.once('error', error => {
23
- finalize();
24
- reject(error);
72
+ if (finalize()) {
73
+ reject(error);
74
+ }
25
75
  });
26
76
  child.once('spawn', () => {
27
77
  const pid = child.pid;
28
78
  if (pid === undefined) {
29
- finalize();
30
- reject(new Error('spawn succeeded without pid'));
79
+ if (finalize()) {
80
+ reject(new Error('spawn succeeded without pid'));
81
+ }
31
82
  return;
32
83
  }
33
84
  child.unref();
34
- finalize();
35
- resolve({ pid, pgid: pid, logPath });
85
+ if (finalize()) {
86
+ resolve({ pid, pgid: pid, logPath });
87
+ }
36
88
  });
37
89
  return promise;
38
90
  }
@@ -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>;