@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 CHANGED
@@ -1,20 +1,27 @@
1
1
  # worktree-command-tui
2
2
 
3
- `worktree-command-tui` is a terminal UI (TUI) tool for operating multiple Git worktrees from one repository.
4
- It helps you inspect, start, and stop per-worktree processes with quick keyboard-driven workflows.
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
- - List and monitor worktrees for the current repository
9
- - Start/stop worktree-specific commands
10
- - Keep process handling centralized for each session
11
- - Persist namespace-aware runtime state
12
- - Support JSONC config (with comments and trailing commas)
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
- - A Git repository in the target working directory
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
- ## Usage
32
+ Installed binaries:
26
33
 
27
- ### 1) Initialize configuration
34
+ - `wctui`
35
+ - `worktree-command-tui` (compatibility alias)
28
36
 
29
- Run this once in a repository root (or any subdirectory of the repository):
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 creates `.worktree-command-tui.jsonc` with a sensible default configuration.
47
+ This writes `.worktree-command-tui.jsonc` at the repository root.
36
48
 
37
- To regenerate an existing configuration:
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
- `worktree-command-tui` is still available as a compatibility alias.
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
- If no configuration file is found, the CLI will prompt you to run `wctui init`.
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 reads these files in this order:
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
- A minimal example of the generated config:
107
+ Example config:
61
108
 
62
109
  ```jsonc
63
110
  {
64
- // Session namespace used for logs/state
111
+ // Session namespace used for git-common-dir state files and logs.
65
112
  "namespace": "worktree-command-tui",
66
- // Command executed in each selected worktree
67
- "command": ["npm", "run", "start"],
68
- // Optional setup command run manually in the selected worktree
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
- // Port used for cleanup/monitoring
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
- // Required files that must exist in a worktree
126
+
127
+ // Files that must exist in a worktree before the command can be started there.
73
128
  "requiredFiles": ["package.json"],
74
- // Optional command substrings considered orphaned processes
129
+
130
+ // Extra process command-line substrings treated as orphans for cleanup.
75
131
  "orphanMatchers": []
76
132
  }
77
133
  ```
78
134
 
79
- When `setupCommand` is configured, press `i` in the TUI to run it for the selected worktree.
80
- It is never run automatically when switching worktrees.
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 run test # Run test suite
87
- npm run typecheck # Run TypeScript type-check
88
- npm run build # Build distributable output to dist/
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:", model.activeBranch ?? '-'] }), rootHeight >= 2 ? _jsxs(Text, { wrap: "truncate-end", children: ["S:", selected?.branch ?? '-'] }) : null, rootHeight >= 3 ? _jsx(Text, { wrap: "truncate-end", children: confirmationOpen ? `D:${visibleStatus.message}` : `T:${model.status.kind}` }) : null, rootHeight >= 4 ? (confirmationOpen
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: ", model.activeBranch ?? '-'] }), _jsxs(Text, { wrap: "truncate-end", children: ["Selected: ", selected?.branch ?? '-'] }), completedAlert
549
- ? _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["\u2714 ", completedAlert] })
550
- : model.status.kind === 'setting-up' || model.status.kind === 'starting' || model.status.kind === 'stopping' ? (_jsx(Spinner, { label: `Status: ${model.status.kind} — ${model.status.message}` })) : (_jsxs(Text, { wrap: "truncate-end", children: ["Status: ", visibleStatus.kind, " \u2014 ", visibleStatus.message] })), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: confirmationOpen
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 }), completedAlert ? (_jsx(Box, { position: "absolute", top: 1, right: 2, children: _jsx(Alert, { variant: "success", children: completedAlert }) })) : null] }));
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
- return (_jsxs(Box, { borderStyle: "round", borderColor: KIND_TO_COLOR[status.kind], flexDirection: "column", paddingX: 1, children: [isBusy ? (_jsx(Spinner, { label: `Status: ${status.kind} — ${status.message}` })) : (_jsxs(Text, { color: KIND_TO_COLOR[status.kind], wrap: "truncate-end", children: [KIND_TO_ICON[status.kind], " Status: ", status.kind, " \u2014 ", status.message] })), _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
+ 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
- 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: ", repoName] }), _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["Active: ", activeBranch ?? '-'] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["Namespace: ", namespace] })] }));
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
- reject(error);
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
- reject(error);
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
- reject(new Error('spawn succeeded without pid'));
79
+ if (finalize()) {
80
+ reject(new Error('spawn succeeded without pid'));
81
+ }
75
82
  return;
76
83
  }
77
84
  child.unref();
78
- finalize();
79
- resolve({ pid, pgid: pid, logPath });
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: readStringList(config.orphanMatchers, '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 readFile(path.join(repoRoot, fileName), 'utf8');
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
- // Useful for first-time dependency installation without doing it on every switch.
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
- // Use argv form so spaces and shell metacharacters are passed safely.
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 process command-line substrings treated as orphans for cleanup.
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
- if (repository.host !== 'github.com' && repository.host !== 'www.github.com') {
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 || !url || !baseRefName) {
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
  }
@@ -1,17 +1,32 @@
1
1
  import path from 'node:path';
2
- import { readdir, readFile, stat } from 'node:fs/promises';
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
- if (activeEntry) {
23
- selectedEntries = [activeEntry];
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 readFile(entry.path, 'utf8')),
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 execFileAsync('kill', [pid]);
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
- function isPositiveInteger(value) {
4
- return typeof value === 'number' && Number.isInteger(value) && value > 0;
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
- isPositiveInteger(record.pid) &&
15
- isPositiveInteger(record.pgid) &&
16
- isPositiveInteger(record.port) &&
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 parsed = JSON.parse(await readFile(paths.sessionFile, 'utf8'));
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ohzw/worktree-command-tui",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "A TUI for managing git worktrees",
5
5
  "private": false,
6
6
  "type": "module",