@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.
@@ -38,7 +38,10 @@ const child_process_1 = require("child_process");
38
38
  const util_1 = require("util");
39
39
  const fs = __importStar(require("fs/promises"));
40
40
  const git_status_1 = require("./git-status");
41
- const execAsync = (0, util_1.promisify)(child_process_1.exec);
41
+ // execFile (no shell): git args are passed as an argv array, so a branch name
42
+ // containing shell metacharacters can never be interpreted as a command.
43
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
44
+ const git = (args, opts) => execFileAsync('git', args, opts);
42
45
  const GIT_TIMEOUT = 30000;
43
46
  const switchBranch = async (repoPath, repoName, targetBranch) => {
44
47
  try {
@@ -55,7 +58,7 @@ const switchBranch = async (repoPath, repoName, targetBranch) => {
55
58
  }
56
59
  // Check if it's a git repository
57
60
  try {
58
- await execAsync('git rev-parse --git-dir', { cwd: repoPath });
61
+ await git(['rev-parse', '--git-dir'], { cwd: repoPath });
59
62
  }
60
63
  catch {
61
64
  return {
@@ -65,7 +68,7 @@ const switchBranch = async (repoPath, repoName, targetBranch) => {
65
68
  };
66
69
  }
67
70
  // Get current branch
68
- const { stdout: currentBranchOutput } = await execAsync('git branch --show-current', { cwd: repoPath });
71
+ const { stdout: currentBranchOutput } = await git(['branch', '--show-current'], { cwd: repoPath });
69
72
  const currentBranch = currentBranchOutput.trim();
70
73
  if (currentBranch === targetBranch) {
71
74
  return {
@@ -76,7 +79,7 @@ const switchBranch = async (repoPath, repoName, targetBranch) => {
76
79
  };
77
80
  }
78
81
  // Check for uncommitted changes
79
- const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: repoPath });
82
+ const { stdout: statusOutput } = await git(['status', '--porcelain'], { cwd: repoPath });
80
83
  if (statusOutput.trim().length > 0) {
81
84
  return {
82
85
  repo: repoName,
@@ -86,17 +89,17 @@ const switchBranch = async (repoPath, repoName, targetBranch) => {
86
89
  };
87
90
  }
88
91
  // Fetch to ensure we have latest branches
89
- await execAsync('git fetch', { cwd: repoPath, timeout: GIT_TIMEOUT });
92
+ await git(['fetch'], { cwd: repoPath, timeout: GIT_TIMEOUT });
90
93
  // Check if branch exists locally
91
94
  try {
92
- await execAsync(`git rev-parse --verify ${targetBranch}`, { cwd: repoPath });
95
+ await git(['rev-parse', '--verify', targetBranch], { cwd: repoPath });
93
96
  }
94
97
  catch {
95
98
  // Branch doesn't exist locally, check remote
96
99
  try {
97
- await execAsync(`git rev-parse --verify origin/${targetBranch}`, { cwd: repoPath });
100
+ await git(['rev-parse', '--verify', `origin/${targetBranch}`], { cwd: repoPath });
98
101
  // Branch exists on remote, create local tracking branch
99
- await execAsync(`git checkout -b ${targetBranch} origin/${targetBranch}`, { cwd: repoPath });
102
+ await git(['checkout', '-b', targetBranch, `origin/${targetBranch}`], { cwd: repoPath });
100
103
  return {
101
104
  repo: repoName,
102
105
  status: 'success',
@@ -114,7 +117,7 @@ const switchBranch = async (repoPath, repoName, targetBranch) => {
114
117
  }
115
118
  }
116
119
  // Switch to existing local branch
117
- await execAsync(`git checkout ${targetBranch}`, { cwd: repoPath });
120
+ await git(['checkout', targetBranch], { cwd: repoPath });
118
121
  return {
119
122
  repo: repoName,
120
123
  status: 'success',
@@ -147,16 +150,10 @@ const createBranch = async (repoPath, repoName, branchName, baseBranch, force) =
147
150
  }
148
151
  // Checkout base branch if specified
149
152
  if (baseBranch) {
150
- await execAsync(`git checkout ${baseBranch}`, {
151
- cwd: repoPath,
152
- timeout: GIT_TIMEOUT,
153
- });
153
+ await git(['checkout', baseBranch], { cwd: repoPath, timeout: GIT_TIMEOUT });
154
154
  }
155
155
  // Create and checkout new branch
156
- await execAsync(`git checkout -b ${branchName}`, {
157
- cwd: repoPath,
158
- timeout: GIT_TIMEOUT,
159
- });
156
+ await git(['checkout', '-b', branchName], { cwd: repoPath, timeout: GIT_TIMEOUT });
160
157
  return {
161
158
  repo: repoName,
162
159
  success: true,
@@ -176,22 +173,16 @@ exports.createBranch = createBranch;
176
173
  const mergeBranch = async (repoPath, repoName, sourceBranch, targetBranch, options) => {
177
174
  try {
178
175
  // Checkout target branch
179
- await execAsync(`git checkout ${targetBranch}`, {
180
- cwd: repoPath,
181
- timeout: GIT_TIMEOUT,
182
- });
183
- // Build merge command
184
- let mergeCmd = `git merge ${sourceBranch}`;
176
+ await git(['checkout', targetBranch], { cwd: repoPath, timeout: GIT_TIMEOUT });
177
+ // Build merge argv
178
+ const mergeArgs = ['merge', sourceBranch];
185
179
  if (options?.noFf)
186
- mergeCmd += ' --no-ff';
180
+ mergeArgs.push('--no-ff');
187
181
  if (options?.ffOnly)
188
- mergeCmd += ' --ff-only';
182
+ mergeArgs.push('--ff-only');
189
183
  if (options?.squash)
190
- mergeCmd += ' --squash';
191
- await execAsync(mergeCmd, {
192
- cwd: repoPath,
193
- timeout: GIT_TIMEOUT,
194
- });
184
+ mergeArgs.push('--squash');
185
+ await git(mergeArgs, { cwd: repoPath, timeout: GIT_TIMEOUT });
195
186
  return {
196
187
  repo: repoName,
197
188
  success: true,
@@ -210,10 +201,7 @@ const mergeBranch = async (repoPath, repoName, sourceBranch, targetBranch, optio
210
201
  exports.mergeBranch = mergeBranch;
211
202
  const rebaseBranch = async (repoPath, repoName, targetBranch) => {
212
203
  try {
213
- await execAsync(`git rebase ${targetBranch}`, {
214
- cwd: repoPath,
215
- timeout: GIT_TIMEOUT,
216
- });
204
+ await git(['rebase', targetBranch], { cwd: repoPath, timeout: GIT_TIMEOUT });
217
205
  return {
218
206
  repo: repoName,
219
207
  success: true,
@@ -38,10 +38,10 @@ const child_process_1 = require("child_process");
38
38
  const util_1 = require("util");
39
39
  const fs = __importStar(require("fs/promises"));
40
40
  const path = __importStar(require("path"));
41
- const execAsync = (0, util_1.promisify)(child_process_1.exec);
41
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
42
42
  const calculateDirSize = async (dirPath) => {
43
43
  try {
44
- const { stdout } = await execAsync(`du -sk "${dirPath}"`);
44
+ const { stdout } = await execFileAsync('du', ['-sk', dirPath]);
45
45
  const sizeInKB = parseInt(stdout.split('\t')[0], 10);
46
46
  return sizeInKB;
47
47
  }
@@ -98,9 +98,9 @@ const removeBuildArtifacts = async (repoPath) => {
98
98
  exports.removeBuildArtifacts = removeBuildArtifacts;
99
99
  const gitClean = async (repoPath, dryRun = false) => {
100
100
  try {
101
- const cmd = dryRun ? 'git clean -fdxn' : 'git clean -fdx';
102
- const { stdout } = await execAsync(cmd, { cwd: repoPath });
103
- const lines = stdout.split('\n').filter(l => l.trim());
101
+ const args = dryRun ? ['clean', '-fdxn'] : ['clean', '-fdx'];
102
+ const { stdout } = await execFileAsync('git', args, { cwd: repoPath });
103
+ const lines = stdout.split('\n').filter((l) => l.trim());
104
104
  return {
105
105
  operation: 'Git clean',
106
106
  filesRemoved: lines.length,
@@ -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);
@@ -2,6 +2,10 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.logStep = exports.logWarning = exports.logError = exports.logSuccess = exports.logInfo = void 0;
4
4
  const colors_1 = require("./colors");
5
+ // Diagnostics (info/success/error/warn/step) go to STDERR so stdout carries only
6
+ // a command's actual data — required for clean `--json` piping (nemus list
7
+ // --json | jq …) and for non-TTY consumers. Use `outputJson`/stdout for data.
8
+ const logStream = (line) => console.error(line);
5
9
  const getTimestamp = () => {
6
10
  const now = new Date();
7
11
  return now.toLocaleTimeString('en-US', {
@@ -12,29 +16,29 @@ const getTimestamp = () => {
12
16
  });
13
17
  };
14
18
  const logInfo = (message) => {
15
- console.log(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${message}`);
19
+ logStream(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${message}`);
16
20
  };
17
21
  exports.logInfo = logInfo;
18
22
  const logSuccess = (message) => {
19
- console.log(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('✓', 'green')} ${message}`);
23
+ logStream(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('✓', 'green')} ${message}`);
20
24
  };
21
25
  exports.logSuccess = logSuccess;
22
26
  const logError = (message) => {
23
- console.log(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('✗', 'red')} ${message}`);
27
+ logStream(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('✗', 'red')} ${message}`);
24
28
  };
25
29
  exports.logError = logError;
26
30
  const logWarning = (message) => {
27
- console.log(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('⚠', 'yellow')} ${message}`);
31
+ logStream(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('⚠', 'yellow')} ${message}`);
28
32
  };
29
33
  exports.logWarning = logWarning;
30
34
  const logStep = (stepOrMessage, total, message) => {
31
35
  if (typeof stepOrMessage === 'string') {
32
36
  // Single parameter version: just a message
33
- console.log(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('▸', 'cyan')} ${stepOrMessage}`);
37
+ logStream(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('▸', 'cyan')} ${stepOrMessage}`);
34
38
  }
35
39
  else {
36
40
  // Three parameter version: step, total, message
37
- console.log(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)(`[${stepOrMessage}/${total}]`, 'cyan')} ${message}`);
41
+ logStream(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)(`[${stepOrMessage}/${total}]`, 'cyan')} ${message}`);
38
42
  }
39
43
  };
40
44
  exports.logStep = logStep;
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ /**
3
+ * Machine-readable output helpers. The one rule: a command's DATA goes to
4
+ * stdout, diagnostics go to stderr (see logger.ts). In `--json` mode a command
5
+ * writes exactly one JSON document to stdout and nothing else, so it pipes
6
+ * cleanly into `jq` and is safe for scripts/CI.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.outputJson = outputJson;
10
+ exports.outputJsonError = outputJsonError;
11
+ /** Write one pretty-printed JSON document to stdout (data channel). */
12
+ function outputJson(data) {
13
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n');
14
+ }
15
+ /**
16
+ * Emit a structured error as JSON to stdout for `--json` callers, so a script
17
+ * parsing stdout always gets a parseable object (`{ ok: false, error }`) rather
18
+ * than empty stdout + a human log line on stderr. The caller still signals
19
+ * failure with a non-zero exit code.
20
+ */
21
+ function outputJsonError(message) {
22
+ outputJson({ ok: false, error: message });
23
+ }
package/package.json CHANGED
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "name": "@nemus-cli/nemus",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
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",
@@ -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}`);