@ohzw/worktree-command-tui 0.1.2 → 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.
- package/README.md +89 -29
- package/dist/app.js +11 -5
- package/dist/components/ContextBar.js +3 -1
- package/dist/components/Header.js +5 -1
- package/dist/components/LogPanel.js +2 -1
- package/dist/core/command-runner.js +19 -11
- package/dist/core/config-lifecycle.js +24 -7
- package/dist/core/github-metadata.d.ts +7 -0
- package/dist/core/github-metadata.js +24 -8
- package/dist/core/log-reader.js +20 -4
- package/dist/core/posix-process.d.ts +2 -2
- package/dist/core/posix-process.js +19 -4
- package/dist/core/process-control.d.ts +2 -2
- package/dist/core/process-control.js +5 -2
- package/dist/core/session-store.js +22 -7
- package/dist/core/worktree-projection.js +9 -1
- package/dist/main.js +6 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
# worktree-command-tui
|
|
2
2
|
|
|
3
|
-
`worktree-command-tui` is a terminal UI
|
|
4
|
-
It
|
|
3
|
+
`worktree-command-tui` is a terminal UI for managing Git worktrees from inside a repository.
|
|
4
|
+
It keeps one active runtime session per namespace, lets you switch worktrees with the keyboard, and keeps logs/process cleanup tied to the repo's shared Git state.
|
|
5
5
|
|
|
6
6
|
## Features
|
|
7
7
|
|
|
8
|
-
-
|
|
9
|
-
- Start
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
8
|
+
- Discover worktrees from the current repository even when launched from a subdirectory
|
|
9
|
+
- Start or switch the active worktree session with `Enter`
|
|
10
|
+
- Stop the active session and clean up recorded orphan processes with `s`
|
|
11
|
+
- Run an optional per-worktree setup command with `i`
|
|
12
|
+
- Open the selected worktree in your editor with `e`
|
|
13
|
+
- Open the selected branch's pull request in a browser with `o`
|
|
14
|
+
- Delete a non-root worktree from the TUI with `d`, then confirm
|
|
15
|
+
- Inspect branch, upstream, working tree, and pull request metadata in the detail pane
|
|
16
|
+
- Tail ANSI-colored logs inline or in a full-screen log view
|
|
17
|
+
- Generate and load JSONC config with comments and trailing commas
|
|
13
18
|
|
|
14
19
|
## Requirements
|
|
15
20
|
|
|
16
21
|
- Node.js `>=20`
|
|
17
|
-
-
|
|
22
|
+
- Git
|
|
23
|
+
- A Git repository (additional linked worktrees optional)
|
|
24
|
+
- Optional: GitHub CLI (`gh`) and a GitHub origin remote for pull request metadata and `o` / Open PR
|
|
18
25
|
|
|
19
26
|
## Installation
|
|
20
27
|
|
|
@@ -22,19 +29,24 @@ It helps you inspect, start, and stop per-worktree processes with quick keyboard
|
|
|
22
29
|
npm install -g @ohzw/worktree-command-tui
|
|
23
30
|
```
|
|
24
31
|
|
|
25
|
-
|
|
32
|
+
Installed binaries:
|
|
26
33
|
|
|
27
|
-
|
|
34
|
+
- `wctui`
|
|
35
|
+
- `worktree-command-tui` (compatibility alias)
|
|
28
36
|
|
|
29
|
-
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
### 1) Initialize config
|
|
40
|
+
|
|
41
|
+
Run this from the repo root or any subdirectory inside the repo:
|
|
30
42
|
|
|
31
43
|
```bash
|
|
32
44
|
wctui init
|
|
33
45
|
```
|
|
34
46
|
|
|
35
|
-
This
|
|
47
|
+
This writes `.worktree-command-tui.jsonc` at the repository root.
|
|
36
48
|
|
|
37
|
-
To
|
|
49
|
+
To overwrite an existing config:
|
|
38
50
|
|
|
39
51
|
```bash
|
|
40
52
|
wctui init --force
|
|
@@ -46,46 +58,94 @@ wctui init --force
|
|
|
46
58
|
wctui
|
|
47
59
|
```
|
|
48
60
|
|
|
49
|
-
|
|
61
|
+
If config is missing, the CLI exits with a message telling you to run `wctui init`.
|
|
62
|
+
|
|
63
|
+
## Keyboard shortcuts
|
|
64
|
+
|
|
65
|
+
Primary shortcuts in the footer:
|
|
66
|
+
|
|
67
|
+
- `↑↓` / `j` `k` — move selection
|
|
68
|
+
- `Enter` — start or switch to selected worktree
|
|
69
|
+
- `i` — run `setupCommand` when configured
|
|
70
|
+
- `e` — open the selected worktree in the configured editor when `editorCommand` is configured
|
|
71
|
+
- `o` — open the selected worktree's pull request when GitHub metadata is available
|
|
72
|
+
- `d` — arm worktree deletion
|
|
73
|
+
- `L` — open full-screen logs
|
|
74
|
+
- `s` — stop active session
|
|
75
|
+
- `r` — refresh worktree metadata
|
|
76
|
+
- `?` — show help
|
|
77
|
+
- `q` — quit
|
|
78
|
+
|
|
79
|
+
Additional shortcuts from the help window:
|
|
80
|
+
|
|
81
|
+
- `g` / `G` — jump to first / last worktree
|
|
82
|
+
- `[` / `]` — scroll logs
|
|
83
|
+
- `PageUp` / `PageDn` — page the selection list
|
|
84
|
+
- Mouse wheel — scroll the pane under the cursor
|
|
85
|
+
- `d` / `y` — confirm delete after arming it
|
|
86
|
+
- `Esc` / `n` / `q` — cancel delete confirmation
|
|
87
|
+
|
|
88
|
+
## Security and network behavior
|
|
89
|
+
|
|
90
|
+
`wctui` executes the argv commands stored in `.worktree-command-tui.jsonc` when you press the matching keys. Treat repository config as trusted code:
|
|
50
91
|
|
|
51
|
-
|
|
92
|
+
- `Enter` starts `command` in the selected worktree.
|
|
93
|
+
- `i` runs `setupCommand`; package-manager install commands may run dependency lifecycle scripts.
|
|
94
|
+
- `e` runs `editorCommand` with the selected worktree path appended.
|
|
95
|
+
|
|
96
|
+
Review config before using those actions in an untrusted repository or worktree.
|
|
97
|
+
|
|
98
|
+
The TUI also reads pull request metadata with the GitHub CLI when `remote.origin.url` points at `github.com`. This uses `gh api`, your existing `gh` authentication, and a short timeout. Non-GitHub remote hosts are ignored by default.
|
|
52
99
|
|
|
53
100
|
## Configuration
|
|
54
101
|
|
|
55
|
-
The tool
|
|
102
|
+
The tool looks for config in this order:
|
|
56
103
|
|
|
57
104
|
1. `.worktree-command-tui.jsonc`
|
|
58
105
|
2. `.worktree-command-tui.json`
|
|
59
106
|
|
|
60
|
-
|
|
107
|
+
Example config:
|
|
61
108
|
|
|
62
109
|
```jsonc
|
|
63
110
|
{
|
|
64
|
-
// Session namespace used for logs
|
|
111
|
+
// Session namespace used for git-common-dir state files and logs.
|
|
65
112
|
"namespace": "worktree-command-tui",
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
113
|
+
|
|
114
|
+
// Command launched in the selected worktree.
|
|
115
|
+
"command": ["npm", "run", "dev"],
|
|
116
|
+
|
|
117
|
+
// Optional command run manually with the setup key in the selected worktree.
|
|
69
118
|
"setupCommand": ["npm", "install"],
|
|
70
|
-
|
|
119
|
+
|
|
120
|
+
// Optional command that opens the selected worktree path in an editor.
|
|
121
|
+
// The selected worktree path is appended as the final argv entry.
|
|
122
|
+
"editorCommand": ["code"],
|
|
123
|
+
|
|
124
|
+
// TCP port owned by the command, used when stopping stale/orphaned processes.
|
|
71
125
|
"port": 3000,
|
|
72
|
-
|
|
126
|
+
|
|
127
|
+
// Files that must exist in a worktree before the command can be started there.
|
|
73
128
|
"requiredFiles": ["package.json"],
|
|
74
|
-
|
|
129
|
+
|
|
130
|
+
// Extra process command-line substrings treated as orphans for cleanup.
|
|
75
131
|
"orphanMatchers": []
|
|
76
132
|
}
|
|
77
133
|
```
|
|
78
134
|
|
|
79
|
-
|
|
80
|
-
|
|
135
|
+
Notes:
|
|
136
|
+
|
|
137
|
+
- `setupCommand` is optional and never runs automatically; `i` only appears when it is configured
|
|
138
|
+
- `editorCommand` is optional; when set, the selected worktree path is appended to the argv and `e` becomes available
|
|
139
|
+
- The generated default config auto-detects package manager hints from `packageManager` or common lockfiles and chooses a default script such as `dev`, `start`, or `serve`
|
|
140
|
+
- Session records and logs are stored under the repository's Git common dir, so they are shared across worktrees in the same repo
|
|
81
141
|
|
|
82
142
|
## Development
|
|
83
143
|
|
|
84
144
|
```bash
|
|
85
145
|
npm install
|
|
86
|
-
npm
|
|
87
|
-
npm run typecheck
|
|
88
|
-
npm run build
|
|
146
|
+
npm test
|
|
147
|
+
npm run typecheck
|
|
148
|
+
npm run build
|
|
89
149
|
```
|
|
90
150
|
|
|
91
151
|
## License
|
package/dist/app.js
CHANGED
|
@@ -10,6 +10,7 @@ import { FloatingLogWindow } from './components/FloatingLogWindow.js';
|
|
|
10
10
|
import { LogPanel, buildLogLines } from './components/LogPanel.js';
|
|
11
11
|
import { WorktreeList } from './components/WorktreeList.js';
|
|
12
12
|
import { clampSelectionIndex, decideEnterInteraction, decideSetupInteraction, getNextSelectedPath, getSelectedIndex } from './core/tui-interaction.js';
|
|
13
|
+
import { sanitizeInlineText } from './core/worktree-projection.js';
|
|
13
14
|
const ENABLE_MOUSE_TRACKING = '\u001B[?1000h\u001B[?1006h';
|
|
14
15
|
const DISABLE_MOUSE_TRACKING = '\u001B[?1000l\u001B[?1006l';
|
|
15
16
|
function parseMouseWheelEvents(input) {
|
|
@@ -536,20 +537,25 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
|
536
537
|
if (isHelpOverlayOpen) {
|
|
537
538
|
return (_jsx(HelpWindow, { setupAvailable: model.setupAvailable, editorAvailable: model.editorAvailable, width: Math.max(1, rootWidth - 1), height: rootHeight }));
|
|
538
539
|
}
|
|
540
|
+
const safeActiveBranch = model.activeBranch === null ? '-' : sanitizeInlineText(model.activeBranch);
|
|
541
|
+
const safeSelectedBranch = selected === undefined ? '-' : sanitizeInlineText(selected.branch);
|
|
542
|
+
const safeVisibleStatusMessage = sanitizeInlineText(visibleStatus.message);
|
|
543
|
+
const safeModelStatusMessage = sanitizeInlineText(model.status.message);
|
|
544
|
+
const safeCompletedAlert = completedAlert === null ? null : sanitizeInlineText(completedAlert);
|
|
539
545
|
if (isLogOverlayOpen) {
|
|
540
546
|
return (_jsx(FloatingLogWindow, { logs: model.logs, width: Math.max(1, rootWidth - 1), height: rootHeight, scrollOffset: logScrollOffset }));
|
|
541
547
|
}
|
|
542
548
|
if (minimalLayout) {
|
|
543
|
-
return (_jsxs(Box, { width: rootWidth, height: rootHeight, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["A:",
|
|
549
|
+
return (_jsxs(Box, { width: rootWidth, height: rootHeight, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["A:", safeActiveBranch] }), rootHeight >= 2 ? _jsxs(Text, { wrap: "truncate-end", children: ["S:", safeSelectedBranch] }) : null, rootHeight >= 3 ? _jsx(Text, { wrap: "truncate-end", children: confirmationOpen ? `D:${safeVisibleStatusMessage}` : `T:${model.status.kind}` }) : null, rootHeight >= 4 ? (confirmationOpen
|
|
544
550
|
? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "d/y confirm \u00B7 Esc/n/q cancel" })
|
|
545
551
|
: _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193jk\u21B5", model.setupAvailable ? 'i' : '', model.editorAvailable ? 'e' : '', "odLq"] })) : null] }));
|
|
546
552
|
}
|
|
547
553
|
if (compactLayout) {
|
|
548
|
-
return (_jsxs(Box, { width: rootWidth, height: rootHeight, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["Active: ",
|
|
549
|
-
? _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["\u2714 ",
|
|
550
|
-
: model.status.kind === 'setting-up' || model.status.kind === 'starting' || model.status.kind === 'stopping' ? (_jsx(Spinner, { label: `Status: ${model.status.kind} — ${
|
|
554
|
+
return (_jsxs(Box, { width: rootWidth, height: rootHeight, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["Active: ", safeActiveBranch] }), _jsxs(Text, { wrap: "truncate-end", children: ["Selected: ", safeSelectedBranch] }), safeCompletedAlert
|
|
555
|
+
? _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["\u2714 ", safeCompletedAlert] })
|
|
556
|
+
: model.status.kind === 'setting-up' || model.status.kind === 'starting' || model.status.kind === 'stopping' ? (_jsx(Spinner, { label: `Status: ${model.status.kind} — ${safeModelStatusMessage}` })) : (_jsxs(Text, { wrap: "truncate-end", children: ["Status: ", visibleStatus.kind, " \u2014 ", safeVisibleStatusMessage] })), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: confirmationOpen
|
|
551
557
|
? 'Keys: d/y confirm | Esc/n/q cancel'
|
|
552
558
|
: `Keys: ↑↓/jk g/G ↵${model.setupAvailable ? ' i' : ''}${model.editorAvailable ? ' e' : ''} o d L s r q · Resize terminal for split view` })] }));
|
|
553
559
|
}
|
|
554
|
-
return (_jsxs(Box, { width: rootWidth, height: rootHeight, flexDirection: "column", children: [_jsx(Header, { repoName: model.repoName, namespace: model.namespace, activeBranch: model.activeBranch }), _jsxs(Box, { flexDirection: stackedLayout ? 'column' : 'row', flexGrow: stackedLayout ? 0 : 1, flexShrink: 1, children: [_jsx(WorktreeList, { rows: model.rows, selectedIndex: selectedIndex, width: stackedLayout ? bodyWidth : listWidth, height: paneHeight, stacked: stackedLayout, scrollOffset: worktreeScrollOffset }), _jsx(ActionPanel, { selectedRow: selected, activePath: model.activePath, setupAvailable: model.setupAvailable, stacked: stackedLayout, width: stackedLayout ? bodyWidth : actionWidth, height: paneHeight, compactDetails: compactDetailPane, scrollOffset: selectionScrollOffset })] }), showLogPanel ? _jsx(LogPanel, { logs: model.logs, width: bodyWidth, height: logPaneHeight, scrollOffset: logScrollOffset }) : null, _jsx(ContextBar, { status: visibleStatus, setupAvailable: model.setupAvailable, editorAvailable: model.editorAvailable, confirmationOpen: confirmationOpen }),
|
|
560
|
+
return (_jsxs(Box, { width: rootWidth, height: rootHeight, flexDirection: "column", children: [_jsx(Header, { repoName: model.repoName, namespace: model.namespace, activeBranch: model.activeBranch }), _jsxs(Box, { flexDirection: stackedLayout ? 'column' : 'row', flexGrow: stackedLayout ? 0 : 1, flexShrink: 1, children: [_jsx(WorktreeList, { rows: model.rows, selectedIndex: selectedIndex, width: stackedLayout ? bodyWidth : listWidth, height: paneHeight, stacked: stackedLayout, scrollOffset: worktreeScrollOffset }), _jsx(ActionPanel, { selectedRow: selected, activePath: model.activePath, setupAvailable: model.setupAvailable, stacked: stackedLayout, width: stackedLayout ? bodyWidth : actionWidth, height: paneHeight, compactDetails: compactDetailPane, scrollOffset: selectionScrollOffset })] }), showLogPanel ? _jsx(LogPanel, { logs: model.logs, width: bodyWidth, height: logPaneHeight, scrollOffset: logScrollOffset }) : null, _jsx(ContextBar, { status: visibleStatus, setupAvailable: model.setupAvailable, editorAvailable: model.editorAvailable, confirmationOpen: confirmationOpen }), safeCompletedAlert ? (_jsx(Box, { position: "absolute", top: 1, right: 2, children: _jsx(Alert, { variant: "success", children: safeCompletedAlert }) })) : null] }));
|
|
555
561
|
}
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { Box, Text } from 'ink';
|
|
4
4
|
import { Spinner } from '@inkjs/ui';
|
|
5
|
+
import { sanitizeInlineText } from '../core/worktree-projection.js';
|
|
5
6
|
const KIND_TO_ICON = {
|
|
6
7
|
idle: 'ℹ',
|
|
7
8
|
starting: '⚠',
|
|
@@ -41,5 +42,6 @@ function buildKeyHints(setupAvailable, editorAvailable, confirmationOpen) {
|
|
|
41
42
|
export function ContextBar({ status, setupAvailable, editorAvailable, confirmationOpen, }) {
|
|
42
43
|
const isBusy = status.kind === 'setting-up' || status.kind === 'starting' || status.kind === 'stopping';
|
|
43
44
|
const keyHints = buildKeyHints(setupAvailable, editorAvailable, confirmationOpen);
|
|
44
|
-
|
|
45
|
+
const statusMessage = sanitizeInlineText(status.message);
|
|
46
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: KIND_TO_COLOR[status.kind], flexDirection: "column", paddingX: 1, children: [isBusy ? (_jsx(Spinner, { label: `Status: ${status.kind} — ${statusMessage}` })) : (_jsxs(Text, { color: KIND_TO_COLOR[status.kind], wrap: "truncate-end", children: [KIND_TO_ICON[status.kind], " Status: ", status.kind, " \u2014 ", statusMessage] })), _jsx(Text, { wrap: "truncate-end", children: keyHints.map((hint, hintIndex) => (_jsxs(React.Fragment, { children: [hintIndex === 0 ? null : _jsx(Text, { dimColor: true, children: " | " }), _jsx(Text, { color: "white", children: hint.binding }), _jsxs(Text, { dimColor: true, children: [" ", hint.label] })] }, hint.binding))) })] }));
|
|
45
47
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
+
import { sanitizeInlineText } from '../core/worktree-projection.js';
|
|
3
4
|
export function Header({ repoName, namespace, activeBranch, }) {
|
|
4
|
-
|
|
5
|
+
const safeRepoName = sanitizeInlineText(repoName);
|
|
6
|
+
const safeNamespace = sanitizeInlineText(namespace);
|
|
7
|
+
const safeActiveBranch = activeBranch === null ? '-' : sanitizeInlineText(activeBranch);
|
|
8
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: "blue", flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "blue", wrap: "truncate-end", children: ["Worktree Command TUI \u00B7 Repo: ", safeRepoName] }), _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["Active: ", safeActiveBranch] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["Namespace: ", safeNamespace] })] }));
|
|
5
9
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
+
import { sanitizeInlineText } from '../core/worktree-projection.js';
|
|
3
4
|
import { getScrollbarThumbRows, sliceTailViewport } from '../terminal/viewport.js';
|
|
4
5
|
const ESCAPE = '\u001B';
|
|
5
6
|
const CSI = '\u009B';
|
|
@@ -241,7 +242,7 @@ export function buildLogLines(logs) {
|
|
|
241
242
|
if (index > 0) {
|
|
242
243
|
lines.push(plainLine(' ', { dimColor: true }));
|
|
243
244
|
}
|
|
244
|
-
lines.push(plainLine(`[${log.name}]`, { color: 'cyan' }));
|
|
245
|
+
lines.push(plainLine(`[${sanitizeInlineText(log.name)}]`, { color: 'cyan' }));
|
|
245
246
|
lines.push(...parseLogContent(log.content.length > 0 ? log.content : '(empty)'));
|
|
246
247
|
}
|
|
247
248
|
return lines;
|
|
@@ -16,10 +16,11 @@ export function runCommandToLog({ command, cwd, logsDir, logFileBase, errorLabel
|
|
|
16
16
|
let settled = false;
|
|
17
17
|
const finalize = () => {
|
|
18
18
|
if (settled) {
|
|
19
|
-
return;
|
|
19
|
+
return false;
|
|
20
20
|
}
|
|
21
21
|
settled = true;
|
|
22
22
|
closeSync(fd);
|
|
23
|
+
return true;
|
|
23
24
|
};
|
|
24
25
|
const child = spawn(command[0], command.slice(1), {
|
|
25
26
|
cwd,
|
|
@@ -27,11 +28,14 @@ export function runCommandToLog({ command, cwd, logsDir, logFileBase, errorLabel
|
|
|
27
28
|
stdio: ['ignore', fd, fd],
|
|
28
29
|
});
|
|
29
30
|
child.once('error', error => {
|
|
30
|
-
finalize()
|
|
31
|
-
|
|
31
|
+
if (finalize()) {
|
|
32
|
+
reject(error);
|
|
33
|
+
}
|
|
32
34
|
});
|
|
33
35
|
child.once('exit', (code, signal) => {
|
|
34
|
-
finalize()
|
|
36
|
+
if (!finalize()) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
35
39
|
if (code === 0) {
|
|
36
40
|
resolve({ logPath });
|
|
37
41
|
return;
|
|
@@ -58,25 +62,29 @@ export function startDetachedCommand({ command, cwd, logsDir, logFileBase, }) {
|
|
|
58
62
|
});
|
|
59
63
|
const finalize = () => {
|
|
60
64
|
if (settled) {
|
|
61
|
-
return;
|
|
65
|
+
return false;
|
|
62
66
|
}
|
|
63
67
|
settled = true;
|
|
64
68
|
closeSync(fd);
|
|
69
|
+
return true;
|
|
65
70
|
};
|
|
66
71
|
child.once('error', error => {
|
|
67
|
-
finalize()
|
|
68
|
-
|
|
72
|
+
if (finalize()) {
|
|
73
|
+
reject(error);
|
|
74
|
+
}
|
|
69
75
|
});
|
|
70
76
|
child.once('spawn', () => {
|
|
71
77
|
const pid = child.pid;
|
|
72
78
|
if (pid === undefined) {
|
|
73
|
-
finalize()
|
|
74
|
-
|
|
79
|
+
if (finalize()) {
|
|
80
|
+
reject(new Error('spawn succeeded without pid'));
|
|
81
|
+
}
|
|
75
82
|
return;
|
|
76
83
|
}
|
|
77
84
|
child.unref();
|
|
78
|
-
finalize()
|
|
79
|
-
|
|
85
|
+
if (finalize()) {
|
|
86
|
+
resolve({ pid, pgid: pid, logPath });
|
|
87
|
+
}
|
|
80
88
|
});
|
|
81
89
|
return promise;
|
|
82
90
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { CONFIG_FILE_NAME, CONFIG_FILE_NAMES, parseJsonc } from './config.js';
|
|
4
4
|
const SAFE_NAMESPACE_PATTERN = /^[A-Za-z0-9._-]+$/u;
|
|
@@ -7,6 +7,7 @@ const LEADING_NAMESPACE_HYPHENS_PATTERN = /^-+/u;
|
|
|
7
7
|
const TRAILING_NAMESPACE_HYPHENS_PATTERN = /-+$/u;
|
|
8
8
|
const SAFE_NAMESPACE_DESCRIPTION = '[A-Za-z0-9._-]+';
|
|
9
9
|
const DEFAULT_NAMESPACE = 'worktree-command-tui';
|
|
10
|
+
const MAX_CONFIG_BYTES = 64 * 1024;
|
|
10
11
|
function isNonEmptyString(value) {
|
|
11
12
|
return typeof value === 'string' && value.length > 0;
|
|
12
13
|
}
|
|
@@ -27,6 +28,15 @@ function readStringList(value, fieldName) {
|
|
|
27
28
|
}
|
|
28
29
|
return value;
|
|
29
30
|
}
|
|
31
|
+
function readOrphanMatchers(value) {
|
|
32
|
+
const matchers = readStringList(value, 'orphanMatchers');
|
|
33
|
+
for (const matcher of matchers) {
|
|
34
|
+
if (!/\S+\s+\S+/u.test(matcher)) {
|
|
35
|
+
throw new Error('orphanMatchers entries must include a command plus argument fragment');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return matchers;
|
|
39
|
+
}
|
|
30
40
|
function readRequiredCommand(value, fieldName) {
|
|
31
41
|
if (!Array.isArray(value) || value.length === 0 || value.some(part => !isNonEmptyString(part))) {
|
|
32
42
|
throw new Error(`${fieldName} must be a non-empty string array`);
|
|
@@ -61,7 +71,7 @@ export function validateToolConfig(raw) {
|
|
|
61
71
|
editorCommand: readOptionalCommand(config.editorCommand, 'editorCommand'),
|
|
62
72
|
port: readPort(config.port),
|
|
63
73
|
requiredFiles: readStringList(config.requiredFiles, 'requiredFiles'),
|
|
64
|
-
orphanMatchers:
|
|
74
|
+
orphanMatchers: readOrphanMatchers(config.orphanMatchers),
|
|
65
75
|
};
|
|
66
76
|
}
|
|
67
77
|
export function createDefaultToolConfig(options) {
|
|
@@ -75,11 +85,17 @@ export function createDefaultToolConfig(options) {
|
|
|
75
85
|
orphanMatchers: [],
|
|
76
86
|
});
|
|
77
87
|
}
|
|
88
|
+
async function readConfigFile(configPath) {
|
|
89
|
+
if ((await stat(configPath)).size > MAX_CONFIG_BYTES) {
|
|
90
|
+
throw new Error('config file is too large');
|
|
91
|
+
}
|
|
92
|
+
return readFile(configPath, 'utf8');
|
|
93
|
+
}
|
|
78
94
|
async function readFirstConfig(repoRoot) {
|
|
79
95
|
let firstError;
|
|
80
96
|
for (const fileName of CONFIG_FILE_NAMES) {
|
|
81
97
|
try {
|
|
82
|
-
return await
|
|
98
|
+
return await readConfigFile(path.join(repoRoot, fileName));
|
|
83
99
|
}
|
|
84
100
|
catch (error) {
|
|
85
101
|
firstError ??= error;
|
|
@@ -93,7 +109,7 @@ export async function loadToolConfig({ repoRoot }) {
|
|
|
93
109
|
export function renderConfigJsonc(config) {
|
|
94
110
|
const setupCommandSection = config.setupCommand === undefined ? '' : `
|
|
95
111
|
// Optional command run manually with the setup key in the selected worktree.
|
|
96
|
-
//
|
|
112
|
+
// Review before running in untrusted worktrees; package installs may run lifecycle scripts.
|
|
97
113
|
"setupCommand": ${JSON.stringify(config.setupCommand)},
|
|
98
114
|
`;
|
|
99
115
|
const editorCommandSection = config.editorCommand === undefined ? '' : `
|
|
@@ -106,8 +122,8 @@ export function renderConfigJsonc(config) {
|
|
|
106
122
|
// Keep this filesystem-safe: letters, numbers, dots, underscores, and hyphens only.
|
|
107
123
|
"namespace": ${JSON.stringify(config.namespace)},
|
|
108
124
|
|
|
109
|
-
// Command launched in the selected worktree.
|
|
110
|
-
//
|
|
125
|
+
// Command launched in the selected worktree when you press Enter.
|
|
126
|
+
// Treat this config as trusted code. argv form avoids shell metacharacter expansion.
|
|
111
127
|
"command": ${JSON.stringify(config.command)},
|
|
112
128
|
${setupCommandSection}${editorCommandSection}
|
|
113
129
|
// TCP port owned by the command, used when stopping stale/orphaned processes.
|
|
@@ -116,7 +132,8 @@ ${setupCommandSection}${editorCommandSection}
|
|
|
116
132
|
// Files that must exist in a worktree before the command can be started there.
|
|
117
133
|
"requiredFiles": ${JSON.stringify(config.requiredFiles)},
|
|
118
134
|
|
|
119
|
-
// Extra
|
|
135
|
+
// Extra command-line substrings for cleanup within the recorded process group only.
|
|
136
|
+
// Include a command plus argument fragment; broad single-token matchers are rejected.
|
|
120
137
|
// Example: ["node --watch", "vite --host 0.0.0.0"]
|
|
121
138
|
"orphanMatchers": ${JSON.stringify(config.orphanMatchers)},
|
|
122
139
|
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
export interface GitHubRepository {
|
|
2
|
+
host: string;
|
|
3
|
+
owner: string;
|
|
4
|
+
name: string;
|
|
5
|
+
}
|
|
1
6
|
export type PullRequestInfo = {
|
|
2
7
|
kind: 'found';
|
|
3
8
|
number: number;
|
|
@@ -11,4 +16,6 @@ export type PullRequestInfo = {
|
|
|
11
16
|
} | {
|
|
12
17
|
kind: 'unavailable';
|
|
13
18
|
};
|
|
19
|
+
export declare function isGitHubMetadataHostAllowed(host: string): boolean;
|
|
20
|
+
export declare function normalizePullRequestUrlForRepository(url: string, repository: GitHubRepository, number: number): string | null;
|
|
14
21
|
export declare function readPullRequestInfo(cwd: string, branch: string): Promise<PullRequestInfo>;
|
|
@@ -37,10 +37,28 @@ function parseGitHubRepositoryFromRemoteUrl(remoteUrl) {
|
|
|
37
37
|
}
|
|
38
38
|
return { host: scpMatch[1].toLowerCase(), owner: segments[0], name: segments[1] };
|
|
39
39
|
}
|
|
40
|
+
export function isGitHubMetadataHostAllowed(host) {
|
|
41
|
+
return host.toLowerCase() === 'github.com';
|
|
42
|
+
}
|
|
43
|
+
export function normalizePullRequestUrlForRepository(url, repository, number) {
|
|
44
|
+
if (!URL.canParse(url)) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const parsedUrl = new URL(url);
|
|
48
|
+
const expectedPath = `/${repository.owner}/${repository.name}/pull/${number}`;
|
|
49
|
+
if (parsedUrl.protocol !== 'https:'
|
|
50
|
+
|| parsedUrl.hostname.toLowerCase() !== repository.host
|
|
51
|
+
|| parsedUrl.pathname !== expectedPath
|
|
52
|
+
|| parsedUrl.search !== ''
|
|
53
|
+
|| parsedUrl.hash !== '') {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return `https://${repository.host}${expectedPath}`;
|
|
57
|
+
}
|
|
40
58
|
async function readGitHubRepository(cwd) {
|
|
41
59
|
const { stdout } = await execFileAsync('git', ['config', '--get', 'remote.origin.url'], { cwd });
|
|
42
60
|
const repository = parseGitHubRepositoryFromRemoteUrl(stdout);
|
|
43
|
-
if (!repository) {
|
|
61
|
+
if (!repository || !isGitHubMetadataHostAllowed(repository.host)) {
|
|
44
62
|
throw new Error('GitHub repository remote unavailable');
|
|
45
63
|
}
|
|
46
64
|
return repository;
|
|
@@ -58,9 +76,7 @@ function buildPullRequestListArgs(repository, branch, state) {
|
|
|
58
76
|
'-F',
|
|
59
77
|
'per_page=1',
|
|
60
78
|
];
|
|
61
|
-
|
|
62
|
-
args.push('--hostname', repository.host);
|
|
63
|
-
}
|
|
79
|
+
args.push('--hostname', repository.host);
|
|
64
80
|
return args;
|
|
65
81
|
}
|
|
66
82
|
function normalizePullRequestState(state, mergedAt) {
|
|
@@ -69,17 +85,17 @@ function normalizePullRequestState(state, mergedAt) {
|
|
|
69
85
|
}
|
|
70
86
|
return typeof mergedAt === 'string' && mergedAt.length > 0 ? 'MERGED' : 'CLOSED';
|
|
71
87
|
}
|
|
72
|
-
function parsePullRequest(item) {
|
|
88
|
+
function parsePullRequest(item, repository) {
|
|
73
89
|
if (typeof item !== 'object' || item === null) {
|
|
74
90
|
return null;
|
|
75
91
|
}
|
|
76
92
|
const pullRequest = item;
|
|
77
93
|
const number = typeof pullRequest.number === 'number' ? pullRequest.number : NaN;
|
|
78
94
|
const title = typeof pullRequest.title === 'string' ? pullRequest.title : '';
|
|
79
|
-
const url = typeof pullRequest.html_url === 'string' ? pullRequest.html_url :
|
|
95
|
+
const url = typeof pullRequest.html_url === 'string' && Number.isFinite(number) ? normalizePullRequestUrlForRepository(pullRequest.html_url, repository, number) : null;
|
|
80
96
|
const isDraft = typeof pullRequest.draft === 'boolean' ? pullRequest.draft : false;
|
|
81
97
|
const baseRefName = typeof pullRequest.base?.ref === 'string' ? pullRequest.base.ref : '';
|
|
82
|
-
if (!Number.isFinite(number) || !title ||
|
|
98
|
+
if (!Number.isFinite(number) || !title || url === null || !baseRefName) {
|
|
83
99
|
return null;
|
|
84
100
|
}
|
|
85
101
|
return {
|
|
@@ -101,7 +117,7 @@ async function readPullRequestList(cwd, branch, state) {
|
|
|
101
117
|
}
|
|
102
118
|
const pullRequests = [];
|
|
103
119
|
for (const item of payload) {
|
|
104
|
-
const parsed = parsePullRequest(item);
|
|
120
|
+
const parsed = parsePullRequest(item, repository);
|
|
105
121
|
if (parsed !== null) {
|
|
106
122
|
pullRequests.push(parsed);
|
|
107
123
|
}
|
package/dist/core/log-reader.js
CHANGED
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import { open, readdir, stat } from 'node:fs/promises';
|
|
3
3
|
const MAX_LOG_BYTES = 16 * 1024;
|
|
4
4
|
const MAX_LOG_LINES = 120;
|
|
5
|
+
const MAX_LOG_FILES = 100;
|
|
5
6
|
export function tailLogContent(content) {
|
|
6
7
|
const byteTrimmed = content.length > MAX_LOG_BYTES ? content.slice(-MAX_LOG_BYTES) : content;
|
|
7
8
|
const lines = byteTrimmed.replace(/\r\n/g, '\n').split('\n');
|
|
8
9
|
const tailLines = lines.length > MAX_LOG_LINES ? lines.slice(-MAX_LOG_LINES) : lines;
|
|
9
10
|
return tailLines.join('\n').trimEnd();
|
|
10
11
|
}
|
|
12
|
+
async function readLogTail(filePath) {
|
|
13
|
+
const stats = await stat(filePath);
|
|
14
|
+
const bytesToRead = Math.min(stats.size, MAX_LOG_BYTES);
|
|
15
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
16
|
+
const file = await open(filePath, 'r');
|
|
17
|
+
try {
|
|
18
|
+
await file.read(buffer, 0, bytesToRead, Math.max(0, stats.size - bytesToRead));
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
await file.close();
|
|
22
|
+
}
|
|
23
|
+
return buffer.toString('utf8');
|
|
24
|
+
}
|
|
11
25
|
export async function readLogs(logsDir, activeLogPath) {
|
|
12
26
|
try {
|
|
13
27
|
const entries = (await readdir(logsDir, { withFileTypes: true }))
|
|
14
28
|
.filter(entry => entry.isFile() && entry.name.endsWith('.log'))
|
|
29
|
+
.slice(0, MAX_LOG_FILES)
|
|
15
30
|
.map(entry => ({ name: entry.name, path: path.join(logsDir, entry.name) }));
|
|
16
31
|
if (entries.length === 0) {
|
|
17
32
|
return [];
|
|
@@ -19,8 +34,9 @@ export async function readLogs(logsDir, activeLogPath) {
|
|
|
19
34
|
let selectedEntries = entries;
|
|
20
35
|
if (activeLogPath !== null) {
|
|
21
36
|
const activeEntry = entries.find(entry => entry.path === activeLogPath);
|
|
22
|
-
|
|
23
|
-
|
|
37
|
+
selectedEntries = activeEntry ? [activeEntry] : [];
|
|
38
|
+
if (selectedEntries.length === 0) {
|
|
39
|
+
return [];
|
|
24
40
|
}
|
|
25
41
|
}
|
|
26
42
|
else {
|
|
@@ -34,7 +50,7 @@ export async function readLogs(logsDir, activeLogPath) {
|
|
|
34
50
|
return await Promise.all(selectedEntries.map(async (entry) => ({
|
|
35
51
|
name: entry.name,
|
|
36
52
|
path: entry.path,
|
|
37
|
-
content: tailLogContent(await
|
|
53
|
+
content: tailLogContent(await readLogTail(entry.path)),
|
|
38
54
|
})));
|
|
39
55
|
}
|
|
40
56
|
catch {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export declare function isProcessGroupAlive(pgid: number): Promise<boolean>;
|
|
2
2
|
export declare function killProcessGroup(pgid: number, signal?: NodeJS.Signals): Promise<void>;
|
|
3
|
-
export declare function killPortOwner(port: number): Promise<void>;
|
|
4
|
-
export declare function killOrphans(matcher: string): Promise<void>;
|
|
3
|
+
export declare function killPortOwner(port: number, pgid: number): Promise<void>;
|
|
4
|
+
export declare function killOrphans(matcher: string, pgid: number): Promise<void>;
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
const execFileAsync = promisify(execFile);
|
|
4
|
+
async function readProcessGroupId(pid) {
|
|
5
|
+
try {
|
|
6
|
+
const { stdout } = await execFileAsync('ps', ['-o', 'pgid=', '-p', pid]);
|
|
7
|
+
const pgid = Number(stdout.trim());
|
|
8
|
+
return Number.isInteger(pgid) ? pgid : null;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
4
14
|
export async function isProcessGroupAlive(pgid) {
|
|
5
15
|
try {
|
|
6
16
|
process.kill(-pgid, 0);
|
|
@@ -11,6 +21,9 @@ export async function isProcessGroupAlive(pgid) {
|
|
|
11
21
|
}
|
|
12
22
|
}
|
|
13
23
|
export async function killProcessGroup(pgid, signal = 'SIGTERM') {
|
|
24
|
+
if (pgid <= 1) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
14
27
|
try {
|
|
15
28
|
process.kill(-pgid, signal);
|
|
16
29
|
}
|
|
@@ -18,23 +31,25 @@ export async function killProcessGroup(pgid, signal = 'SIGTERM') {
|
|
|
18
31
|
// Process group already gone.
|
|
19
32
|
}
|
|
20
33
|
}
|
|
21
|
-
export async function killPortOwner(port) {
|
|
34
|
+
export async function killPortOwner(port, pgid) {
|
|
22
35
|
try {
|
|
23
36
|
const { stdout } = await execFileAsync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']);
|
|
24
37
|
for (const pid of stdout
|
|
25
38
|
.split('\n')
|
|
26
39
|
.map(line => line.trim())
|
|
27
40
|
.filter(Boolean)) {
|
|
28
|
-
await
|
|
41
|
+
if (await readProcessGroupId(pid) === pgid) {
|
|
42
|
+
await execFileAsync('kill', [pid]);
|
|
43
|
+
}
|
|
29
44
|
}
|
|
30
45
|
}
|
|
31
46
|
catch {
|
|
32
47
|
// Port not owned or lsof found nothing.
|
|
33
48
|
}
|
|
34
49
|
}
|
|
35
|
-
export async function killOrphans(matcher) {
|
|
50
|
+
export async function killOrphans(matcher, pgid) {
|
|
36
51
|
try {
|
|
37
|
-
await execFileAsync('pkill', ['-f', matcher]);
|
|
52
|
+
await execFileAsync('pkill', ['-g', String(pgid), '-f', matcher]);
|
|
38
53
|
}
|
|
39
54
|
catch {
|
|
40
55
|
// No matching orphan process.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export interface CleanupDeps {
|
|
2
2
|
killProcessGroup: (pgid: number, signal?: NodeJS.Signals) => Promise<void>;
|
|
3
|
-
killPortOwner: (port: number) => Promise<void>;
|
|
4
|
-
killOrphans: (matcher: string) => Promise<void>;
|
|
3
|
+
killPortOwner: (port: number, pgid: number) => Promise<void>;
|
|
4
|
+
killOrphans: (matcher: string, pgid: number) => Promise<void>;
|
|
5
5
|
isSessionAlive: (pgid: number) => Promise<boolean>;
|
|
6
6
|
}
|
|
7
7
|
export declare function stopSessionWithFallback(input: {
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
export async function stopSessionWithFallback(input, deps) {
|
|
2
|
+
if (input.pgid <= 1) {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
2
5
|
await deps.killProcessGroup(input.pgid, 'SIGTERM');
|
|
3
6
|
if (!(await deps.isSessionAlive(input.pgid))) {
|
|
4
7
|
return true;
|
|
5
8
|
}
|
|
6
|
-
await deps.killPortOwner(input.port);
|
|
9
|
+
await deps.killPortOwner(input.port, input.pgid);
|
|
7
10
|
for (const matcher of input.orphanMatchers) {
|
|
8
|
-
await deps.killOrphans(matcher);
|
|
11
|
+
await deps.killOrphans(matcher, input.pgid);
|
|
9
12
|
}
|
|
10
13
|
if (!(await deps.isSessionAlive(input.pgid))) {
|
|
11
14
|
return true;
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
const MAX_SESSION_BYTES = 16 * 1024;
|
|
4
|
+
function isSafeProcessId(value) {
|
|
5
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 1;
|
|
6
|
+
}
|
|
7
|
+
function isValidPort(value) {
|
|
8
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 65535;
|
|
5
9
|
}
|
|
6
10
|
function isSessionRecord(value) {
|
|
7
11
|
if (typeof value !== 'object' || value === null) {
|
|
@@ -11,9 +15,9 @@ function isSessionRecord(value) {
|
|
|
11
15
|
return (typeof record.namespace === 'string' &&
|
|
12
16
|
typeof record.worktreePath === 'string' &&
|
|
13
17
|
typeof record.branch === 'string' &&
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
isSafeProcessId(record.pid) &&
|
|
19
|
+
isSafeProcessId(record.pgid) &&
|
|
20
|
+
isValidPort(record.port) &&
|
|
17
21
|
typeof record.logPath === 'string' &&
|
|
18
22
|
typeof record.startedAt === 'string');
|
|
19
23
|
}
|
|
@@ -25,9 +29,20 @@ export function getSessionPaths(gitCommonDir, namespace) {
|
|
|
25
29
|
sessionFile: path.join(baseDir, `${namespace}.json`),
|
|
26
30
|
};
|
|
27
31
|
}
|
|
32
|
+
async function readSessionFile(sessionFile) {
|
|
33
|
+
if ((await stat(sessionFile)).size > MAX_SESSION_BYTES) {
|
|
34
|
+
await rm(sessionFile, { force: true });
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return readFile(sessionFile, 'utf8');
|
|
38
|
+
}
|
|
28
39
|
export async function readSessionRecord(paths, { isSessionAlive }) {
|
|
29
40
|
try {
|
|
30
|
-
const
|
|
41
|
+
const source = await readSessionFile(paths.sessionFile);
|
|
42
|
+
if (source === null) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const parsed = JSON.parse(source);
|
|
31
46
|
if (!isSessionRecord(parsed)) {
|
|
32
47
|
await rm(paths.sessionFile, { force: true });
|
|
33
48
|
return null;
|
|
@@ -4,8 +4,16 @@ const tagPriority = {
|
|
|
4
4
|
external: 2,
|
|
5
5
|
invalid: 3,
|
|
6
6
|
};
|
|
7
|
+
const ANSI_CSI_PATTERN = /\u001B\[[0-?]*[ -/]*[@-~]/gu;
|
|
8
|
+
const ANSI_OSC_PATTERN = /\u001B\][^\u0007\u001B\u009C]*(?:\u0007|\u001B\\|\u009C)?/gu;
|
|
9
|
+
const ANSI_STRING_PATTERN = /\u001B[P^_X][\s\S]*?(?:\u001B\\|\u009C|$)/gu;
|
|
10
|
+
const C1_STRING_PATTERN = /[\u0090\u0098\u009D\u009E\u009F][^\u0007\u009C]*(?:\u0007|\u009C)?/gu;
|
|
7
11
|
export function sanitizeInlineText(value) {
|
|
8
12
|
return value
|
|
13
|
+
.replace(ANSI_OSC_PATTERN, '')
|
|
14
|
+
.replace(ANSI_STRING_PATTERN, '')
|
|
15
|
+
.replace(C1_STRING_PATTERN, '')
|
|
16
|
+
.replace(ANSI_CSI_PATTERN, '')
|
|
9
17
|
.replace(/[\r\n\t\u2028\u2029]+/g, ' ')
|
|
10
18
|
.replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
|
|
11
19
|
.replace(/\p{Cf}/gu, '')
|
|
@@ -60,7 +68,7 @@ export function projectAction(row, activePath) {
|
|
|
60
68
|
}
|
|
61
69
|
export function projectNote(row) {
|
|
62
70
|
if (row.invalidReason) {
|
|
63
|
-
return { kind: 'invalid', severity: 'error', invalidReason: row.invalidReason };
|
|
71
|
+
return { kind: 'invalid', severity: 'error', invalidReason: sanitizeInlineText(row.invalidReason) };
|
|
64
72
|
}
|
|
65
73
|
if (hasTag(row, 'external')) {
|
|
66
74
|
return { kind: 'external', severity: 'info' };
|
package/dist/main.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { createConfigForRepo, parseInitArgs } from './core/init.js';
|
|
4
4
|
import { CONFIG_FILE_NAME, CONFIG_FILE_NAMES } from './core/config.js';
|
|
5
|
+
import { sanitizeInlineText } from './core/worktree-projection.js';
|
|
5
6
|
import { ThemeProvider } from '@inkjs/ui';
|
|
6
7
|
import { render } from 'ink';
|
|
7
8
|
import { APP_RENDER_OPTIONS } from './render-options.js';
|
|
@@ -37,7 +38,7 @@ async function handleInitCommand() {
|
|
|
37
38
|
parsed = parseInitArgs(args.slice(1));
|
|
38
39
|
}
|
|
39
40
|
catch (error) {
|
|
40
|
-
console.error(error.message);
|
|
41
|
+
console.error(sanitizeInlineText(error.message));
|
|
41
42
|
process.exit(1);
|
|
42
43
|
}
|
|
43
44
|
if (parsed.help) {
|
|
@@ -46,10 +47,10 @@ async function handleInitCommand() {
|
|
|
46
47
|
}
|
|
47
48
|
try {
|
|
48
49
|
const result = await createConfigForRepo({ cwd, force: parsed.force });
|
|
49
|
-
console.log(`Created ${result.path}`);
|
|
50
|
+
console.log(`Created ${sanitizeInlineText(result.path)}`);
|
|
50
51
|
}
|
|
51
52
|
catch (error) {
|
|
52
|
-
console.error(error.message);
|
|
53
|
+
console.error(sanitizeInlineText(error.message));
|
|
53
54
|
process.exit(1);
|
|
54
55
|
}
|
|
55
56
|
}
|
|
@@ -62,7 +63,7 @@ if (args.includes('-h') || args.includes('--help')) {
|
|
|
62
63
|
process.exit(0);
|
|
63
64
|
}
|
|
64
65
|
if (subcommand !== undefined) {
|
|
65
|
-
console.error(`Unknown command: ${subcommand}`);
|
|
66
|
+
console.error(`Unknown command: ${sanitizeInlineText(subcommand)}`);
|
|
66
67
|
process.exit(1);
|
|
67
68
|
}
|
|
68
69
|
try {
|
|
@@ -90,6 +91,6 @@ try {
|
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
93
|
catch (error) {
|
|
93
|
-
console.error(describeError(error));
|
|
94
|
+
console.error(sanitizeInlineText(describeError(error)));
|
|
94
95
|
process.exit(1);
|
|
95
96
|
}
|