@tiny-fish/cli 0.20.2-next.190 → 0.21.1-next.194
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/connect.js +18 -27
- package/dist/commands/doctor.js +89 -37
- package/dist/lib/auth.d.ts +2 -0
- package/dist/lib/auth.js +5 -1
- package/dist/lib/connect-clients.js +11 -14
- package/dist/lib/connect-install.js +8 -13
- package/dist/lib/connect-runtime.d.ts +22 -3
- package/dist/lib/connect-runtime.js +51 -5
- package/dist/lib/cursor-config.d.ts +2 -1
- package/dist/lib/cursor-config.js +6 -3
- package/dist/lib/doctor-report.d.ts +6 -21
- package/dist/lib/doctor-report.js +4 -4
- package/dist/lib/harness-detect.d.ts +10 -0
- package/dist/lib/harness-detect.js +13 -0
- package/dist/lib/registration-detect.d.ts +3 -3
- package/dist/lib/registration-detect.js +52 -42
- package/dist/lib/verify.d.ts +2 -0
- package/dist/lib/verify.js +1 -0
- package/package.json +1 -1
package/dist/commands/connect.js
CHANGED
|
@@ -2,9 +2,10 @@ import spawn from "cross-spawn";
|
|
|
2
2
|
import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, validatedApiKey, } from "../lib/auth.js";
|
|
3
3
|
import { CLAUDE_CODE, CODEX, HERMES, OPENCLAW, OPENCLAW_SKILL_INSTALL_ARGS, OPENCODE, launchNativeMcpClient, launchOpenClawWalkthrough, openExternalUrl, } from "../lib/connect-clients.js";
|
|
4
4
|
import { ensureCliAuthenticated, installTinyFishCli, installWebSkill, SKILL_INSTALL_TIMEOUT_MS, UPGRADE_HINT, } from "../lib/connect-install.js";
|
|
5
|
-
import { ConnectInterruptedError, createConnectTelemetry, requireCommandSupport, runGuarded, settle, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "../lib/connect-runtime.js";
|
|
5
|
+
import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, requireCommandSupport, runGuarded, settle, spawnStepError, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "../lib/connect-runtime.js";
|
|
6
6
|
import { cursorInstallDeeplink, cursorMcpPath, writeCursorMcpConfig, } from "../lib/cursor-config.js";
|
|
7
7
|
import { runConnectAll } from "../lib/connect-all.js";
|
|
8
|
+
import { AuthMode } from "../lib/harness-detect.js";
|
|
8
9
|
import { detectHumanInitiated } from "../lib/harness.js";
|
|
9
10
|
import { emitNotice } from "../lib/notice.js";
|
|
10
11
|
import { errLine } from "../lib/output.js";
|
|
@@ -22,12 +23,7 @@ function removeExistingRegistration(client, removal) {
|
|
|
22
23
|
const details = result.stderr?.trim() || result.error?.message || "unknown error";
|
|
23
24
|
if (/No MCP server named "tinyfish"/i.test(details))
|
|
24
25
|
return;
|
|
25
|
-
|
|
26
|
-
throw new Error(`Could not remove existing ${removal.label}: ${details}`, {
|
|
27
|
-
cause: result.error,
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
throw new Error(`Could not remove existing ${removal.label}: ${details}`);
|
|
26
|
+
throw spawnStepError(`Could not remove existing ${removal.label}: ${details}`, result);
|
|
31
27
|
}
|
|
32
28
|
/** false: fall back to OAuth — a config naming an unset variable authenticates nothing. */
|
|
33
29
|
function exportApiKey(apiKey, displayName) {
|
|
@@ -49,7 +45,8 @@ async function connectNativeMcpClient(client, options) {
|
|
|
49
45
|
const state = { stage: "prerequisite_check", settled: false };
|
|
50
46
|
await runGuarded(state, telemetry, () => {
|
|
51
47
|
telemetry.track("started");
|
|
52
|
-
const { optionalSupported, keyAuthSupported } = requireCommandSupport(client);
|
|
48
|
+
const { optionalSupported, keyAuthSupported, harnessVersion } = requireCommandSupport(client);
|
|
49
|
+
telemetry.setHarnessVersion(harnessVersion);
|
|
53
50
|
state.harnessDegraded = !!client.loginArgs && !optionalSupported;
|
|
54
51
|
// Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
|
|
55
52
|
const storedKey = validatedApiKey(options.apiKey);
|
|
@@ -92,10 +89,7 @@ async function connectNativeMcpClient(client, options) {
|
|
|
92
89
|
stdio: "inherit",
|
|
93
90
|
});
|
|
94
91
|
if (addResult.error || addResult.status !== 0) {
|
|
95
|
-
|
|
96
|
-
throw new Error(`Could not add TinyFish to ${client.displayName}`, {
|
|
97
|
-
cause: addResult.error,
|
|
98
|
-
});
|
|
92
|
+
throw spawnStepError(`Could not add TinyFish to ${client.displayName}`, addResult);
|
|
99
93
|
}
|
|
100
94
|
telemetry.track("checkpoint", { phase: "registered" });
|
|
101
95
|
// Codex runs OAuth inside `mcp add` and exits 0 either way, so the recorded state decides
|
|
@@ -121,19 +115,16 @@ async function connectNativeMcpClient(client, options) {
|
|
|
121
115
|
stdio: "inherit",
|
|
122
116
|
});
|
|
123
117
|
if (loginResult.error || loginResult.status !== 0) {
|
|
124
|
-
|
|
125
|
-
throw new Error(`Could not authenticate TinyFish in ${client.displayName}`, {
|
|
126
|
-
cause: loginResult.error,
|
|
127
|
-
});
|
|
118
|
+
throw spawnStepError(`Could not authenticate TinyFish in ${client.displayName}`, loginResult);
|
|
128
119
|
}
|
|
129
120
|
telemetry.track("checkpoint", { phase: "oauth_done" });
|
|
130
121
|
}
|
|
131
122
|
const signInDeferred = !useKeyAuth && !addSignedIn && !pendingLogin;
|
|
132
123
|
const authMode = useKeyAuth
|
|
133
|
-
?
|
|
124
|
+
? AuthMode.ApiKey
|
|
134
125
|
: signInDeferred
|
|
135
126
|
? "deferred"
|
|
136
|
-
:
|
|
127
|
+
: AuthMode.OAuth;
|
|
137
128
|
state.authMode = authMode;
|
|
138
129
|
// Registration/OAuth make MCP work; later steps are cosmetic and must not fail the attempt.
|
|
139
130
|
settle(state, telemetry, "completed", { authMode });
|
|
@@ -181,7 +172,10 @@ function runPostInstallSteps(client, options, state, telemetry, signInDeferred)
|
|
|
181
172
|
/** Warn + report post_install_failed; interrupts are abandonment, not failure. */
|
|
182
173
|
function reportPostInstallFailure(connectClient, displayName, state, telemetry, error) {
|
|
183
174
|
if (!(error instanceof ConnectInterruptedError)) {
|
|
184
|
-
telemetry.track("post_install_failed", {
|
|
175
|
+
telemetry.track("post_install_failed", {
|
|
176
|
+
failedStage: state.stage,
|
|
177
|
+
...(error instanceof ConnectStepError ? { failureReason: error.failureReason } : {}),
|
|
178
|
+
});
|
|
185
179
|
errLine(`The ${displayName} MCP connection succeeded, but a finishing step ` +
|
|
186
180
|
`(${state.stage}) failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
187
181
|
}
|
|
@@ -209,7 +203,7 @@ export async function connectOpenClaw(options) {
|
|
|
209
203
|
};
|
|
210
204
|
await runGuarded(state, telemetry, () => {
|
|
211
205
|
telemetry.track("started");
|
|
212
|
-
requireCommandSupport(OPENCLAW);
|
|
206
|
+
telemetry.setHarnessVersion(requireCommandSupport(OPENCLAW).harnessVersion);
|
|
213
207
|
telemetry.track("checkpoint", { phase: "prerequisite_ok" });
|
|
214
208
|
if (options.installCli !== false) {
|
|
215
209
|
state.stage = "cli_install";
|
|
@@ -223,10 +217,7 @@ export async function connectOpenClaw(options) {
|
|
|
223
217
|
timeout: SKILL_INSTALL_TIMEOUT_MS,
|
|
224
218
|
});
|
|
225
219
|
if (skillInstallResult.error || skillInstallResult.status !== 0) {
|
|
226
|
-
|
|
227
|
-
throw new Error("Could not install the TinyFish skill in OpenClaw", {
|
|
228
|
-
cause: skillInstallResult.error,
|
|
229
|
-
});
|
|
220
|
+
throw spawnStepError("Could not install the TinyFish skill in OpenClaw", skillInstallResult);
|
|
230
221
|
}
|
|
231
222
|
telemetry.track("checkpoint", { phase: "skill_installed" });
|
|
232
223
|
// Stores the key before probing, so a passed --api-key never reaches an interactive login.
|
|
@@ -280,8 +271,8 @@ export async function connectCursor(options) {
|
|
|
280
271
|
// Attempt id rides telemetry only; persisting it would break idempotency.
|
|
281
272
|
const result = writeCursorMcpConfig(mcpUrl.toString(), resolvedKey);
|
|
282
273
|
if (result.status === "corrupt_skip") {
|
|
283
|
-
throw new
|
|
284
|
-
`Fix the file, then re-run: tinyfish connect cursor
|
|
274
|
+
throw new ConnectStepError(`Could not update ${cursorMcpPath()}: existing file could not be read or is not valid JSON (${result.error}). ` +
|
|
275
|
+
`Fix the file, then re-run: tinyfish connect cursor`, "invalid_config");
|
|
285
276
|
}
|
|
286
277
|
if (result.backupPath) {
|
|
287
278
|
errLine(`Backed up existing Cursor MCP config to ${result.backupPath}`);
|
|
@@ -308,7 +299,7 @@ export async function connectCursor(options) {
|
|
|
308
299
|
else {
|
|
309
300
|
errLine("TinyFish is connected. Reload the Cursor window, then approve/sign in under Settings → MCP.");
|
|
310
301
|
}
|
|
311
|
-
settle(state, telemetry, "completed", { authMode: resolvedKey ?
|
|
302
|
+
settle(state, telemetry, "completed", { authMode: resolvedKey ? AuthMode.ApiKey : "deferred" });
|
|
312
303
|
});
|
|
313
304
|
}
|
|
314
305
|
/** Best-effort: false when no handler/open fails — caller falls back to reload copy. */
|
package/dist/commands/doctor.js
CHANGED
|
@@ -2,20 +2,22 @@ import spawn from "cross-spawn";
|
|
|
2
2
|
import { apiKeyStatus } from "../lib/auth.js";
|
|
3
3
|
import { CLI_VERSION } from "../lib/constants.js";
|
|
4
4
|
import { DOCTOR_COULD_NOT_RUN, DOCTOR_SCHEMA_VERSION, doctorReportSchema, exitCodeFor, renderPretty, } from "../lib/doctor-report.js";
|
|
5
|
-
import { ALL_HARNESSES } from "../lib/harness-detect.js";
|
|
5
|
+
import { ALL_HARNESSES, AuthMode, Registered } from "../lib/harness-detect.js";
|
|
6
6
|
import { detectHumanInitiated } from "../lib/harness.js";
|
|
7
7
|
import { err, errLine, out, outLine } from "../lib/output.js";
|
|
8
8
|
import { detectRegistrations } from "../lib/registration-detect.js";
|
|
9
9
|
import { verifyMcpAuth, verifyMcpHealth } from "../lib/verify.js";
|
|
10
10
|
import { connectClaudeCode, connectCodex, connectCursor, connectHermes, connectOpenClaw, connectOpencode, DEFAULT_MCP_URL, } from "./connect.js";
|
|
11
11
|
// A green auth mode with a red auth call proves nothing, so the call's verdict gates the claim.
|
|
12
|
-
function provesHarnessReach(status,
|
|
12
|
+
function provesHarnessReach(status, keyAuth, healthOk, mcpUrl) {
|
|
13
13
|
// An endpoint doctor could not reach proves nothing about a harness pointed at it.
|
|
14
14
|
if (!healthOk)
|
|
15
15
|
return false;
|
|
16
16
|
if (pointsElsewhere(status.registeredUrl, mcpUrl))
|
|
17
17
|
return false;
|
|
18
|
-
return status.registered ===
|
|
18
|
+
return (status.registered === Registered.Yes &&
|
|
19
|
+
status.authMode === AuthMode.ApiKey &&
|
|
20
|
+
keyAuth?.ok === true);
|
|
19
21
|
}
|
|
20
22
|
// Reports the version, never judges it: staleness is the server's call via X-TF-Notice.
|
|
21
23
|
function checkCliVersion() {
|
|
@@ -55,13 +57,31 @@ function pointsElsewhere(registeredUrl, mcpUrl) {
|
|
|
55
57
|
return "an unparseable endpoint";
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
|
-
function
|
|
60
|
+
function keyedVerdict(keyAuth) {
|
|
61
|
+
if (!keyAuth)
|
|
62
|
+
return { status: "warn", detail: "registered, API key present but unverified" };
|
|
63
|
+
if (keyAuth.ok) {
|
|
64
|
+
return {
|
|
65
|
+
status: "pass",
|
|
66
|
+
detail: "registered, auth mode api-key (key verified against the TinyFish API)",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (keyAuth.status === 401) {
|
|
70
|
+
return { status: "fail", detail: "registered, but TinyFish rejects its API key" };
|
|
71
|
+
}
|
|
72
|
+
// Only 401 blames the key; connect would loop on the rest.
|
|
73
|
+
if (keyAuth.status === 403) {
|
|
74
|
+
return { status: "warn", detail: "registered, but its API key is not allowed to list runs" };
|
|
75
|
+
}
|
|
76
|
+
return { status: "warn", detail: "registered, key could not be verified" };
|
|
77
|
+
}
|
|
78
|
+
function registrationVerdict(status, mcpUrl, keyAuth) {
|
|
59
79
|
if (!status.detected)
|
|
60
80
|
return { status: "skip", detail: "harness not installed" };
|
|
61
|
-
if (status.registered ===
|
|
81
|
+
if (status.registered === Registered.Unknown) {
|
|
62
82
|
return { status: "warn", detail: status.reason ?? "could not determine registration" };
|
|
63
83
|
}
|
|
64
|
-
if (status.registered ===
|
|
84
|
+
if (status.registered === Registered.Yes) {
|
|
65
85
|
// A `tinyfish` entry aimed at a dev server is registered and still cannot reach TinyFish.
|
|
66
86
|
const elsewhere = pointsElsewhere(status.registeredUrl, mcpUrl);
|
|
67
87
|
if (elsewhere) {
|
|
@@ -70,6 +90,9 @@ function registrationVerdict(status, mcpUrl) {
|
|
|
70
90
|
detail: `registered, but points at ${elsewhere} rather than ${endpointOf(mcpUrl)}`,
|
|
71
91
|
};
|
|
72
92
|
}
|
|
93
|
+
// Widening past api-key would warn every healthy oauth install.
|
|
94
|
+
if (status.authMode === AuthMode.ApiKey)
|
|
95
|
+
return keyedVerdict(keyAuth);
|
|
73
96
|
// A probe that had to explain itself to reach `yes` is the only thing that explains `unknown`.
|
|
74
97
|
const because = status.reason ? ` (${status.reason})` : "";
|
|
75
98
|
return { status: "pass", detail: `registered, auth mode ${status.authMode}${because}` };
|
|
@@ -79,12 +102,12 @@ function registrationVerdict(status, mcpUrl) {
|
|
|
79
102
|
? { status: "fail", detail: "connected previously but TinyFish is no longer registered" }
|
|
80
103
|
: { status: "warn", detail: "installed but TinyFish was never connected" };
|
|
81
104
|
}
|
|
82
|
-
function checkRegistration(status, mcpUrl) {
|
|
105
|
+
function checkRegistration(status, mcpUrl, keyAuth) {
|
|
83
106
|
return {
|
|
84
107
|
id: "harness-registration",
|
|
85
108
|
title: `${status.harness} registration`,
|
|
86
109
|
harness: status.harness,
|
|
87
|
-
...registrationVerdict(status, mcpUrl),
|
|
110
|
+
...registrationVerdict(status, mcpUrl, keyAuth),
|
|
88
111
|
};
|
|
89
112
|
}
|
|
90
113
|
function checkCredential() {
|
|
@@ -98,8 +121,8 @@ function checkCredential() {
|
|
|
98
121
|
};
|
|
99
122
|
return "key" in resolved ? { check, key: resolved.key } : { check };
|
|
100
123
|
}
|
|
101
|
-
|
|
102
|
-
if (!
|
|
124
|
+
function checkAuthCall(auth) {
|
|
125
|
+
if (!auth) {
|
|
103
126
|
return {
|
|
104
127
|
id: "cli-auth-call",
|
|
105
128
|
title: "Authenticated call",
|
|
@@ -108,7 +131,6 @@ async function checkAuthCall(key) {
|
|
|
108
131
|
harness: null,
|
|
109
132
|
};
|
|
110
133
|
}
|
|
111
|
-
const auth = await verifyMcpAuth(key);
|
|
112
134
|
return {
|
|
113
135
|
id: "cli-auth-call",
|
|
114
136
|
title: "Authenticated call",
|
|
@@ -117,32 +139,37 @@ async function checkAuthCall(key) {
|
|
|
117
139
|
harness: null,
|
|
118
140
|
};
|
|
119
141
|
}
|
|
142
|
+
// `verifyMcpAuth` hits BASE_URL; a sandbox key 401s against prod.
|
|
143
|
+
function isDefaultEndpoint(mcpUrl) {
|
|
144
|
+
try {
|
|
145
|
+
return endpointOf(mcpUrl) === endpointOf(DEFAULT_MCP_URL);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function verifyHarnessKey(status, cliKey, cliAuth, mcpUrl) {
|
|
152
|
+
if (!isDefaultEndpoint(mcpUrl))
|
|
153
|
+
return undefined;
|
|
154
|
+
// `pointsElsewhere` already fails; a second reason only misleads.
|
|
155
|
+
if (pointsElsewhere(status.registeredUrl, mcpUrl))
|
|
156
|
+
return undefined;
|
|
157
|
+
// OpenClaw's skill shells this CLI, so its verdict stands in.
|
|
158
|
+
if (status.harness === "openclaw")
|
|
159
|
+
return cliAuth;
|
|
160
|
+
if (!status.apiKey)
|
|
161
|
+
return undefined;
|
|
162
|
+
if (status.apiKey === cliKey && cliAuth)
|
|
163
|
+
return cliAuth;
|
|
164
|
+
return verifyMcpAuth(status.apiKey);
|
|
165
|
+
}
|
|
120
166
|
// Only Cursor's repair is a pure local file write; every other one needs a browser sign-in.
|
|
121
167
|
const UNATTENDED_SAFE = new Set(["cursor"]);
|
|
122
168
|
function repairsFor(checks, statuses) {
|
|
123
169
|
const repairs = [];
|
|
124
|
-
const credentialResolves = checks.some((c) => c.id === "cli-credential" && c.status === "pass");
|
|
125
|
-
for (const status of statuses) {
|
|
126
|
-
// `unknown` earns nothing: running connect off a failed probe contradicts the check's detail.
|
|
127
|
-
// A registered-but-misaimed entry does earn one, since connect rewrites the URL.
|
|
128
|
-
const misaimed = checks.some((c) => c.harness === status.harness && c.status === "fail");
|
|
129
|
-
if (!status.detected || (status.registered !== "no" && !misaimed))
|
|
130
|
-
continue;
|
|
131
|
-
repairs.push({
|
|
132
|
-
for: status.registered === "yes"
|
|
133
|
-
? `${status.harness}-points-elsewhere`
|
|
134
|
-
: status.connectedBefore
|
|
135
|
-
? `${status.harness}-registration-lost`
|
|
136
|
-
: `${status.harness}-not-connected`,
|
|
137
|
-
action: "connect",
|
|
138
|
-
harness: status.harness,
|
|
139
|
-
command: `tinyfish connect ${status.harness}`,
|
|
140
|
-
// Without a credential even Cursor's repair shells an interactive `auth login`.
|
|
141
|
-
unattended_safe: UNATTENDED_SAFE.has(status.harness) && credentialResolves,
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
170
|
// A revoked-but-well-formed key passes the credential check and fails the call; both need login.
|
|
145
171
|
const credentialBroken = checks.some((c) => (c.id === "cli-credential" || c.id === "cli-auth-call") && c.status === "fail");
|
|
172
|
+
// connect writes the stored key, so replace a dead one first.
|
|
146
173
|
if (credentialBroken) {
|
|
147
174
|
repairs.push({
|
|
148
175
|
for: "cli-credential-invalid",
|
|
@@ -152,26 +179,51 @@ function repairsFor(checks, statuses) {
|
|
|
152
179
|
unattended_safe: false,
|
|
153
180
|
});
|
|
154
181
|
}
|
|
182
|
+
const authCallPassed = checks.some((c) => c.id === "cli-auth-call" && c.status === "pass");
|
|
183
|
+
for (const status of statuses) {
|
|
184
|
+
// `unknown` earns nothing: connect would contradict the check's detail.
|
|
185
|
+
// A registered entry that still fails earns one too.
|
|
186
|
+
const brokenRegistration = checks.some((c) => c.harness === status.harness && c.status === "fail");
|
|
187
|
+
if (!status.detected || (status.registered !== Registered.No && !brokenRegistration))
|
|
188
|
+
continue;
|
|
189
|
+
repairs.push({
|
|
190
|
+
for: status.registered === Registered.Yes
|
|
191
|
+
? `${status.harness}-registration-broken`
|
|
192
|
+
: status.connectedBefore
|
|
193
|
+
? `${status.harness}-registration-lost`
|
|
194
|
+
: `${status.harness}-not-connected`,
|
|
195
|
+
action: "connect",
|
|
196
|
+
harness: status.harness,
|
|
197
|
+
command: `tinyfish connect ${status.harness}`,
|
|
198
|
+
// A dead well-formed key passes the format check.
|
|
199
|
+
unattended_safe: UNATTENDED_SAFE.has(status.harness) && authCallPassed,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
155
202
|
return repairs;
|
|
156
203
|
}
|
|
157
204
|
export async function runDoctor(options) {
|
|
158
205
|
const statuses = detectRegistrations(options.harness ? [options.harness] : undefined);
|
|
159
206
|
const credential = checkCredential();
|
|
207
|
+
const cliAuth = credential.key ? verifyMcpAuth(credential.key) : undefined;
|
|
208
|
+
const [connectivity, cliAuthResult, keyAuths] = await Promise.all([
|
|
209
|
+
checkConnectivity(options.mcpUrl),
|
|
210
|
+
cliAuth,
|
|
211
|
+
Promise.all(statuses.map((status) => verifyHarnessKey(status, credential.key, cliAuth, options.mcpUrl))),
|
|
212
|
+
]);
|
|
160
213
|
const checks = [
|
|
161
214
|
checkCliVersion(),
|
|
162
|
-
|
|
163
|
-
...statuses.map((status) => checkRegistration(status, options.mcpUrl)),
|
|
215
|
+
connectivity,
|
|
216
|
+
...statuses.map((status, i) => checkRegistration(status, options.mcpUrl, keyAuths[i])),
|
|
164
217
|
credential.check,
|
|
165
|
-
|
|
218
|
+
checkAuthCall(cliAuthResult),
|
|
166
219
|
];
|
|
167
|
-
const authCallPassed = checks.some((c) => c.id === "cli-auth-call" && c.status === "pass");
|
|
168
220
|
const healthOk = checks.some((c) => c.id === "mcp-connectivity" && c.status === "pass");
|
|
169
|
-
const harnesses = statuses.map((status) => ({
|
|
221
|
+
const harnesses = statuses.map((status, i) => ({
|
|
170
222
|
harness: status.harness,
|
|
171
223
|
detected: status.detected,
|
|
172
224
|
registered: status.registered,
|
|
173
225
|
auth_mode: status.authMode,
|
|
174
|
-
proves_harness_reach: provesHarnessReach(status,
|
|
226
|
+
proves_harness_reach: provesHarnessReach(status, keyAuths[i], healthOk, options.mcpUrl),
|
|
175
227
|
}));
|
|
176
228
|
const body = {
|
|
177
229
|
schema_version: DOCTOR_SCHEMA_VERSION,
|
package/dist/lib/auth.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export declare function persistApiKeyToEnvironment(apiKey: string, options?: Env
|
|
|
36
36
|
export declare function clearConfig(): boolean;
|
|
37
37
|
/** The one rule that is ours to enforce: the key becomes an HTTP header, so CR/LF cannot pass. */
|
|
38
38
|
export declare function isHeaderSafe(key: string): boolean;
|
|
39
|
+
/** No length rule: short real keys must survive. */
|
|
40
|
+
export declare function hasKeyPrefix(key: string): boolean;
|
|
39
41
|
export declare function validateKeyFormat(key: string): boolean;
|
|
40
42
|
export declare function maskKey(key: string): string;
|
|
41
43
|
export type KeySource = "explicit" | "env" | "config" | "none";
|
package/dist/lib/auth.js
CHANGED
|
@@ -192,8 +192,12 @@ export function isHeaderSafe(key) {
|
|
|
192
192
|
}
|
|
193
193
|
return true;
|
|
194
194
|
}
|
|
195
|
+
/** No length rule: short real keys must survive. */
|
|
196
|
+
export function hasKeyPrefix(key) {
|
|
197
|
+
return KEY_PREFIXES.some((p) => key.startsWith(p));
|
|
198
|
+
}
|
|
195
199
|
export function validateKeyFormat(key) {
|
|
196
|
-
return isHeaderSafe(key) &&
|
|
200
|
+
return isHeaderSafe(key) && hasKeyPrefix(key) && key.length > MIN_KEY_LENGTH;
|
|
197
201
|
}
|
|
198
202
|
export function maskKey(key) {
|
|
199
203
|
if (key.length <= 8)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import spawn from "cross-spawn";
|
|
2
2
|
import { errLine, sanitizeLine } from "./output.js";
|
|
3
|
-
import {
|
|
3
|
+
import { spawnStepError, } from "./connect-runtime.js";
|
|
4
4
|
import { codexOauthCompleted } from "./registration-detect.js";
|
|
5
5
|
const HERMES_SEED_TIMEOUT_MS = 120_000;
|
|
6
6
|
// An old install is only one reason a flag can go unseen, so no message asserts the cause.
|
|
@@ -122,7 +122,7 @@ function launchCodexWalkthrough() {
|
|
|
122
122
|
deepLink.searchParams.set("path", process.cwd());
|
|
123
123
|
const result = openExternalUrl(deepLink.toString());
|
|
124
124
|
if (result.error || result.status !== 0) {
|
|
125
|
-
throw
|
|
125
|
+
throw spawnStepError("Could not open Codex", result);
|
|
126
126
|
}
|
|
127
127
|
errLine("Codex opened with the TinyFish walkthrough ready. Send the prompt to start.");
|
|
128
128
|
}
|
|
@@ -156,10 +156,7 @@ function launchHermesWalkthrough() {
|
|
|
156
156
|
timeout: HERMES_SEED_TIMEOUT_MS,
|
|
157
157
|
});
|
|
158
158
|
if (seedResult.error || seedResult.status !== 0) {
|
|
159
|
-
|
|
160
|
-
throw new Error("Could not start the TinyFish walkthrough in Hermes", {
|
|
161
|
-
cause: seedResult.error,
|
|
162
|
-
});
|
|
159
|
+
throw spawnStepError("Could not start the TinyFish walkthrough in Hermes", seedResult);
|
|
163
160
|
}
|
|
164
161
|
// A colourised id would carry its trailing escape sequence into `--resume` (PF-3452).
|
|
165
162
|
const sessionId = sanitizeLine(seedResult.stderr ?? "").match(/session_id:\s*([^\s]+)/i)?.[1];
|
|
@@ -168,12 +165,12 @@ function launchHermesWalkthrough() {
|
|
|
168
165
|
}
|
|
169
166
|
const result = spawn.sync("hermes", ["--resume", sessionId], { stdio: "inherit" });
|
|
170
167
|
if (result.error) {
|
|
171
|
-
throw
|
|
168
|
+
throw spawnStepError("Could not launch Hermes", result);
|
|
172
169
|
}
|
|
173
170
|
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
174
171
|
return;
|
|
175
172
|
if (result.status !== 0) {
|
|
176
|
-
throw
|
|
173
|
+
throw spawnStepError(`Hermes walkthrough exited with status ${result.status ?? "unknown"}`, result);
|
|
177
174
|
}
|
|
178
175
|
}
|
|
179
176
|
export const HERMES = {
|
|
@@ -200,12 +197,12 @@ function launchOpencode() {
|
|
|
200
197
|
stdio: "inherit",
|
|
201
198
|
});
|
|
202
199
|
if (result.error) {
|
|
203
|
-
throw
|
|
200
|
+
throw spawnStepError("Could not launch OpenCode", result);
|
|
204
201
|
}
|
|
205
202
|
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
206
203
|
return;
|
|
207
204
|
if (result.status !== 0) {
|
|
208
|
-
throw
|
|
205
|
+
throw spawnStepError(`OpenCode exited with status ${result.status ?? "unknown"}`, result);
|
|
209
206
|
}
|
|
210
207
|
}
|
|
211
208
|
export const OPENCODE = {
|
|
@@ -252,12 +249,12 @@ export function launchNativeMcpClient(client) {
|
|
|
252
249
|
}
|
|
253
250
|
const result = spawn.sync(client.command, [DEFAULT_ONBOARDING_PROMPT], { stdio: "inherit" });
|
|
254
251
|
if (result.error) {
|
|
255
|
-
throw
|
|
252
|
+
throw spawnStepError(`Could not launch ${client.displayName}`, result);
|
|
256
253
|
}
|
|
257
254
|
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
258
255
|
return false;
|
|
259
256
|
if (result.status !== 0) {
|
|
260
|
-
throw
|
|
257
|
+
throw spawnStepError(`${client.displayName} walkthrough exited with status ${result.status ?? "unknown"}`, result);
|
|
261
258
|
}
|
|
262
259
|
return true;
|
|
263
260
|
}
|
|
@@ -267,12 +264,12 @@ export function launchOpenClawWalkthrough() {
|
|
|
267
264
|
stdio: "inherit",
|
|
268
265
|
});
|
|
269
266
|
if (result.error) {
|
|
270
|
-
throw
|
|
267
|
+
throw spawnStepError("Could not launch OpenClaw", result);
|
|
271
268
|
}
|
|
272
269
|
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
273
270
|
return false;
|
|
274
271
|
if (result.status !== 0) {
|
|
275
|
-
throw
|
|
272
|
+
throw spawnStepError(`OpenClaw walkthrough exited with status ${result.status ?? "unknown"}`, result);
|
|
276
273
|
}
|
|
277
274
|
return true;
|
|
278
275
|
}
|
|
@@ -2,7 +2,7 @@ import * as path from "node:path";
|
|
|
2
2
|
import spawn from "cross-spawn";
|
|
3
3
|
import { loadConfig, persistApiKeyToEnvironment, validateKeyFormat, writeConfig } from "./auth.js";
|
|
4
4
|
import { NATIVE_MCP_CLIENTS, OPENCLAW_SKILL_INSTALL_ARGS, } from "./connect-clients.js";
|
|
5
|
-
import { commandNotFound, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "./connect-runtime.js";
|
|
5
|
+
import { commandNotFound, ConnectStepError, spawnStepError, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "./connect-runtime.js";
|
|
6
6
|
import { TINYFISH_CLI_PACKAGE } from "./constants.js";
|
|
7
7
|
import { installRoot } from "./install-root.js";
|
|
8
8
|
import { errLine } from "./output.js";
|
|
@@ -24,8 +24,7 @@ export function installTinyFishCli() {
|
|
|
24
24
|
timeout: SKILL_INSTALL_TIMEOUT_MS,
|
|
25
25
|
});
|
|
26
26
|
if (result.error || result.status !== 0) {
|
|
27
|
-
|
|
28
|
-
throw new Error("Could not install the TinyFish CLI", { cause: result.error });
|
|
27
|
+
throw spawnStepError("Could not install the TinyFish CLI", result);
|
|
29
28
|
}
|
|
30
29
|
}
|
|
31
30
|
/** `skills add` is an unconditional overwrite, so it doubles as the refresh path. */
|
|
@@ -72,10 +71,7 @@ export function installWebSkill(client) {
|
|
|
72
71
|
if (output.trim())
|
|
73
72
|
errLine(output.trimEnd());
|
|
74
73
|
if (result.error || result.status !== 0 || SKILL_INSTALL_FAILURE_PATTERN.test(output)) {
|
|
75
|
-
|
|
76
|
-
throw new Error(`Could not install the TinyFish web skill in ${client.displayName}`, {
|
|
77
|
-
cause: result.error,
|
|
78
|
-
});
|
|
74
|
+
throw spawnStepError(`Could not install the TinyFish web skill in ${client.displayName}`, result);
|
|
79
75
|
}
|
|
80
76
|
}
|
|
81
77
|
/**
|
|
@@ -164,7 +160,7 @@ export function ensureCliAuthenticated(source, apiKey) {
|
|
|
164
160
|
const envKey = apiKey ?? process.env["TINYFISH_API_KEY"];
|
|
165
161
|
if (envKey) {
|
|
166
162
|
if (!validateKeyFormat(envKey)) {
|
|
167
|
-
throw new
|
|
163
|
+
throw new ConnectStepError("TINYFISH_API_KEY has invalid format", "invalid_config");
|
|
168
164
|
}
|
|
169
165
|
// Throwing variant: a write failure must reach connect's catch/flush, not exit(1) past it.
|
|
170
166
|
writeConfig(envKey);
|
|
@@ -176,7 +172,7 @@ export function ensureCliAuthenticated(source, apiKey) {
|
|
|
176
172
|
timeout: NON_INTERACTIVE_TIMEOUT_MS,
|
|
177
173
|
});
|
|
178
174
|
if (commandNotFound(authStatus.error)) {
|
|
179
|
-
throw
|
|
175
|
+
throw spawnStepError(TINYFISH_CLI_NOT_FOUND_MESSAGE, authStatus);
|
|
180
176
|
}
|
|
181
177
|
if (authStatus.status === 0)
|
|
182
178
|
return;
|
|
@@ -184,17 +180,16 @@ export function ensureCliAuthenticated(source, apiKey) {
|
|
|
184
180
|
// "not signed in" and drop the user into an interactive login they never asked for.
|
|
185
181
|
throwIfInterrupted(authStatus);
|
|
186
182
|
if (authStatus.error) {
|
|
187
|
-
throw
|
|
183
|
+
throw spawnStepError("Could not read TinyFish CLI auth status", authStatus);
|
|
188
184
|
}
|
|
189
185
|
errLine("Signing in to the TinyFish CLI...");
|
|
190
186
|
const loginResult = spawn.sync("tinyfish", ["auth", "login", "--source", source], {
|
|
191
187
|
stdio: "inherit",
|
|
192
188
|
});
|
|
193
189
|
if (commandNotFound(loginResult.error)) {
|
|
194
|
-
throw
|
|
190
|
+
throw spawnStepError(TINYFISH_CLI_NOT_FOUND_MESSAGE, loginResult);
|
|
195
191
|
}
|
|
196
192
|
if (loginResult.error || loginResult.status !== 0) {
|
|
197
|
-
|
|
198
|
-
throw new Error("Could not authenticate the TinyFish CLI", { cause: loginResult.error });
|
|
193
|
+
throw spawnStepError("Could not authenticate the TinyFish CLI", loginResult);
|
|
199
194
|
}
|
|
200
195
|
}
|
|
@@ -1,18 +1,35 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AuthMode } from "./harness-detect.js";
|
|
2
2
|
export declare const NON_INTERACTIVE_TIMEOUT_MS = 10000;
|
|
3
3
|
export type AgentClient = "claude-code" | "codex" | "cursor" | "hermes" | "openclaw" | "opencode";
|
|
4
4
|
type ConnectStage = "started" | "checkpoint" | "completed" | "failed" | "aborted" | "post_install_failed";
|
|
5
5
|
type ConnectFailureStage = "prerequisite_check" | "registration_cleanup" | "registration" | "registration_or_authentication" | "client_oauth" | "cli_install" | "skill_install" | "authentication" | "walkthrough_launch";
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
export declare const CONNECT_FAILURE_REASONS: readonly ["harness_not_installed", "harness_command_unsupported", "harness_too_old", "command_not_found", "timeout", "spawn_error", "nonzero_exit", "invalid_config"];
|
|
7
|
+
type ConnectFailureReason = (typeof CONNECT_FAILURE_REASONS)[number];
|
|
8
|
+
export type ConnectAuthMode = Exclude<AuthMode, AuthMode.Unknown> | "deferred";
|
|
8
9
|
type ConnectCheckpoint = "prerequisite_ok" | "registered" | "oauth_done" | "cli_installed" | "skill_installed" | "authenticated";
|
|
9
10
|
/** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
|
|
10
11
|
export declare class ConnectInterruptedError extends Error {
|
|
11
12
|
}
|
|
13
|
+
/** A classified step failure; runGuarded forwards the reason. */
|
|
14
|
+
export declare class ConnectStepError extends Error {
|
|
15
|
+
readonly failureReason: ConnectFailureReason;
|
|
16
|
+
readonly harnessVersion?: string;
|
|
17
|
+
constructor(message: string, failureReason: ConnectFailureReason, opts?: {
|
|
18
|
+
harnessVersion?: string;
|
|
19
|
+
cause?: unknown;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
12
22
|
export declare function throwIfInterrupted(result: {
|
|
13
23
|
signal?: string | null;
|
|
14
24
|
error?: Error;
|
|
15
25
|
}): void;
|
|
26
|
+
interface SpawnStepResult {
|
|
27
|
+
status?: number | null;
|
|
28
|
+
signal?: string | null;
|
|
29
|
+
error?: Error;
|
|
30
|
+
}
|
|
31
|
+
/** Classifies a spawn failure; interrupts throw instead. */
|
|
32
|
+
export declare function spawnStepError(message: string, result: SpawnStepResult): ConnectStepError;
|
|
16
33
|
export declare function commandNotFound(error: unknown): boolean;
|
|
17
34
|
export interface ConnectRunState {
|
|
18
35
|
stage: ConnectFailureStage;
|
|
@@ -34,6 +51,7 @@ interface ConnectStageDetail {
|
|
|
34
51
|
export interface ConnectTelemetry {
|
|
35
52
|
attemptId: string;
|
|
36
53
|
track(stage: ConnectStage, detail?: ConnectStageDetail): void;
|
|
54
|
+
setHarnessVersion(version?: string): void;
|
|
37
55
|
flush(): Promise<void>;
|
|
38
56
|
}
|
|
39
57
|
/** Terminal events exactly once, whichever of flow or signal handler wins. */
|
|
@@ -57,6 +75,7 @@ export interface SupportedCommand {
|
|
|
57
75
|
export declare function requireCommandSupport(client: SupportedCommand): {
|
|
58
76
|
optionalSupported: boolean;
|
|
59
77
|
keyAuthSupported: boolean;
|
|
78
|
+
harnessVersion?: string;
|
|
60
79
|
};
|
|
61
80
|
export declare function createConnectTelemetry(mcpUrl: string, client: AgentClient, opts?: {
|
|
62
81
|
apiKey?: string;
|
|
@@ -7,10 +7,22 @@ import { errLine, sanitizeLine } from "./output.js";
|
|
|
7
7
|
import { postConnectEvent, telemetryDisabled } from "./setup-telemetry.js";
|
|
8
8
|
import { installSignalGuard } from "./signals.js";
|
|
9
9
|
export const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
|
|
10
|
+
// Mirrors the route's Zod enum; tests both sides pin it.
|
|
11
|
+
export const CONNECT_FAILURE_REASONS = [
|
|
12
|
+
"harness_not_installed",
|
|
13
|
+
"harness_command_unsupported",
|
|
14
|
+
"harness_too_old",
|
|
15
|
+
"command_not_found",
|
|
16
|
+
"timeout",
|
|
17
|
+
"spawn_error",
|
|
18
|
+
"nonzero_exit",
|
|
19
|
+
"invalid_config",
|
|
20
|
+
];
|
|
10
21
|
/** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
|
|
11
22
|
export class ConnectInterruptedError extends Error {
|
|
12
23
|
}
|
|
13
|
-
|
|
24
|
+
/** A classified step failure; runGuarded forwards the reason. */
|
|
25
|
+
export class ConnectStepError extends Error {
|
|
14
26
|
failureReason;
|
|
15
27
|
harnessVersion;
|
|
16
28
|
constructor(message, failureReason, opts) {
|
|
@@ -19,6 +31,8 @@ class PrerequisiteError extends Error {
|
|
|
19
31
|
this.harnessVersion = opts?.harnessVersion;
|
|
20
32
|
}
|
|
21
33
|
}
|
|
34
|
+
class PrerequisiteError extends ConnectStepError {
|
|
35
|
+
}
|
|
22
36
|
export function throwIfInterrupted(result) {
|
|
23
37
|
// spawn.sync kills a timed-out child with SIGTERM, so signal alone would misread a slow
|
|
24
38
|
// network as the user walking away. A timeout is a failure and must report as one.
|
|
@@ -28,6 +42,19 @@ export function throwIfInterrupted(result) {
|
|
|
28
42
|
throw new ConnectInterruptedError("Setup interrupted");
|
|
29
43
|
}
|
|
30
44
|
}
|
|
45
|
+
/** Classifies a spawn failure; interrupts throw instead. */
|
|
46
|
+
export function spawnStepError(message, result) {
|
|
47
|
+
throwIfInterrupted(result);
|
|
48
|
+
const code = result.error?.code;
|
|
49
|
+
const reason = !result.error
|
|
50
|
+
? "nonzero_exit"
|
|
51
|
+
: code === "ENOENT"
|
|
52
|
+
? "command_not_found"
|
|
53
|
+
: code === "ETIMEDOUT"
|
|
54
|
+
? "timeout"
|
|
55
|
+
: "spawn_error";
|
|
56
|
+
return new ConnectStepError(message, reason, { cause: result.error });
|
|
57
|
+
}
|
|
31
58
|
export function commandNotFound(error) {
|
|
32
59
|
return error?.code === "ENOENT";
|
|
33
60
|
}
|
|
@@ -65,7 +92,7 @@ export async function runGuarded(state, telemetry, body) {
|
|
|
65
92
|
}
|
|
66
93
|
settle(state, telemetry, "failed", {
|
|
67
94
|
failedStage: state.stage,
|
|
68
|
-
...(error instanceof
|
|
95
|
+
...(error instanceof ConnectStepError
|
|
69
96
|
? { failureReason: error.failureReason, harnessVersion: error.harnessVersion }
|
|
70
97
|
: {}),
|
|
71
98
|
});
|
|
@@ -88,7 +115,14 @@ export function requireCommandSupport(client) {
|
|
|
88
115
|
}
|
|
89
116
|
if (result.error || result.status !== 0) {
|
|
90
117
|
throwIfInterrupted(result);
|
|
91
|
-
|
|
118
|
+
// A hung probe is a timeout, not a missing capability.
|
|
119
|
+
if (result.error?.code === "ETIMEDOUT") {
|
|
120
|
+
throw new ConnectStepError(client.supportCheck.unavailableMessage, "timeout", {
|
|
121
|
+
cause: result.error,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// It ran and exited non-zero, so the version is still answerable.
|
|
125
|
+
throw new PrerequisiteError(client.supportCheck.unavailableMessage, "harness_command_unsupported", { cause: result.error, harnessVersion: probeHarnessVersion(client.command) });
|
|
92
126
|
}
|
|
93
127
|
// Belt and braces with the colour env: a client that ignores NO_COLOR still has to match.
|
|
94
128
|
const output = sanitizeLine(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
|
|
@@ -105,7 +139,7 @@ export function requireCommandSupport(client) {
|
|
|
105
139
|
harnessVersion: probeHarnessVersion(client.command),
|
|
106
140
|
});
|
|
107
141
|
}
|
|
108
|
-
return { optionalSupported, keyAuthSupported };
|
|
142
|
+
return { optionalSupported, keyAuthSupported, harnessVersion: probeHarnessVersion(client.command) };
|
|
109
143
|
}
|
|
110
144
|
/** Best-effort; the server rejects non-printable characters and >32 chars. */
|
|
111
145
|
function probeHarnessVersion(command) {
|
|
@@ -115,7 +149,9 @@ function probeHarnessVersion(command) {
|
|
|
115
149
|
});
|
|
116
150
|
if (result.error || result.status !== 0)
|
|
117
151
|
return undefined;
|
|
152
|
+
// First line only: sanitizeLine keeps newlines, so stripping splices lines.
|
|
118
153
|
const version = sanitizeLine(result.stdout ?? "")
|
|
154
|
+
.split("\n")[0]
|
|
119
155
|
.replace(/[^\x20-\x7E]/g, "")
|
|
120
156
|
.trim();
|
|
121
157
|
return version ? version.slice(0, 32) : undefined;
|
|
@@ -140,6 +176,8 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
140
176
|
const attemptId = opts?.attemptId ?? seeded ?? randomUUID();
|
|
141
177
|
const endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
|
|
142
178
|
const pending = [];
|
|
179
|
+
let lastTrackAt;
|
|
180
|
+
let harnessVersion;
|
|
143
181
|
async function deliver(body) {
|
|
144
182
|
// Ahead of the key read; postConnectEvent's own opt-out check is too late to skip it.
|
|
145
183
|
if (telemetryDisabled())
|
|
@@ -156,7 +194,14 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
156
194
|
return {
|
|
157
195
|
attemptId,
|
|
158
196
|
// Fire-and-forget; awaiting each event stalls setup when telemetry down.
|
|
197
|
+
setHarnessVersion(version) {
|
|
198
|
+
harnessVersion = version;
|
|
199
|
+
},
|
|
159
200
|
track(stage, detail) {
|
|
201
|
+
const now = Date.now();
|
|
202
|
+
// Clamped: a backward clock step would send a route-rejected negative.
|
|
203
|
+
const stageDurationMs = lastTrackAt === undefined ? undefined : Math.max(0, now - lastTrackAt);
|
|
204
|
+
lastTrackAt = now;
|
|
160
205
|
// Absorbed here, not in flush: flush runs in runGuarded's finally, so a rejection would
|
|
161
206
|
// replace the error the flow is already reporting.
|
|
162
207
|
pending.push(deliver(JSON.stringify({
|
|
@@ -166,9 +211,10 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
166
211
|
failed_stage: detail?.failedStage,
|
|
167
212
|
phase: detail?.phase,
|
|
168
213
|
failure_reason: detail?.failureReason,
|
|
169
|
-
harness_version: detail?.harnessVersion,
|
|
214
|
+
harness_version: detail?.harnessVersion ?? harnessVersion,
|
|
170
215
|
auth_mode: detail?.authMode,
|
|
171
216
|
harness_degraded: detail?.harnessDegraded,
|
|
217
|
+
stage_duration_ms: stageDurationMs,
|
|
172
218
|
runtime_platform: process.platform,
|
|
173
219
|
node_version: process.version,
|
|
174
220
|
cli_version: CLI_VERSION,
|
|
@@ -14,9 +14,10 @@ export interface CursorTinyfishEntry {
|
|
|
14
14
|
hasApiKeyHeader: boolean;
|
|
15
15
|
/** Registered endpoint, so a caller can tell "registered" from "registered at the right place". */
|
|
16
16
|
url?: string;
|
|
17
|
+
apiKey?: string;
|
|
17
18
|
error?: string;
|
|
18
19
|
}
|
|
19
|
-
/**
|
|
20
|
+
/** Carries the header value; callers keep it off the report. */
|
|
20
21
|
export declare function readCursorTinyfishEntry(): CursorTinyfishEntry;
|
|
21
22
|
/** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
|
|
22
23
|
export declare function writeCursorMcpConfig(mcpUrl: string, apiKey?: string): CursorMcpWriteResult;
|
|
@@ -65,7 +65,7 @@ export function planCursorWrite(mcpUrl, apiKey) {
|
|
|
65
65
|
? `${filePath}: would create with a "tinyfish" MCP server entry${authNote}`
|
|
66
66
|
: `${filePath}: would back up to a timestamped copy, then merge in the "tinyfish" MCP server entry${authNote}`;
|
|
67
67
|
}
|
|
68
|
-
/**
|
|
68
|
+
/** Carries the header value; callers keep it off the report. */
|
|
69
69
|
export function readCursorTinyfishEntry() {
|
|
70
70
|
const existing = readExisting();
|
|
71
71
|
if ("error" in existing)
|
|
@@ -77,10 +77,13 @@ export function readCursorTinyfishEntry() {
|
|
|
77
77
|
const headers = entry.headers;
|
|
78
78
|
// Header names are case-insensitive, and this file is hand-editable — matching only the
|
|
79
79
|
// casing we write would understate auth mode for a user who typed it differently.
|
|
80
|
+
const keyHeader = isPlainRecord(headers)
|
|
81
|
+
? Object.entries(headers).find(([name, value]) => name.toLowerCase() === "x-api-key" && typeof value === "string")
|
|
82
|
+
: undefined;
|
|
80
83
|
return {
|
|
81
84
|
present: true,
|
|
82
|
-
hasApiKeyHeader:
|
|
83
|
-
|
|
85
|
+
hasApiKeyHeader: keyHeader !== undefined,
|
|
86
|
+
...(keyHeader ? { apiKey: keyHeader[1] } : {}),
|
|
84
87
|
...(typeof entry.url === "string" ? { url: entry.url } : {}),
|
|
85
88
|
};
|
|
86
89
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { AuthMode, Registered } from "./harness-detect.js";
|
|
2
3
|
/** Bumped whenever a consumer could misread the payload; the cookbook skill releases separately. */
|
|
3
|
-
export declare const DOCTOR_SCHEMA_VERSION =
|
|
4
|
+
export declare const DOCTOR_SCHEMA_VERSION = 2;
|
|
4
5
|
declare const checkStatusSchema: z.ZodEnum<{
|
|
5
6
|
pass: "pass";
|
|
6
7
|
fail: "fail";
|
|
@@ -36,16 +37,8 @@ declare const doctorHarnessSchema: z.ZodObject<{
|
|
|
36
37
|
"claude-code": "claude-code";
|
|
37
38
|
}>;
|
|
38
39
|
detected: z.ZodBoolean;
|
|
39
|
-
registered: z.ZodEnum<
|
|
40
|
-
|
|
41
|
-
yes: "yes";
|
|
42
|
-
no: "no";
|
|
43
|
-
}>;
|
|
44
|
-
auth_mode: z.ZodEnum<{
|
|
45
|
-
unknown: "unknown";
|
|
46
|
-
"api-key": "api-key";
|
|
47
|
-
oauth: "oauth";
|
|
48
|
-
}>;
|
|
40
|
+
registered: z.ZodEnum<typeof Registered>;
|
|
41
|
+
auth_mode: z.ZodEnum<typeof AuthMode>;
|
|
49
42
|
proves_harness_reach: z.ZodBoolean;
|
|
50
43
|
}, z.core.$strip>;
|
|
51
44
|
declare const doctorRepairSchema: z.ZodObject<{
|
|
@@ -98,16 +91,8 @@ export declare const doctorReportSchema: z.ZodObject<{
|
|
|
98
91
|
"claude-code": "claude-code";
|
|
99
92
|
}>;
|
|
100
93
|
detected: z.ZodBoolean;
|
|
101
|
-
registered: z.ZodEnum<
|
|
102
|
-
|
|
103
|
-
yes: "yes";
|
|
104
|
-
no: "no";
|
|
105
|
-
}>;
|
|
106
|
-
auth_mode: z.ZodEnum<{
|
|
107
|
-
unknown: "unknown";
|
|
108
|
-
"api-key": "api-key";
|
|
109
|
-
oauth: "oauth";
|
|
110
|
-
}>;
|
|
94
|
+
registered: z.ZodEnum<typeof Registered>;
|
|
95
|
+
auth_mode: z.ZodEnum<typeof AuthMode>;
|
|
111
96
|
proves_harness_reach: z.ZodBoolean;
|
|
112
97
|
}, z.core.$strip>>;
|
|
113
98
|
repairs: z.ZodArray<z.ZodObject<{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { ALL_HARNESSES } from "./harness-detect.js";
|
|
2
|
+
import { ALL_HARNESSES, AuthMode, Registered } from "./harness-detect.js";
|
|
3
3
|
/** Bumped whenever a consumer could misread the payload; the cookbook skill releases separately. */
|
|
4
|
-
export const DOCTOR_SCHEMA_VERSION =
|
|
4
|
+
export const DOCTOR_SCHEMA_VERSION = 2;
|
|
5
5
|
const checkStatusSchema = z.enum(["pass", "fail", "warn", "skip"]);
|
|
6
6
|
const harnessSchema = z.enum(ALL_HARNESSES);
|
|
7
7
|
const doctorCheckSchema = z.object({
|
|
@@ -14,8 +14,8 @@ const doctorCheckSchema = z.object({
|
|
|
14
14
|
const doctorHarnessSchema = z.object({
|
|
15
15
|
harness: harnessSchema,
|
|
16
16
|
detected: z.boolean(),
|
|
17
|
-
registered: z.enum(
|
|
18
|
-
auth_mode: z.enum(
|
|
17
|
+
registered: z.enum(Registered),
|
|
18
|
+
auth_mode: z.enum(AuthMode),
|
|
19
19
|
proves_harness_reach: z.boolean(),
|
|
20
20
|
});
|
|
21
21
|
// `action` is what `--fix` dispatches on, not the harness field: keying off a null harness
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
export declare const ALL_HARNESSES: readonly ["claude-code", "codex", "cursor", "hermes", "openclaw", "opencode"];
|
|
2
2
|
export type Harness = (typeof ALL_HARNESSES)[number];
|
|
3
|
+
export declare enum Registered {
|
|
4
|
+
Yes = "yes",
|
|
5
|
+
No = "no",
|
|
6
|
+
Unknown = "unknown"
|
|
7
|
+
}
|
|
8
|
+
export declare enum AuthMode {
|
|
9
|
+
ApiKey = "api-key",
|
|
10
|
+
OAuth = "oauth",
|
|
11
|
+
Unknown = "unknown"
|
|
12
|
+
}
|
|
3
13
|
export declare function harnessConfigPath(harness: Harness): string;
|
|
4
14
|
/** For reason strings that name a location; keeps them in sync with the table above. */
|
|
5
15
|
export declare function harnessDisplayPath(harness: Harness): string;
|
|
@@ -9,6 +9,19 @@ export const ALL_HARNESSES = [
|
|
|
9
9
|
"openclaw",
|
|
10
10
|
"opencode",
|
|
11
11
|
];
|
|
12
|
+
// Values are the wire contract; the report schema derives from these.
|
|
13
|
+
export var Registered;
|
|
14
|
+
(function (Registered) {
|
|
15
|
+
Registered["Yes"] = "yes";
|
|
16
|
+
Registered["No"] = "no";
|
|
17
|
+
Registered["Unknown"] = "unknown";
|
|
18
|
+
})(Registered || (Registered = {}));
|
|
19
|
+
export var AuthMode;
|
|
20
|
+
(function (AuthMode) {
|
|
21
|
+
AuthMode["ApiKey"] = "api-key";
|
|
22
|
+
AuthMode["OAuth"] = "oauth";
|
|
23
|
+
AuthMode["Unknown"] = "unknown";
|
|
24
|
+
})(AuthMode || (AuthMode = {}));
|
|
12
25
|
// Presence detection only — dir existence means "installed", nothing more.
|
|
13
26
|
const CONFIG_DIRS = {
|
|
14
27
|
"claude-code": ".claude",
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import { type Harness } from "./harness-detect.js";
|
|
2
|
-
export type Registered = "yes" | "no" | "unknown";
|
|
3
|
-
export type AuthMode = "api-key" | "oauth" | "unknown";
|
|
1
|
+
import { AuthMode, type Harness, Registered } from "./harness-detect.js";
|
|
4
2
|
export interface RegistrationStatus {
|
|
5
3
|
harness: Harness;
|
|
6
4
|
detected: boolean;
|
|
@@ -9,6 +7,8 @@ export interface RegistrationStatus {
|
|
|
9
7
|
connectedBefore: boolean;
|
|
10
8
|
/** The endpoint the harness is actually pointed at, when the probe exposes it. */
|
|
11
9
|
registeredUrl?: string;
|
|
10
|
+
/** For doctor to verify; never for the report. */
|
|
11
|
+
apiKey?: string;
|
|
12
12
|
reason?: string;
|
|
13
13
|
}
|
|
14
14
|
export declare function codexOauthCompleted(): boolean | undefined;
|
|
@@ -2,10 +2,10 @@ import * as fs from "fs";
|
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import spawn from "cross-spawn";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { loadConfig } from "./auth.js";
|
|
5
|
+
import { hasKeyPrefix, loadConfig } from "./auth.js";
|
|
6
6
|
import { errLine } from "./output.js";
|
|
7
7
|
import { readCursorTinyfishEntry } from "./cursor-config.js";
|
|
8
|
-
import { detectInstalledHarnesses, harnessConfigPath, harnessDisplayPath, } from "./harness-detect.js";
|
|
8
|
+
import { detectInstalledHarnesses, harnessConfigPath, harnessDisplayPath, AuthMode, Registered, } from "./harness-detect.js";
|
|
9
9
|
// 6s tripped on a cold `hermes mcp list`, measured at 6.34s.
|
|
10
10
|
const PROBE_TIMEOUT_MS = 15_000;
|
|
11
11
|
// A list, verified against codex 0.146. A name-keyed map was accepted here too, but every
|
|
@@ -24,6 +24,12 @@ const UNSUPPORTED_SUBCOMMAND = /unknown command|unrecognized subcommand|invalid
|
|
|
24
24
|
// `claude mcp get` exits 1 for a usage error too, so only this message is evidence of absence.
|
|
25
25
|
const NO_SUCH_SERVER = /no (?:mcp )?server named/i;
|
|
26
26
|
const API_KEY_HEADER = /x-api-key/i;
|
|
27
|
+
// `claude mcp get` prints the value; `mcp add` masks it.
|
|
28
|
+
const API_KEY_HEADER_VALUE = /^\s*X-API-Key:\s*(\S+)\s*$/im;
|
|
29
|
+
// Charset is the redaction guard: `***`, `[REDACTED]`, dotted masks.
|
|
30
|
+
function extractableKey(value) {
|
|
31
|
+
return value && hasKeyPrefix(value) && /^[A-Za-z0-9_-]+$/.test(value) ? value : undefined;
|
|
32
|
+
}
|
|
27
33
|
// OpenCode colours its list even through a pipe, putting `\x1b[34m` between the line start and
|
|
28
34
|
// the server name, so an unstripped probe reads a registered harness as absent.
|
|
29
35
|
// eslint-disable-next-line no-control-regex
|
|
@@ -84,8 +90,8 @@ function fromPluginRegistration(command) {
|
|
|
84
90
|
return undefined;
|
|
85
91
|
// `mcp list` prints no headers, so the credential behind it is unproven, never assumed.
|
|
86
92
|
return {
|
|
87
|
-
registered:
|
|
88
|
-
authMode:
|
|
93
|
+
registered: Registered.Yes,
|
|
94
|
+
authMode: AuthMode.Unknown,
|
|
89
95
|
registeredUrl: match[2],
|
|
90
96
|
reason: `registered as ${match[1]}, which \`mcp get tinyfish\` does not resolve`,
|
|
91
97
|
};
|
|
@@ -94,13 +100,13 @@ function fromPluginRegistration(command) {
|
|
|
94
100
|
function fromMcpGet(command, keyAuthPattern) {
|
|
95
101
|
const probe = runProbe(command, ["mcp", "get", "tinyfish"]);
|
|
96
102
|
if (probe.outcome === "unavailable") {
|
|
97
|
-
return { registered:
|
|
103
|
+
return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
|
|
98
104
|
}
|
|
99
105
|
if (probe.exitCode !== 0) {
|
|
100
106
|
if (UNSUPPORTED_SUBCOMMAND.test(probe.output)) {
|
|
101
107
|
return {
|
|
102
|
-
registered:
|
|
103
|
-
authMode:
|
|
108
|
+
registered: Registered.Unknown,
|
|
109
|
+
authMode: AuthMode.Unknown,
|
|
104
110
|
reason: `\`${command} mcp get\` is unsupported by this version`,
|
|
105
111
|
};
|
|
106
112
|
}
|
|
@@ -111,20 +117,22 @@ function fromMcpGet(command, keyAuthPattern) {
|
|
|
111
117
|
// A broken CLI also exits nonzero, and reading that as absence earns a spurious repair.
|
|
112
118
|
if (!NO_SUCH_SERVER.test(probe.output)) {
|
|
113
119
|
return {
|
|
114
|
-
registered:
|
|
115
|
-
authMode:
|
|
120
|
+
registered: Registered.Unknown,
|
|
121
|
+
authMode: AuthMode.Unknown,
|
|
116
122
|
reason: `\`${command} mcp get\` failed without reporting the server as absent`,
|
|
117
123
|
};
|
|
118
124
|
}
|
|
119
|
-
return { registered:
|
|
125
|
+
return { registered: Registered.No, authMode: AuthMode.Unknown };
|
|
120
126
|
}
|
|
121
127
|
// Positive evidence only: whether `mcp get` echoes headers at all is unverified, so absence
|
|
122
128
|
// of the pattern is `unknown`, never proof of OAuth.
|
|
123
129
|
const url = /^\s*URL:\s*(\S+)/m.exec(probe.output)?.[1];
|
|
130
|
+
const key = extractableKey(API_KEY_HEADER_VALUE.exec(probe.output)?.[1]);
|
|
124
131
|
return {
|
|
125
|
-
registered:
|
|
126
|
-
authMode: keyAuthPattern.test(probe.output) ?
|
|
132
|
+
registered: Registered.Yes,
|
|
133
|
+
authMode: keyAuthPattern.test(probe.output) ? AuthMode.ApiKey : AuthMode.Unknown,
|
|
127
134
|
...(url ? { registeredUrl: url } : {}),
|
|
135
|
+
...(key ? { apiKey: key } : {}),
|
|
128
136
|
};
|
|
129
137
|
}
|
|
130
138
|
// `codex mcp get <missing>` prints an error and exits 0, so presence must never be read from
|
|
@@ -170,20 +178,20 @@ export function codexOauthCompleted() {
|
|
|
170
178
|
function probeCodex() {
|
|
171
179
|
const read = readCodexEntry();
|
|
172
180
|
if ("reason" in read)
|
|
173
|
-
return { registered:
|
|
181
|
+
return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: read.reason };
|
|
174
182
|
const entry = read.entry;
|
|
175
183
|
if (!entry)
|
|
176
|
-
return { registered:
|
|
184
|
+
return { registered: Registered.No, authMode: AuthMode.Unknown };
|
|
177
185
|
const codexUrl = entry.transport?.url;
|
|
178
186
|
return {
|
|
179
|
-
registered:
|
|
187
|
+
registered: Registered.Yes,
|
|
180
188
|
// Read from this entry: codex emits `bearer_token_env_var` on every HTTP server, so testing
|
|
181
189
|
// the whole payload reports api-key for TinyFish because some other server carries a key.
|
|
182
190
|
authMode: entry.auth_status === "o_auth"
|
|
183
|
-
?
|
|
191
|
+
? AuthMode.OAuth
|
|
184
192
|
: entry.transport?.bearer_token_env_var
|
|
185
|
-
?
|
|
186
|
-
:
|
|
193
|
+
? AuthMode.ApiKey
|
|
194
|
+
: AuthMode.Unknown,
|
|
187
195
|
...(codexUrl ? { registeredUrl: codexUrl } : {}),
|
|
188
196
|
};
|
|
189
197
|
}
|
|
@@ -191,17 +199,19 @@ function probeCursor() {
|
|
|
191
199
|
const entry = readCursorTinyfishEntry();
|
|
192
200
|
if (entry.error) {
|
|
193
201
|
return {
|
|
194
|
-
registered:
|
|
195
|
-
authMode:
|
|
202
|
+
registered: Registered.Unknown,
|
|
203
|
+
authMode: AuthMode.Unknown,
|
|
196
204
|
reason: "mcp.json exists but could not be read or parsed",
|
|
197
205
|
};
|
|
198
206
|
}
|
|
199
207
|
if (!entry.present)
|
|
200
|
-
return { registered:
|
|
208
|
+
return { registered: Registered.No, authMode: AuthMode.Unknown };
|
|
209
|
+
const key = extractableKey(entry.apiKey);
|
|
201
210
|
return {
|
|
202
|
-
registered:
|
|
203
|
-
authMode: entry.hasApiKeyHeader ?
|
|
211
|
+
registered: Registered.Yes,
|
|
212
|
+
authMode: entry.hasApiKeyHeader ? AuthMode.ApiKey : AuthMode.Unknown,
|
|
204
213
|
...(entry.url ? { registeredUrl: entry.url } : {}),
|
|
214
|
+
...(key ? { apiKey: key } : {}),
|
|
205
215
|
};
|
|
206
216
|
}
|
|
207
217
|
// Both list commands print this header in every state, including "no servers configured".
|
|
@@ -213,31 +223,31 @@ const OPENCODE_ROW_URL = /^[^\w]*tinyfish(?![\w-]).*\n[^\w]*(https?:\S+)/m;
|
|
|
213
223
|
function fromMcpList(command, output, registeredMode, urlPattern) {
|
|
214
224
|
if (LISTED_AS_TINYFISH.test(output)) {
|
|
215
225
|
const url = urlPattern?.exec(output)?.[1];
|
|
216
|
-
return { registered:
|
|
226
|
+
return { registered: Registered.Yes, authMode: registeredMode, ...(url ? { registeredUrl: url } : {}) };
|
|
217
227
|
}
|
|
218
228
|
if (!MCP_LIST_HEADER.test(output)) {
|
|
219
229
|
return {
|
|
220
|
-
registered:
|
|
221
|
-
authMode:
|
|
230
|
+
registered: Registered.Unknown,
|
|
231
|
+
authMode: AuthMode.Unknown,
|
|
222
232
|
reason: `could not interpret \`${command} mcp list\` output`,
|
|
223
233
|
};
|
|
224
234
|
}
|
|
225
|
-
return { registered:
|
|
235
|
+
return { registered: Registered.No, authMode: AuthMode.Unknown };
|
|
226
236
|
}
|
|
227
237
|
// connect only ever writes an OAuth Hermes entry, so `oauth` is a fact, not a detection gap.
|
|
228
238
|
function probeHermes() {
|
|
229
239
|
const probe = runProbe("hermes", ["mcp", "list"]);
|
|
230
240
|
if (probe.outcome === "unavailable") {
|
|
231
|
-
return { registered:
|
|
241
|
+
return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
|
|
232
242
|
}
|
|
233
243
|
if (probe.exitCode !== 0) {
|
|
234
244
|
return {
|
|
235
|
-
registered:
|
|
236
|
-
authMode:
|
|
245
|
+
registered: Registered.Unknown,
|
|
246
|
+
authMode: AuthMode.Unknown,
|
|
237
247
|
reason: `\`hermes mcp list\` exited ${probe.exitCode}`,
|
|
238
248
|
};
|
|
239
249
|
}
|
|
240
|
-
return fromMcpList("hermes", probe.output,
|
|
250
|
+
return fromMcpList("hermes", probe.output, AuthMode.OAuth);
|
|
241
251
|
}
|
|
242
252
|
const SKILL_DIR_IS_TINYFISH = /^(?:@tinyfish[/_-])?tinyfish(?![\w-])/i;
|
|
243
253
|
// OpenClaw installs a skill, not an MCP server; the skill shells the CLI, so auth is the CLI key.
|
|
@@ -249,30 +259,30 @@ function probeOpenClaw() {
|
|
|
249
259
|
}
|
|
250
260
|
catch {
|
|
251
261
|
return {
|
|
252
|
-
registered:
|
|
253
|
-
authMode:
|
|
262
|
+
registered: Registered.Unknown,
|
|
263
|
+
authMode: AuthMode.Unknown,
|
|
254
264
|
reason: `no global skills directory at ${harnessDisplayPath("openclaw")}/skills; OpenClaw layout is unverified`,
|
|
255
265
|
};
|
|
256
266
|
}
|
|
257
267
|
// A bare substring also matched a skill merely named `not-tinyfish-thing`.
|
|
258
268
|
return entries.some((name) => SKILL_DIR_IS_TINYFISH.test(name))
|
|
259
|
-
? { registered:
|
|
260
|
-
: { registered:
|
|
269
|
+
? { registered: Registered.Yes, authMode: AuthMode.ApiKey }
|
|
270
|
+
: { registered: Registered.No, authMode: AuthMode.Unknown };
|
|
261
271
|
}
|
|
262
272
|
// `opencode mcp list` prints no headers, so a key-authed registration is indistinguishable.
|
|
263
273
|
function probeOpencode() {
|
|
264
274
|
const probe = runProbe("opencode", ["mcp", "list"]);
|
|
265
275
|
if (probe.outcome === "unavailable") {
|
|
266
|
-
return { registered:
|
|
276
|
+
return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
|
|
267
277
|
}
|
|
268
278
|
if (probe.exitCode !== 0) {
|
|
269
279
|
return {
|
|
270
|
-
registered:
|
|
271
|
-
authMode:
|
|
280
|
+
registered: Registered.Unknown,
|
|
281
|
+
authMode: AuthMode.Unknown,
|
|
272
282
|
reason: `\`opencode mcp list\` exited ${probe.exitCode}`,
|
|
273
283
|
};
|
|
274
284
|
}
|
|
275
|
-
return fromMcpList("opencode", probe.output,
|
|
285
|
+
return fromMcpList("opencode", probe.output, AuthMode.Unknown, OPENCODE_ROW_URL);
|
|
276
286
|
}
|
|
277
287
|
const PROBES = {
|
|
278
288
|
"claude-code": () => fromMcpGet("claude", API_KEY_HEADER),
|
|
@@ -295,7 +305,7 @@ export function detectRegistrations(harnesses) {
|
|
|
295
305
|
};
|
|
296
306
|
// Undetected means nothing to probe: no spawn cost, and no misleading "unknown".
|
|
297
307
|
if (!detection.detected) {
|
|
298
|
-
return { ...base, registered:
|
|
308
|
+
return { ...base, registered: Registered.No, authMode: AuthMode.Unknown };
|
|
299
309
|
}
|
|
300
310
|
try {
|
|
301
311
|
return { ...base, ...PROBES[detection.harness]() };
|
|
@@ -308,8 +318,8 @@ export function detectRegistrations(harnesses) {
|
|
|
308
318
|
}
|
|
309
319
|
return {
|
|
310
320
|
...base,
|
|
311
|
-
registered:
|
|
312
|
-
authMode:
|
|
321
|
+
registered: Registered.Unknown,
|
|
322
|
+
authMode: AuthMode.Unknown,
|
|
313
323
|
reason: "the probe failed unexpectedly",
|
|
314
324
|
};
|
|
315
325
|
}
|
package/dist/lib/verify.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export interface VerifyResult {
|
|
|
5
5
|
reason?: string;
|
|
6
6
|
/** Authored summary of `reason`, safe to publish. Additive: `connect` still prints `reason`. */
|
|
7
7
|
code?: string;
|
|
8
|
+
/** Lets doctor tell 401 from 403 without matching prose. */
|
|
9
|
+
status?: number;
|
|
8
10
|
}
|
|
9
11
|
/** Reachability check. Verify failure is a warning, never install failure. */
|
|
10
12
|
export declare function verifyMcpHealth(mcpUrl: string): Promise<VerifyResult>;
|
package/dist/lib/verify.js
CHANGED