@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.
- package/CHANGELOG.md +70 -1
- package/README.md +53 -17
- package/dist/cli/ai-prompt.js +2 -2
- package/dist/commands/configure.js +2 -2
- package/dist/commands/delete.js +75 -42
- package/dist/mcp/install.js +2 -2
- package/dist/utils/branch-operations.js +22 -34
- package/dist/utils/cleanup-operations.js +5 -5
- package/dist/utils/ghq-integration.js +29 -19
- package/dist/utils/git-operations.js +2 -3
- package/dist/utils/health-checks.js +2 -1
- package/package.json +4 -1
- package/scripts/check-lock-version.js +43 -0
- package/src/cli/ai-prompt.test.ts +5 -1
- package/src/cli/ai-prompt.ts +3 -3
- package/src/commands/configure.ts +3 -3
- package/src/commands/delete.ts +75 -41
- package/src/mcp/install.ts +5 -4
- package/src/utils/branch-operations.test.ts +41 -0
- package/src/utils/branch-operations.ts +26 -38
- package/src/utils/cleanup-operations.ts +6 -6
- package/src/utils/ghq-integration.test.ts +29 -5
- package/src/utils/ghq-integration.ts +31 -32
- package/src/utils/git-operations.ts +3 -4
- package/src/utils/health-checks.ts +3 -2
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
125
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
281
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 {
|
package/src/cli/ai-prompt.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn,
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|
package/src/commands/delete.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import * as fs from 'fs/promises';
|
|
3
|
-
import {
|
|
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 =
|
|
41
|
-
|
|
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:
|
|
60
|
-
? `Delete workspace ${
|
|
61
|
-
: `Delete these ${
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
}
|
package/src/mcp/install.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
+
});
|