@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
|
@@ -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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
84
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
262
|
-
await
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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)
|
package/src/utils/logger.ts
CHANGED
|
@@ -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
|
-
|
|
19
|
+
logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${message}`);
|
|
15
20
|
};
|
|
16
21
|
|
|
17
22
|
export const logSuccess = (message: string): void => {
|
|
18
|
-
|
|
23
|
+
logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('✓', 'green')} ${message}`);
|
|
19
24
|
};
|
|
20
25
|
|
|
21
26
|
export const logError = (message: string): void => {
|
|
22
|
-
|
|
27
|
+
logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('✗', 'red')} ${message}`);
|
|
23
28
|
};
|
|
24
29
|
|
|
25
30
|
export const logWarning = (message: string): void => {
|
|
26
|
-
|
|
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
|
-
|
|
37
|
+
logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('▸', 'cyan')} ${stepOrMessage}`);
|
|
33
38
|
} else {
|
|
34
39
|
// Three parameter version: step, total, message
|
|
35
|
-
|
|
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
|
+
}
|