@nemus-cli/nemus 0.2.9 → 0.2.11
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 +84 -0
- package/README.md +68 -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/commands/doctor.js +35 -7
- package/dist/commands/list.js +24 -1
- package/dist/commands/status.js +37 -8
- 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/dist/utils/logger.js +10 -6
- package/dist/utils/output.js +23 -0
- package/package.json +4 -1
- 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/commands/doctor.ts +32 -7
- package/src/commands/list.test.ts +30 -0
- package/src/commands/list.ts +26 -2
- package/src/commands/status.ts +35 -8
- 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
- package/src/utils/logger.ts +11 -6
- package/src/utils/output.test.ts +36 -0
- package/src/utils/output.ts +21 -0
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/commands/doctor.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { WORKSPACES_DIR } from '../utils/config';
|
|
|
4
4
|
import { loadMetadata } from '../utils/workspace-meta';
|
|
5
5
|
import { runAllHealthChecks, calculateHealthScore } from '../utils/health-checks';
|
|
6
6
|
import { logError, logInfo, logStep, logSuccess, logWarning } from '../utils/logger';
|
|
7
|
+
import { outputJson, outputJsonError } from '../utils/output';
|
|
7
8
|
import { colorize } from '../utils/colors';
|
|
8
9
|
import { HealthCheckResult } from '../types';
|
|
9
10
|
import { resolveWorkspace } from '../utils/command-helpers';
|
|
@@ -59,22 +60,44 @@ export function registerDoctorCommand(parent: Command) {
|
|
|
59
60
|
.alias('doc')
|
|
60
61
|
.description('Run comprehensive health checks')
|
|
61
62
|
.argument('[workspace]', 'Workspace name')
|
|
62
|
-
.
|
|
63
|
-
|
|
63
|
+
.option('--json', 'Output as JSON')
|
|
64
|
+
.action(async (workspace, opts) => {
|
|
65
|
+
await handleDoctor(workspace, opts);
|
|
64
66
|
});
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
async function handleDoctor(workspaceArg?: string) {
|
|
69
|
+
async function handleDoctor(workspaceArg?: string, opts: { json?: boolean } = {}) {
|
|
70
|
+
// In --json mode, failures are parseable JSON on stdout + exit 1; otherwise a
|
|
71
|
+
// human log on stderr. `process.exit(1)` stays the last statement so TS still
|
|
72
|
+
// narrows `metadata` to non-null below.
|
|
68
73
|
try {
|
|
74
|
+
// JSON mode is non-interactive: require an explicit workspace rather than prompt.
|
|
75
|
+
if (opts.json && !workspaceArg) {
|
|
76
|
+
outputJsonError('doctor --json requires a workspace name');
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
69
79
|
const selectedWorkspace = await resolveWorkspace(workspaceArg);
|
|
70
80
|
const workspacePath = path.join(WORKSPACES_DIR, selectedWorkspace);
|
|
71
81
|
const metadata = await loadMetadata(workspacePath);
|
|
72
82
|
|
|
73
83
|
if (!metadata) {
|
|
74
|
-
|
|
84
|
+
if (opts.json) outputJsonError(`Workspace metadata not found for: ${selectedWorkspace}`);
|
|
85
|
+
else logError(`Workspace metadata not found for: ${selectedWorkspace}`);
|
|
75
86
|
process.exit(1);
|
|
76
87
|
}
|
|
77
88
|
|
|
89
|
+
if (opts.json) {
|
|
90
|
+
const results = await runAllHealthChecks(workspacePath, metadata);
|
|
91
|
+
const score = calculateHealthScore(results);
|
|
92
|
+
const overall = results.some(r => r.status === 'error')
|
|
93
|
+
? 'error'
|
|
94
|
+
: results.some(r => r.status === 'warning')
|
|
95
|
+
? 'warning'
|
|
96
|
+
: 'healthy';
|
|
97
|
+
outputJson({ workspace: selectedWorkspace, score, status: overall, checks: results });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
78
101
|
logStep(`Running health checks for workspace: ${colorize(selectedWorkspace, 'cyan')}`);
|
|
79
102
|
logInfo('This may take a moment...');
|
|
80
103
|
|
|
@@ -101,9 +124,11 @@ async function handleDoctor(workspaceArg?: string) {
|
|
|
101
124
|
logSuccess('All health checks passed! Your workspace is in good shape.');
|
|
102
125
|
}
|
|
103
126
|
} catch (error) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
127
|
+
if (opts.json) {
|
|
128
|
+
outputJsonError(error instanceof Error ? error.message : 'Failed to run health checks');
|
|
129
|
+
} else {
|
|
130
|
+
logError('Failed to run health checks');
|
|
131
|
+
if (error instanceof Error) logError(error.message);
|
|
107
132
|
}
|
|
108
133
|
process.exit(1);
|
|
109
134
|
}
|
|
@@ -196,6 +196,36 @@ describe('list-workspaces main', () => {
|
|
|
196
196
|
expect(messages).toContainEqual(expect.stringContaining('ws-a'));
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
+
it('--json: writes one valid JSON document to stdout and does not prompt', async () => {
|
|
200
|
+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
201
|
+
process.argv = ['node', 'list-workspaces.js', '--json'];
|
|
202
|
+
mockListWorkspaces.mockResolvedValueOnce(makeWorkspaceList('ws-a', 'ws-b'));
|
|
203
|
+
|
|
204
|
+
await main();
|
|
205
|
+
|
|
206
|
+
expect(mockPrompt).not.toHaveBeenCalled();
|
|
207
|
+
expect(writeSpy).toHaveBeenCalledTimes(1);
|
|
208
|
+
const payload = JSON.parse(writeSpy.mock.calls[0][0] as string);
|
|
209
|
+
expect(payload.count).toBe(2);
|
|
210
|
+
expect(payload.workspaces.map((w: any) => w.name).sort()).toEqual(['ws-a', 'ws-b']);
|
|
211
|
+
expect(payload.workspaces[0]).toMatchObject({ repoCount: 1, hasSession: false });
|
|
212
|
+
writeSpy.mockRestore();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('--json: empty list emits count 0, no prompt, no log noise', async () => {
|
|
216
|
+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
217
|
+
process.argv = ['node', 'list-workspaces.js', '--json'];
|
|
218
|
+
mockListWorkspaces.mockResolvedValueOnce([]);
|
|
219
|
+
|
|
220
|
+
await main();
|
|
221
|
+
|
|
222
|
+
expect(logInfo).not.toHaveBeenCalled();
|
|
223
|
+
expect(mockPrompt).not.toHaveBeenCalled();
|
|
224
|
+
const payload = JSON.parse(writeSpy.mock.calls[0][0] as string);
|
|
225
|
+
expect(payload).toEqual({ archived: false, count: 0, workspaces: [] });
|
|
226
|
+
writeSpy.mockRestore();
|
|
227
|
+
});
|
|
228
|
+
|
|
199
229
|
it('sorts workspaces with sessions before those without', async () => {
|
|
200
230
|
mockListWorkspaces.mockResolvedValueOnce(makeWorkspaceList('no-session', 'has-session'));
|
|
201
231
|
mockGetWorkspaceSessions.mockResolvedValueOnce([{
|
package/src/commands/list.ts
CHANGED
|
@@ -5,6 +5,7 @@ import * as fsPromises from 'fs/promises';
|
|
|
5
5
|
import { listWorkspaces } from '../utils/workspace-meta';
|
|
6
6
|
import { getWorkspaceSessions } from '../utils/claude-sessions';
|
|
7
7
|
import { logInfo, logError } from '../utils/logger';
|
|
8
|
+
import { outputJson } from '../utils/output';
|
|
8
9
|
import { colorize } from '../utils/colors';
|
|
9
10
|
import inquirer from 'inquirer';
|
|
10
11
|
import autocompletePrompt from 'inquirer-autocomplete-prompt';
|
|
@@ -31,12 +32,13 @@ export function registerListCommand(parent: Command) {
|
|
|
31
32
|
.alias('l')
|
|
32
33
|
.description('List workspaces and navigate to one')
|
|
33
34
|
.option('-a, --archived', 'Show archived workspaces')
|
|
35
|
+
.option('--json', 'Output as JSON (no interactive selection)')
|
|
34
36
|
.action(async (opts) => {
|
|
35
37
|
await handleList(opts);
|
|
36
38
|
});
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
async function handleList(opts: { archived?: boolean }) {
|
|
41
|
+
async function handleList(opts: { archived?: boolean; json?: boolean }) {
|
|
40
42
|
const showArchived = opts.archived ?? false;
|
|
41
43
|
const title = showArchived ? 'Archived Workspaces' : 'Existing Workspaces';
|
|
42
44
|
|
|
@@ -49,6 +51,10 @@ async function handleList(opts: { archived?: boolean }) {
|
|
|
49
51
|
]);
|
|
50
52
|
|
|
51
53
|
if (workspaces.length === 0) {
|
|
54
|
+
if (opts.json) {
|
|
55
|
+
outputJson({ archived: showArchived, count: 0, workspaces: [] });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
52
58
|
console.log('\n' + '='.repeat(60));
|
|
53
59
|
console.log(colorize(title, 'bright'));
|
|
54
60
|
console.log('='.repeat(60) + '\n');
|
|
@@ -85,6 +91,23 @@ async function handleList(opts: { archived?: boolean }) {
|
|
|
85
91
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
|
86
92
|
});
|
|
87
93
|
|
|
94
|
+
// JSON mode: one document to stdout, no table, no interactive selection.
|
|
95
|
+
if (opts.json) {
|
|
96
|
+
outputJson({
|
|
97
|
+
archived: showArchived,
|
|
98
|
+
count: items.length,
|
|
99
|
+
workspaces: items.map(i => ({
|
|
100
|
+
name: i.name,
|
|
101
|
+
path: i.wsPath,
|
|
102
|
+
repoCount: i.repoCount,
|
|
103
|
+
createdAt: i.createdAt || null,
|
|
104
|
+
lastActive: i.lastActiveLabel,
|
|
105
|
+
hasSession: i.hasSession,
|
|
106
|
+
})),
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
88
111
|
console.log('');
|
|
89
112
|
console.log(colorize(' ' + title, 'bright') + colorize(' (sorted by last active)', 'dim'));
|
|
90
113
|
console.log(colorize(' ' + '─'.repeat(54), 'dim'));
|
|
@@ -172,5 +195,6 @@ async function handleList(opts: { archived?: boolean }) {
|
|
|
172
195
|
export async function main() {
|
|
173
196
|
const args = process.argv.slice(2);
|
|
174
197
|
const archived = args.includes('--archived') || args.includes('-a');
|
|
175
|
-
|
|
198
|
+
const json = args.includes('--json');
|
|
199
|
+
await handleList({ archived, json });
|
|
176
200
|
}
|
package/src/commands/status.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { WORKSPACES_DIR } from '../utils/config';
|
|
|
4
4
|
import { loadMetadata } from '../utils/workspace-meta';
|
|
5
5
|
import { getAllReposStatus } from '../utils/git-status';
|
|
6
6
|
import { logError, logInfo, logStep } from '../utils/logger';
|
|
7
|
+
import { outputJson, outputJsonError } from '../utils/output';
|
|
7
8
|
import { colorize } from '../utils/colors';
|
|
8
9
|
import { resolveWorkspace } from '../utils/command-helpers';
|
|
9
10
|
|
|
@@ -78,33 +79,59 @@ export function registerStatusCommand(parent: Command) {
|
|
|
78
79
|
.alias('st')
|
|
79
80
|
.description('Show git status across all repos')
|
|
80
81
|
.argument('[workspace]', 'Workspace name')
|
|
81
|
-
.
|
|
82
|
-
|
|
82
|
+
.option('--json', 'Output as JSON')
|
|
83
|
+
.action(async (workspace, opts) => {
|
|
84
|
+
await handleStatus(workspace, opts);
|
|
83
85
|
});
|
|
84
86
|
}
|
|
85
87
|
|
|
86
|
-
async function handleStatus(workspaceArg?: string) {
|
|
88
|
+
async function handleStatus(workspaceArg?: string, opts: { json?: boolean } = {}) {
|
|
89
|
+
// In --json mode, failures are parseable JSON on stdout + exit 1; otherwise a
|
|
90
|
+
// human log on stderr. `process.exit(1)` stays the last statement so TS still
|
|
91
|
+
// narrows `metadata` to non-null below.
|
|
87
92
|
try {
|
|
93
|
+
// JSON mode is non-interactive: require an explicit workspace rather than prompt.
|
|
94
|
+
if (opts.json && !workspaceArg) {
|
|
95
|
+
outputJsonError('status --json requires a workspace name');
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
88
98
|
const selectedWorkspace = await resolveWorkspace(workspaceArg);
|
|
89
99
|
const workspacePath = path.join(WORKSPACES_DIR, selectedWorkspace);
|
|
90
100
|
const metadata = await loadMetadata(workspacePath);
|
|
91
101
|
|
|
92
102
|
if (!metadata) {
|
|
93
|
-
|
|
103
|
+
if (opts.json) outputJsonError(`Workspace metadata not found for: ${selectedWorkspace}`);
|
|
104
|
+
else logError(`Workspace metadata not found for: ${selectedWorkspace}`);
|
|
94
105
|
process.exit(1);
|
|
95
106
|
}
|
|
96
107
|
|
|
108
|
+
const repoDirectoryNames = metadata.repositories.map(r => r.directoryName);
|
|
109
|
+
|
|
110
|
+
if (opts.json) {
|
|
111
|
+
const statuses = await getAllReposStatus(workspacePath, repoDirectoryNames, 3);
|
|
112
|
+
outputJson({
|
|
113
|
+
workspace: selectedWorkspace,
|
|
114
|
+
path: workspacePath,
|
|
115
|
+
repoCount: statuses.length,
|
|
116
|
+
clean: statuses.every(s => s.clean),
|
|
117
|
+
repositories: statuses,
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
97
122
|
logStep(`Checking status for workspace: ${colorize(selectedWorkspace, 'cyan')}`);
|
|
98
123
|
logInfo(`Found ${metadata.repositories.length} repositories`);
|
|
99
124
|
|
|
100
|
-
const repoDirectoryNames = metadata.repositories.map(r => r.directoryName);
|
|
101
125
|
const statuses = await getAllReposStatus(workspacePath, repoDirectoryNames, 3);
|
|
102
126
|
|
|
103
127
|
displayStatusTable(statuses);
|
|
104
128
|
} catch (error) {
|
|
105
|
-
|
|
106
|
-
if (
|
|
107
|
-
|
|
129
|
+
const msg = error instanceof Error ? error.message : 'Failed to check workspace status';
|
|
130
|
+
if (opts.json) {
|
|
131
|
+
outputJsonError(msg);
|
|
132
|
+
} else {
|
|
133
|
+
logError('Failed to check workspace status');
|
|
134
|
+
if (error instanceof Error) logError(error.message);
|
|
108
135
|
}
|
|
109
136
|
process.exit(1);
|
|
110
137
|
}
|
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
|
+
});
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
2
|
import { promisify } from 'util';
|
|
3
3
|
import * as fs from 'fs/promises';
|
|
4
|
-
import * as path from 'path';
|
|
5
4
|
import { hasUncommittedChanges } from './git-status';
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
// execFile (no shell): git args are passed as an argv array, so a branch name
|
|
7
|
+
// containing shell metacharacters can never be interpreted as a command.
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const git = (args: string[], opts: { cwd: string; timeout?: number }) =>
|
|
10
|
+
execFileAsync('git', args, opts);
|
|
8
11
|
const GIT_TIMEOUT = 30000;
|
|
9
12
|
|
|
10
13
|
export interface BranchSwitchResult {
|
|
@@ -34,7 +37,7 @@ export const switchBranch = async (
|
|
|
34
37
|
|
|
35
38
|
// Check if it's a git repository
|
|
36
39
|
try {
|
|
37
|
-
await
|
|
40
|
+
await git(['rev-parse', '--git-dir'], { cwd: repoPath });
|
|
38
41
|
} catch {
|
|
39
42
|
return {
|
|
40
43
|
repo: repoName,
|
|
@@ -44,7 +47,7 @@ export const switchBranch = async (
|
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
// Get current branch
|
|
47
|
-
const { stdout: currentBranchOutput } = await
|
|
50
|
+
const { stdout: currentBranchOutput } = await git(['branch', '--show-current'], { cwd: repoPath });
|
|
48
51
|
const currentBranch = currentBranchOutput.trim();
|
|
49
52
|
|
|
50
53
|
if (currentBranch === targetBranch) {
|
|
@@ -57,7 +60,7 @@ export const switchBranch = async (
|
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
// Check for uncommitted changes
|
|
60
|
-
const { stdout: statusOutput } = await
|
|
63
|
+
const { stdout: statusOutput } = await git(['status', '--porcelain'], { cwd: repoPath });
|
|
61
64
|
if (statusOutput.trim().length > 0) {
|
|
62
65
|
return {
|
|
63
66
|
repo: repoName,
|
|
@@ -68,17 +71,17 @@ export const switchBranch = async (
|
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
// Fetch to ensure we have latest branches
|
|
71
|
-
await
|
|
74
|
+
await git(['fetch'], { cwd: repoPath, timeout: GIT_TIMEOUT });
|
|
72
75
|
|
|
73
76
|
// Check if branch exists locally
|
|
74
77
|
try {
|
|
75
|
-
await
|
|
78
|
+
await git(['rev-parse', '--verify', targetBranch], { cwd: repoPath });
|
|
76
79
|
} catch {
|
|
77
80
|
// Branch doesn't exist locally, check remote
|
|
78
81
|
try {
|
|
79
|
-
await
|
|
82
|
+
await git(['rev-parse', '--verify', `origin/${targetBranch}`], { cwd: repoPath });
|
|
80
83
|
// Branch exists on remote, create local tracking branch
|
|
81
|
-
await
|
|
84
|
+
await git(['checkout', '-b', targetBranch, `origin/${targetBranch}`], { cwd: repoPath });
|
|
82
85
|
return {
|
|
83
86
|
repo: repoName,
|
|
84
87
|
status: 'success',
|
|
@@ -96,7 +99,7 @@ export const switchBranch = async (
|
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
// Switch to existing local branch
|
|
99
|
-
await
|
|
102
|
+
await git(['checkout', targetBranch], { cwd: repoPath });
|
|
100
103
|
|
|
101
104
|
return {
|
|
102
105
|
repo: repoName,
|
|
@@ -143,17 +146,11 @@ export const createBranch = async (
|
|
|
143
146
|
|
|
144
147
|
// Checkout base branch if specified
|
|
145
148
|
if (baseBranch) {
|
|
146
|
-
await
|
|
147
|
-
cwd: repoPath,
|
|
148
|
-
timeout: GIT_TIMEOUT,
|
|
149
|
-
});
|
|
149
|
+
await git(['checkout', baseBranch], { cwd: repoPath, timeout: GIT_TIMEOUT });
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
// Create and checkout new branch
|
|
153
|
-
await
|
|
154
|
-
cwd: repoPath,
|
|
155
|
-
timeout: GIT_TIMEOUT,
|
|
156
|
-
});
|
|
153
|
+
await git(['checkout', '-b', branchName], { cwd: repoPath, timeout: GIT_TIMEOUT });
|
|
157
154
|
|
|
158
155
|
return {
|
|
159
156
|
repo: repoName,
|
|
@@ -179,21 +176,15 @@ export const mergeBranch = async (
|
|
|
179
176
|
): Promise<BranchOperationResult> => {
|
|
180
177
|
try {
|
|
181
178
|
// Checkout target branch
|
|
182
|
-
await
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (options?.squash) mergeCmd += ' --squash';
|
|
192
|
-
|
|
193
|
-
await execAsync(mergeCmd, {
|
|
194
|
-
cwd: repoPath,
|
|
195
|
-
timeout: GIT_TIMEOUT,
|
|
196
|
-
});
|
|
179
|
+
await git(['checkout', targetBranch], { cwd: repoPath, timeout: GIT_TIMEOUT });
|
|
180
|
+
|
|
181
|
+
// Build merge argv
|
|
182
|
+
const mergeArgs = ['merge', sourceBranch];
|
|
183
|
+
if (options?.noFf) mergeArgs.push('--no-ff');
|
|
184
|
+
if (options?.ffOnly) mergeArgs.push('--ff-only');
|
|
185
|
+
if (options?.squash) mergeArgs.push('--squash');
|
|
186
|
+
|
|
187
|
+
await git(mergeArgs, { cwd: repoPath, timeout: GIT_TIMEOUT });
|
|
197
188
|
|
|
198
189
|
return {
|
|
199
190
|
repo: repoName,
|
|
@@ -216,10 +207,7 @@ export const rebaseBranch = async (
|
|
|
216
207
|
targetBranch: string
|
|
217
208
|
): Promise<BranchOperationResult> => {
|
|
218
209
|
try {
|
|
219
|
-
await
|
|
220
|
-
cwd: repoPath,
|
|
221
|
-
timeout: GIT_TIMEOUT,
|
|
222
|
-
});
|
|
210
|
+
await git(['rebase', targetBranch], { cwd: repoPath, timeout: GIT_TIMEOUT });
|
|
223
211
|
|
|
224
212
|
return {
|
|
225
213
|
repo: repoName,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
2
|
import { promisify } from 'util';
|
|
3
3
|
import * as fs from 'fs/promises';
|
|
4
4
|
import * as path from 'path';
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
7
|
|
|
8
8
|
export interface CleanupResult {
|
|
9
9
|
operation: string;
|
|
@@ -14,7 +14,7 @@ export interface CleanupResult {
|
|
|
14
14
|
|
|
15
15
|
export const calculateDirSize = async (dirPath: string): Promise<number> => {
|
|
16
16
|
try {
|
|
17
|
-
const { stdout } = await
|
|
17
|
+
const { stdout } = await execFileAsync('du', ['-sk', dirPath]);
|
|
18
18
|
const sizeInKB = parseInt(stdout.split('\t')[0], 10);
|
|
19
19
|
return sizeInKB;
|
|
20
20
|
} catch {
|
|
@@ -72,10 +72,10 @@ export const removeBuildArtifacts = async (repoPath: string): Promise<CleanupRes
|
|
|
72
72
|
|
|
73
73
|
export const gitClean = async (repoPath: string, dryRun: boolean = false): Promise<CleanupResult> => {
|
|
74
74
|
try {
|
|
75
|
-
const
|
|
76
|
-
const { stdout } = await
|
|
75
|
+
const args = dryRun ? ['clean', '-fdxn'] : ['clean', '-fdx'];
|
|
76
|
+
const { stdout } = await execFileAsync('git', args, { cwd: repoPath });
|
|
77
77
|
|
|
78
|
-
const lines = stdout.split('\n').filter(l => l.trim());
|
|
78
|
+
const lines = stdout.split('\n').filter((l: string) => l.trim());
|
|
79
79
|
|
|
80
80
|
return {
|
|
81
81
|
operation: 'Git clean',
|