@os-eco/overstory-cli 0.6.9 → 0.6.10
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/README.md +17 -5
- package/agents/builder.md +6 -15
- package/agents/lead.md +4 -6
- package/agents/merger.md +5 -13
- package/agents/reviewer.md +2 -9
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +52 -0
- package/src/agents/hooks-deployer.ts +22 -7
- package/src/agents/overlay.test.ts +156 -1
- package/src/agents/overlay.ts +67 -7
- package/src/commands/completions.test.ts +8 -20
- package/src/commands/completions.ts +4 -2
- package/src/commands/doctor.ts +97 -48
- package/src/commands/ecosystem.ts +291 -0
- package/src/commands/feed.ts +2 -2
- package/src/commands/sling.ts +1 -1
- package/src/commands/upgrade.test.ts +46 -0
- package/src/commands/upgrade.ts +259 -0
- package/src/doctor/databases.test.ts +38 -0
- package/src/doctor/databases.ts +7 -10
- package/src/doctor/ecosystem.test.ts +307 -0
- package/src/doctor/ecosystem.ts +155 -0
- package/src/doctor/merge-queue.test.ts +98 -0
- package/src/doctor/merge-queue.ts +23 -0
- package/src/doctor/structure.test.ts +130 -1
- package/src/doctor/structure.ts +87 -1
- package/src/doctor/types.ts +5 -2
- package/src/index.ts +23 -1
package/src/agents/overlay.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { DEFAULT_QUALITY_GATES } from "../config.ts";
|
|
3
4
|
import { AgentError } from "../errors.ts";
|
|
4
5
|
import type { OverlayConfig, QualityGate } from "../types.ts";
|
|
5
6
|
|
|
@@ -76,12 +77,65 @@ Your parent has already gathered the context you need.
|
|
|
76
77
|
* a lightweight section that only tells them to close the issue and report.
|
|
77
78
|
* Writable agents get the full quality gates (tests, lint, build, commit).
|
|
78
79
|
*/
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Resolve quality gates: use provided gates if non-empty, otherwise fall back to defaults.
|
|
82
|
+
*/
|
|
83
|
+
function resolveGates(gates: QualityGate[] | undefined): QualityGate[] {
|
|
84
|
+
return gates && gates.length > 0 ? gates : DEFAULT_QUALITY_GATES;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Format quality gates as inline backtick-delimited commands for prose sections.
|
|
89
|
+
* Example: `bun test`, `bun run lint`, `bun run typecheck`
|
|
90
|
+
*/
|
|
91
|
+
export function formatQualityGatesInline(gates: QualityGate[] | undefined): string {
|
|
92
|
+
return resolveGates(gates)
|
|
93
|
+
.map((g) => `\`${g.command}\``)
|
|
94
|
+
.join(", ");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Format quality gates as a numbered step list for completion-protocol sections.
|
|
99
|
+
* Example:
|
|
100
|
+
* 1. Run `bun test` -- all tests must pass.
|
|
101
|
+
* 2. Run `bun run lint` -- lint and formatting must be clean.
|
|
102
|
+
*/
|
|
103
|
+
export function formatQualityGatesSteps(gates: QualityGate[] | undefined): string {
|
|
104
|
+
return resolveGates(gates)
|
|
105
|
+
.map((g, i) => `${i + 1}. Run \`${g.command}\` -- ${g.description}.`)
|
|
106
|
+
.join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Format quality gates as a bash code block for workflow sections.
|
|
111
|
+
* Example:
|
|
112
|
+
* ```bash
|
|
113
|
+
* bun test # All tests must pass
|
|
114
|
+
* bun run lint # Lint and format must be clean
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
export function formatQualityGatesBash(gates: QualityGate[] | undefined): string {
|
|
118
|
+
const resolved = resolveGates(gates);
|
|
119
|
+
// Pad commands to align comments
|
|
120
|
+
const maxLen = Math.max(...resolved.map((g) => g.command.length));
|
|
121
|
+
const lines = resolved.map((g) => {
|
|
122
|
+
const padded = g.command.padEnd(maxLen + 2);
|
|
123
|
+
return `${padded}# ${g.description[0]?.toUpperCase() ?? ""}${g.description.slice(1)}`;
|
|
124
|
+
});
|
|
125
|
+
return ["```bash", ...lines, "```"].join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Format quality gates as a bullet list for capabilities sections.
|
|
130
|
+
* Example:
|
|
131
|
+
* - `bun test` (run tests)
|
|
132
|
+
* - `bun run lint` (lint and format check via biome)
|
|
133
|
+
*/
|
|
134
|
+
export function formatQualityGatesCapabilities(gates: QualityGate[] | undefined): string {
|
|
135
|
+
return resolveGates(gates)
|
|
136
|
+
.map((g) => ` - \`${g.command}\` (${g.description})`)
|
|
137
|
+
.join("\n");
|
|
138
|
+
}
|
|
85
139
|
|
|
86
140
|
function formatQualityGates(config: OverlayConfig): string {
|
|
87
141
|
if (READ_ONLY_CAPABILITIES.has(config.capability)) {
|
|
@@ -99,7 +153,9 @@ function formatQualityGates(config: OverlayConfig): string {
|
|
|
99
153
|
}
|
|
100
154
|
|
|
101
155
|
const gates =
|
|
102
|
-
config.qualityGates && config.qualityGates.length > 0
|
|
156
|
+
config.qualityGates && config.qualityGates.length > 0
|
|
157
|
+
? config.qualityGates
|
|
158
|
+
: DEFAULT_QUALITY_GATES;
|
|
103
159
|
|
|
104
160
|
const gateLines = gates.map(
|
|
105
161
|
(gate, i) => `${i + 1}. **${gate.name}:** \`${gate.command}\` — ${gate.description}`,
|
|
@@ -220,6 +276,10 @@ export async function generateOverlay(config: OverlayConfig): Promise<string> {
|
|
|
220
276
|
"{{SPEC_INSTRUCTION}}": specInstruction,
|
|
221
277
|
"{{SKIP_SCOUT}}": config.skipScout ? SKIP_SCOUT_SECTION : "",
|
|
222
278
|
"{{BASE_DEFINITION}}": config.baseDefinition,
|
|
279
|
+
"{{QUALITY_GATE_INLINE}}": formatQualityGatesInline(config.qualityGates),
|
|
280
|
+
"{{QUALITY_GATE_STEPS}}": formatQualityGatesSteps(config.qualityGates),
|
|
281
|
+
"{{QUALITY_GATE_BASH}}": formatQualityGatesBash(config.qualityGates),
|
|
282
|
+
"{{QUALITY_GATE_CAPABILITIES}}": formatQualityGatesCapabilities(config.qualityGates),
|
|
223
283
|
"{{TRACKER_CLI}}": config.trackerCli ?? "bd",
|
|
224
284
|
"{{TRACKER_NAME}}": config.trackerName ?? "beads",
|
|
225
285
|
};
|
|
@@ -203,54 +203,42 @@ describe("completionsCommand", () => {
|
|
|
203
203
|
});
|
|
204
204
|
|
|
205
205
|
it("should exit with error for missing shell argument", () => {
|
|
206
|
-
const
|
|
206
|
+
const originalExitCode = process.exitCode;
|
|
207
207
|
const originalStderr = process.stderr.write;
|
|
208
|
-
let exitCode: number | undefined;
|
|
209
208
|
let stderrOutput = "";
|
|
210
209
|
|
|
211
|
-
process.exit = mock((code?: string | number | null | undefined) => {
|
|
212
|
-
exitCode = typeof code === "number" ? code : 1;
|
|
213
|
-
throw new Error("process.exit called");
|
|
214
|
-
}) as never;
|
|
215
|
-
|
|
216
210
|
process.stderr.write = mock((chunk: unknown) => {
|
|
217
211
|
stderrOutput += String(chunk);
|
|
218
212
|
return true;
|
|
219
213
|
});
|
|
220
214
|
|
|
221
215
|
try {
|
|
222
|
-
|
|
223
|
-
expect(exitCode).toBe(1);
|
|
216
|
+
completionsCommand([]);
|
|
217
|
+
expect(process.exitCode).toBe(1);
|
|
224
218
|
expect(stderrOutput).toContain("missing shell argument");
|
|
225
219
|
} finally {
|
|
226
|
-
process.
|
|
220
|
+
process.exitCode = originalExitCode;
|
|
227
221
|
process.stderr.write = originalStderr;
|
|
228
222
|
}
|
|
229
223
|
});
|
|
230
224
|
|
|
231
225
|
it("should exit with error for unknown shell", () => {
|
|
232
|
-
const
|
|
226
|
+
const originalExitCode = process.exitCode;
|
|
233
227
|
const originalStderr = process.stderr.write;
|
|
234
|
-
let exitCode: number | undefined;
|
|
235
228
|
let stderrOutput = "";
|
|
236
229
|
|
|
237
|
-
process.exit = mock((code?: string | number | null | undefined) => {
|
|
238
|
-
exitCode = typeof code === "number" ? code : 1;
|
|
239
|
-
throw new Error("process.exit called");
|
|
240
|
-
}) as never;
|
|
241
|
-
|
|
242
230
|
process.stderr.write = mock((chunk: unknown) => {
|
|
243
231
|
stderrOutput += String(chunk);
|
|
244
232
|
return true;
|
|
245
233
|
});
|
|
246
234
|
|
|
247
235
|
try {
|
|
248
|
-
|
|
249
|
-
expect(exitCode).toBe(1);
|
|
236
|
+
completionsCommand(["powershell"]);
|
|
237
|
+
expect(process.exitCode).toBe(1);
|
|
250
238
|
expect(stderrOutput).toContain("unknown shell");
|
|
251
239
|
expect(stderrOutput).toContain("powershell");
|
|
252
240
|
} finally {
|
|
253
|
-
process.
|
|
241
|
+
process.exitCode = originalExitCode;
|
|
254
242
|
process.stderr.write = originalStderr;
|
|
255
243
|
}
|
|
256
244
|
});
|
|
@@ -874,7 +874,8 @@ export function completionsCommand(args: string[]): void {
|
|
|
874
874
|
|
|
875
875
|
if (!shell) {
|
|
876
876
|
printError("missing shell argument", "Usage: ov --completions <bash|zsh|fish>");
|
|
877
|
-
process.
|
|
877
|
+
process.exitCode = 1;
|
|
878
|
+
return;
|
|
878
879
|
}
|
|
879
880
|
|
|
880
881
|
let script: string;
|
|
@@ -890,7 +891,8 @@ export function completionsCommand(args: string[]): void {
|
|
|
890
891
|
break;
|
|
891
892
|
default:
|
|
892
893
|
printError(`unknown shell '${shell}'`, "Supported shells: bash, zsh, fish");
|
|
893
|
-
process.
|
|
894
|
+
process.exitCode = 1;
|
|
895
|
+
return;
|
|
894
896
|
}
|
|
895
897
|
|
|
896
898
|
process.stdout.write(script);
|
package/src/commands/doctor.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { checkConfig } from "../doctor/config-check.ts";
|
|
|
12
12
|
import { checkConsistency } from "../doctor/consistency.ts";
|
|
13
13
|
import { checkDatabases } from "../doctor/databases.ts";
|
|
14
14
|
import { checkDependencies } from "../doctor/dependencies.ts";
|
|
15
|
+
import { checkEcosystem } from "../doctor/ecosystem.ts";
|
|
15
16
|
import { checkLogs } from "../doctor/logs.ts";
|
|
16
17
|
import { checkMergeQueue } from "../doctor/merge-queue.ts";
|
|
17
18
|
import { checkStructure } from "../doctor/structure.ts";
|
|
@@ -32,8 +33,25 @@ const ALL_CHECKS: Array<{ category: DoctorCategory; fn: DoctorCheckFn }> = [
|
|
|
32
33
|
{ category: "merge", fn: checkMergeQueue },
|
|
33
34
|
{ category: "logs", fn: checkLogs },
|
|
34
35
|
{ category: "version", fn: checkVersion },
|
|
36
|
+
{ category: "ecosystem", fn: checkEcosystem },
|
|
35
37
|
];
|
|
36
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Execute all fix functions on non-passing fixable checks.
|
|
41
|
+
* Returns a list of human-readable actions taken.
|
|
42
|
+
*/
|
|
43
|
+
async function applyFixes(checks: DoctorCheck[]): Promise<string[]> {
|
|
44
|
+
const fixable = checks.filter((c) => c.fixable && c.status !== "pass" && c.fix);
|
|
45
|
+
const fixed: string[] = [];
|
|
46
|
+
for (const check of fixable) {
|
|
47
|
+
if (check.fix) {
|
|
48
|
+
const actions = await check.fix();
|
|
49
|
+
fixed.push(...actions);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return fixed;
|
|
53
|
+
}
|
|
54
|
+
|
|
37
55
|
/**
|
|
38
56
|
* Format human-readable output for doctor checks.
|
|
39
57
|
*/
|
|
@@ -41,6 +59,7 @@ function printHumanReadable(
|
|
|
41
59
|
checks: DoctorCheck[],
|
|
42
60
|
verbose: boolean,
|
|
43
61
|
checkRegistry: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>,
|
|
62
|
+
fixedItems?: string[],
|
|
44
63
|
): void {
|
|
45
64
|
const w = process.stdout.write.bind(process.stdout);
|
|
46
65
|
|
|
@@ -105,17 +124,28 @@ function printHumanReadable(
|
|
|
105
124
|
w(
|
|
106
125
|
`${color.bold("Summary:")} ${color.green(`${pass} passed`)}, ${color.yellow(`${warn} warning${warn === 1 ? "" : "s"}`)}, ${color.red(`${fail} failure${fail === 1 ? "" : "s"}`)}\n`,
|
|
107
126
|
);
|
|
127
|
+
|
|
128
|
+
if (fixedItems && fixedItems.length > 0) {
|
|
129
|
+
w(`\n${color.bold("Fixed:")}\n`);
|
|
130
|
+
for (const item of fixedItems) {
|
|
131
|
+
w(` ${color.green("-")} ${item}\n`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
108
134
|
}
|
|
109
135
|
|
|
110
136
|
/**
|
|
111
137
|
* Format JSON output for doctor checks.
|
|
112
138
|
*/
|
|
113
|
-
function printJSON(checks: DoctorCheck[]): void {
|
|
139
|
+
function printJSON(checks: DoctorCheck[], fixed?: string[]): void {
|
|
114
140
|
const pass = checks.filter((c) => c.status === "pass").length;
|
|
115
141
|
const warn = checks.filter((c) => c.status === "warn").length;
|
|
116
142
|
const fail = checks.filter((c) => c.status === "fail").length;
|
|
117
143
|
|
|
118
|
-
jsonOutput("doctor", {
|
|
144
|
+
jsonOutput("doctor", {
|
|
145
|
+
checks,
|
|
146
|
+
summary: { pass, warn, fail },
|
|
147
|
+
...(fixed && fixed.length > 0 ? { fixed } : {}),
|
|
148
|
+
});
|
|
119
149
|
}
|
|
120
150
|
|
|
121
151
|
/** Options for dependency injection in doctorCommand. */
|
|
@@ -133,59 +163,78 @@ export function createDoctorCommand(options?: DoctorCommandOptions): Command {
|
|
|
133
163
|
.option("--json", "Output as JSON")
|
|
134
164
|
.option("--verbose", "Show passing checks (default: only problems)")
|
|
135
165
|
.option("--category <name>", "Run only one category")
|
|
166
|
+
.option("--fix", "Attempt to auto-fix issues")
|
|
136
167
|
.addHelpText(
|
|
137
168
|
"after",
|
|
138
|
-
"\nCategories: dependencies, structure, config, databases, consistency, agents, merge, logs, version",
|
|
169
|
+
"\nCategories: dependencies, structure, config, databases, consistency, agents, merge, logs, version, ecosystem",
|
|
139
170
|
)
|
|
140
|
-
.action(
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
171
|
+
.action(
|
|
172
|
+
async (opts: { json?: boolean; verbose?: boolean; category?: string; fix?: boolean }) => {
|
|
173
|
+
const json = opts.json ?? false;
|
|
174
|
+
const verbose = opts.verbose ?? false;
|
|
175
|
+
const categoryFilter = opts.category;
|
|
176
|
+
const fix = opts.fix ?? false;
|
|
177
|
+
|
|
178
|
+
// Validate category filter if provided
|
|
179
|
+
if (categoryFilter !== undefined) {
|
|
180
|
+
const validCategories = ALL_CHECKS.map((c) => c.category);
|
|
181
|
+
if (!validCategories.includes(categoryFilter as DoctorCategory)) {
|
|
182
|
+
throw new ValidationError(
|
|
183
|
+
`Invalid category: ${categoryFilter}. Valid categories: ${validCategories.join(", ")}`,
|
|
184
|
+
{
|
|
185
|
+
field: "category",
|
|
186
|
+
value: categoryFilter,
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
}
|
|
156
190
|
}
|
|
157
|
-
}
|
|
158
191
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
// Filter checks by category if specified
|
|
164
|
-
const allChecks = options?.checkRunners ?? ALL_CHECKS;
|
|
165
|
-
const checksToRun = categoryFilter
|
|
166
|
-
? allChecks.filter((c) => c.category === categoryFilter)
|
|
167
|
-
: allChecks;
|
|
168
|
-
|
|
169
|
-
// Run all checks sequentially
|
|
170
|
-
const results: DoctorCheck[] = [];
|
|
171
|
-
for (const { fn } of checksToRun) {
|
|
172
|
-
const checkResults = await fn(config, overstoryDir);
|
|
173
|
-
results.push(...checkResults);
|
|
174
|
-
}
|
|
192
|
+
const cwd = process.cwd();
|
|
193
|
+
const config = await loadConfig(cwd);
|
|
194
|
+
const overstoryDir = join(config.project.root, ".overstory");
|
|
175
195
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
196
|
+
// Filter checks by category if specified
|
|
197
|
+
const allChecks = options?.checkRunners ?? ALL_CHECKS;
|
|
198
|
+
const checksToRun = categoryFilter
|
|
199
|
+
? allChecks.filter((c) => c.category === categoryFilter)
|
|
200
|
+
: allChecks;
|
|
182
201
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
202
|
+
// Run all checks sequentially
|
|
203
|
+
let results: DoctorCheck[] = [];
|
|
204
|
+
for (const { fn } of checksToRun) {
|
|
205
|
+
const checkResults = await fn(config, overstoryDir);
|
|
206
|
+
results.push(...checkResults);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Apply fixes if requested
|
|
210
|
+
let fixedItems: string[] | undefined;
|
|
211
|
+
if (fix) {
|
|
212
|
+
const applied = await applyFixes(results);
|
|
213
|
+
if (applied.length > 0) {
|
|
214
|
+
fixedItems = applied;
|
|
215
|
+
// Re-run all checks to get fresh results after fixes
|
|
216
|
+
results = [];
|
|
217
|
+
for (const { fn } of checksToRun) {
|
|
218
|
+
const checkResults = await fn(config, overstoryDir);
|
|
219
|
+
results.push(...checkResults);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Output results
|
|
225
|
+
if (json) {
|
|
226
|
+
printJSON(results, fixedItems);
|
|
227
|
+
} else {
|
|
228
|
+
printHumanReadable(results, verbose, allChecks, fixedItems);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Set exit code if any check failed
|
|
232
|
+
const hasFailures = results.some((c) => c.status === "fail");
|
|
233
|
+
if (hasFailures) {
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
);
|
|
189
238
|
}
|
|
190
239
|
|
|
191
240
|
/**
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: ov ecosystem
|
|
3
|
+
*
|
|
4
|
+
* Shows a summary dashboard of all installed os-eco tools: version, update
|
|
5
|
+
* status (latest vs outdated), and doctor health (overstory only).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import { jsonError, jsonOutput } from "../json.ts";
|
|
10
|
+
import { accent, brand, color, muted } from "../logging/color.ts";
|
|
11
|
+
|
|
12
|
+
const TOOLS = [
|
|
13
|
+
{ name: "overstory", cli: "ov", npm: "@os-eco/overstory-cli" },
|
|
14
|
+
{ name: "mulch", cli: "ml", npm: "@os-eco/mulch-cli" },
|
|
15
|
+
{ name: "seeds", cli: "sd", npm: "@os-eco/seeds-cli" },
|
|
16
|
+
{ name: "canopy", cli: "cn", npm: "@os-eco/canopy-cli" },
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
export interface EcosystemOptions {
|
|
20
|
+
json?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface DoctorSummary {
|
|
24
|
+
pass: number;
|
|
25
|
+
warn: number;
|
|
26
|
+
fail: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ToolResult {
|
|
30
|
+
name: string;
|
|
31
|
+
cli: string;
|
|
32
|
+
npm: string;
|
|
33
|
+
installed: boolean;
|
|
34
|
+
version?: string;
|
|
35
|
+
latest?: string;
|
|
36
|
+
upToDate?: boolean;
|
|
37
|
+
doctorSummary?: DoctorSummary;
|
|
38
|
+
latestError?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function getInstalledVersion(cli: string): Promise<string | null> {
|
|
42
|
+
// Try --version --json first
|
|
43
|
+
try {
|
|
44
|
+
const proc = Bun.spawn([cli, "--version", "--json"], {
|
|
45
|
+
stdout: "pipe",
|
|
46
|
+
stderr: "pipe",
|
|
47
|
+
});
|
|
48
|
+
const exitCode = await proc.exited;
|
|
49
|
+
if (exitCode === 0) {
|
|
50
|
+
const stdout = await new Response(proc.stdout).text();
|
|
51
|
+
try {
|
|
52
|
+
const data = JSON.parse(stdout.trim()) as { version?: string };
|
|
53
|
+
if (data.version) return data.version;
|
|
54
|
+
} catch {
|
|
55
|
+
// Not valid JSON, fall through to plain text
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// CLI not found — fall through to plain text fallback
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fallback: --version plain text
|
|
63
|
+
try {
|
|
64
|
+
const proc = Bun.spawn([cli, "--version"], {
|
|
65
|
+
stdout: "pipe",
|
|
66
|
+
stderr: "pipe",
|
|
67
|
+
});
|
|
68
|
+
const exitCode = await proc.exited;
|
|
69
|
+
if (exitCode === 0) {
|
|
70
|
+
const stdout = await new Response(proc.stdout).text();
|
|
71
|
+
const match = stdout.match(/(\d+\.\d+\.\d+)/);
|
|
72
|
+
if (match?.[1]) return match[1];
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// CLI not found
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function fetchLatestVersion(packageName: string): Promise<string> {
|
|
82
|
+
const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
throw new Error(`npm registry error: ${res.status} ${res.statusText}`);
|
|
85
|
+
}
|
|
86
|
+
const data = (await res.json()) as { version: string };
|
|
87
|
+
return data.version;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function getDoctorSummary(): Promise<DoctorSummary | undefined> {
|
|
91
|
+
try {
|
|
92
|
+
const proc = Bun.spawn(["ov", "doctor", "--json"], {
|
|
93
|
+
stdout: "pipe",
|
|
94
|
+
stderr: "pipe",
|
|
95
|
+
});
|
|
96
|
+
await proc.exited;
|
|
97
|
+
const stdout = await new Response(proc.stdout).text();
|
|
98
|
+
const trimmed = stdout.trim();
|
|
99
|
+
if (trimmed) {
|
|
100
|
+
const data = JSON.parse(trimmed) as {
|
|
101
|
+
summary?: { pass?: number; warn?: number; fail?: number };
|
|
102
|
+
};
|
|
103
|
+
if (data.summary) {
|
|
104
|
+
return {
|
|
105
|
+
pass: data.summary.pass ?? 0,
|
|
106
|
+
warn: data.summary.warn ?? 0,
|
|
107
|
+
fail: data.summary.fail ?? 0,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Doctor failed — report nothing
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function checkTool(tool: { name: string; cli: string; npm: string }): Promise<ToolResult> {
|
|
118
|
+
const version = await getInstalledVersion(tool.cli);
|
|
119
|
+
|
|
120
|
+
if (version === null) {
|
|
121
|
+
return { name: tool.name, cli: tool.cli, npm: tool.npm, installed: false };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let latest: string | undefined;
|
|
125
|
+
let latestError: string | undefined;
|
|
126
|
+
let doctorSummary: DoctorSummary | undefined;
|
|
127
|
+
|
|
128
|
+
const latestPromise = fetchLatestVersion(tool.npm)
|
|
129
|
+
.then((v) => {
|
|
130
|
+
latest = v;
|
|
131
|
+
})
|
|
132
|
+
.catch((err) => {
|
|
133
|
+
latestError = err instanceof Error ? err.message : String(err);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const doctorPromise =
|
|
137
|
+
tool.name === "overstory"
|
|
138
|
+
? getDoctorSummary().then((d) => {
|
|
139
|
+
doctorSummary = d;
|
|
140
|
+
})
|
|
141
|
+
: Promise.resolve();
|
|
142
|
+
|
|
143
|
+
await Promise.all([latestPromise, doctorPromise]);
|
|
144
|
+
|
|
145
|
+
const upToDate = latest !== undefined ? version === latest : undefined;
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
name: tool.name,
|
|
149
|
+
cli: tool.cli,
|
|
150
|
+
npm: tool.npm,
|
|
151
|
+
installed: true,
|
|
152
|
+
version,
|
|
153
|
+
latest,
|
|
154
|
+
upToDate,
|
|
155
|
+
doctorSummary,
|
|
156
|
+
latestError,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function formatDoctorLine(summary: DoctorSummary): string {
|
|
161
|
+
const parts: string[] = [];
|
|
162
|
+
if (summary.pass > 0) parts.push(color.green(`${summary.pass} passed`));
|
|
163
|
+
if (summary.warn > 0) parts.push(color.yellow(`${summary.warn} warn`));
|
|
164
|
+
if (summary.fail > 0) parts.push(color.red(`${summary.fail} fail`));
|
|
165
|
+
return parts.length > 0 ? parts.join(", ") : "no checks";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function printHumanOutput(results: ToolResult[]): void {
|
|
169
|
+
process.stdout.write(`${brand.bold("os-eco Ecosystem")}\n`);
|
|
170
|
+
process.stdout.write(`${"═".repeat(60)}\n`);
|
|
171
|
+
process.stdout.write("\n");
|
|
172
|
+
|
|
173
|
+
for (const tool of results) {
|
|
174
|
+
if (!tool.installed) {
|
|
175
|
+
process.stdout.write(
|
|
176
|
+
` ${color.red("x")} ${accent(tool.name)} ${muted(`(${tool.cli})`)} ${color.red("not installed")}\n`,
|
|
177
|
+
);
|
|
178
|
+
process.stdout.write(` ${muted(`npm i -g ${tool.npm}`)}\n`);
|
|
179
|
+
process.stdout.write("\n");
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Determine status icon
|
|
184
|
+
let icon: string;
|
|
185
|
+
if (tool.latestError !== undefined || tool.upToDate === undefined) {
|
|
186
|
+
icon = muted("-");
|
|
187
|
+
} else if (tool.upToDate) {
|
|
188
|
+
icon = color.green("-");
|
|
189
|
+
} else {
|
|
190
|
+
icon = color.yellow("!");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
process.stdout.write(` ${icon} ${accent(tool.name)} ${muted(`(${tool.cli})`)}\n`);
|
|
194
|
+
|
|
195
|
+
// Version line
|
|
196
|
+
let versionLine = `Version: ${tool.version}`;
|
|
197
|
+
if (tool.latestError !== undefined) {
|
|
198
|
+
versionLine += ` ${muted("(version check failed)")}`;
|
|
199
|
+
} else if (tool.upToDate === true) {
|
|
200
|
+
versionLine += ` ${color.green("(up to date)")}`;
|
|
201
|
+
} else if (tool.upToDate === false) {
|
|
202
|
+
versionLine += ` ${color.yellow(`(outdated, latest: ${tool.latest})`)}`;
|
|
203
|
+
}
|
|
204
|
+
process.stdout.write(` ${versionLine}\n`);
|
|
205
|
+
|
|
206
|
+
// Doctor summary (overstory only)
|
|
207
|
+
if (tool.doctorSummary !== undefined) {
|
|
208
|
+
process.stdout.write(` Doctor: ${formatDoctorLine(tool.doctorSummary)}\n`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
process.stdout.write("\n");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const installed = results.filter((t) => t.installed).length;
|
|
215
|
+
const missing = results.filter((t) => !t.installed).length;
|
|
216
|
+
const outdated = results.filter(
|
|
217
|
+
(t) => t.installed && t.upToDate === false && t.latestError === undefined,
|
|
218
|
+
).length;
|
|
219
|
+
|
|
220
|
+
let summary = `Summary: ${installed}/${results.length} installed`;
|
|
221
|
+
if (missing > 0) summary += `, ${missing} missing`;
|
|
222
|
+
if (outdated > 0) summary += `, ${outdated} outdated`;
|
|
223
|
+
process.stdout.write(`${summary}\n`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function executeEcosystem(opts: EcosystemOptions): Promise<void> {
|
|
227
|
+
const json = opts.json ?? false;
|
|
228
|
+
|
|
229
|
+
let results: ToolResult[];
|
|
230
|
+
try {
|
|
231
|
+
results = await Promise.all(TOOLS.map((tool) => checkTool(tool)));
|
|
232
|
+
} catch (err) {
|
|
233
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
234
|
+
if (json) {
|
|
235
|
+
jsonError("ecosystem", msg);
|
|
236
|
+
} else {
|
|
237
|
+
process.stderr.write(`Error: ${msg}\n`);
|
|
238
|
+
}
|
|
239
|
+
process.exitCode = 1;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (json) {
|
|
244
|
+
const installed = results.filter((t) => t.installed).length;
|
|
245
|
+
const missing = results.filter((t) => !t.installed).length;
|
|
246
|
+
const outdated = results.filter(
|
|
247
|
+
(t) => t.installed && t.upToDate === false && t.latestError === undefined,
|
|
248
|
+
).length;
|
|
249
|
+
|
|
250
|
+
jsonOutput("ecosystem", {
|
|
251
|
+
tools: results.map((t) => {
|
|
252
|
+
const entry: Record<string, unknown> = {
|
|
253
|
+
name: t.name,
|
|
254
|
+
cli: t.cli,
|
|
255
|
+
npm: t.npm,
|
|
256
|
+
installed: t.installed,
|
|
257
|
+
};
|
|
258
|
+
if (t.installed) {
|
|
259
|
+
entry.version = t.version;
|
|
260
|
+
entry.latest = t.latest;
|
|
261
|
+
entry.upToDate = t.upToDate;
|
|
262
|
+
if (t.doctorSummary !== undefined) {
|
|
263
|
+
entry.doctorSummary = t.doctorSummary;
|
|
264
|
+
}
|
|
265
|
+
if (t.latestError !== undefined) {
|
|
266
|
+
entry.latestError = t.latestError;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return entry;
|
|
270
|
+
}),
|
|
271
|
+
summary: {
|
|
272
|
+
total: results.length,
|
|
273
|
+
installed,
|
|
274
|
+
missing,
|
|
275
|
+
outdated,
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
printHumanOutput(results);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function createEcosystemCommand(): Command {
|
|
285
|
+
return new Command("ecosystem")
|
|
286
|
+
.description("Show a summary dashboard of all installed os-eco tools")
|
|
287
|
+
.option("--json", "Output as JSON")
|
|
288
|
+
.action(async (opts: EcosystemOptions) => {
|
|
289
|
+
await executeEcosystem(opts);
|
|
290
|
+
});
|
|
291
|
+
}
|
package/src/commands/feed.ts
CHANGED
|
@@ -264,7 +264,7 @@ async function executeFeed(opts: FeedOpts): Promise<void> {
|
|
|
264
264
|
} else {
|
|
265
265
|
// JSON mode: print each event as a line
|
|
266
266
|
for (const event of initialEvents) {
|
|
267
|
-
|
|
267
|
+
jsonOutput("feed", { event });
|
|
268
268
|
}
|
|
269
269
|
if (initialEvents.length > 0) {
|
|
270
270
|
const lastEvent = initialEvents[initialEvents.length - 1];
|
|
@@ -308,7 +308,7 @@ async function executeFeed(opts: FeedOpts): Promise<void> {
|
|
|
308
308
|
} else {
|
|
309
309
|
// JSON mode: print each event as a line
|
|
310
310
|
for (const event of newEvents) {
|
|
311
|
-
|
|
311
|
+
jsonOutput("feed", { event });
|
|
312
312
|
}
|
|
313
313
|
}
|
|
314
314
|
|