@nemus-cli/nemus 0.2.8 → 0.2.10

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.
@@ -47,10 +47,14 @@ exports.getGhqStatus = getGhqStatus;
47
47
  const child_process_1 = require("child_process");
48
48
  const util_1 = require("util");
49
49
  const path = __importStar(require("path"));
50
+ const fs = __importStar(require("fs/promises"));
50
51
  const logger_1 = require("./logger");
51
52
  const colors_1 = require("./colors");
52
53
  const config_1 = require("./config");
53
- const execAsync = (0, util_1.promisify)(child_process_1.exec);
54
+ // execFile (no shell): every value below (repo URLs, ghq paths, branch names)
55
+ // is passed as an argv element, so none can be interpreted as a shell command.
56
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
57
+ const git = (args, opts = {}) => execFileAsync('git', args, opts);
54
58
  /**
55
59
  * Turn a raw child-process clone failure (from exec or execFile) into an
56
60
  * actionable message. The child API discards the reason on timeout/buffer kill (you just get "Command
@@ -80,7 +84,7 @@ function describeCloneError(error, timeoutMs = config_1.CLONE_TIMEOUT_MS) {
80
84
  */
81
85
  async function isGhqInstalled() {
82
86
  try {
83
- await execAsync('which ghq');
87
+ await execFileAsync('which', ['ghq']);
84
88
  return true;
85
89
  }
86
90
  catch {
@@ -106,7 +110,7 @@ async function warnIfGhqMissing() {
106
110
  */
107
111
  async function getGhqRoot() {
108
112
  try {
109
- const { stdout } = await execAsync('ghq root');
113
+ const { stdout } = await execFileAsync('ghq', ['root']);
110
114
  return stdout.trim();
111
115
  }
112
116
  catch {
@@ -121,8 +125,8 @@ async function ghqRepoExists(repoUrl) {
121
125
  const repoPath = await getGhqRepoPath(repoUrl);
122
126
  if (!repoPath)
123
127
  return false;
124
- await execAsync(`test -d "${repoPath}"`);
125
- return true;
128
+ const stat = await fs.stat(repoPath);
129
+ return stat.isDirectory();
126
130
  }
127
131
  catch {
128
132
  return false;
@@ -154,7 +158,7 @@ async function getGhqRepoPath(repoUrl) {
154
158
  async function ghqGet(repoUrl) {
155
159
  try {
156
160
  (0, logger_1.logInfo)(`Using ghq to clone ${(0, colors_1.colorize)(repoUrl, 'cyan')}...`);
157
- await execAsync(`ghq get "${repoUrl}"`, { timeout: config_1.CLONE_TIMEOUT_MS, maxBuffer: config_1.CLONE_MAX_BUFFER });
161
+ await execFileAsync('ghq', ['get', repoUrl], { timeout: config_1.CLONE_TIMEOUT_MS, maxBuffer: config_1.CLONE_MAX_BUFFER });
158
162
  const repoPath = await getGhqRepoPath(repoUrl);
159
163
  if (!repoPath) {
160
164
  return { success: false, error: 'Could not determine ghq path' };
@@ -172,7 +176,7 @@ async function ghqGet(repoUrl) {
172
176
  */
173
177
  async function ghqList() {
174
178
  try {
175
- const { stdout } = await execAsync('ghq list');
179
+ const { stdout } = await execFileAsync('ghq', ['list']);
176
180
  return stdout.trim().split('\n').filter(line => line.length > 0);
177
181
  }
178
182
  catch {
@@ -227,15 +231,15 @@ async function cloneWithGhq(repoUrl, targetPath) {
227
231
  // would serve stale state via hardlinks — user would open a workspace
228
232
  // missing dozens of recent commits.
229
233
  try {
230
- await execAsync(`git -C "${sourcePath}" fetch origin --quiet --prune`, { timeout: 90 * 1000 });
234
+ await git(['-C', sourcePath, 'fetch', 'origin', '--quiet', '--prune'], { timeout: 90 * 1000 });
231
235
  }
232
236
  catch {
233
237
  // Network failure or no upstream — fall through with stale cache,
234
238
  // we'll log a warning after the local clone if we can't align to HEAD.
235
239
  }
236
- await execAsync(`git clone --local "${sourcePath}" "${targetPath}"`, { timeout: 60 * 1000 });
240
+ await git(['clone', '--local', sourcePath, targetPath], { timeout: 60 * 1000 });
237
241
  // Reset remote to point to the original repo URL (not the local ghq path)
238
- await execAsync(`git remote set-url origin "${repoUrl}"`, { cwd: targetPath });
242
+ await git(['remote', 'set-url', 'origin', repoUrl], { cwd: targetPath });
239
243
  // Make sure the working tree is at origin's default-branch tip.
240
244
  // Robust against:
241
245
  // - cache having a non-default branch checked out
@@ -244,20 +248,24 @@ async function cloneWithGhq(repoUrl, targetPath) {
244
248
  let alignedToHead = false;
245
249
  let alignError = null;
246
250
  try {
247
- await execAsync(`git fetch origin --quiet`, { cwd: targetPath, timeout: 60 * 1000 });
251
+ await git(['fetch', 'origin', '--quiet'], { cwd: targetPath, timeout: 60 * 1000 });
248
252
  // Explicitly set refs/remotes/origin/HEAD by querying the remote.
249
253
  // Without this, symbolic-ref may fail if the local symref wasn’t set.
250
- await execAsync(`git remote set-head origin --auto`, { cwd: targetPath, timeout: 30 * 1000 })
254
+ await git(['remote', 'set-head', 'origin', '--auto'], { cwd: targetPath, timeout: 30 * 1000 })
251
255
  .catch(() => { });
252
256
  let defaultBranch = null;
253
257
  try {
254
- const headRes = await execAsync(`git symbolic-ref --short refs/remotes/origin/HEAD`, { cwd: targetPath, timeout: 5000 });
258
+ const headRes = await git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], {
259
+ cwd: targetPath, timeout: 5000,
260
+ });
255
261
  defaultBranch = headRes.stdout.trim().replace(/^origin\//, '');
256
262
  }
257
263
  catch {
258
264
  // Fall back to ls-remote query against the actual remote
259
265
  try {
260
- const lsRes = await execAsync(`git ls-remote --symref origin HEAD`, { cwd: targetPath, timeout: 30 * 1000 });
266
+ const lsRes = await git(['ls-remote', '--symref', 'origin', 'HEAD'], {
267
+ cwd: targetPath, timeout: 30 * 1000,
268
+ });
261
269
  const match = lsRes.stdout.match(/^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m);
262
270
  if (match)
263
271
  defaultBranch = match[1];
@@ -267,7 +275,9 @@ async function cloneWithGhq(repoUrl, targetPath) {
267
275
  // Final fallback: try common default branches
268
276
  if (!defaultBranch) {
269
277
  for (const candidate of ['main', 'master']) {
270
- const exists = await execAsync(`git rev-parse --verify --quiet origin/${candidate}`, { cwd: targetPath, timeout: 5000 }).then(() => true).catch(() => false);
278
+ const exists = await git(['rev-parse', '--verify', '--quiet', `origin/${candidate}`], {
279
+ cwd: targetPath, timeout: 5000,
280
+ }).then(() => true).catch(() => false);
271
281
  if (exists) {
272
282
  defaultBranch = candidate;
273
283
  break;
@@ -277,8 +287,8 @@ async function cloneWithGhq(repoUrl, targetPath) {
277
287
  if (!defaultBranch) {
278
288
  throw new Error('could not determine default branch (no origin/HEAD, no main, no master)');
279
289
  }
280
- await execAsync(`git checkout --quiet "${defaultBranch}"`, { cwd: targetPath, timeout: 10 * 1000 });
281
- await execAsync(`git reset --hard --quiet "origin/${defaultBranch}"`, { cwd: targetPath, timeout: 10 * 1000 });
290
+ await git(['checkout', '--quiet', defaultBranch], { cwd: targetPath, timeout: 10 * 1000 });
291
+ await git(['reset', '--hard', '--quiet', `origin/${defaultBranch}`], { cwd: targetPath, timeout: 10 * 1000 });
282
292
  alignedToHead = true;
283
293
  }
284
294
  catch (error) {
@@ -298,7 +308,7 @@ async function cloneWithGhq(repoUrl, targetPath) {
298
308
  (0, logger_1.logWarning)(`Local clone from ghq failed: ${errorMessage}`);
299
309
  // Clean up partial clone before fallback
300
310
  try {
301
- await execAsync(`rm -rf "${targetPath}"`);
311
+ await fs.rm(targetPath, { recursive: true, force: true });
302
312
  }
303
313
  catch { /* ignore */ }
304
314
  // Fall through to direct clone
@@ -307,7 +317,7 @@ async function cloneWithGhq(repoUrl, targetPath) {
307
317
  }
308
318
  // Fallback to direct git clone
309
319
  try {
310
- await execAsync(`git clone "${repoUrl}" "${targetPath}"`, { timeout: config_1.CLONE_TIMEOUT_MS, maxBuffer: config_1.CLONE_MAX_BUFFER });
320
+ await git(['clone', repoUrl, targetPath], { timeout: config_1.CLONE_TIMEOUT_MS, maxBuffer: config_1.CLONE_MAX_BUFFER });
311
321
  return { success: true, usedGhq: false };
312
322
  }
313
323
  catch (error) {
@@ -44,7 +44,6 @@ const ghq_integration_1 = require("./ghq-integration");
44
44
  const retry_1 = require("./retry");
45
45
  const progress_1 = require("./progress");
46
46
  const config_1 = require("./config");
47
- const execAsync = (0, util_1.promisify)(child_process_1.exec);
48
47
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
49
48
  const CONCURRENCY_LIMIT = 3;
50
49
  const CLONE_TIMEOUT = config_1.CLONE_TIMEOUT_MS;
@@ -119,13 +118,13 @@ async function cloneLocal(repo, sourcePath, workspacePath, directoryName) {
119
118
  const targetPath = path.join(workspacePath, directoryName);
120
119
  try {
121
120
  // git clone --local creates hardlinks for .git/objects — nearly instant
122
- await execAsync(`git clone --local "${sourcePath}" "${targetPath}"`, {
121
+ await execFileAsync('git', ['clone', '--local', sourcePath, targetPath], {
123
122
  timeout: CLONE_TIMEOUT,
124
123
  maxBuffer: config_1.CLONE_MAX_BUFFER,
125
124
  });
126
125
  // Reset the remote to point to the original repo (not the local source)
127
126
  const remoteUrl = (0, config_1.getCloneUrl)(repo);
128
- await execAsync(`git remote set-url origin "${remoteUrl}"`, {
127
+ await execFileAsync('git', ['remote', 'set-url', 'origin', remoteUrl], {
129
128
  cwd: targetPath,
130
129
  });
131
130
  return {
@@ -40,6 +40,7 @@ const fs = __importStar(require("fs/promises"));
40
40
  const path = __importStar(require("path"));
41
41
  const git_status_1 = require("./git-status");
42
42
  const execAsync = (0, util_1.promisify)(child_process_1.exec);
43
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
43
44
  const checkMissingRepositories = async (workspacePath, metadata) => {
44
45
  const missingRepos = [];
45
46
  for (const repo of metadata.repositories) {
@@ -221,7 +222,7 @@ exports.checkDependencies = checkDependencies;
221
222
  const checkDiskSpace = async (workspacePath) => {
222
223
  try {
223
224
  // Get disk usage for workspace
224
- const { stdout } = await execAsync(`du -sh "${workspacePath}"`, { timeout: 30000 });
225
+ const { stdout } = await execFileAsync('du', ['-sh', workspacePath], { timeout: 30000 });
225
226
  const sizeStr = stdout.trim().split('\t')[0];
226
227
  // Parse size (rough check for > 10GB)
227
228
  const sizeValue = parseFloat(sizeStr);
package/package.json CHANGED
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "name": "@nemus-cli/nemus",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
+ "workspaces": [
5
+ "packages/*"
6
+ ],
4
7
  "description": "Nemus — a CLI for managing multi-repository workspaces. Create, sync, and operate across dozens of repos with a single command, and wire them up to your favorite coding agent.",
5
8
  "keywords": [
6
9
  "workspace",
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CI guard: fail if package-lock.json's version fields drift from package.json.
4
+ *
5
+ * A stale lockfile version makes `npm ci` fail at release time (npm errors on a
6
+ * package.json/lock mismatch before build/publish run). This catches the drift
7
+ * in PR CI instead. Checks both the lockfile root `version` and the self entry
8
+ * at `packages[""]`.
9
+ *
10
+ * Usage: node scripts/check-lock-version.js
11
+ * Exit 0 when consistent, 1 (with an ::error:: annotation) otherwise.
12
+ */
13
+ 'use strict';
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ function read(file) {
19
+ return JSON.parse(fs.readFileSync(path.join(__dirname, '..', file), 'utf8'));
20
+ }
21
+
22
+ const pkg = read('package.json');
23
+ const lock = read('package-lock.json');
24
+
25
+ const pkgV = pkg.version;
26
+ const rootV = lock.version;
27
+ const selfV = lock.packages && lock.packages[''] && lock.packages[''].version;
28
+
29
+ const errs = [];
30
+ if (rootV !== pkgV) {
31
+ errs.push(`package-lock.json root version "${rootV}" != package.json "${pkgV}"`);
32
+ }
33
+ if (selfV !== pkgV) {
34
+ errs.push(`package-lock.json packages[""] version "${selfV}" != package.json "${pkgV}"`);
35
+ }
36
+
37
+ if (errs.length) {
38
+ console.error('::error::' + errs.join('; '));
39
+ console.error('Fix: run `npm install --package-lock-only` and commit package-lock.json.');
40
+ process.exit(1);
41
+ }
42
+
43
+ console.log(`\u2713 package-lock.json version matches package.json (${pkgV})`);
@@ -2,12 +2,14 @@ import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
2
2
 
3
3
  const {
4
4
  mockExec,
5
+ mockExecFile,
5
6
  mockExecFileSync,
6
7
  mockSpawn,
7
8
  mockMkdirSync,
8
9
  mockWriteFileSync,
9
10
  } = vi.hoisted(() => ({
10
11
  mockExec: vi.fn(),
12
+ mockExecFile: vi.fn(),
11
13
  mockExecFileSync: vi.fn(),
12
14
  mockSpawn: vi.fn(),
13
15
  mockMkdirSync: vi.fn(),
@@ -16,6 +18,7 @@ const {
16
18
 
17
19
  vi.mock('child_process', () => ({
18
20
  exec: mockExec,
21
+ execFile: mockExecFile,
19
22
  execFileSync: mockExecFileSync,
20
23
  spawn: mockSpawn,
21
24
  }));
@@ -69,7 +72,8 @@ function setupSpawnMock(exitCode: number = 0) {
69
72
  }
70
73
 
71
74
  function setupExecMock(success: boolean) {
72
- mockExec.mockImplementation((_cmd: string, cb: Function) => {
75
+ // isPrimaryAgentAvailable now uses execFile('which', [cmd], cb) cb is the 3rd arg.
76
+ mockExecFile.mockImplementation((_file: string, _args: string[], cb: Function) => {
73
77
  if (success) {
74
78
  cb(null, { stdout: '/usr/local/bin/claude\n', stderr: '' });
75
79
  } else {
@@ -1,4 +1,4 @@
1
- import { spawn, exec, execFileSync } from 'child_process';
1
+ import { spawn, execFile, execFileSync } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
@@ -9,7 +9,7 @@ import { colorize } from '../utils/colors';
9
9
  import { getPrimaryAgent } from '../utils/agent-config';
10
10
  import { sanitizeWorkspaceName, checkWorkspaceExists, resolveWorkspaceNameConflict } from '../utils/validation';
11
11
 
12
- const execAsync = promisify(exec);
12
+ const execFileAsync = promisify(execFile);
13
13
 
14
14
  export const AI_PROMPT_FILE = path.join(os.homedir(), '.workspace-ai-prompt');
15
15
 
@@ -97,7 +97,7 @@ export function buildInvestigationPreamble(
97
97
  export async function isPrimaryAgentAvailable(): Promise<boolean> {
98
98
  const agent = getPrimaryAgent();
99
99
  try {
100
- await execAsync(`which ${agent.launchCommand}`);
100
+ await execFileAsync('which', [agent.launchCommand]);
101
101
  return true;
102
102
  } catch {
103
103
  return false;
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { execSync } from 'child_process';
2
+ import { execFileSync } from 'child_process';
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
5
5
  import inquirer from 'inquirer';
@@ -117,7 +117,7 @@ async function handleConfigure() {
117
117
  logInfo('Installing MCP server...');
118
118
  try {
119
119
  const mcpInstallScript = path.join(__dirname, '..', '..', 'dist', 'mcp', 'install.js');
120
- execSync(`node "${mcpInstallScript}" install`, { stdio: 'inherit' });
120
+ execFileSync('node', [mcpInstallScript, 'install'], { stdio: 'inherit' });
121
121
  mcpInstalled = true;
122
122
  } catch { logError('MCP install failed. You can retry later with: w mcp install'); }
123
123
  }
@@ -169,7 +169,7 @@ function installShellIntegration(): void {
169
169
  }
170
170
 
171
171
  try {
172
- execSync(`bash "${scriptPath}" ${shellType}`, { stdio: 'inherit' });
172
+ execFileSync('bash', [scriptPath, shellType], { stdio: 'inherit' });
173
173
  } catch {
174
174
  logWarning('Shell integration install failed — you can run it manually:');
175
175
  logInfo(` bash "${scriptPath}" ${shellType}`);
@@ -1,7 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import * as fs from 'fs/promises';
3
- import { WORKSPACES_DIR } from '../utils/config';
4
- import * as path from 'path';
3
+ import { safeWorkspacePath } from '../utils/validation';
5
4
  import { listWorkspaces } from '../utils/workspace-meta';
6
5
  import { promptMultiWorkspaceSelection } from '../utils/prompts';
7
6
  import { logInfo, logSuccess, logError, logWarning } from '../utils/logger';
@@ -35,10 +34,35 @@ async function handleDelete(opts: {
35
34
  if (opts.workspace) {
36
35
  const selectedNames = parseList(opts.workspace);
37
36
  const workspaces = await listWorkspaces();
37
+ const known = new Map(workspaces.map(ws => [ws.name, ws]));
38
38
 
39
+ // Resolve to validated, existing targets. safeWorkspacePath() both
40
+ // enforces the name allowlist and pins the path inside WORKSPACES_DIR, so
41
+ // a name like "../../etc" can never reach fs.rm; unknown names are skipped
42
+ // rather than deleted at a guessed path.
43
+ const targets: { name: string; path: string; workspace: typeof workspaces[number] }[] = [];
39
44
  for (const name of selectedNames) {
40
- const workspace = workspaces.find(ws => ws.name === name);
41
- const workspacePath = path.join(WORKSPACES_DIR, name);
45
+ const workspace = known.get(name);
46
+ if (!workspace) {
47
+ logError(`Workspace "${name}" not found — skipping`);
48
+ continue;
49
+ }
50
+ let workspacePath: string;
51
+ try {
52
+ workspacePath = safeWorkspacePath(name);
53
+ } catch (error) {
54
+ logError(error instanceof Error ? error.message : `Invalid workspace name "${name}"`);
55
+ continue;
56
+ }
57
+ targets.push({ name, path: workspacePath, workspace });
58
+ }
59
+
60
+ if (targets.length === 0) {
61
+ logInfo('Nothing to delete');
62
+ return;
63
+ }
64
+
65
+ for (const { name, path: workspacePath, workspace } of targets) {
42
66
  if (workspace?.metadata) {
43
67
  console.log(`${colorize(name, 'cyan')}`);
44
68
  console.log(` Repositories: ${workspace.metadata.repositories.length}`);
@@ -56,9 +80,9 @@ async function handleDelete(opts: {
56
80
  {
57
81
  type: 'confirm',
58
82
  name: 'confirmed',
59
- message: selectedNames.length === 1
60
- ? `Delete workspace ${selectedNames[0]}?`
61
- : `Delete these ${selectedNames.length} workspaces?`,
83
+ message: targets.length === 1
84
+ ? `Delete workspace ${targets[0].name}?`
85
+ : `Delete these ${targets.length} workspaces?`,
62
86
  default: true,
63
87
  },
64
88
  ]);
@@ -68,8 +92,7 @@ async function handleDelete(opts: {
68
92
  }
69
93
  }
70
94
 
71
- for (const name of selectedNames) {
72
- const workspacePath = path.join(WORKSPACES_DIR, name);
95
+ for (const { name, path: workspacePath } of targets) {
73
96
  try {
74
97
  await fs.rm(workspacePath, { recursive: true, force: true });
75
98
  logSuccess(`Deleted "${colorize(name, 'cyan')}"`);
@@ -92,45 +115,56 @@ async function handleDelete(opts: {
92
115
 
93
116
  const selectedNames = await promptMultiWorkspaceSelection(workspaces);
94
117
 
118
+ // Resolve + validate paths once. Names come from disk, but safeWorkspacePath
119
+ // must not throw mid-flow and crash the interactive session, so skip any
120
+ // name that fails the allowlist rather than aborting.
121
+ const resolved: { name: string; path: string; workspace: typeof workspaces[number] | undefined }[] = [];
95
122
  for (const name of selectedNames) {
96
- const workspace = workspaces.find(ws => ws.name === name);
97
- const workspacePath = path.join(WORKSPACES_DIR, name);
98
- if (workspace?.metadata) {
99
- console.log(`${colorize(name, 'cyan')}`);
100
- console.log(` Repositories: ${workspace.metadata.repositories.length}`);
101
- console.log(` Created: ${new Date(workspace.metadata.createdAt).toLocaleString()}`);
102
- console.log(` Path: ${workspacePath}`);
103
- } else {
104
- console.log(`${colorize(name, 'cyan')}`);
105
- console.log(` Path: ${workspacePath}`);
123
+ try {
124
+ resolved.push({ name, path: safeWorkspacePath(name), workspace: workspaces.find(ws => ws.name === name) });
125
+ } catch (error) {
126
+ logError(error instanceof Error ? error.message : `Invalid workspace name "${name}"`);
106
127
  }
107
128
  }
108
- console.log('');
109
129
 
110
- logWarning('This will permanently delete all cloned repositories in the selected workspaces!');
130
+ if (resolved.length > 0) {
131
+ for (const { name, path: workspacePath, workspace } of resolved) {
132
+ if (workspace?.metadata) {
133
+ console.log(`${colorize(name, 'cyan')}`);
134
+ console.log(` Repositories: ${workspace.metadata.repositories.length}`);
135
+ console.log(` Created: ${new Date(workspace.metadata.createdAt).toLocaleString()}`);
136
+ console.log(` Path: ${workspacePath}`);
137
+ } else {
138
+ console.log(`${colorize(name, 'cyan')}`);
139
+ console.log(` Path: ${workspacePath}`);
140
+ }
141
+ }
142
+ console.log('');
111
143
 
112
- const confirmMessage = selectedNames.length === 1
113
- ? `Delete workspace ${selectedNames[0]}?`
114
- : `Delete these ${selectedNames.length} workspaces?`;
144
+ logWarning('This will permanently delete all cloned repositories in the selected workspaces!');
115
145
 
116
- const { confirmed } = await inquirer.prompt([
117
- {
118
- type: 'confirm',
119
- name: 'confirmed',
120
- message: confirmMessage,
121
- default: true,
122
- },
123
- ]);
146
+ const confirmMessage = resolved.length === 1
147
+ ? `Delete workspace ${resolved[0].name}?`
148
+ : `Delete these ${resolved.length} workspaces?`;
149
+
150
+ const { confirmed } = await inquirer.prompt([
151
+ {
152
+ type: 'confirm',
153
+ name: 'confirmed',
154
+ message: confirmMessage,
155
+ default: true,
156
+ },
157
+ ]);
124
158
 
125
- if (confirmed) {
126
- for (const name of selectedNames) {
127
- const workspacePath = path.join(WORKSPACES_DIR, name);
128
- try {
129
- await fs.rm(workspacePath, { recursive: true, force: true });
130
- logSuccess(`Deleted "${colorize(name, 'cyan')}"`);
131
- } catch (error) {
132
- logError(`Failed to delete "${name}"`);
133
- if (error instanceof Error) logError(error.message);
159
+ if (confirmed) {
160
+ for (const { name, path: workspacePath } of resolved) {
161
+ try {
162
+ await fs.rm(workspacePath, { recursive: true, force: true });
163
+ logSuccess(`Deleted "${colorize(name, 'cyan')}"`);
164
+ } catch (error) {
165
+ logError(`Failed to delete "${name}"`);
166
+ if (error instanceof Error) logError(error.message);
167
+ }
134
168
  }
135
169
  }
136
170
  }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env ts-node
2
2
 
3
- import { execSync } from 'child_process';
3
+ import { execSync, execFileSync } from 'child_process';
4
4
  import * as path from 'path';
5
5
  import * as fs from 'fs';
6
6
  import { logSuccess, logError, logInfo, logWarning } from '../utils/logger';
@@ -37,7 +37,7 @@ function installShellIntegration(): void {
37
37
  }
38
38
 
39
39
  try {
40
- execSync(`bash "${scriptPath}" ${shellType}`, { stdio: 'inherit' });
40
+ execFileSync('bash', [scriptPath, shellType], { stdio: 'inherit' });
41
41
  logSuccess('Shell integration installed (auto-CD for w/workspace commands)');
42
42
  } catch {
43
43
  logInfo('Shell integration install skipped (non-critical)');
@@ -264,8 +264,9 @@ async function install() {
264
264
  logInfo(`MCP server path: ${colorize(serverPath, 'cyan')}`);
265
265
 
266
266
  try {
267
- execSync(
268
- `claude mcp add nemus -s user -- node "${serverPath}"`,
267
+ execFileSync(
268
+ 'claude',
269
+ ['mcp', 'add', 'nemus', '-s', 'user', '--', 'node', serverPath],
269
270
  { stdio: 'pipe' }
270
271
  );
271
272
  logSuccess('MCP server registered globally with Claude Code');
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Record execFile invocations; every git call must pass args as an argv ARRAY
4
+ // (no shell), so a branch name with shell metacharacters is inert.
5
+ const mockExecFile = vi.fn();
6
+ vi.mock('child_process', () => ({
7
+ execFile: (...args: unknown[]) => {
8
+ const cb = args[args.length - 1] as (e: Error | null, r: { stdout: string; stderr: string }) => void;
9
+ mockExecFile(args[0], args[1], args[2]);
10
+ cb(null, { stdout: '', stderr: '' });
11
+ },
12
+ }));
13
+ vi.mock('./git-status', () => ({ hasUncommittedChanges: vi.fn().mockResolvedValue(false) }));
14
+
15
+ import { createBranch } from './branch-operations';
16
+
17
+ describe('branch-operations argv safety (no shell injection)', () => {
18
+ beforeEach(() => vi.clearAllMocks());
19
+
20
+ it('passes a malicious branch name as a single argv element, never a shell string', async () => {
21
+ const evil = 'foo$(touch /tmp/pwned)';
22
+ const res = await createBranch('/repo', 'api', evil);
23
+ expect(res.success).toBe(true);
24
+
25
+ // Every call is execFile('git', [...args]) — args is an array, and the evil
26
+ // name appears verbatim as one element (so the shell never sees it).
27
+ for (const [bin, args] of mockExecFile.mock.calls) {
28
+ expect(bin).toBe('git');
29
+ expect(Array.isArray(args)).toBe(true);
30
+ }
31
+ const checkout = mockExecFile.mock.calls.find(([, a]) => (a as string[])[0] === 'checkout');
32
+ expect(checkout![1]).toEqual(['checkout', '-b', evil]);
33
+ });
34
+
35
+ it('checks out a base branch as its own argv element too', async () => {
36
+ await createBranch('/repo', 'api', 'feature', 'release/1.0');
37
+ const calls = mockExecFile.mock.calls.map(([, a]) => a as string[]);
38
+ expect(calls).toContainEqual(['checkout', 'release/1.0']);
39
+ expect(calls).toContainEqual(['checkout', '-b', 'feature']);
40
+ });
41
+ });