@tiny-fish/cli 0.15.1-next.158 → 0.15.1-next.160
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/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.js +217 -0
- package/dist/index.js +2 -0
- package/dist/lib/doctor-report.d.ts +135 -0
- package/dist/lib/doctor-report.js +60 -0
- package/dist/lib/verify.d.ts +2 -0
- package/dist/lib/verify.js +45 -2
- package/package.json +1 -1
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { type DoctorReport } from "../lib/doctor-report.js";
|
|
3
|
+
import { type FiveHarness } from "../lib/harness-detect.js";
|
|
4
|
+
export interface DoctorOptions {
|
|
5
|
+
mcpUrl: string;
|
|
6
|
+
harness?: FiveHarness;
|
|
7
|
+
}
|
|
8
|
+
export declare function runDoctor(options: DoctorOptions): Promise<DoctorReport>;
|
|
9
|
+
export declare function registerDoctor(program: Command): void;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { apiKeyStatus } from "../lib/auth.js";
|
|
2
|
+
import { CLI_VERSION } from "../lib/constants.js";
|
|
3
|
+
import { DOCTOR_COULD_NOT_RUN, DOCTOR_SCHEMA_VERSION, doctorReportSchema, exitCodeFor, renderPretty, } from "../lib/doctor-report.js";
|
|
4
|
+
import { FIVE_HARNESSES } from "../lib/harness-detect.js";
|
|
5
|
+
import { err, errLine, out, outLine } from "../lib/output.js";
|
|
6
|
+
import { detectRegistrations } from "../lib/registration-detect.js";
|
|
7
|
+
import { verifyMcpAuth, verifyMcpHealth } from "../lib/verify.js";
|
|
8
|
+
import { DEFAULT_MCP_URL } from "./connect.js";
|
|
9
|
+
// A green auth mode with a red auth call proves nothing, so the call's verdict gates the claim.
|
|
10
|
+
function provesHarnessReach(status, authCallPassed, healthOk, mcpUrl) {
|
|
11
|
+
// An endpoint doctor could not reach proves nothing about a harness pointed at it.
|
|
12
|
+
if (!healthOk)
|
|
13
|
+
return false;
|
|
14
|
+
if (pointsElsewhere(status.registeredUrl, mcpUrl))
|
|
15
|
+
return false;
|
|
16
|
+
return status.registered === "yes" && status.authMode === "api-key" && authCallPassed;
|
|
17
|
+
}
|
|
18
|
+
// Reports the version, never judges it: staleness is the server's call via X-TF-Notice.
|
|
19
|
+
function checkCliVersion() {
|
|
20
|
+
return {
|
|
21
|
+
id: "cli-version",
|
|
22
|
+
title: `CLI ${CLI_VERSION}`,
|
|
23
|
+
status: "pass",
|
|
24
|
+
detail: `${CLI_VERSION}; the server reports staleness via X-TF-Notice`,
|
|
25
|
+
harness: null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
async function checkConnectivity(mcpUrl) {
|
|
29
|
+
const health = await verifyMcpHealth(mcpUrl);
|
|
30
|
+
return {
|
|
31
|
+
id: "mcp-connectivity",
|
|
32
|
+
title: "MCP endpoint reachable",
|
|
33
|
+
status: health.ok ? "pass" : "fail",
|
|
34
|
+
detail: health.ok ? mcpUrl : (health.code ?? "the endpoint is unreachable"),
|
|
35
|
+
harness: null,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// Query strings carry the connect attempt id, so they differ on every healthy install.
|
|
39
|
+
function endpointOf(url) {
|
|
40
|
+
const parsed = new URL(url);
|
|
41
|
+
return `${parsed.origin}${parsed.pathname.replace(/\/$/, "")}`;
|
|
42
|
+
}
|
|
43
|
+
/** Host alone let `/wrong-path` and plain `http://` through and claim the harness reaches us. */
|
|
44
|
+
function pointsElsewhere(registeredUrl, mcpUrl) {
|
|
45
|
+
if (!registeredUrl)
|
|
46
|
+
return undefined;
|
|
47
|
+
try {
|
|
48
|
+
const registered = endpointOf(registeredUrl);
|
|
49
|
+
return registered === endpointOf(mcpUrl) ? undefined : registered;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// An endpoint that will not parse cannot be shown to match, so it does not.
|
|
53
|
+
return "an unparseable endpoint";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function registrationVerdict(status, mcpUrl) {
|
|
57
|
+
if (!status.detected)
|
|
58
|
+
return { status: "skip", detail: "harness not installed" };
|
|
59
|
+
if (status.registered === "unknown") {
|
|
60
|
+
return { status: "warn", detail: status.reason ?? "could not determine registration" };
|
|
61
|
+
}
|
|
62
|
+
if (status.registered === "yes") {
|
|
63
|
+
// A `tinyfish` entry aimed at a dev server is registered and still cannot reach TinyFish.
|
|
64
|
+
const elsewhere = pointsElsewhere(status.registeredUrl, mcpUrl);
|
|
65
|
+
if (elsewhere) {
|
|
66
|
+
return {
|
|
67
|
+
status: "fail",
|
|
68
|
+
detail: `registered, but points at ${elsewhere} rather than ${endpointOf(mcpUrl)}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { status: "pass", detail: `registered, auth mode ${status.authMode}` };
|
|
72
|
+
}
|
|
73
|
+
// Connected once and now absent is a broken install; never connected is just not set up.
|
|
74
|
+
return status.connectedBefore
|
|
75
|
+
? { status: "fail", detail: "connected previously but TinyFish is no longer registered" }
|
|
76
|
+
: { status: "warn", detail: "installed but TinyFish was never connected" };
|
|
77
|
+
}
|
|
78
|
+
function checkRegistration(status, mcpUrl) {
|
|
79
|
+
return {
|
|
80
|
+
id: "harness-registration",
|
|
81
|
+
title: `${status.harness} registration`,
|
|
82
|
+
harness: status.harness,
|
|
83
|
+
...registrationVerdict(status, mcpUrl),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function checkCredential() {
|
|
87
|
+
const resolved = apiKeyStatus();
|
|
88
|
+
const check = {
|
|
89
|
+
id: "cli-credential",
|
|
90
|
+
title: "CLI credential",
|
|
91
|
+
status: "key" in resolved ? "pass" : "fail",
|
|
92
|
+
detail: "key" in resolved ? `valid key from ${resolved.source}` : resolved.error,
|
|
93
|
+
harness: null,
|
|
94
|
+
};
|
|
95
|
+
return "key" in resolved ? { check, key: resolved.key } : { check };
|
|
96
|
+
}
|
|
97
|
+
async function checkAuthCall(key) {
|
|
98
|
+
if (!key) {
|
|
99
|
+
return {
|
|
100
|
+
id: "cli-auth-call",
|
|
101
|
+
title: "Authenticated call",
|
|
102
|
+
status: "skip",
|
|
103
|
+
detail: "no credential to test",
|
|
104
|
+
harness: null,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const auth = await verifyMcpAuth(key);
|
|
108
|
+
return {
|
|
109
|
+
id: "cli-auth-call",
|
|
110
|
+
title: "Authenticated call",
|
|
111
|
+
status: auth.ok ? "pass" : "fail",
|
|
112
|
+
detail: auth.ok ? "listRuns succeeded" : (auth.code ?? "the authenticated call failed"),
|
|
113
|
+
harness: null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// Only Cursor's repair is a pure local file write; every other one needs a browser sign-in.
|
|
117
|
+
const UNATTENDED_SAFE = new Set(["cursor"]);
|
|
118
|
+
function repairsFor(checks, statuses) {
|
|
119
|
+
const repairs = [];
|
|
120
|
+
for (const status of statuses) {
|
|
121
|
+
// `unknown` earns nothing: running connect off a failed probe contradicts the check's detail.
|
|
122
|
+
// A registered-but-misaimed entry does earn one, since connect rewrites the URL.
|
|
123
|
+
const misaimed = checks.some((c) => c.harness === status.harness && c.status === "fail");
|
|
124
|
+
if (!status.detected || (status.registered !== "no" && !misaimed))
|
|
125
|
+
continue;
|
|
126
|
+
repairs.push({
|
|
127
|
+
for: status.registered === "yes"
|
|
128
|
+
? `${status.harness}-points-elsewhere`
|
|
129
|
+
: status.connectedBefore
|
|
130
|
+
? `${status.harness}-registration-lost`
|
|
131
|
+
: `${status.harness}-not-connected`,
|
|
132
|
+
action: "connect",
|
|
133
|
+
harness: status.harness,
|
|
134
|
+
command: `tinyfish connect ${status.harness}`,
|
|
135
|
+
unattended_safe: UNATTENDED_SAFE.has(status.harness),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// A revoked-but-well-formed key passes the credential check and fails the call; both need login.
|
|
139
|
+
const credentialBroken = checks.some((c) => (c.id === "cli-credential" || c.id === "cli-auth-call") && c.status === "fail");
|
|
140
|
+
if (credentialBroken) {
|
|
141
|
+
repairs.push({
|
|
142
|
+
for: "cli-credential-invalid",
|
|
143
|
+
action: "auth-login",
|
|
144
|
+
harness: null,
|
|
145
|
+
command: "tinyfish auth login",
|
|
146
|
+
unattended_safe: false,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return repairs;
|
|
150
|
+
}
|
|
151
|
+
export async function runDoctor(options) {
|
|
152
|
+
const statuses = detectRegistrations(options.harness ? [options.harness] : undefined);
|
|
153
|
+
const credential = checkCredential();
|
|
154
|
+
const checks = [
|
|
155
|
+
checkCliVersion(),
|
|
156
|
+
await checkConnectivity(options.mcpUrl),
|
|
157
|
+
...statuses.map((status) => checkRegistration(status, options.mcpUrl)),
|
|
158
|
+
credential.check,
|
|
159
|
+
await checkAuthCall(credential.key),
|
|
160
|
+
];
|
|
161
|
+
const authCallPassed = checks.some((c) => c.id === "cli-auth-call" && c.status === "pass");
|
|
162
|
+
const healthOk = checks.some((c) => c.id === "mcp-connectivity" && c.status === "pass");
|
|
163
|
+
const harnesses = statuses.map((status) => ({
|
|
164
|
+
harness: status.harness,
|
|
165
|
+
detected: status.detected,
|
|
166
|
+
registered: status.registered,
|
|
167
|
+
auth_mode: status.authMode,
|
|
168
|
+
proves_harness_reach: provesHarnessReach(status, authCallPassed, healthOk, options.mcpUrl),
|
|
169
|
+
}));
|
|
170
|
+
const body = {
|
|
171
|
+
schema_version: DOCTOR_SCHEMA_VERSION,
|
|
172
|
+
cli_version: CLI_VERSION,
|
|
173
|
+
ok: exitCodeFor(checks) === 0,
|
|
174
|
+
checks,
|
|
175
|
+
harnesses,
|
|
176
|
+
repairs: repairsFor(checks, statuses),
|
|
177
|
+
};
|
|
178
|
+
// Nothing to scrub: every string above is authored here or enumerated at its source.
|
|
179
|
+
return doctorReportSchema.parse(body);
|
|
180
|
+
}
|
|
181
|
+
export function registerDoctor(program) {
|
|
182
|
+
program
|
|
183
|
+
.command("doctor")
|
|
184
|
+
.description("Diagnose your TinyFish setup")
|
|
185
|
+
.option("--pretty", "Human-readable check list instead of JSON")
|
|
186
|
+
.option("--harness <name>", `Restrict to one harness (${FIVE_HARNESSES.join(", ")})`)
|
|
187
|
+
.option("--url <mcpUrl>", "MCP endpoint override")
|
|
188
|
+
.action(async (options) => {
|
|
189
|
+
if (options.harness && !FIVE_HARNESSES.includes(options.harness)) {
|
|
190
|
+
err({ error: `Unknown --harness value: ${options.harness}` });
|
|
191
|
+
process.exitCode = DOCTOR_COULD_NOT_RUN;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
let report;
|
|
195
|
+
try {
|
|
196
|
+
report = await runDoctor({
|
|
197
|
+
mcpUrl: options.url ?? DEFAULT_MCP_URL,
|
|
198
|
+
...(options.harness ? { harness: options.harness } : {}),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
// Exit 2 means doctor itself broke, a different claim from "your setup is broken".
|
|
203
|
+
// Authored, like every other string a user might paste; raw text is behind --debug.
|
|
204
|
+
if (process.env["TINYFISH_DEBUG"]) {
|
|
205
|
+
errLine(e instanceof Error ? (e.stack ?? e.message) : String(e));
|
|
206
|
+
}
|
|
207
|
+
err({ error: "doctor could not complete; re-run with --debug for the raw error" });
|
|
208
|
+
process.exitCode = DOCTOR_COULD_NOT_RUN;
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (options.pretty)
|
|
212
|
+
outLine(renderPretty(report));
|
|
213
|
+
else
|
|
214
|
+
out(report);
|
|
215
|
+
process.exitCode = exitCodeFor(report.checks);
|
|
216
|
+
});
|
|
217
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { registerRuns } from "./commands/runs.js";
|
|
|
12
12
|
import { registerConfigureClaude } from "./commands/config-claude.js";
|
|
13
13
|
import { registerSearch } from "./commands/search.js";
|
|
14
14
|
import { registerConnect } from "./commands/connect.js";
|
|
15
|
+
import { registerDoctor } from "./commands/doctor.js";
|
|
15
16
|
import { registerUpgrade } from "./commands/upgrade.js";
|
|
16
17
|
import { registerOnboard } from "./commands/onboard.js";
|
|
17
18
|
const { version } = createRequire(import.meta.url)("../package.json");
|
|
@@ -32,6 +33,7 @@ program
|
|
|
32
33
|
registerAuth(program);
|
|
33
34
|
registerOnboard(program);
|
|
34
35
|
registerConnect(program);
|
|
36
|
+
registerDoctor(program);
|
|
35
37
|
registerUpgrade(program);
|
|
36
38
|
registerConfigureClaude(program);
|
|
37
39
|
registerBrowser(program);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Bumped whenever a consumer could misread the payload; the cookbook skill releases separately. */
|
|
3
|
+
export declare const DOCTOR_SCHEMA_VERSION = 1;
|
|
4
|
+
declare const checkStatusSchema: z.ZodEnum<{
|
|
5
|
+
pass: "pass";
|
|
6
|
+
fail: "fail";
|
|
7
|
+
warn: "warn";
|
|
8
|
+
skip: "skip";
|
|
9
|
+
}>;
|
|
10
|
+
declare const doctorCheckSchema: z.ZodObject<{
|
|
11
|
+
id: z.ZodString;
|
|
12
|
+
title: z.ZodString;
|
|
13
|
+
status: z.ZodEnum<{
|
|
14
|
+
pass: "pass";
|
|
15
|
+
fail: "fail";
|
|
16
|
+
warn: "warn";
|
|
17
|
+
skip: "skip";
|
|
18
|
+
}>;
|
|
19
|
+
detail: z.ZodString;
|
|
20
|
+
harness: z.ZodNullable<z.ZodEnum<{
|
|
21
|
+
openclaw: "openclaw";
|
|
22
|
+
cursor: "cursor";
|
|
23
|
+
codex: "codex";
|
|
24
|
+
hermes: "hermes";
|
|
25
|
+
"claude-code": "claude-code";
|
|
26
|
+
}>>;
|
|
27
|
+
}, z.core.$strip>;
|
|
28
|
+
declare const doctorHarnessSchema: z.ZodObject<{
|
|
29
|
+
harness: z.ZodEnum<{
|
|
30
|
+
openclaw: "openclaw";
|
|
31
|
+
cursor: "cursor";
|
|
32
|
+
codex: "codex";
|
|
33
|
+
hermes: "hermes";
|
|
34
|
+
"claude-code": "claude-code";
|
|
35
|
+
}>;
|
|
36
|
+
detected: z.ZodBoolean;
|
|
37
|
+
registered: z.ZodEnum<{
|
|
38
|
+
unknown: "unknown";
|
|
39
|
+
yes: "yes";
|
|
40
|
+
no: "no";
|
|
41
|
+
}>;
|
|
42
|
+
auth_mode: z.ZodEnum<{
|
|
43
|
+
unknown: "unknown";
|
|
44
|
+
oauth: "oauth";
|
|
45
|
+
"api-key": "api-key";
|
|
46
|
+
}>;
|
|
47
|
+
proves_harness_reach: z.ZodBoolean;
|
|
48
|
+
}, z.core.$strip>;
|
|
49
|
+
declare const doctorRepairSchema: z.ZodObject<{
|
|
50
|
+
for: z.ZodString;
|
|
51
|
+
action: z.ZodEnum<{
|
|
52
|
+
connect: "connect";
|
|
53
|
+
"auth-login": "auth-login";
|
|
54
|
+
}>;
|
|
55
|
+
harness: z.ZodNullable<z.ZodEnum<{
|
|
56
|
+
openclaw: "openclaw";
|
|
57
|
+
cursor: "cursor";
|
|
58
|
+
codex: "codex";
|
|
59
|
+
hermes: "hermes";
|
|
60
|
+
"claude-code": "claude-code";
|
|
61
|
+
}>>;
|
|
62
|
+
command: z.ZodString;
|
|
63
|
+
unattended_safe: z.ZodBoolean;
|
|
64
|
+
}, z.core.$strip>;
|
|
65
|
+
export declare const doctorReportSchema: z.ZodObject<{
|
|
66
|
+
schema_version: z.ZodInt;
|
|
67
|
+
cli_version: z.ZodString;
|
|
68
|
+
ok: z.ZodBoolean;
|
|
69
|
+
checks: z.ZodArray<z.ZodObject<{
|
|
70
|
+
id: z.ZodString;
|
|
71
|
+
title: z.ZodString;
|
|
72
|
+
status: z.ZodEnum<{
|
|
73
|
+
pass: "pass";
|
|
74
|
+
fail: "fail";
|
|
75
|
+
warn: "warn";
|
|
76
|
+
skip: "skip";
|
|
77
|
+
}>;
|
|
78
|
+
detail: z.ZodString;
|
|
79
|
+
harness: z.ZodNullable<z.ZodEnum<{
|
|
80
|
+
openclaw: "openclaw";
|
|
81
|
+
cursor: "cursor";
|
|
82
|
+
codex: "codex";
|
|
83
|
+
hermes: "hermes";
|
|
84
|
+
"claude-code": "claude-code";
|
|
85
|
+
}>>;
|
|
86
|
+
}, z.core.$strip>>;
|
|
87
|
+
harnesses: z.ZodArray<z.ZodObject<{
|
|
88
|
+
harness: z.ZodEnum<{
|
|
89
|
+
openclaw: "openclaw";
|
|
90
|
+
cursor: "cursor";
|
|
91
|
+
codex: "codex";
|
|
92
|
+
hermes: "hermes";
|
|
93
|
+
"claude-code": "claude-code";
|
|
94
|
+
}>;
|
|
95
|
+
detected: z.ZodBoolean;
|
|
96
|
+
registered: z.ZodEnum<{
|
|
97
|
+
unknown: "unknown";
|
|
98
|
+
yes: "yes";
|
|
99
|
+
no: "no";
|
|
100
|
+
}>;
|
|
101
|
+
auth_mode: z.ZodEnum<{
|
|
102
|
+
unknown: "unknown";
|
|
103
|
+
oauth: "oauth";
|
|
104
|
+
"api-key": "api-key";
|
|
105
|
+
}>;
|
|
106
|
+
proves_harness_reach: z.ZodBoolean;
|
|
107
|
+
}, z.core.$strip>>;
|
|
108
|
+
repairs: z.ZodArray<z.ZodObject<{
|
|
109
|
+
for: z.ZodString;
|
|
110
|
+
action: z.ZodEnum<{
|
|
111
|
+
connect: "connect";
|
|
112
|
+
"auth-login": "auth-login";
|
|
113
|
+
}>;
|
|
114
|
+
harness: z.ZodNullable<z.ZodEnum<{
|
|
115
|
+
openclaw: "openclaw";
|
|
116
|
+
cursor: "cursor";
|
|
117
|
+
codex: "codex";
|
|
118
|
+
hermes: "hermes";
|
|
119
|
+
"claude-code": "claude-code";
|
|
120
|
+
}>>;
|
|
121
|
+
command: z.ZodString;
|
|
122
|
+
unattended_safe: z.ZodBoolean;
|
|
123
|
+
}, z.core.$strip>>;
|
|
124
|
+
}, z.core.$strip>;
|
|
125
|
+
export type CheckStatus = z.infer<typeof checkStatusSchema>;
|
|
126
|
+
export type DoctorCheck = z.infer<typeof doctorCheckSchema>;
|
|
127
|
+
export type DoctorHarness = z.infer<typeof doctorHarnessSchema>;
|
|
128
|
+
export type DoctorRepair = z.infer<typeof doctorRepairSchema>;
|
|
129
|
+
export type DoctorReport = z.infer<typeof doctorReportSchema>;
|
|
130
|
+
/** Doctor itself broke, which is a different claim from "your setup is broken". */
|
|
131
|
+
export declare const DOCTOR_COULD_NOT_RUN = 2;
|
|
132
|
+
/** Skips never fail the run; a skip means not applicable, not broken. */
|
|
133
|
+
export declare function exitCodeFor(checks: DoctorCheck[]): 0 | 1;
|
|
134
|
+
export declare function renderPretty(report: DoctorReport): string;
|
|
135
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { FIVE_HARNESSES } from "./harness-detect.js";
|
|
3
|
+
/** Bumped whenever a consumer could misread the payload; the cookbook skill releases separately. */
|
|
4
|
+
export const DOCTOR_SCHEMA_VERSION = 1;
|
|
5
|
+
const checkStatusSchema = z.enum(["pass", "fail", "warn", "skip"]);
|
|
6
|
+
const harnessSchema = z.enum(FIVE_HARNESSES);
|
|
7
|
+
const doctorCheckSchema = z.object({
|
|
8
|
+
id: z.string(),
|
|
9
|
+
title: z.string(),
|
|
10
|
+
status: checkStatusSchema,
|
|
11
|
+
detail: z.string(),
|
|
12
|
+
harness: harnessSchema.nullable(),
|
|
13
|
+
});
|
|
14
|
+
const doctorHarnessSchema = z.object({
|
|
15
|
+
harness: harnessSchema,
|
|
16
|
+
detected: z.boolean(),
|
|
17
|
+
registered: z.enum(["yes", "no", "unknown"]),
|
|
18
|
+
auth_mode: z.enum(["api-key", "oauth", "unknown"]),
|
|
19
|
+
proves_harness_reach: z.boolean(),
|
|
20
|
+
});
|
|
21
|
+
// `action` is what `--fix` dispatches on, not the harness field: keying off a null harness
|
|
22
|
+
// conflated "run auth login" with a non-executable repair and the credential fix never ran.
|
|
23
|
+
const doctorRepairSchema = z.object({
|
|
24
|
+
for: z.string(),
|
|
25
|
+
action: z.enum(["connect", "auth-login"]),
|
|
26
|
+
harness: harnessSchema.nullable(),
|
|
27
|
+
command: z.string(),
|
|
28
|
+
unattended_safe: z.boolean(),
|
|
29
|
+
});
|
|
30
|
+
export const doctorReportSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
// Not `z.literal`: a consumer must be able to read a future version and say so.
|
|
33
|
+
schema_version: z.int().positive(),
|
|
34
|
+
cli_version: z.string(),
|
|
35
|
+
ok: z.boolean(),
|
|
36
|
+
checks: z.array(doctorCheckSchema),
|
|
37
|
+
harnesses: z.array(doctorHarnessSchema),
|
|
38
|
+
repairs: z.array(doctorRepairSchema),
|
|
39
|
+
})
|
|
40
|
+
// A report that says ok while a check failed is worse than no report.
|
|
41
|
+
.refine((report) => !report.ok || report.checks.every((check) => check.status !== "fail"), {
|
|
42
|
+
message: "ok cannot be true while a check has failed",
|
|
43
|
+
path: ["ok"],
|
|
44
|
+
});
|
|
45
|
+
const GLYPHS = { pass: "✓", fail: "✗", warn: "⚠", skip: "…" };
|
|
46
|
+
/** Doctor itself broke, which is a different claim from "your setup is broken". */
|
|
47
|
+
export const DOCTOR_COULD_NOT_RUN = 2;
|
|
48
|
+
/** Skips never fail the run; a skip means not applicable, not broken. */
|
|
49
|
+
export function exitCodeFor(checks) {
|
|
50
|
+
return checks.some((check) => check.status === "fail") ? 1 : 0;
|
|
51
|
+
}
|
|
52
|
+
export function renderPretty(report) {
|
|
53
|
+
const lines = report.checks.map((c) => `${GLYPHS[c.status]} ${c.title}${c.detail ? ` — ${c.detail}` : ""}`);
|
|
54
|
+
if (report.repairs.length > 0) {
|
|
55
|
+
lines.push("", "Repairs:");
|
|
56
|
+
for (const repair of report.repairs)
|
|
57
|
+
lines.push(` ${repair.command}`);
|
|
58
|
+
}
|
|
59
|
+
return lines.join("\n");
|
|
60
|
+
}
|
package/dist/lib/verify.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export interface VerifyResult {
|
|
|
3
3
|
depth: VerifyDepth;
|
|
4
4
|
ok: boolean;
|
|
5
5
|
reason?: string;
|
|
6
|
+
/** Authored summary of `reason`, safe to publish. Additive: `connect` still prints `reason`. */
|
|
7
|
+
code?: string;
|
|
6
8
|
}
|
|
7
9
|
/** Reachability check. Verify failure is a warning, never install failure. */
|
|
8
10
|
export declare function verifyMcpHealth(mcpUrl: string): Promise<VerifyResult>;
|
package/dist/lib/verify.js
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
import { listRuns } from "./client.js";
|
|
2
|
+
import { ApiError } from "./output.js";
|
|
2
3
|
const VERIFY_TIMEOUT_MS = 5_000;
|
|
4
|
+
// `fetch` collapses every network fault to "fetch failed" and hides the cause on `e.cause`.
|
|
5
|
+
function networkCode(e) {
|
|
6
|
+
const cause = e?.cause;
|
|
7
|
+
switch (cause?.code) {
|
|
8
|
+
case "ECONNREFUSED":
|
|
9
|
+
return "connection refused";
|
|
10
|
+
case "ENOTFOUND":
|
|
11
|
+
return "host not found";
|
|
12
|
+
case "ETIMEDOUT":
|
|
13
|
+
return "connection timed out";
|
|
14
|
+
case "CERT_HAS_EXPIRED":
|
|
15
|
+
case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
|
|
16
|
+
case "DEPTH_ZERO_SELF_SIGNED_CERT":
|
|
17
|
+
return "TLS verification failed";
|
|
18
|
+
default:
|
|
19
|
+
return e instanceof Error && e.name === "TimeoutError"
|
|
20
|
+
? "request timed out"
|
|
21
|
+
: "could not reach the endpoint";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function authCode(e) {
|
|
25
|
+
if (!(e instanceof ApiError))
|
|
26
|
+
return "the authenticated call failed";
|
|
27
|
+
if (e.status === 401)
|
|
28
|
+
return "the API key is invalid or expired";
|
|
29
|
+
if (e.status === 403)
|
|
30
|
+
return "the API key is not allowed to do this";
|
|
31
|
+
if (e.status === 429)
|
|
32
|
+
return "rate limited";
|
|
33
|
+
return e.status >= 500 ? "TinyFish returned a server error" : `the API returned HTTP ${e.status}`;
|
|
34
|
+
}
|
|
3
35
|
/** Reachability check. Verify failure is a warning, never install failure. */
|
|
4
36
|
export async function verifyMcpHealth(mcpUrl) {
|
|
5
37
|
try {
|
|
@@ -11,7 +43,12 @@ export async function verifyMcpHealth(mcpUrl) {
|
|
|
11
43
|
});
|
|
12
44
|
// MCP rejects bare GET with 4xx — still proves endpoint routed.
|
|
13
45
|
if (response.status >= 500) {
|
|
14
|
-
return {
|
|
46
|
+
return {
|
|
47
|
+
depth: "health",
|
|
48
|
+
ok: false,
|
|
49
|
+
reason: `endpoint returned HTTP ${response.status}`,
|
|
50
|
+
code: `the endpoint returned HTTP ${response.status}`,
|
|
51
|
+
};
|
|
15
52
|
}
|
|
16
53
|
return { depth: "health", ok: true };
|
|
17
54
|
}
|
|
@@ -20,6 +57,7 @@ export async function verifyMcpHealth(mcpUrl) {
|
|
|
20
57
|
depth: "health",
|
|
21
58
|
ok: false,
|
|
22
59
|
reason: e instanceof Error ? e.message : "network error reaching MCP endpoint",
|
|
60
|
+
code: networkCode(e),
|
|
23
61
|
};
|
|
24
62
|
}
|
|
25
63
|
}
|
|
@@ -31,6 +69,11 @@ export async function verifyMcpAuth(apiKey) {
|
|
|
31
69
|
return { depth: "health+auth", ok: true };
|
|
32
70
|
}
|
|
33
71
|
catch (e) {
|
|
34
|
-
return {
|
|
72
|
+
return {
|
|
73
|
+
depth: "health+auth",
|
|
74
|
+
ok: false,
|
|
75
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
76
|
+
code: authCode(e),
|
|
77
|
+
};
|
|
35
78
|
}
|
|
36
79
|
}
|