@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.
@@ -1,6 +1,15 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
 
3
3
  const mockExecAsync = vi.fn();
4
+ const mockExecFile = vi.fn();
5
+ // ghqRepoExists now uses fs.stat (not `test -d`); cleanup uses fs.rm.
6
+ const mockStat = vi.fn();
7
+ const mockRm = vi.fn().mockResolvedValue(undefined);
8
+ vi.mock('fs/promises', () => ({
9
+ stat: (...a: unknown[]) => mockStat(...a),
10
+ rm: (...a: unknown[]) => mockRm(...a),
11
+ }));
12
+ const cached = () => mockStat.mockResolvedValue({ isDirectory: () => true });
4
13
  vi.mock('child_process', () => ({
5
14
  exec: (...args: unknown[]) => {
6
15
  const callback = args[args.length - 1] as (err: Error | null, result: { stdout: string; stderr: string }) => void;
@@ -10,6 +19,18 @@ vi.mock('child_process', () => ({
10
19
  .then((result: { stdout: string; stderr: string }) => callback(null, result))
11
20
  .catch((err: Error) => callback(err, { stdout: '', stderr: '' }));
12
21
  },
22
+ // execFile(file, args, [opts], cb): reconstruct a command string so existing
23
+ // expectations that assert on the command keep working, and record argv.
24
+ execFile: (...args: unknown[]) => {
25
+ const callback = args[args.length - 1] as (err: Error | null, result: { stdout: string; stderr: string }) => void;
26
+ const file = args[0] as string;
27
+ const fileArgs = Array.isArray(args[1]) ? (args[1] as string[]) : [];
28
+ const opts = typeof args[2] === 'object' && args[2] !== null ? args[2] : {};
29
+ mockExecFile(file, fileArgs, opts);
30
+ mockExecAsync([file, ...fileArgs].join(' '), opts)
31
+ .then((result: { stdout: string; stderr: string }) => callback(null, result))
32
+ .catch((err: Error) => callback(err, { stdout: '', stderr: '' }));
33
+ },
13
34
  }));
14
35
 
15
36
  vi.mock('./logger', () => ({
@@ -28,13 +49,16 @@ import { cloneWithGhq, describeCloneError } from './ghq-integration';
28
49
  describe('cloneWithGhq', () => {
29
50
  beforeEach(() => {
30
51
  vi.clearAllMocks();
52
+ mockRm.mockResolvedValue(undefined);
53
+ // default: not cached (fs.stat rejects) unless a test opts into cached()
54
+ mockStat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
31
55
  });
32
56
 
33
57
  it('uses git clone --local from ghq cache when repo exists', async () => {
58
+ cached();
34
59
  mockExecAsync.mockImplementation((cmd: string) => {
35
60
  if (cmd === 'which ghq') return Promise.resolve({ stdout: '/usr/bin/ghq\n', stderr: '' });
36
61
  if (cmd === 'ghq root') return Promise.resolve({ stdout: '/home/user/ghq\n', stderr: '' });
37
- if (cmd.startsWith('test -d')) return Promise.resolve({ stdout: '', stderr: '' });
38
62
  if (cmd.startsWith('git clone --local')) return Promise.resolve({ stdout: '', stderr: '' });
39
63
  if (cmd.startsWith('git remote set-url')) return Promise.resolve({ stdout: '', stderr: '' });
40
64
  return Promise.resolve({ stdout: '', stderr: '' });
@@ -55,10 +79,10 @@ describe('cloneWithGhq', () => {
55
79
  });
56
80
 
57
81
  it('does not call ghq get -u when repo is already cached', async () => {
82
+ cached();
58
83
  mockExecAsync.mockImplementation((cmd: string) => {
59
84
  if (cmd === 'which ghq') return Promise.resolve({ stdout: '/usr/bin/ghq\n', stderr: '' });
60
85
  if (cmd === 'ghq root') return Promise.resolve({ stdout: '/home/user/ghq\n', stderr: '' });
61
- if (cmd.startsWith('test -d')) return Promise.resolve({ stdout: '', stderr: '' });
62
86
  if (cmd.startsWith('git clone --local')) return Promise.resolve({ stdout: '', stderr: '' });
63
87
  if (cmd.startsWith('git remote set-url')) return Promise.resolve({ stdout: '', stderr: '' });
64
88
  return Promise.resolve({ stdout: '', stderr: '' });
@@ -109,8 +133,8 @@ describe('cloneWithGhq', () => {
109
133
  const calls = mockExecAsync.mock.calls.map(([cmd]: [string]) => cmd);
110
134
  // Should NOT attempt git clone --local since ghq get failed
111
135
  expect(calls.some((cmd: string) => cmd.includes('--local'))).toBe(false);
112
- // Should fall back to direct clone
113
- expect(calls.some((cmd: string) => cmd.startsWith('git clone "git@github.com'))).toBe(true);
136
+ // Should fall back to direct clone (argv form: no shell quotes)
137
+ expect(calls.some((cmd: string) => cmd.startsWith('git clone git@github.com'))).toBe(true);
114
138
  });
115
139
 
116
140
  it('falls back to direct git clone when ghq is not installed', async () => {
@@ -126,7 +150,7 @@ describe('cloneWithGhq', () => {
126
150
  expect(result.usedGhq).toBe(false);
127
151
 
128
152
  const calls = mockExecAsync.mock.calls.map(([cmd]: [string]) => cmd);
129
- expect(calls.some((cmd: string) => cmd.startsWith('git clone "git@github.com'))).toBe(true);
153
+ expect(calls.some((cmd: string) => cmd.startsWith('git clone git@github.com'))).toBe(true);
130
154
  expect(calls.some((cmd: string) => cmd.includes('--local'))).toBe(false);
131
155
  });
132
156
 
@@ -1,11 +1,16 @@
1
- import { exec } from 'child_process';
1
+ import { execFile } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import * as path from 'path';
4
+ import * as fs from 'fs/promises';
4
5
  import { logInfo, logSuccess, logWarning } from './logger';
5
6
  import { colorize } from './colors';
6
7
  import { CLONE_TIMEOUT_MS, CLONE_MAX_BUFFER } from './config';
7
8
 
8
- const execAsync = promisify(exec);
9
+ // execFile (no shell): every value below (repo URLs, ghq paths, branch names)
10
+ // is passed as an argv element, so none can be interpreted as a shell command.
11
+ const execFileAsync = promisify(execFile);
12
+ type ExecOpts = { cwd?: string; timeout?: number; maxBuffer?: number };
13
+ const git = (args: string[], opts: ExecOpts = {}) => execFileAsync('git', args, opts);
9
14
 
10
15
  /**
11
16
  * Turn a raw child-process clone failure (from exec or execFile) into an
@@ -39,7 +44,7 @@ export function describeCloneError(error: unknown, timeoutMs: number = CLONE_TIM
39
44
  */
40
45
  export async function isGhqInstalled(): Promise<boolean> {
41
46
  try {
42
- await execAsync('which ghq');
47
+ await execFileAsync('which', ['ghq']);
43
48
  return true;
44
49
  } catch {
45
50
  return false;
@@ -65,7 +70,7 @@ export async function warnIfGhqMissing(): Promise<boolean> {
65
70
  */
66
71
  export async function getGhqRoot(): Promise<string | null> {
67
72
  try {
68
- const { stdout } = await execAsync('ghq root');
73
+ const { stdout } = await execFileAsync('ghq', ['root']);
69
74
  return stdout.trim();
70
75
  } catch {
71
76
  return null;
@@ -80,8 +85,8 @@ export async function ghqRepoExists(repoUrl: string): Promise<boolean> {
80
85
  const repoPath = await getGhqRepoPath(repoUrl);
81
86
  if (!repoPath) return false;
82
87
 
83
- await execAsync(`test -d "${repoPath}"`);
84
- return true;
88
+ const stat = await fs.stat(repoPath);
89
+ return stat.isDirectory();
85
90
  } catch {
86
91
  return false;
87
92
  }
@@ -114,7 +119,7 @@ export async function ghqGet(repoUrl: string): Promise<{ success: boolean; path?
114
119
  try {
115
120
  logInfo(`Using ghq to clone ${colorize(repoUrl, 'cyan')}...`);
116
121
 
117
- await execAsync(`ghq get "${repoUrl}"`, { timeout: CLONE_TIMEOUT_MS, maxBuffer: CLONE_MAX_BUFFER });
122
+ await execFileAsync('ghq', ['get', repoUrl], { timeout: CLONE_TIMEOUT_MS, maxBuffer: CLONE_MAX_BUFFER });
118
123
 
119
124
  const repoPath = await getGhqRepoPath(repoUrl);
120
125
  if (!repoPath) {
@@ -135,7 +140,7 @@ export async function ghqGet(repoUrl: string): Promise<{ success: boolean; path?
135
140
  */
136
141
  export async function ghqList(): Promise<string[]> {
137
142
  try {
138
- const { stdout } = await execAsync('ghq list');
143
+ const { stdout } = await execFileAsync('ghq', ['list']);
139
144
  return stdout.trim().split('\n').filter(line => line.length > 0);
140
145
  } catch {
141
146
  return [];
@@ -196,18 +201,15 @@ export async function cloneWithGhq(
196
201
  // would serve stale state via hardlinks — user would open a workspace
197
202
  // missing dozens of recent commits.
198
203
  try {
199
- await execAsync(
200
- `git -C "${sourcePath}" fetch origin --quiet --prune`,
201
- { timeout: 90 * 1000 },
202
- );
204
+ await git(['-C', sourcePath, 'fetch', 'origin', '--quiet', '--prune'], { timeout: 90 * 1000 });
203
205
  } catch {
204
206
  // Network failure or no upstream — fall through with stale cache,
205
207
  // we'll log a warning after the local clone if we can't align to HEAD.
206
208
  }
207
209
 
208
- await execAsync(`git clone --local "${sourcePath}" "${targetPath}"`, { timeout: 60 * 1000 });
210
+ await git(['clone', '--local', sourcePath, targetPath], { timeout: 60 * 1000 });
209
211
  // Reset remote to point to the original repo URL (not the local ghq path)
210
- await execAsync(`git remote set-url origin "${repoUrl}"`, { cwd: targetPath });
212
+ await git(['remote', 'set-url', 'origin', repoUrl], { cwd: targetPath });
211
213
 
212
214
  // Make sure the working tree is at origin's default-branch tip.
213
215
  // Robust against:
@@ -217,27 +219,25 @@ export async function cloneWithGhq(
217
219
  let alignedToHead = false;
218
220
  let alignError: string | null = null;
219
221
  try {
220
- await execAsync(`git fetch origin --quiet`, { cwd: targetPath, timeout: 60 * 1000 });
222
+ await git(['fetch', 'origin', '--quiet'], { cwd: targetPath, timeout: 60 * 1000 });
221
223
 
222
224
  // Explicitly set refs/remotes/origin/HEAD by querying the remote.
223
225
  // Without this, symbolic-ref may fail if the local symref wasn’t set.
224
- await execAsync(`git remote set-head origin --auto`, { cwd: targetPath, timeout: 30 * 1000 })
226
+ await git(['remote', 'set-head', 'origin', '--auto'], { cwd: targetPath, timeout: 30 * 1000 })
225
227
  .catch(() => { /* if this fails the next step might still succeed */ });
226
228
 
227
229
  let defaultBranch: string | null = null;
228
230
  try {
229
- const headRes = await execAsync(
230
- `git symbolic-ref --short refs/remotes/origin/HEAD`,
231
- { cwd: targetPath, timeout: 5000 },
232
- );
231
+ const headRes = await git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], {
232
+ cwd: targetPath, timeout: 5000,
233
+ });
233
234
  defaultBranch = headRes.stdout.trim().replace(/^origin\//, '');
234
235
  } catch {
235
236
  // Fall back to ls-remote query against the actual remote
236
237
  try {
237
- const lsRes = await execAsync(
238
- `git ls-remote --symref origin HEAD`,
239
- { cwd: targetPath, timeout: 30 * 1000 },
240
- );
238
+ const lsRes = await git(['ls-remote', '--symref', 'origin', 'HEAD'], {
239
+ cwd: targetPath, timeout: 30 * 1000,
240
+ });
241
241
  const match = lsRes.stdout.match(/^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m);
242
242
  if (match) defaultBranch = match[1];
243
243
  } catch { /* fall through to common-name fallback */ }
@@ -246,10 +246,9 @@ export async function cloneWithGhq(
246
246
  // Final fallback: try common default branches
247
247
  if (!defaultBranch) {
248
248
  for (const candidate of ['main', 'master']) {
249
- const exists = await execAsync(
250
- `git rev-parse --verify --quiet origin/${candidate}`,
251
- { cwd: targetPath, timeout: 5000 },
252
- ).then(() => true).catch(() => false);
249
+ const exists = await git(['rev-parse', '--verify', '--quiet', `origin/${candidate}`], {
250
+ cwd: targetPath, timeout: 5000,
251
+ }).then(() => true).catch(() => false);
253
252
  if (exists) { defaultBranch = candidate; break; }
254
253
  }
255
254
  }
@@ -258,8 +257,8 @@ export async function cloneWithGhq(
258
257
  throw new Error('could not determine default branch (no origin/HEAD, no main, no master)');
259
258
  }
260
259
 
261
- await execAsync(`git checkout --quiet "${defaultBranch}"`, { cwd: targetPath, timeout: 10 * 1000 });
262
- await execAsync(`git reset --hard --quiet "origin/${defaultBranch}"`, { cwd: targetPath, timeout: 10 * 1000 });
260
+ await git(['checkout', '--quiet', defaultBranch], { cwd: targetPath, timeout: 10 * 1000 });
261
+ await git(['reset', '--hard', '--quiet', `origin/${defaultBranch}`], { cwd: targetPath, timeout: 10 * 1000 });
263
262
  alignedToHead = true;
264
263
  } catch (error) {
265
264
  alignError = error instanceof Error ? error.message : String(error);
@@ -277,7 +276,7 @@ export async function cloneWithGhq(
277
276
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
278
277
  logWarning(`Local clone from ghq failed: ${errorMessage}`);
279
278
  // Clean up partial clone before fallback
280
- try { await execAsync(`rm -rf "${targetPath}"`); } catch { /* ignore */ }
279
+ try { await fs.rm(targetPath, { recursive: true, force: true }); } catch { /* ignore */ }
281
280
  // Fall through to direct clone
282
281
  }
283
282
  }
@@ -285,7 +284,7 @@ export async function cloneWithGhq(
285
284
 
286
285
  // Fallback to direct git clone
287
286
  try {
288
- await execAsync(`git clone "${repoUrl}" "${targetPath}"`, { timeout: CLONE_TIMEOUT_MS, maxBuffer: CLONE_MAX_BUFFER });
287
+ await git(['clone', repoUrl, targetPath], { timeout: CLONE_TIMEOUT_MS, maxBuffer: CLONE_MAX_BUFFER });
289
288
  return { success: true, usedGhq: false };
290
289
  } catch (error) {
291
290
  return { success: false, error: describeCloneError(error), usedGhq: false };
@@ -1,4 +1,4 @@
1
- import { exec, execFile } from 'child_process';
1
+ import { execFile } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import * as path from 'path';
4
4
  import * as fs from 'fs/promises';
@@ -10,7 +10,6 @@ import { withRetry } from './retry';
10
10
  import { createSimpleProgressBar } from './progress';
11
11
  import { getCloneUrl, CLONE_TIMEOUT_MS, CLONE_MAX_BUFFER } from './config';
12
12
 
13
- const execAsync = promisify(exec);
14
13
  const execFileAsync = promisify(execFile);
15
14
 
16
15
  const CONCURRENCY_LIMIT = 3;
@@ -96,13 +95,13 @@ async function cloneLocal(
96
95
  const targetPath = path.join(workspacePath, directoryName);
97
96
  try {
98
97
  // git clone --local creates hardlinks for .git/objects — nearly instant
99
- await execAsync(`git clone --local "${sourcePath}" "${targetPath}"`, {
98
+ await execFileAsync('git', ['clone', '--local', sourcePath, targetPath], {
100
99
  timeout: CLONE_TIMEOUT,
101
100
  maxBuffer: CLONE_MAX_BUFFER,
102
101
  });
103
102
  // Reset the remote to point to the original repo (not the local source)
104
103
  const remoteUrl = getCloneUrl(repo);
105
- await execAsync(`git remote set-url origin "${remoteUrl}"`, {
104
+ await execFileAsync('git', ['remote', 'set-url', 'origin', remoteUrl], {
106
105
  cwd: targetPath,
107
106
  });
108
107
  return {
@@ -1,4 +1,4 @@
1
- import { exec } from 'child_process';
1
+ import { exec, 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';
@@ -6,6 +6,7 @@ import { HealthCheckResult, WorkspaceMetadata } from '../types';
6
6
  import { isGitRepository, hasUncommittedChanges } from './git-status';
7
7
 
8
8
  const execAsync = promisify(exec);
9
+ const execFileAsync = promisify(execFile);
9
10
 
10
11
  export const checkMissingRepositories = async (
11
12
  workspacePath: string,
@@ -215,7 +216,7 @@ export const checkDependencies = async (
215
216
  export const checkDiskSpace = async (workspacePath: string): Promise<HealthCheckResult> => {
216
217
  try {
217
218
  // Get disk usage for workspace
218
- const { stdout } = await execAsync(`du -sh "${workspacePath}"`, { timeout: 30000 });
219
+ const { stdout } = await execFileAsync('du', ['-sh', workspacePath], { timeout: 30000 });
219
220
  const sizeStr = stdout.trim().split('\t')[0];
220
221
 
221
222
  // Parse size (rough check for > 10GB)
@@ -1,5 +1,10 @@
1
1
  import { colors, colorize } from './colors';
2
2
 
3
+ // Diagnostics (info/success/error/warn/step) go to STDERR so stdout carries only
4
+ // a command's actual data — required for clean `--json` piping (nemus list
5
+ // --json | jq …) and for non-TTY consumers. Use `outputJson`/stdout for data.
6
+ const logStream = (line: string): void => console.error(line);
7
+
3
8
  const getTimestamp = (): string => {
4
9
  const now = new Date();
5
10
  return now.toLocaleTimeString('en-US', {
@@ -11,27 +16,27 @@ const getTimestamp = (): string => {
11
16
  };
12
17
 
13
18
  export const logInfo = (message: string): void => {
14
- console.log(`${colors.gray}[${getTimestamp()}]${colors.reset} ${message}`);
19
+ logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${message}`);
15
20
  };
16
21
 
17
22
  export const logSuccess = (message: string): void => {
18
- console.log(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('✓', 'green')} ${message}`);
23
+ logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('✓', 'green')} ${message}`);
19
24
  };
20
25
 
21
26
  export const logError = (message: string): void => {
22
- console.log(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('✗', 'red')} ${message}`);
27
+ logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('✗', 'red')} ${message}`);
23
28
  };
24
29
 
25
30
  export const logWarning = (message: string): void => {
26
- console.log(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('⚠', 'yellow')} ${message}`);
31
+ logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('⚠', 'yellow')} ${message}`);
27
32
  };
28
33
 
29
34
  export const logStep = (stepOrMessage: number | string, total?: number, message?: string): void => {
30
35
  if (typeof stepOrMessage === 'string') {
31
36
  // Single parameter version: just a message
32
- console.log(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('▸', 'cyan')} ${stepOrMessage}`);
37
+ logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('▸', 'cyan')} ${stepOrMessage}`);
33
38
  } else {
34
39
  // Three parameter version: step, total, message
35
- console.log(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize(`[${stepOrMessage}/${total}]`, 'cyan')} ${message}`);
40
+ logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize(`[${stepOrMessage}/${total}]`, 'cyan')} ${message}`);
36
41
  }
37
42
  };
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { outputJson, outputJsonError } from './output';
3
+
4
+ function captureStdout(): { calls: string[]; restore: () => void } {
5
+ const calls: string[] = [];
6
+ const spy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: any) => {
7
+ calls.push(String(chunk));
8
+ return true;
9
+ });
10
+ return { calls, restore: () => spy.mockRestore() };
11
+ }
12
+
13
+ describe('outputJson', () => {
14
+ afterEach(() => vi.restoreAllMocks());
15
+
16
+ it('writes exactly one pretty JSON document + trailing newline to stdout', () => {
17
+ const { calls, restore } = captureStdout();
18
+ outputJson({ a: 1, b: ['x', 'y'] });
19
+ restore();
20
+ expect(calls).toHaveLength(1);
21
+ expect(calls[0].endsWith('\n')).toBe(true);
22
+ expect(calls[0]).toBe(JSON.stringify({ a: 1, b: ['x', 'y'] }, null, 2) + '\n');
23
+ expect(JSON.parse(calls[0])).toEqual({ a: 1, b: ['x', 'y'] });
24
+ });
25
+ });
26
+
27
+ describe('outputJsonError', () => {
28
+ afterEach(() => vi.restoreAllMocks());
29
+
30
+ it('emits a parseable { ok:false, error } object to stdout', () => {
31
+ const { calls, restore } = captureStdout();
32
+ outputJsonError('boom');
33
+ restore();
34
+ expect(JSON.parse(calls[0])).toEqual({ ok: false, error: 'boom' });
35
+ });
36
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Machine-readable output helpers. The one rule: a command's DATA goes to
3
+ * stdout, diagnostics go to stderr (see logger.ts). In `--json` mode a command
4
+ * writes exactly one JSON document to stdout and nothing else, so it pipes
5
+ * cleanly into `jq` and is safe for scripts/CI.
6
+ */
7
+
8
+ /** Write one pretty-printed JSON document to stdout (data channel). */
9
+ export function outputJson(data: unknown): void {
10
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n');
11
+ }
12
+
13
+ /**
14
+ * Emit a structured error as JSON to stdout for `--json` callers, so a script
15
+ * parsing stdout always gets a parseable object (`{ ok: false, error }`) rather
16
+ * than empty stdout + a human log line on stderr. The caller still signals
17
+ * failure with a non-zero exit code.
18
+ */
19
+ export function outputJsonError(message: string): void {
20
+ outputJson({ ok: false, error: message });
21
+ }