@elmundi/ship-cli 0.8.1 → 0.11.2
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 +415 -22
- package/bin/shipctl.mjs +165 -0
- package/lib/adapters/_fs.mjs +165 -0
- package/lib/adapters/agents/index.mjs +26 -0
- package/lib/adapters/ci/azure-pipelines.mjs +23 -0
- package/lib/adapters/ci/buildkite.mjs +24 -0
- package/lib/adapters/ci/circleci.mjs +23 -0
- package/lib/adapters/ci/gh-actions.mjs +29 -0
- package/lib/adapters/ci/gitlab-ci.mjs +23 -0
- package/lib/adapters/ci/jenkins.mjs +23 -0
- package/lib/adapters/ci/manual.mjs +18 -0
- package/lib/adapters/index.mjs +122 -0
- package/lib/adapters/language/dart.mjs +23 -0
- package/lib/adapters/language/go.mjs +23 -0
- package/lib/adapters/language/java.mjs +27 -0
- package/lib/adapters/language/js.mjs +32 -0
- package/lib/adapters/language/kotlin.mjs +48 -0
- package/lib/adapters/language/py.mjs +34 -0
- package/lib/adapters/language/rust.mjs +23 -0
- package/lib/adapters/language/swift.mjs +37 -0
- package/lib/adapters/language/ts.mjs +35 -0
- package/lib/adapters/trackers/azure-boards.mjs +49 -0
- package/lib/adapters/trackers/clickup.mjs +43 -0
- package/lib/adapters/trackers/github-issues.mjs +52 -0
- package/lib/adapters/trackers/jira.mjs +72 -0
- package/lib/adapters/trackers/linear.mjs +62 -0
- package/lib/adapters/trackers/none.mjs +18 -0
- package/lib/adapters/trackers/spreadsheet.mjs +28 -0
- package/lib/artifacts/fs-index.mjs +230 -0
- package/lib/bootstrap/render.mjs +373 -0
- package/lib/cache/store.mjs +422 -0
- package/lib/commands/bootstrap.mjs +4 -0
- package/lib/commands/callback.mjs +302 -0
- package/lib/commands/config.mjs +257 -0
- package/lib/commands/docs.mjs +1 -1
- package/lib/commands/doctor.mjs +583 -0
- package/lib/commands/feedback.mjs +355 -0
- package/lib/commands/help.mjs +96 -21
- package/lib/commands/init.mjs +830 -158
- package/lib/commands/kickoff.mjs +192 -0
- package/lib/commands/knowledge.mjs +368 -0
- package/lib/commands/lanes.mjs +502 -0
- package/lib/commands/manifest-catalog.mjs +102 -38
- package/lib/commands/migrate.mjs +204 -0
- package/lib/commands/new.mjs +452 -0
- package/lib/commands/patterns.mjs +9 -43
- package/lib/commands/run.mjs +617 -0
- package/lib/commands/sync.mjs +749 -0
- package/lib/commands/telemetry.mjs +390 -0
- package/lib/commands/verify.mjs +187 -0
- package/lib/config/io.mjs +232 -0
- package/lib/config/migrate.mjs +215 -0
- package/lib/config/schema.mjs +650 -0
- package/lib/detect.mjs +162 -19
- package/lib/feedback/drafts.mjs +129 -0
- package/lib/find-ship-root.mjs +16 -10
- package/lib/http.mjs +237 -11
- package/lib/state/idempotency.mjs +183 -0
- package/lib/state/lockfile.mjs +180 -0
- package/lib/telemetry/outbox.mjs +224 -0
- package/lib/templates.mjs +53 -65
- package/lib/verify/checks/agents-on-disk.mjs +58 -0
- package/lib/verify/checks/api-reachable.mjs +39 -0
- package/lib/verify/checks/artifacts-up-to-date.mjs +78 -0
- package/lib/verify/checks/bootstrap-files.mjs +67 -0
- package/lib/verify/checks/cache-integrity.mjs +51 -0
- package/lib/verify/checks/ci-secrets.mjs +86 -0
- package/lib/verify/checks/config-present.mjs +39 -0
- package/lib/verify/checks/gitignore-cache.mjs +51 -0
- package/lib/verify/checks/rules-markers.mjs +135 -0
- package/lib/verify/checks/stack-enums.mjs +33 -0
- package/lib/verify/checks/tracker-labels.mjs +91 -0
- package/lib/verify/registry.mjs +120 -0
- package/lib/version.mjs +34 -0
- package/package.json +10 -3
- package/bin/ship.mjs +0 -68
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { detectAll } from "../adapters/index.mjs";
|
|
4
|
+
import { isFile, isDir, pkgDeps, readJson } from "../adapters/_fs.mjs";
|
|
5
|
+
import { findShipRoot, readConfig } from "../config/io.mjs";
|
|
6
|
+
import { resolveAgentSignal } from "../detect.mjs";
|
|
7
|
+
|
|
8
|
+
const INVENTORY_REL = path.join(".ship", "inventory.json");
|
|
9
|
+
const CONFIG_REL = path.join(".ship", "config.yml");
|
|
10
|
+
const CACHE_REL = path.join(".ship", "cache");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {{ json: boolean, yes: boolean, force: boolean, dryRun: boolean }} ctx
|
|
14
|
+
* @param {string[]} args
|
|
15
|
+
*/
|
|
16
|
+
export async function doctorCommand(ctx, args) {
|
|
17
|
+
if (args[0] === "help" || args[0] === "-h" || args[0] === "--help") {
|
|
18
|
+
printDoctorHelp();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let cwd = process.cwd();
|
|
23
|
+
let writeInventory = false;
|
|
24
|
+
let jsonOut = !!ctx.json;
|
|
25
|
+
/* eslint-disable-next-line no-unused-vars */
|
|
26
|
+
let noNetwork = false;
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < args.length; i++) {
|
|
29
|
+
const a = args[i];
|
|
30
|
+
if (a === "--cwd" && args[i + 1]) {
|
|
31
|
+
cwd = path.resolve(String(args[++i]));
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (a.startsWith("--cwd=")) {
|
|
35
|
+
cwd = path.resolve(a.slice("--cwd=".length));
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (a === "--write-inventory") {
|
|
39
|
+
writeInventory = true;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (a === "--json") {
|
|
43
|
+
jsonOut = true;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (a === "--no-network") {
|
|
47
|
+
noNetwork = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (a === "--help" || a === "-h") {
|
|
51
|
+
printDoctorHelp();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`doctor: unknown argument: ${a}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const findings = await detectAll(cwd);
|
|
58
|
+
const inferred = inferStack(cwd, findings);
|
|
59
|
+
const presetInfo = inferPreset(cwd);
|
|
60
|
+
inferred.preset = presetInfo.preset;
|
|
61
|
+
|
|
62
|
+
const existing = shipArtifactsSnapshot(cwd);
|
|
63
|
+
|
|
64
|
+
const configInfo = loadShipConfig(cwd);
|
|
65
|
+
const reconciled = reconcileStack(findings, inferred, configInfo);
|
|
66
|
+
|
|
67
|
+
const report = {
|
|
68
|
+
version: 1,
|
|
69
|
+
detected_at: new Date().toISOString(),
|
|
70
|
+
cwd: path.resolve(cwd),
|
|
71
|
+
findings,
|
|
72
|
+
inferred,
|
|
73
|
+
preset_evidence: presetInfo.evidence,
|
|
74
|
+
existing,
|
|
75
|
+
config: configInfo.stack,
|
|
76
|
+
disk: { ...inferred, preset_evidence: presetInfo.evidence },
|
|
77
|
+
reconciled,
|
|
78
|
+
recommendations: buildRecommendations({
|
|
79
|
+
inferred,
|
|
80
|
+
existing,
|
|
81
|
+
config: configInfo.stack,
|
|
82
|
+
reconciled,
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
if (jsonOut) {
|
|
87
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
88
|
+
} else {
|
|
89
|
+
printHumanReport(report);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (writeInventory) {
|
|
93
|
+
const invPath = await writeInventoryFile(cwd, report);
|
|
94
|
+
if (!jsonOut) {
|
|
95
|
+
process.stdout.write(`\nWrote ${path.relative(cwd, invPath) || invPath}.\n`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function printDoctorHelp() {
|
|
101
|
+
console.log(`shipctl doctor — inspect a repository and propose a Ship stack.
|
|
102
|
+
|
|
103
|
+
USAGE
|
|
104
|
+
shipctl doctor [--json] [--cwd DIR] [--write-inventory] [--no-network]
|
|
105
|
+
|
|
106
|
+
DESCRIPTION
|
|
107
|
+
Runs every registered tracker/CI/language/agent adapter's detect() hook
|
|
108
|
+
against the target repo, prints a human-readable report (or JSON with
|
|
109
|
+
--json), and optionally writes .ship/inventory.json for consumption by
|
|
110
|
+
'shipctl init --bootstrap'.
|
|
111
|
+
|
|
112
|
+
FLAGS
|
|
113
|
+
--cwd DIR Inspect DIR instead of the current working directory.
|
|
114
|
+
--write-inventory Persist findings to .ship/inventory.json.
|
|
115
|
+
--json Machine-readable JSON output.
|
|
116
|
+
--no-network Reserved; doctor never makes network calls in v1.
|
|
117
|
+
`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Produce the inferred stack fields. Picks the highest-confidence non-zero
|
|
122
|
+
* adapter per category, falling back to `none`/`manual` for tracker/ci when
|
|
123
|
+
* nothing confident was detected, and to `multi` for language.
|
|
124
|
+
*/
|
|
125
|
+
function inferStack(_cwd, findings) {
|
|
126
|
+
const pickTop = (arr, { min = 0, fallback = null } = {}) => {
|
|
127
|
+
const confident = arr.filter((e) => e.present && e.confidence > min);
|
|
128
|
+
if (confident.length) return confident[0].id;
|
|
129
|
+
return fallback;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const trackerExclFallback = findings.trackers.filter((t) => t.id !== "none");
|
|
133
|
+
const ciExclFallback = findings.ci.filter((c) => c.id !== "manual");
|
|
134
|
+
|
|
135
|
+
const tracker = pickTop(trackerExclFallback, { min: 0.1, fallback: "none" });
|
|
136
|
+
const ci = pickTop(ciExclFallback, { min: 0.1, fallback: "manual" });
|
|
137
|
+
const language = pickTop(findings.language, { min: 0.1, fallback: "multi" }) || "multi";
|
|
138
|
+
|
|
139
|
+
const agents = findings.agents
|
|
140
|
+
.filter((a) => a.present && a.confidence >= 0.5)
|
|
141
|
+
.map((a) => a.id);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
tracker,
|
|
145
|
+
ci,
|
|
146
|
+
language,
|
|
147
|
+
agents,
|
|
148
|
+
preset: "adoption-minimum",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Inspect the repo for preset heuristics. Returns the first match per the
|
|
154
|
+
* priority in the RFC-matching task spec; `adoption-minimum` if none match.
|
|
155
|
+
*/
|
|
156
|
+
function inferPreset(cwd) {
|
|
157
|
+
const evidence = [];
|
|
158
|
+
|
|
159
|
+
const pkg = readJson(cwd, "package.json");
|
|
160
|
+
const deps = pkgDeps(pkg);
|
|
161
|
+
const hasDep = (name) => Object.prototype.hasOwnProperty.call(deps, name);
|
|
162
|
+
|
|
163
|
+
// Mobile app
|
|
164
|
+
if (isFile(cwd, "pubspec.yaml")) {
|
|
165
|
+
evidence.push("pubspec.yaml (Flutter / Dart)");
|
|
166
|
+
return { preset: "mobile-app", evidence };
|
|
167
|
+
}
|
|
168
|
+
if (isDir(cwd, "ios") && isDir(cwd, "android")) {
|
|
169
|
+
evidence.push("ios/ and android/ directories");
|
|
170
|
+
return { preset: "mobile-app", evidence };
|
|
171
|
+
}
|
|
172
|
+
if (hasDep("react-native")) {
|
|
173
|
+
evidence.push("react-native in deps");
|
|
174
|
+
return { preset: "mobile-app", evidence };
|
|
175
|
+
}
|
|
176
|
+
if (hasDep("expo")) {
|
|
177
|
+
evidence.push("expo in deps");
|
|
178
|
+
return { preset: "mobile-app", evidence };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Monorepo
|
|
182
|
+
if (isDir(cwd, "packages")) {
|
|
183
|
+
evidence.push("packages/ directory");
|
|
184
|
+
return { preset: "monorepo", evidence };
|
|
185
|
+
}
|
|
186
|
+
if (isFile(cwd, "pnpm-workspace.yaml")) {
|
|
187
|
+
evidence.push("pnpm-workspace.yaml");
|
|
188
|
+
return { preset: "monorepo", evidence };
|
|
189
|
+
}
|
|
190
|
+
if (isFile(cwd, "lerna.json")) {
|
|
191
|
+
evidence.push("lerna.json");
|
|
192
|
+
return { preset: "monorepo", evidence };
|
|
193
|
+
}
|
|
194
|
+
if (isFile(cwd, "turbo.json")) {
|
|
195
|
+
evidence.push("turbo.json");
|
|
196
|
+
return { preset: "monorepo", evidence };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Web app
|
|
200
|
+
for (const f of ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"]) {
|
|
201
|
+
if (isFile(cwd, f)) {
|
|
202
|
+
evidence.push(f);
|
|
203
|
+
return { preset: "web-app", evidence };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const f of ["vite.config.js", "vite.config.mjs", "vite.config.ts", "vite.config.cjs"]) {
|
|
207
|
+
if (isFile(cwd, f)) {
|
|
208
|
+
evidence.push(f);
|
|
209
|
+
return { preset: "web-app", evidence };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const f of ["svelte.config.js", "svelte.config.mjs", "svelte.config.ts"]) {
|
|
213
|
+
if (isFile(cwd, f)) {
|
|
214
|
+
evidence.push(f);
|
|
215
|
+
return { preset: "web-app", evidence };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// API backend
|
|
220
|
+
if (isFile(cwd, "Dockerfile")) {
|
|
221
|
+
const hasBackendEntry =
|
|
222
|
+
isFile(cwd, "main.py") ||
|
|
223
|
+
isFile(cwd, "server.ts") ||
|
|
224
|
+
isFile(cwd, "server.js") ||
|
|
225
|
+
isFile(cwd, "app.py") ||
|
|
226
|
+
isFile(cwd, "app.ts");
|
|
227
|
+
const hasUiHint =
|
|
228
|
+
isFile(cwd, "index.html") ||
|
|
229
|
+
isDir(cwd, "public") ||
|
|
230
|
+
isDir(cwd, "src/pages") ||
|
|
231
|
+
isDir(cwd, "app");
|
|
232
|
+
if (hasBackendEntry && !hasUiHint) {
|
|
233
|
+
evidence.push("Dockerfile + backend entry (main.py|server.ts) and no UI folder");
|
|
234
|
+
return { preset: "api-backend", evidence };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// CLI
|
|
239
|
+
if (isDir(cwd, "bin") && pkg && typeof pkg.bin === "object") {
|
|
240
|
+
evidence.push("bin/ + package.json:bin");
|
|
241
|
+
return { preset: "cli", evidence };
|
|
242
|
+
}
|
|
243
|
+
if (isFile(cwd, "go.mod") && isDir(cwd, "cmd")) {
|
|
244
|
+
evidence.push("go.mod + cmd/");
|
|
245
|
+
return { preset: "cli", evidence };
|
|
246
|
+
}
|
|
247
|
+
if (isFile(cwd, "Cargo.toml") && isDir(cwd, "src/bin")) {
|
|
248
|
+
evidence.push("Cargo.toml + src/bin/");
|
|
249
|
+
return { preset: "cli", evidence };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return { preset: "adoption-minimum", evidence: ["no strong preset signals"] };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function shipArtifactsSnapshot(cwd) {
|
|
256
|
+
const cursorRulesHit = fs.existsSync(path.join(cwd, ".cursor", "rules"))
|
|
257
|
+
? detectCursorShipRules(cwd)
|
|
258
|
+
: null;
|
|
259
|
+
return {
|
|
260
|
+
config_yml: isFile(cwd, CONFIG_REL) ? "present" : "missing",
|
|
261
|
+
cache_dir: isDir(cwd, CACHE_REL) ? "present" : "missing",
|
|
262
|
+
inventory_json: isFile(cwd, INVENTORY_REL) ? "present" : "missing",
|
|
263
|
+
cursor_ship_rules: cursorRulesHit || "missing",
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function detectCursorShipRules(cwd) {
|
|
268
|
+
const dir = path.join(cwd, ".cursor", "rules");
|
|
269
|
+
let entries;
|
|
270
|
+
try {
|
|
271
|
+
entries = fs.readdirSync(dir);
|
|
272
|
+
} catch {
|
|
273
|
+
return "missing";
|
|
274
|
+
}
|
|
275
|
+
const hit = entries.find((n) => n.startsWith("ship-"));
|
|
276
|
+
return hit ? `present (${hit})` : "missing";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function recommendations(inferred, existing) {
|
|
280
|
+
const steps = [];
|
|
281
|
+
if (existing.config_yml !== "present") {
|
|
282
|
+
steps.push("shipctl config init");
|
|
283
|
+
}
|
|
284
|
+
const agentsPart = inferred.agents.length ? ` --agents ${inferred.agents.join(",")}` : "";
|
|
285
|
+
steps.push(
|
|
286
|
+
`shipctl init --bootstrap --tracker ${inferred.tracker} --ci ${inferred.ci}${agentsPart} --preset ${inferred.preset}`,
|
|
287
|
+
);
|
|
288
|
+
steps.push("shipctl sync");
|
|
289
|
+
steps.push("shipctl verify");
|
|
290
|
+
return steps;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Load .ship/config.yml starting from `cwd` (walking upward). Returns the
|
|
295
|
+
* declared stack subtree (tracker/ci/language/preset/agents plus
|
|
296
|
+
* api.channel) or null if no config is present. Parse errors are swallowed
|
|
297
|
+
* into `null` so doctor never crashes on malformed YAML — the disk-only
|
|
298
|
+
* recommendation path still runs.
|
|
299
|
+
*
|
|
300
|
+
* @param {string} cwd
|
|
301
|
+
* @returns {{filePath:string|null, stack: null | {tracker:string|null, ci:string|null, language:string|null, preset:string|null, agents:string[], channel:string|null}}}
|
|
302
|
+
*/
|
|
303
|
+
function loadShipConfig(cwd) {
|
|
304
|
+
try {
|
|
305
|
+
const root = findShipRoot(cwd);
|
|
306
|
+
if (!root) return { filePath: null, stack: null };
|
|
307
|
+
const { config, filePath } = readConfig(root);
|
|
308
|
+
const s = (config && config.stack) || {};
|
|
309
|
+
const api = (config && config.api) || {};
|
|
310
|
+
return {
|
|
311
|
+
filePath,
|
|
312
|
+
stack: {
|
|
313
|
+
tracker: typeof s.tracker === "string" ? s.tracker : null,
|
|
314
|
+
ci: typeof s.ci === "string" ? s.ci : null,
|
|
315
|
+
language: typeof s.language === "string" ? s.language : null,
|
|
316
|
+
preset: typeof s.preset === "string" ? s.preset : null,
|
|
317
|
+
agents: Array.isArray(s.agents) ? [...s.agents] : [],
|
|
318
|
+
channel: typeof api.channel === "string" ? api.channel : null,
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
} catch {
|
|
322
|
+
return { filePath: null, stack: null };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Merge disk-inferred signals with the declared `.ship/config.yml` stack.
|
|
328
|
+
* For tracker/ci/language/preset, config wins when present. For agents,
|
|
329
|
+
* we union config-declared ids with disk-detected ids (after mapping raw
|
|
330
|
+
* signals like `agents-md` → `codex` via `resolveAgentSignal`).
|
|
331
|
+
*
|
|
332
|
+
* Returns both the merged view and per-agent provenance so the JSON /
|
|
333
|
+
* human reporter can explain why AGENTS.md was counted as codex.
|
|
334
|
+
*
|
|
335
|
+
* @param {{trackers:Array, ci:Array, language:Array, agents:Array}} findings
|
|
336
|
+
* @param {{tracker:string, ci:string, language:string, agents:string[], preset:string}} inferred
|
|
337
|
+
* @param {ReturnType<typeof loadShipConfig>} configInfo
|
|
338
|
+
*/
|
|
339
|
+
function reconcileStack(findings, inferred, configInfo) {
|
|
340
|
+
const config = configInfo.stack;
|
|
341
|
+
const configAgents = config ? config.agents : [];
|
|
342
|
+
|
|
343
|
+
const diskAgentSignals = (findings.agents || [])
|
|
344
|
+
.filter((a) => a.present && a.confidence >= 0.5)
|
|
345
|
+
.map((a) => ({
|
|
346
|
+
signal: a.id,
|
|
347
|
+
resolved: resolveAgentSignal(a.id, configAgents),
|
|
348
|
+
confidence: a.confidence,
|
|
349
|
+
evidence: (a.evidence && a.evidence[0] && a.evidence[0].where) || null,
|
|
350
|
+
label: (a.evidence && a.evidence[0] && a.evidence[0].match) || null,
|
|
351
|
+
}));
|
|
352
|
+
|
|
353
|
+
const agentSet = new Set(configAgents);
|
|
354
|
+
for (const s of diskAgentSignals) agentSet.add(s.resolved);
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
tracker: config?.tracker || inferred.tracker,
|
|
358
|
+
ci: config?.ci || inferred.ci,
|
|
359
|
+
language: config?.language || inferred.language,
|
|
360
|
+
preset: config?.preset || inferred.preset,
|
|
361
|
+
agents: [...agentSet],
|
|
362
|
+
config_agents: [...configAgents],
|
|
363
|
+
disk_agents: diskAgentSignals.map((s) => s.resolved),
|
|
364
|
+
agent_signals: diskAgentSignals,
|
|
365
|
+
source: {
|
|
366
|
+
tracker: config?.tracker ? "config" : "disk",
|
|
367
|
+
ci: config?.ci ? "config" : "disk",
|
|
368
|
+
language: config?.language ? "config" : "disk",
|
|
369
|
+
preset: config?.preset ? "config" : "disk",
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Decide what to tell the operator. When `.ship/config.yml` is present,
|
|
376
|
+
* never propose a stack that contradicts it — instead propose additive
|
|
377
|
+
* init commands that bring disk into agreement with config.
|
|
378
|
+
*
|
|
379
|
+
* @param {{inferred:object, existing:object, config:object|null, reconciled:object}} ctx
|
|
380
|
+
*/
|
|
381
|
+
function buildRecommendations(ctx) {
|
|
382
|
+
const { inferred, existing, config, reconciled } = ctx;
|
|
383
|
+
if (!config) return recommendations(inferred, existing);
|
|
384
|
+
|
|
385
|
+
const configAgents = new Set(config.agents || []);
|
|
386
|
+
const diskResolved = new Set(reconciled.disk_agents || []);
|
|
387
|
+
const extras = [...diskResolved].filter((id) => !configAgents.has(id));
|
|
388
|
+
const missingOnDisk = configAgents.size > 0 && diskResolved.size === 0;
|
|
389
|
+
|
|
390
|
+
const diskPresent =
|
|
391
|
+
existing.config_yml === "present" &&
|
|
392
|
+
(existing.cache_dir === "present" || diskResolved.size > 0);
|
|
393
|
+
|
|
394
|
+
if (missingOnDisk) {
|
|
395
|
+
const list = [...configAgents].join(",");
|
|
396
|
+
return [`shipctl init --bootstrap --copy-rules --agents ${list}`, "shipctl verify"];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (extras.length) {
|
|
400
|
+
const union = [...new Set([...configAgents, ...extras])].join(",");
|
|
401
|
+
return [
|
|
402
|
+
`shipctl init --agents ${union} --copy-rules`,
|
|
403
|
+
"shipctl verify",
|
|
404
|
+
];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (!diskPresent) {
|
|
408
|
+
return ["shipctl init --bootstrap --copy-rules", "shipctl verify"];
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return ["Config and disk agree. Run `shipctl verify`."];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function printHumanReport(report) {
|
|
415
|
+
const {
|
|
416
|
+
cwd,
|
|
417
|
+
findings,
|
|
418
|
+
inferred,
|
|
419
|
+
preset_evidence,
|
|
420
|
+
existing,
|
|
421
|
+
recommendations: recs,
|
|
422
|
+
config,
|
|
423
|
+
reconciled,
|
|
424
|
+
} = report;
|
|
425
|
+
const out = [];
|
|
426
|
+
out.push(`Ship doctor — inspecting ${cwd}`);
|
|
427
|
+
out.push("");
|
|
428
|
+
|
|
429
|
+
const topN = (arr, n, filterPresent = true) => {
|
|
430
|
+
const pool = filterPresent ? arr.filter((e) => e.present && e.confidence > 0) : arr;
|
|
431
|
+
return pool.slice(0, n);
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const evToString = (ev) =>
|
|
435
|
+
ev
|
|
436
|
+
.map((e) => {
|
|
437
|
+
const where = e.where && e.where !== "-" ? e.where : "";
|
|
438
|
+
const match = e.match || "";
|
|
439
|
+
return where ? `${where}${match ? ` (${match})` : ""}` : match;
|
|
440
|
+
})
|
|
441
|
+
.filter(Boolean)
|
|
442
|
+
.join(", ");
|
|
443
|
+
|
|
444
|
+
if (config) {
|
|
445
|
+
// Reconciliation view — config is authoritative, disk is annotated.
|
|
446
|
+
const diskPick = (arr) => {
|
|
447
|
+
const pool = arr.filter((e) => e.present && e.confidence > 0);
|
|
448
|
+
return pool[0] || null;
|
|
449
|
+
};
|
|
450
|
+
const fmtDisk = (entry) =>
|
|
451
|
+
entry ? `${entry.id} (${entry.confidence.toFixed(2)})` : "no signal";
|
|
452
|
+
const reconLine = (label, configVal, diskEntry) => {
|
|
453
|
+
const left = configVal ? `${configVal} (config)` : "(unset)";
|
|
454
|
+
const right = `disk: ${fmtDisk(diskEntry)}`;
|
|
455
|
+
out.push(`${label.padEnd(12)} ${left} · ${right}`);
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
reconLine("Tracker:", config.tracker, diskPick(findings.trackers));
|
|
459
|
+
reconLine("CI:", config.ci, diskPick(findings.ci));
|
|
460
|
+
reconLine("Language:", config.language, diskPick(findings.language));
|
|
461
|
+
|
|
462
|
+
const declared = config.agents || [];
|
|
463
|
+
out.push(`${"Agents:".padEnd(12)} declared: ${declared.length ? declared.join(", ") : "(none)"}`);
|
|
464
|
+
const signals = reconciled?.agent_signals || [];
|
|
465
|
+
if (signals.length) {
|
|
466
|
+
const parts = signals.map((s) => {
|
|
467
|
+
const where = s.evidence && s.evidence !== "-" ? s.evidence : s.signal;
|
|
468
|
+
return s.signal !== s.resolved
|
|
469
|
+
? `${where} (→ ${s.resolved} via config)`
|
|
470
|
+
: s.resolved;
|
|
471
|
+
});
|
|
472
|
+
out.push(`${"".padEnd(12)} disk: ${parts.join(", ")}`);
|
|
473
|
+
} else {
|
|
474
|
+
out.push(`${"".padEnd(12)} disk: (none)`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const presetEvidence =
|
|
478
|
+
preset_evidence && preset_evidence.length ? preset_evidence.join(", ") : "no strong signals";
|
|
479
|
+
const diskPresetNote = `disk inferred: ${inferred.preset} — evidence: ${presetEvidence}`;
|
|
480
|
+
out.push(
|
|
481
|
+
`${"Preset:".padEnd(12)} ${config.preset ? `${config.preset} (config)` : "(unset)"} [${diskPresetNote}]`,
|
|
482
|
+
);
|
|
483
|
+
out.push("");
|
|
484
|
+
} else {
|
|
485
|
+
const categoryLine = (label, entries, fallback) => {
|
|
486
|
+
const top = topN(entries, 5);
|
|
487
|
+
if (!top.length) {
|
|
488
|
+
out.push(`${label.padEnd(12)} ${fallback}`);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const head = top[0];
|
|
492
|
+
const evStr = evToString(head.evidence);
|
|
493
|
+
const headLine = `${label.padEnd(12)} ${head.id} (${head.confidence.toFixed(2)})${
|
|
494
|
+
evStr ? ` · evidence: ${evStr}` : ""
|
|
495
|
+
}`;
|
|
496
|
+
out.push(headLine);
|
|
497
|
+
for (const row of top.slice(1)) {
|
|
498
|
+
out.push(`${"".padEnd(12)} ${row.id} (${row.confidence.toFixed(2)})`);
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
categoryLine("Tracker:", findings.trackers, "none detected");
|
|
503
|
+
categoryLine("CI:", findings.ci, "none detected");
|
|
504
|
+
categoryLine("Language:", findings.language, "none detected");
|
|
505
|
+
|
|
506
|
+
const agents = findings.agents.filter((a) => a.present && a.confidence > 0);
|
|
507
|
+
const agentStr =
|
|
508
|
+
agents
|
|
509
|
+
.slice(0, 8)
|
|
510
|
+
.map((a) => `${a.id} (${a.confidence.toFixed(2)})`)
|
|
511
|
+
.join(", ") || "none";
|
|
512
|
+
out.push(`${"Agents:".padEnd(12)} ${agentStr}`);
|
|
513
|
+
out.push("");
|
|
514
|
+
|
|
515
|
+
const presetEvidence =
|
|
516
|
+
preset_evidence && preset_evidence.length ? preset_evidence.join(", ") : "no strong signals";
|
|
517
|
+
out.push(`Inferred preset: ${inferred.preset} (evidence: ${presetEvidence})`);
|
|
518
|
+
out.push("");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
out.push("Existing Ship artifacts:");
|
|
522
|
+
out.push(` .ship/config.yml ${existing.config_yml}`);
|
|
523
|
+
out.push(` .ship/cache/ ${existing.cache_dir}`);
|
|
524
|
+
out.push(` .ship/inventory.json ${existing.inventory_json}`);
|
|
525
|
+
out.push(` .cursor/rules/ship-* ${existing.cursor_ship_rules}`);
|
|
526
|
+
out.push("");
|
|
527
|
+
|
|
528
|
+
out.push("Recommendations:");
|
|
529
|
+
recs.forEach((r, i) => out.push(` ${i + 1}. ${r}`));
|
|
530
|
+
out.push("");
|
|
531
|
+
|
|
532
|
+
const nextCmd =
|
|
533
|
+
recs.find((r) => r.startsWith("shipctl init")) || recs[0] || "";
|
|
534
|
+
out.push(`Next: ${nextCmd}`);
|
|
535
|
+
|
|
536
|
+
process.stdout.write(`${out.join("\n")}\n`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function writeInventoryFile(cwd, report) {
|
|
540
|
+
const body = {
|
|
541
|
+
version: 1,
|
|
542
|
+
detected_at: report.detected_at,
|
|
543
|
+
cwd: report.cwd,
|
|
544
|
+
findings: report.findings,
|
|
545
|
+
inferred: report.inferred,
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
// Prefer the real config-io module when available (race with parallel agent);
|
|
549
|
+
// fall back to node:fs so doctor can always ship its inventory.
|
|
550
|
+
let io = null;
|
|
551
|
+
try {
|
|
552
|
+
io = await import("../config/io.mjs");
|
|
553
|
+
} catch {
|
|
554
|
+
io = null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const absDir = path.join(cwd, ".ship");
|
|
558
|
+
const absPath = path.join(absDir, "inventory.json");
|
|
559
|
+
|
|
560
|
+
if (io && typeof io.findShipRoot === "function") {
|
|
561
|
+
// Honour the existing .ship/ location if config was already initialised
|
|
562
|
+
// nearby; otherwise fall through to the cwd-local path.
|
|
563
|
+
try {
|
|
564
|
+
const root = io.findShipRoot(cwd);
|
|
565
|
+
if (root) {
|
|
566
|
+
const p = path.join(root, ".ship", "inventory.json");
|
|
567
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
568
|
+
const tmp = `${p}.tmp`;
|
|
569
|
+
fs.writeFileSync(tmp, `${JSON.stringify(body, null, 2)}\n`, "utf8");
|
|
570
|
+
fs.renameSync(tmp, p);
|
|
571
|
+
return p;
|
|
572
|
+
}
|
|
573
|
+
} catch {
|
|
574
|
+
// Ignore and fall through to the direct fs write below.
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
fs.mkdirSync(absDir, { recursive: true });
|
|
579
|
+
const tmp = `${absPath}.tmp`;
|
|
580
|
+
fs.writeFileSync(tmp, `${JSON.stringify(body, null, 2)}\n`, "utf8");
|
|
581
|
+
fs.renameSync(tmp, absPath);
|
|
582
|
+
return absPath;
|
|
583
|
+
}
|