@nemus-cli/nemus 0.2.10 → 0.2.12
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 +43 -0
- package/README.md +20 -0
- package/dist/commands/analyze-deps.js +42 -8
- package/dist/commands/doctor.js +35 -7
- package/dist/commands/list.js +24 -1
- package/dist/commands/sessions.js +33 -6
- package/dist/commands/status.js +37 -8
- package/dist/commands/suite/index.js +3 -2
- package/dist/commands/suite/list.js +26 -7
- package/dist/utils/logger.js +10 -6
- package/dist/utils/output.js +23 -0
- package/package.json +1 -1
- package/src/commands/analyze-deps.ts +38 -8
- 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/sessions.ts +32 -5
- package/src/commands/status.ts +35 -8
- package/src/commands/suite/index.ts +3 -2
- package/src/commands/suite/list.test.ts +62 -0
- package/src/commands/suite/list.ts +26 -8
- package/src/utils/logger.ts +11 -6
- package/src/utils/output.test.ts +36 -0
- package/src/utils/output.ts +21 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.12] - 2026-08-27
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`--json` for more read-only reporting commands**, extending 0.2.11's set
|
|
15
|
+
(`list`/`status`/`doctor`) to `suite list`, `sessions`, and `analyze-deps`.
|
|
16
|
+
Each emits exactly one JSON document to stdout (no tables, no interactive
|
|
17
|
+
prompt): `suite list --json` (saved suites + entries), `sessions --json`
|
|
18
|
+
(workspace sessions, list-only — no resume), `analyze-deps --json` (per-repo
|
|
19
|
+
dependencies/dependents/missing + circular deps + suggested missing repos;
|
|
20
|
+
requires an explicit workspace). `--json` errors are parseable
|
|
21
|
+
`{ ok:false, error }` on stdout + exit 1, consistent with 0.2.11.
|
|
22
|
+
|
|
23
|
+
## [0.2.11] - 2026-08-27
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **`--json` output** for the read-only reporting commands `list`, `status`, and
|
|
28
|
+
`doctor`. Emits exactly one JSON document to stdout (no table, no interactive
|
|
29
|
+
prompt), so `nemus list --json | jq …` and CI/scripts get stable,
|
|
30
|
+
machine-readable output. `status --json` / `doctor --json` require an explicit
|
|
31
|
+
workspace name (they never prompt).
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- **stdout/stderr hygiene.** Diagnostic logs (info/success/error/warning/step)
|
|
36
|
+
now go to **stderr**, leaving **stdout** for a command's actual data. This is
|
|
37
|
+
what makes `--json` pipe cleanly and lets non-TTY consumers separate data from
|
|
38
|
+
progress. Human-facing table/plain output is unchanged on stdout.
|
|
39
|
+
|
|
10
40
|
## [0.2.10] - 2026-08-27
|
|
11
41
|
|
|
12
42
|
### Security
|
|
@@ -59,6 +89,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
59
89
|
(P4) a vendor-neutral **notification seam** — `Notifier` with Slack (incoming
|
|
60
90
|
webhook) + generic webhook sinks (`notifierFromEnv`), wired into the CI-loop as
|
|
61
91
|
optional out-of-band report-back.
|
|
92
|
+
**Code-host breadth:** the `GitForge` seam gained a dependency-free **GitLab**
|
|
93
|
+
implementation (`GitLabForge` — merge requests, commit statuses, MR notes;
|
|
94
|
+
self-managed via `GITLAB_API_URL`) alongside GitHub, plus a **forge registry**
|
|
95
|
+
(`createForge`/`registerForge`/`registeredForges` + `NEMUS_FORGE_HOST`) so a
|
|
96
|
+
run targets GitHub or GitLab — or a custom "bring your own backend" host
|
|
97
|
+
(Gitea, Bitbucket, …) — with no code change. Docs in `packages/cloud/README.md`.
|
|
98
|
+
**CI-loop + notifier wired into the agent image:** a second container entry
|
|
99
|
+
mode `NEMUS_MODE=fix-pr` (`runFixPr`/`parseFixPrEnv`, CLI `nemus-cloud fix-pr
|
|
100
|
+
--repo --pr --branch`) drives an *existing* PR to green with the bounded
|
|
101
|
+
CI-loop (P3) + optional Slack/webhook notifications (P4) — clone, checkout PR
|
|
102
|
+
head, `runCiLoop`, then write the same versioned `result.json` with
|
|
103
|
+
`mode: 'fix-pr'` + a compact `ci` summary. So P3+P4 are usable end-to-end in a
|
|
104
|
+
container, on GitHub or GitLab.
|
|
62
105
|
Design: `docs/plans/2026-08-26-cloud-iac.md`.
|
|
63
106
|
|
|
64
107
|
## [0.2.9] - 2026-08-26
|
package/README.md
CHANGED
|
@@ -223,6 +223,26 @@ web feature/x ⚠ 2 modified ↑1 2 files
|
|
|
223
223
|
shared-lib main ✓ Clean ↓3 -
|
|
224
224
|
```
|
|
225
225
|
|
|
226
|
+
### Scripting: `--json`
|
|
227
|
+
|
|
228
|
+
The read-only reporting commands accept `--json` for stable, machine-readable
|
|
229
|
+
output: `list`, `status`, `doctor`, `suite list`, `sessions`, and
|
|
230
|
+
`analyze-deps`. Diagnostics go to stderr, so stdout is a single JSON document
|
|
231
|
+
you can pipe straight into `jq` or a CI step:
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
nemus list --json | jq -r '.workspaces[].name'
|
|
235
|
+
nemus status my-workspace --json | jq '.clean'
|
|
236
|
+
nemus doctor my-workspace --json | jq '.score'
|
|
237
|
+
nemus suite list --json | jq -r '.suites[].name'
|
|
238
|
+
nemus sessions --json | jq -r '.sessions[].workspaceName'
|
|
239
|
+
nemus analyze-deps my-workspace --json | jq '.circularDependencies'
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The workspace-scoped ones (`status`/`doctor`/`analyze-deps`) need an explicit
|
|
243
|
+
workspace name with `--json` (they never prompt). On failure, `--json` prints a
|
|
244
|
+
parseable `{ "ok": false, "error": … }` to stdout and exits non-zero.
|
|
245
|
+
|
|
226
246
|
### Suites (reusable repo collections)
|
|
227
247
|
|
|
228
248
|
```bash
|
|
@@ -42,6 +42,7 @@ const config_1 = require("../utils/config");
|
|
|
42
42
|
const workspace_meta_1 = require("../utils/workspace-meta");
|
|
43
43
|
const dependency_analyzer_1 = require("../utils/dependency-analyzer");
|
|
44
44
|
const logger_1 = require("../utils/logger");
|
|
45
|
+
const output_1 = require("../utils/output");
|
|
45
46
|
const colors_1 = require("../utils/colors");
|
|
46
47
|
const inquirer_1 = __importDefault(require("inquirer"));
|
|
47
48
|
const command_helpers_1 = require("../utils/command-helpers");
|
|
@@ -71,22 +72,51 @@ function registerAnalyzeDepsCommand(parent) {
|
|
|
71
72
|
.alias('ad')
|
|
72
73
|
.description('Analyze inter-repo dependencies')
|
|
73
74
|
.argument('[workspace]', 'Workspace name')
|
|
74
|
-
.
|
|
75
|
-
|
|
75
|
+
.option('--json', 'Output as JSON (no interactive save)')
|
|
76
|
+
.action(async (workspace, opts) => {
|
|
77
|
+
await handleAnalyzeDeps(workspace, opts);
|
|
76
78
|
});
|
|
77
79
|
}
|
|
78
|
-
async function handleAnalyzeDeps(workspaceArg) {
|
|
80
|
+
async function handleAnalyzeDeps(workspaceArg, opts = {}) {
|
|
79
81
|
try {
|
|
82
|
+
// JSON mode is non-interactive: require an explicit workspace rather than prompt.
|
|
83
|
+
if (opts.json && !workspaceArg) {
|
|
84
|
+
(0, output_1.outputJsonError)('analyze-deps --json requires a workspace name');
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
80
87
|
const selectedWorkspace = await (0, command_helpers_1.resolveWorkspace)(workspaceArg);
|
|
81
88
|
const workspacePath = path.join(config_1.WORKSPACES_DIR, selectedWorkspace);
|
|
82
89
|
const metadata = await (0, workspace_meta_1.loadMetadata)(workspacePath);
|
|
83
90
|
if (!metadata) {
|
|
84
|
-
(
|
|
91
|
+
if (opts.json)
|
|
92
|
+
(0, output_1.outputJsonError)(`Workspace metadata not found for: ${selectedWorkspace}`);
|
|
93
|
+
else
|
|
94
|
+
(0, logger_1.logError)(`Workspace metadata not found for: ${selectedWorkspace}`);
|
|
85
95
|
process.exit(1);
|
|
86
96
|
}
|
|
97
|
+
const repoNames = metadata.repositories.map(r => r.name);
|
|
98
|
+
if (opts.json) {
|
|
99
|
+
const analyses = await (0, dependency_analyzer_1.analyzeDependencies)(workspacePath, repoNames);
|
|
100
|
+
const cycles = (0, dependency_analyzer_1.detectCircularDependencies)(analyses);
|
|
101
|
+
const missing = new Set();
|
|
102
|
+
for (const [, a] of analyses)
|
|
103
|
+
for (const dep of a.missingDependencies)
|
|
104
|
+
missing.add(dep);
|
|
105
|
+
(0, output_1.outputJson)({
|
|
106
|
+
workspace: selectedWorkspace,
|
|
107
|
+
repositories: Array.from(analyses.entries()).map(([name, a]) => ({
|
|
108
|
+
name,
|
|
109
|
+
dependencies: a.dependencies,
|
|
110
|
+
dependents: a.dependents,
|
|
111
|
+
missingDependencies: a.missingDependencies,
|
|
112
|
+
})),
|
|
113
|
+
circularDependencies: cycles,
|
|
114
|
+
missingRepositories: Array.from(missing),
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
87
118
|
(0, logger_1.logStep)(`Analyzing dependencies for workspace: ${(0, colors_1.colorize)(selectedWorkspace, 'cyan')}`);
|
|
88
119
|
(0, logger_1.logInfo)('Scanning package.json, Dockerfile, and docker-compose.yml files...');
|
|
89
|
-
const repoNames = metadata.repositories.map(r => r.name);
|
|
90
120
|
const analyses = await (0, dependency_analyzer_1.analyzeDependencies)(workspacePath, repoNames);
|
|
91
121
|
displayDependencyAnalysis(analyses);
|
|
92
122
|
const cycles = (0, dependency_analyzer_1.detectCircularDependencies)(analyses);
|
|
@@ -129,9 +159,13 @@ async function handleAnalyzeDeps(workspaceArg) {
|
|
|
129
159
|
}
|
|
130
160
|
}
|
|
131
161
|
catch (error) {
|
|
132
|
-
(
|
|
133
|
-
|
|
134
|
-
|
|
162
|
+
if (opts.json) {
|
|
163
|
+
(0, output_1.outputJsonError)(error instanceof Error ? error.message : 'Failed to analyze dependencies');
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
(0, logger_1.logError)('Failed to analyze dependencies');
|
|
167
|
+
if (error instanceof Error)
|
|
168
|
+
(0, logger_1.logError)(error.message);
|
|
135
169
|
}
|
|
136
170
|
process.exit(1);
|
|
137
171
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -39,6 +39,7 @@ const config_1 = require("../utils/config");
|
|
|
39
39
|
const workspace_meta_1 = require("../utils/workspace-meta");
|
|
40
40
|
const health_checks_1 = require("../utils/health-checks");
|
|
41
41
|
const logger_1 = require("../utils/logger");
|
|
42
|
+
const output_1 = require("../utils/output");
|
|
42
43
|
const colors_1 = require("../utils/colors");
|
|
43
44
|
const command_helpers_1 = require("../utils/command-helpers");
|
|
44
45
|
const displayHealthCheck = (result) => {
|
|
@@ -96,19 +97,42 @@ function registerDoctorCommand(parent) {
|
|
|
96
97
|
.alias('doc')
|
|
97
98
|
.description('Run comprehensive health checks')
|
|
98
99
|
.argument('[workspace]', 'Workspace name')
|
|
99
|
-
.
|
|
100
|
-
|
|
100
|
+
.option('--json', 'Output as JSON')
|
|
101
|
+
.action(async (workspace, opts) => {
|
|
102
|
+
await handleDoctor(workspace, opts);
|
|
101
103
|
});
|
|
102
104
|
}
|
|
103
|
-
async function handleDoctor(workspaceArg) {
|
|
105
|
+
async function handleDoctor(workspaceArg, opts = {}) {
|
|
106
|
+
// In --json mode, failures are parseable JSON on stdout + exit 1; otherwise a
|
|
107
|
+
// human log on stderr. `process.exit(1)` stays the last statement so TS still
|
|
108
|
+
// narrows `metadata` to non-null below.
|
|
104
109
|
try {
|
|
110
|
+
// JSON mode is non-interactive: require an explicit workspace rather than prompt.
|
|
111
|
+
if (opts.json && !workspaceArg) {
|
|
112
|
+
(0, output_1.outputJsonError)('doctor --json requires a workspace name');
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
105
115
|
const selectedWorkspace = await (0, command_helpers_1.resolveWorkspace)(workspaceArg);
|
|
106
116
|
const workspacePath = path.join(config_1.WORKSPACES_DIR, selectedWorkspace);
|
|
107
117
|
const metadata = await (0, workspace_meta_1.loadMetadata)(workspacePath);
|
|
108
118
|
if (!metadata) {
|
|
109
|
-
(
|
|
119
|
+
if (opts.json)
|
|
120
|
+
(0, output_1.outputJsonError)(`Workspace metadata not found for: ${selectedWorkspace}`);
|
|
121
|
+
else
|
|
122
|
+
(0, logger_1.logError)(`Workspace metadata not found for: ${selectedWorkspace}`);
|
|
110
123
|
process.exit(1);
|
|
111
124
|
}
|
|
125
|
+
if (opts.json) {
|
|
126
|
+
const results = await (0, health_checks_1.runAllHealthChecks)(workspacePath, metadata);
|
|
127
|
+
const score = (0, health_checks_1.calculateHealthScore)(results);
|
|
128
|
+
const overall = results.some(r => r.status === 'error')
|
|
129
|
+
? 'error'
|
|
130
|
+
: results.some(r => r.status === 'warning')
|
|
131
|
+
? 'warning'
|
|
132
|
+
: 'healthy';
|
|
133
|
+
(0, output_1.outputJson)({ workspace: selectedWorkspace, score, status: overall, checks: results });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
112
136
|
(0, logger_1.logStep)(`Running health checks for workspace: ${(0, colors_1.colorize)(selectedWorkspace, 'cyan')}`);
|
|
113
137
|
(0, logger_1.logInfo)('This may take a moment...');
|
|
114
138
|
const results = await (0, health_checks_1.runAllHealthChecks)(workspacePath, metadata);
|
|
@@ -132,9 +156,13 @@ async function handleDoctor(workspaceArg) {
|
|
|
132
156
|
}
|
|
133
157
|
}
|
|
134
158
|
catch (error) {
|
|
135
|
-
(
|
|
136
|
-
|
|
137
|
-
|
|
159
|
+
if (opts.json) {
|
|
160
|
+
(0, output_1.outputJsonError)(error instanceof Error ? error.message : 'Failed to run health checks');
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
(0, logger_1.logError)('Failed to run health checks');
|
|
164
|
+
if (error instanceof Error)
|
|
165
|
+
(0, logger_1.logError)(error.message);
|
|
138
166
|
}
|
|
139
167
|
process.exit(1);
|
|
140
168
|
}
|
package/dist/commands/list.js
CHANGED
|
@@ -44,6 +44,7 @@ const fsPromises = __importStar(require("fs/promises"));
|
|
|
44
44
|
const workspace_meta_1 = require("../utils/workspace-meta");
|
|
45
45
|
const claude_sessions_1 = require("../utils/claude-sessions");
|
|
46
46
|
const logger_1 = require("../utils/logger");
|
|
47
|
+
const output_1 = require("../utils/output");
|
|
47
48
|
const colors_1 = require("../utils/colors");
|
|
48
49
|
const inquirer_1 = __importDefault(require("inquirer"));
|
|
49
50
|
const inquirer_autocomplete_prompt_1 = __importDefault(require("inquirer-autocomplete-prompt"));
|
|
@@ -57,6 +58,7 @@ function registerListCommand(parent) {
|
|
|
57
58
|
.alias('l')
|
|
58
59
|
.description('List workspaces and navigate to one')
|
|
59
60
|
.option('-a, --archived', 'Show archived workspaces')
|
|
61
|
+
.option('--json', 'Output as JSON (no interactive selection)')
|
|
60
62
|
.action(async (opts) => {
|
|
61
63
|
await handleList(opts);
|
|
62
64
|
});
|
|
@@ -72,6 +74,10 @@ async function handleList(opts) {
|
|
|
72
74
|
(0, claude_sessions_1.getWorkspaceSessions)(),
|
|
73
75
|
]);
|
|
74
76
|
if (workspaces.length === 0) {
|
|
77
|
+
if (opts.json) {
|
|
78
|
+
(0, output_1.outputJson)({ archived: showArchived, count: 0, workspaces: [] });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
75
81
|
console.log('\n' + '='.repeat(60));
|
|
76
82
|
console.log((0, colors_1.colorize)(title, 'bright'));
|
|
77
83
|
console.log('='.repeat(60) + '\n');
|
|
@@ -108,6 +114,22 @@ async function handleList(opts) {
|
|
|
108
114
|
return 1;
|
|
109
115
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
|
110
116
|
});
|
|
117
|
+
// JSON mode: one document to stdout, no table, no interactive selection.
|
|
118
|
+
if (opts.json) {
|
|
119
|
+
(0, output_1.outputJson)({
|
|
120
|
+
archived: showArchived,
|
|
121
|
+
count: items.length,
|
|
122
|
+
workspaces: items.map(i => ({
|
|
123
|
+
name: i.name,
|
|
124
|
+
path: i.wsPath,
|
|
125
|
+
repoCount: i.repoCount,
|
|
126
|
+
createdAt: i.createdAt || null,
|
|
127
|
+
lastActive: i.lastActiveLabel,
|
|
128
|
+
hasSession: i.hasSession,
|
|
129
|
+
})),
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
111
133
|
console.log('');
|
|
112
134
|
console.log((0, colors_1.colorize)(' ' + title, 'bright') + (0, colors_1.colorize)(' (sorted by last active)', 'dim'));
|
|
113
135
|
console.log((0, colors_1.colorize)(' ' + '─'.repeat(54), 'dim'));
|
|
@@ -187,5 +209,6 @@ async function handleList(opts) {
|
|
|
187
209
|
async function main() {
|
|
188
210
|
const args = process.argv.slice(2);
|
|
189
211
|
const archived = args.includes('--archived') || args.includes('-a');
|
|
190
|
-
|
|
212
|
+
const json = args.includes('--json');
|
|
213
|
+
await handleList({ archived, json });
|
|
191
214
|
}
|
|
@@ -43,6 +43,7 @@ const fs = __importStar(require("fs/promises"));
|
|
|
43
43
|
const claude_sessions_1 = require("../utils/claude-sessions");
|
|
44
44
|
const workspace_meta_1 = require("../utils/workspace-meta");
|
|
45
45
|
const logger_1 = require("../utils/logger");
|
|
46
|
+
const output_1 = require("../utils/output");
|
|
46
47
|
const colors_1 = require("../utils/colors");
|
|
47
48
|
const inquirer_1 = __importDefault(require("inquirer"));
|
|
48
49
|
const inquirer_autocomplete_prompt_1 = __importDefault(require("inquirer-autocomplete-prompt"));
|
|
@@ -55,14 +56,19 @@ function registerSessionsCommand(parent) {
|
|
|
55
56
|
.command('sessions')
|
|
56
57
|
.alias('ses')
|
|
57
58
|
.description('Resume a Claude session in a workspace')
|
|
58
|
-
.
|
|
59
|
-
|
|
59
|
+
.option('--json', 'List sessions as JSON (no interactive resume)')
|
|
60
|
+
.action(async (opts) => {
|
|
61
|
+
await handleSessions(opts);
|
|
60
62
|
});
|
|
61
63
|
}
|
|
62
|
-
async function handleSessions() {
|
|
64
|
+
async function handleSessions(opts = {}) {
|
|
63
65
|
try {
|
|
64
66
|
const sessions = await (0, claude_sessions_1.getWorkspaceSessions)();
|
|
65
67
|
if (sessions.length === 0) {
|
|
68
|
+
if (opts.json) {
|
|
69
|
+
(0, output_1.outputJson)({ count: 0, sessions: [] });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
66
72
|
(0, logger_1.logInfo)('No workspace sessions found.');
|
|
67
73
|
console.log('\nYou can create a workspace with: nemus create');
|
|
68
74
|
console.log('Or navigate to one with: w go');
|
|
@@ -76,6 +82,22 @@ async function handleSessions() {
|
|
|
76
82
|
const repoLabel = repoCount > 0 ? `${repoCount} repos` : 'no repos';
|
|
77
83
|
return { session, repoLabel };
|
|
78
84
|
});
|
|
85
|
+
// JSON mode: list sessions to stdout, no resume prompt / temp-file writes.
|
|
86
|
+
if (opts.json) {
|
|
87
|
+
(0, output_1.outputJson)({
|
|
88
|
+
count: items.length,
|
|
89
|
+
sessions: items.map(i => ({
|
|
90
|
+
workspaceName: i.session.workspaceName,
|
|
91
|
+
workspacePath: i.session.workspacePath,
|
|
92
|
+
sessionId: i.session.sessionId,
|
|
93
|
+
agentType: i.session.agentType ?? null,
|
|
94
|
+
lastActive: i.session.lastActiveLabel,
|
|
95
|
+
lastActiveAt: i.session.lastActiveAt.toISOString(),
|
|
96
|
+
repoCount: workspaceMap.get(i.session.workspaceName)?.metadata?.repositories?.length ?? 0,
|
|
97
|
+
})),
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
79
101
|
const maxNameLen = Math.max(...items.map(i => i.session.workspaceName.length));
|
|
80
102
|
console.log('');
|
|
81
103
|
console.log((0, colors_1.colorize)(' Workspace Sessions', 'bright') + (0, colors_1.colorize)(' (sorted by last active)', 'dim'));
|
|
@@ -120,9 +142,14 @@ async function handleSessions() {
|
|
|
120
142
|
catch (error) {
|
|
121
143
|
if (error?.name === 'ExitPromptError')
|
|
122
144
|
return;
|
|
123
|
-
(
|
|
124
|
-
|
|
125
|
-
|
|
145
|
+
if (opts.json) {
|
|
146
|
+
(0, output_1.outputJsonError)(error instanceof Error ? error.message : 'Failed to list sessions');
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
(0, logger_1.logError)('Failed to list sessions');
|
|
150
|
+
if (error instanceof Error) {
|
|
151
|
+
(0, logger_1.logError)(error.message);
|
|
152
|
+
}
|
|
126
153
|
}
|
|
127
154
|
process.exit(1);
|
|
128
155
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -39,6 +39,7 @@ const config_1 = require("../utils/config");
|
|
|
39
39
|
const workspace_meta_1 = require("../utils/workspace-meta");
|
|
40
40
|
const git_status_1 = require("../utils/git-status");
|
|
41
41
|
const logger_1 = require("../utils/logger");
|
|
42
|
+
const output_1 = require("../utils/output");
|
|
42
43
|
const colors_1 = require("../utils/colors");
|
|
43
44
|
const command_helpers_1 = require("../utils/command-helpers");
|
|
44
45
|
const displayStatusTable = (statuses) => {
|
|
@@ -109,29 +110,57 @@ function registerStatusCommand(parent) {
|
|
|
109
110
|
.alias('st')
|
|
110
111
|
.description('Show git status across all repos')
|
|
111
112
|
.argument('[workspace]', 'Workspace name')
|
|
112
|
-
.
|
|
113
|
-
|
|
113
|
+
.option('--json', 'Output as JSON')
|
|
114
|
+
.action(async (workspace, opts) => {
|
|
115
|
+
await handleStatus(workspace, opts);
|
|
114
116
|
});
|
|
115
117
|
}
|
|
116
|
-
async function handleStatus(workspaceArg) {
|
|
118
|
+
async function handleStatus(workspaceArg, opts = {}) {
|
|
119
|
+
// In --json mode, failures are parseable JSON on stdout + exit 1; otherwise a
|
|
120
|
+
// human log on stderr. `process.exit(1)` stays the last statement so TS still
|
|
121
|
+
// narrows `metadata` to non-null below.
|
|
117
122
|
try {
|
|
123
|
+
// JSON mode is non-interactive: require an explicit workspace rather than prompt.
|
|
124
|
+
if (opts.json && !workspaceArg) {
|
|
125
|
+
(0, output_1.outputJsonError)('status --json requires a workspace name');
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
118
128
|
const selectedWorkspace = await (0, command_helpers_1.resolveWorkspace)(workspaceArg);
|
|
119
129
|
const workspacePath = path.join(config_1.WORKSPACES_DIR, selectedWorkspace);
|
|
120
130
|
const metadata = await (0, workspace_meta_1.loadMetadata)(workspacePath);
|
|
121
131
|
if (!metadata) {
|
|
122
|
-
(
|
|
132
|
+
if (opts.json)
|
|
133
|
+
(0, output_1.outputJsonError)(`Workspace metadata not found for: ${selectedWorkspace}`);
|
|
134
|
+
else
|
|
135
|
+
(0, logger_1.logError)(`Workspace metadata not found for: ${selectedWorkspace}`);
|
|
123
136
|
process.exit(1);
|
|
124
137
|
}
|
|
138
|
+
const repoDirectoryNames = metadata.repositories.map(r => r.directoryName);
|
|
139
|
+
if (opts.json) {
|
|
140
|
+
const statuses = await (0, git_status_1.getAllReposStatus)(workspacePath, repoDirectoryNames, 3);
|
|
141
|
+
(0, output_1.outputJson)({
|
|
142
|
+
workspace: selectedWorkspace,
|
|
143
|
+
path: workspacePath,
|
|
144
|
+
repoCount: statuses.length,
|
|
145
|
+
clean: statuses.every(s => s.clean),
|
|
146
|
+
repositories: statuses,
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
125
150
|
(0, logger_1.logStep)(`Checking status for workspace: ${(0, colors_1.colorize)(selectedWorkspace, 'cyan')}`);
|
|
126
151
|
(0, logger_1.logInfo)(`Found ${metadata.repositories.length} repositories`);
|
|
127
|
-
const repoDirectoryNames = metadata.repositories.map(r => r.directoryName);
|
|
128
152
|
const statuses = await (0, git_status_1.getAllReposStatus)(workspacePath, repoDirectoryNames, 3);
|
|
129
153
|
displayStatusTable(statuses);
|
|
130
154
|
}
|
|
131
155
|
catch (error) {
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
(0,
|
|
156
|
+
const msg = error instanceof Error ? error.message : 'Failed to check workspace status';
|
|
157
|
+
if (opts.json) {
|
|
158
|
+
(0, output_1.outputJsonError)(msg);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
(0, logger_1.logError)('Failed to check workspace status');
|
|
162
|
+
if (error instanceof Error)
|
|
163
|
+
(0, logger_1.logError)(error.message);
|
|
135
164
|
}
|
|
136
165
|
process.exit(1);
|
|
137
166
|
}
|
|
@@ -49,9 +49,10 @@ function registerSuiteCommands(parent) {
|
|
|
49
49
|
suite
|
|
50
50
|
.command('list')
|
|
51
51
|
.description('List all saved suites')
|
|
52
|
-
.
|
|
52
|
+
.option('--json', 'Output as JSON')
|
|
53
|
+
.action(async (opts) => {
|
|
53
54
|
const { main } = await Promise.resolve().then(() => __importStar(require('./list')));
|
|
54
|
-
await main();
|
|
55
|
+
await main(opts);
|
|
55
56
|
});
|
|
56
57
|
suite
|
|
57
58
|
.command('delete')
|
|
@@ -4,13 +4,28 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
4
4
|
exports.main = main;
|
|
5
5
|
const suite_1 = require("../../utils/suite");
|
|
6
6
|
const logger_1 = require("../../utils/logger");
|
|
7
|
+
const output_1 = require("../../utils/output");
|
|
7
8
|
const colors_1 = require("../../utils/colors");
|
|
8
|
-
async function main() {
|
|
9
|
-
console.log('\n' + '='.repeat(60));
|
|
10
|
-
console.log((0, colors_1.colorize)('Saved Suites', 'bright'));
|
|
11
|
-
console.log('='.repeat(60) + '\n');
|
|
9
|
+
async function main(opts = {}) {
|
|
12
10
|
try {
|
|
13
11
|
const suites = await (0, suite_1.listSuites)();
|
|
12
|
+
if (opts.json) {
|
|
13
|
+
(0, output_1.outputJson)({
|
|
14
|
+
count: suites.length,
|
|
15
|
+
suites: suites.map(s => ({
|
|
16
|
+
name: s.name,
|
|
17
|
+
description: s.description ?? null,
|
|
18
|
+
repoCount: s.entries.length,
|
|
19
|
+
entries: s.entries.map(e => ({ directoryName: e.directoryName, repoName: e.repoName })),
|
|
20
|
+
createdAt: s.createdAt,
|
|
21
|
+
updatedAt: s.updatedAt,
|
|
22
|
+
})),
|
|
23
|
+
});
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
console.log('\n' + '='.repeat(60));
|
|
27
|
+
console.log((0, colors_1.colorize)('Saved Suites', 'bright'));
|
|
28
|
+
console.log('='.repeat(60) + '\n');
|
|
14
29
|
if (suites.length === 0) {
|
|
15
30
|
(0, logger_1.logInfo)('No suites found');
|
|
16
31
|
console.log('\nCreate a new suite with:');
|
|
@@ -38,9 +53,13 @@ async function main() {
|
|
|
38
53
|
console.log('='.repeat(60) + '\n');
|
|
39
54
|
}
|
|
40
55
|
catch (error) {
|
|
41
|
-
(
|
|
42
|
-
|
|
43
|
-
|
|
56
|
+
if (opts.json) {
|
|
57
|
+
(0, output_1.outputJsonError)(error instanceof Error ? error.message : 'Failed to list suites');
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
(0, logger_1.logError)('Failed to list suites');
|
|
61
|
+
if (error instanceof Error)
|
|
62
|
+
(0, logger_1.logError)(error.message);
|
|
44
63
|
}
|
|
45
64
|
process.exit(1);
|
|
46
65
|
}
|
package/dist/utils/logger.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
updateWorkspaceMetadata,
|
|
10
10
|
} from '../utils/dependency-analyzer';
|
|
11
11
|
import { logError, logInfo, logStep, logSuccess } from '../utils/logger';
|
|
12
|
+
import { outputJson, outputJsonError } from '../utils/output';
|
|
12
13
|
import { colorize } from '../utils/colors';
|
|
13
14
|
import inquirer from 'inquirer';
|
|
14
15
|
import { resolveWorkspace } from '../utils/command-helpers';
|
|
@@ -42,26 +43,53 @@ export function registerAnalyzeDepsCommand(parent: Command) {
|
|
|
42
43
|
.alias('ad')
|
|
43
44
|
.description('Analyze inter-repo dependencies')
|
|
44
45
|
.argument('[workspace]', 'Workspace name')
|
|
45
|
-
.
|
|
46
|
-
|
|
46
|
+
.option('--json', 'Output as JSON (no interactive save)')
|
|
47
|
+
.action(async (workspace, opts) => {
|
|
48
|
+
await handleAnalyzeDeps(workspace, opts);
|
|
47
49
|
});
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
async function handleAnalyzeDeps(workspaceArg?: string) {
|
|
52
|
+
async function handleAnalyzeDeps(workspaceArg?: string, opts: { json?: boolean } = {}) {
|
|
51
53
|
try {
|
|
54
|
+
// JSON mode is non-interactive: require an explicit workspace rather than prompt.
|
|
55
|
+
if (opts.json && !workspaceArg) {
|
|
56
|
+
outputJsonError('analyze-deps --json requires a workspace name');
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
52
59
|
const selectedWorkspace = await resolveWorkspace(workspaceArg);
|
|
53
60
|
const workspacePath = path.join(WORKSPACES_DIR, selectedWorkspace);
|
|
54
61
|
const metadata = await loadMetadata(workspacePath);
|
|
55
62
|
|
|
56
63
|
if (!metadata) {
|
|
57
|
-
|
|
64
|
+
if (opts.json) outputJsonError(`Workspace metadata not found for: ${selectedWorkspace}`);
|
|
65
|
+
else logError(`Workspace metadata not found for: ${selectedWorkspace}`);
|
|
58
66
|
process.exit(1);
|
|
59
67
|
}
|
|
60
68
|
|
|
69
|
+
const repoNames = metadata.repositories.map(r => r.name);
|
|
70
|
+
|
|
71
|
+
if (opts.json) {
|
|
72
|
+
const analyses = await analyzeDependencies(workspacePath, repoNames);
|
|
73
|
+
const cycles = detectCircularDependencies(analyses);
|
|
74
|
+
const missing = new Set<string>();
|
|
75
|
+
for (const [, a] of analyses) for (const dep of a.missingDependencies) missing.add(dep);
|
|
76
|
+
outputJson({
|
|
77
|
+
workspace: selectedWorkspace,
|
|
78
|
+
repositories: Array.from(analyses.entries()).map(([name, a]) => ({
|
|
79
|
+
name,
|
|
80
|
+
dependencies: a.dependencies,
|
|
81
|
+
dependents: a.dependents,
|
|
82
|
+
missingDependencies: a.missingDependencies,
|
|
83
|
+
})),
|
|
84
|
+
circularDependencies: cycles,
|
|
85
|
+
missingRepositories: Array.from(missing),
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
61
90
|
logStep(`Analyzing dependencies for workspace: ${colorize(selectedWorkspace, 'cyan')}`);
|
|
62
91
|
logInfo('Scanning package.json, Dockerfile, and docker-compose.yml files...');
|
|
63
92
|
|
|
64
|
-
const repoNames = metadata.repositories.map(r => r.name);
|
|
65
93
|
const analyses = await analyzeDependencies(workspacePath, repoNames);
|
|
66
94
|
|
|
67
95
|
displayDependencyAnalysis(analyses);
|
|
@@ -110,9 +138,11 @@ async function handleAnalyzeDeps(workspaceArg?: string) {
|
|
|
110
138
|
}
|
|
111
139
|
}
|
|
112
140
|
} catch (error) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
141
|
+
if (opts.json) {
|
|
142
|
+
outputJsonError(error instanceof Error ? error.message : 'Failed to analyze dependencies');
|
|
143
|
+
} else {
|
|
144
|
+
logError('Failed to analyze dependencies');
|
|
145
|
+
if (error instanceof Error) logError(error.message);
|
|
116
146
|
}
|
|
117
147
|
process.exit(1);
|
|
118
148
|
}
|
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/sessions.ts
CHANGED
|
@@ -5,6 +5,7 @@ import * as fs from 'fs/promises';
|
|
|
5
5
|
import { getWorkspaceSessions, WorkspaceSession } from '../utils/claude-sessions';
|
|
6
6
|
import { listWorkspaces } from '../utils/workspace-meta';
|
|
7
7
|
import { logError, logInfo } from '../utils/logger';
|
|
8
|
+
import { outputJson, outputJsonError } from '../utils/output';
|
|
8
9
|
import { colorize } from '../utils/colors';
|
|
9
10
|
import inquirer from 'inquirer';
|
|
10
11
|
import autocompletePrompt from 'inquirer-autocomplete-prompt';
|
|
@@ -20,16 +21,21 @@ export function registerSessionsCommand(parent: Command) {
|
|
|
20
21
|
.command('sessions')
|
|
21
22
|
.alias('ses')
|
|
22
23
|
.description('Resume a Claude session in a workspace')
|
|
23
|
-
.
|
|
24
|
-
|
|
24
|
+
.option('--json', 'List sessions as JSON (no interactive resume)')
|
|
25
|
+
.action(async (opts) => {
|
|
26
|
+
await handleSessions(opts);
|
|
25
27
|
});
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
async function handleSessions() {
|
|
30
|
+
async function handleSessions(opts: { json?: boolean } = {}) {
|
|
29
31
|
try {
|
|
30
32
|
const sessions = await getWorkspaceSessions();
|
|
31
33
|
|
|
32
34
|
if (sessions.length === 0) {
|
|
35
|
+
if (opts.json) {
|
|
36
|
+
outputJson({ count: 0, sessions: [] });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
33
39
|
logInfo('No workspace sessions found.');
|
|
34
40
|
console.log('\nYou can create a workspace with: nemus create');
|
|
35
41
|
console.log('Or navigate to one with: w go');
|
|
@@ -46,6 +52,23 @@ async function handleSessions() {
|
|
|
46
52
|
return { session, repoLabel };
|
|
47
53
|
});
|
|
48
54
|
|
|
55
|
+
// JSON mode: list sessions to stdout, no resume prompt / temp-file writes.
|
|
56
|
+
if (opts.json) {
|
|
57
|
+
outputJson({
|
|
58
|
+
count: items.length,
|
|
59
|
+
sessions: items.map(i => ({
|
|
60
|
+
workspaceName: i.session.workspaceName,
|
|
61
|
+
workspacePath: i.session.workspacePath,
|
|
62
|
+
sessionId: i.session.sessionId,
|
|
63
|
+
agentType: i.session.agentType ?? null,
|
|
64
|
+
lastActive: i.session.lastActiveLabel,
|
|
65
|
+
lastActiveAt: i.session.lastActiveAt.toISOString(),
|
|
66
|
+
repoCount: workspaceMap.get(i.session.workspaceName)?.metadata?.repositories?.length ?? 0,
|
|
67
|
+
})),
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
49
72
|
const maxNameLen = Math.max(...items.map(i => i.session.workspaceName.length));
|
|
50
73
|
|
|
51
74
|
console.log('');
|
|
@@ -92,8 +115,12 @@ async function handleSessions() {
|
|
|
92
115
|
console.log(`\n${colorize('Resuming:', 'green')} ${session.workspaceName} (last active ${session.lastActiveLabel})`);
|
|
93
116
|
} catch (error) {
|
|
94
117
|
if ((error as any)?.name === 'ExitPromptError') return;
|
|
95
|
-
|
|
96
|
-
|
|
118
|
+
if (opts.json) {
|
|
119
|
+
outputJsonError(error instanceof Error ? error.message : 'Failed to list sessions');
|
|
120
|
+
} else {
|
|
121
|
+
logError('Failed to list sessions');
|
|
122
|
+
if (error instanceof Error) { logError(error.message); }
|
|
123
|
+
}
|
|
97
124
|
process.exit(1);
|
|
98
125
|
}
|
|
99
126
|
}
|
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
|
}
|
|
@@ -17,9 +17,10 @@ export function registerSuiteCommands(parent: Command) {
|
|
|
17
17
|
suite
|
|
18
18
|
.command('list')
|
|
19
19
|
.description('List all saved suites')
|
|
20
|
-
.
|
|
20
|
+
.option('--json', 'Output as JSON')
|
|
21
|
+
.action(async (opts) => {
|
|
21
22
|
const { main } = await import('./list');
|
|
22
|
-
await main();
|
|
23
|
+
await main(opts);
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
suite
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { mockListSuites } = vi.hoisted(() => ({ mockListSuites: vi.fn() }));
|
|
4
|
+
|
|
5
|
+
vi.mock('../../utils/suite', () => ({ listSuites: mockListSuites }));
|
|
6
|
+
vi.mock('../../utils/logger', () => ({ logInfo: vi.fn(), logError: vi.fn() }));
|
|
7
|
+
vi.mock('../../utils/colors', () => ({ colorize: (t: string) => t }));
|
|
8
|
+
|
|
9
|
+
import { main } from './list';
|
|
10
|
+
|
|
11
|
+
function makeSuite(name: string, entries = 1) {
|
|
12
|
+
return {
|
|
13
|
+
name,
|
|
14
|
+
description: `${name} desc`,
|
|
15
|
+
entries: Array.from({ length: entries }, (_, i) => ({ directoryName: `repo-${i}`, repoName: `repo-${i}` })),
|
|
16
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
17
|
+
updatedAt: '2026-01-02T00:00:00.000Z',
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('suite list --json', () => {
|
|
22
|
+
let writeSpy: ReturnType<typeof vi.spyOn>;
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
26
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => vi.restoreAllMocks());
|
|
29
|
+
|
|
30
|
+
it('emits one valid JSON document with normalized suites', async () => {
|
|
31
|
+
mockListSuites.mockResolvedValueOnce([makeSuite('fees', 2), makeSuite('platform', 1)]);
|
|
32
|
+
await main({ json: true });
|
|
33
|
+
expect(writeSpy).toHaveBeenCalledTimes(1);
|
|
34
|
+
const payload = JSON.parse(writeSpy.mock.calls[0][0] as string);
|
|
35
|
+
expect(payload.count).toBe(2);
|
|
36
|
+
expect(payload.suites[0]).toEqual({
|
|
37
|
+
name: 'fees',
|
|
38
|
+
description: 'fees desc',
|
|
39
|
+
repoCount: 2,
|
|
40
|
+
entries: [
|
|
41
|
+
{ directoryName: 'repo-0', repoName: 'repo-0' },
|
|
42
|
+
{ directoryName: 'repo-1', repoName: 'repo-1' },
|
|
43
|
+
],
|
|
44
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
45
|
+
updatedAt: '2026-01-02T00:00:00.000Z',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('empty list emits count 0 (no header/log noise)', async () => {
|
|
50
|
+
mockListSuites.mockResolvedValueOnce([]);
|
|
51
|
+
await main({ json: true });
|
|
52
|
+
const payload = JSON.parse(writeSpy.mock.calls[0][0] as string);
|
|
53
|
+
expect(payload).toEqual({ count: 0, suites: [] });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('non-json mode does not write to the stdout data channel', async () => {
|
|
57
|
+
mockListSuites.mockResolvedValueOnce([makeSuite('fees')]);
|
|
58
|
+
await main();
|
|
59
|
+
// human output goes through console.log (mocked), not process.stdout.write
|
|
60
|
+
expect(writeSpy).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -2,16 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
import { listSuites } from '../../utils/suite';
|
|
4
4
|
import { logInfo, logError } from '../../utils/logger';
|
|
5
|
+
import { outputJson, outputJsonError } from '../../utils/output';
|
|
5
6
|
import { colorize } from '../../utils/colors';
|
|
6
7
|
|
|
7
|
-
export async function main() {
|
|
8
|
-
console.log('\n' + '='.repeat(60));
|
|
9
|
-
console.log(colorize('Saved Suites', 'bright'));
|
|
10
|
-
console.log('='.repeat(60) + '\n');
|
|
11
|
-
|
|
8
|
+
export async function main(opts: { json?: boolean } = {}) {
|
|
12
9
|
try {
|
|
13
10
|
const suites = await listSuites();
|
|
14
11
|
|
|
12
|
+
if (opts.json) {
|
|
13
|
+
outputJson({
|
|
14
|
+
count: suites.length,
|
|
15
|
+
suites: suites.map(s => ({
|
|
16
|
+
name: s.name,
|
|
17
|
+
description: s.description ?? null,
|
|
18
|
+
repoCount: s.entries.length,
|
|
19
|
+
entries: s.entries.map(e => ({ directoryName: e.directoryName, repoName: e.repoName })),
|
|
20
|
+
createdAt: s.createdAt,
|
|
21
|
+
updatedAt: s.updatedAt,
|
|
22
|
+
})),
|
|
23
|
+
});
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console.log('\n' + '='.repeat(60));
|
|
28
|
+
console.log(colorize('Saved Suites', 'bright'));
|
|
29
|
+
console.log('='.repeat(60) + '\n');
|
|
30
|
+
|
|
15
31
|
if (suites.length === 0) {
|
|
16
32
|
logInfo('No suites found');
|
|
17
33
|
console.log('\nCreate a new suite with:');
|
|
@@ -43,9 +59,11 @@ export async function main() {
|
|
|
43
59
|
|
|
44
60
|
console.log('='.repeat(60) + '\n');
|
|
45
61
|
} catch (error) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
62
|
+
if (opts.json) {
|
|
63
|
+
outputJsonError(error instanceof Error ? error.message : 'Failed to list suites');
|
|
64
|
+
} else {
|
|
65
|
+
logError('Failed to list suites');
|
|
66
|
+
if (error instanceof Error) logError(error.message);
|
|
49
67
|
}
|
|
50
68
|
process.exit(1);
|
|
51
69
|
}
|
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
|
+
}
|