@tiny-fish/cli 0.20.1 → 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/README.md +8 -2
- package/dist/commands/connect.d.ts +8 -5
- package/dist/commands/connect.js +67 -63
- package/dist/commands/doctor.js +89 -37
- package/dist/commands/upgrade.d.ts +2 -1
- package/dist/commands/upgrade.js +16 -5
- package/dist/lib/auth.d.ts +2 -0
- package/dist/lib/auth.js +5 -1
- package/dist/lib/connect-all.js +44 -23
- package/dist/lib/connect-clients.d.ts +8 -2
- package/dist/lib/connect-clients.js +52 -27
- package/dist/lib/connect-install.js +15 -16
- package/dist/lib/connect-runtime.d.ts +27 -3
- package/dist/lib/connect-runtime.js +54 -6
- 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/install-root.d.ts +15 -0
- package/dist/lib/install-root.js +49 -0
- package/dist/lib/registration-detect.d.ts +3 -3
- package/dist/lib/registration-detect.js +54 -44
- package/dist/lib/verify.d.ts +2 -0
- package/dist/lib/verify.js +1 -0
- package/package.json +1 -1
|
@@ -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,14 +115,23 @@ 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 ?? ""}`);
|
|
95
129
|
const listed = (patterns) => patterns.every((pattern) => pattern.test(output));
|
|
96
130
|
const essential = listed(client.supportCheck.patterns);
|
|
97
131
|
const optionalSupported = listed(client.supportCheck.optionalPatterns ?? []);
|
|
98
|
-
|
|
132
|
+
const keyAuthPattern = client.supportCheck.keyAuthPattern;
|
|
133
|
+
const keyAuthSupported = keyAuthPattern ? keyAuthPattern.test(output) : true;
|
|
134
|
+
if ((!essential || !optionalSupported || !keyAuthSupported) && process.env["TINYFISH_DEBUG"]) {
|
|
99
135
|
errLine(`${client.command} ${client.supportCheck.args.join(" ")} printed:\n${output.trim()}`);
|
|
100
136
|
}
|
|
101
137
|
if (!essential) {
|
|
@@ -103,7 +139,7 @@ export function requireCommandSupport(client) {
|
|
|
103
139
|
harnessVersion: probeHarnessVersion(client.command),
|
|
104
140
|
});
|
|
105
141
|
}
|
|
106
|
-
return { optionalSupported };
|
|
142
|
+
return { optionalSupported, keyAuthSupported, harnessVersion: probeHarnessVersion(client.command) };
|
|
107
143
|
}
|
|
108
144
|
/** Best-effort; the server rejects non-printable characters and >32 chars. */
|
|
109
145
|
function probeHarnessVersion(command) {
|
|
@@ -113,7 +149,9 @@ function probeHarnessVersion(command) {
|
|
|
113
149
|
});
|
|
114
150
|
if (result.error || result.status !== 0)
|
|
115
151
|
return undefined;
|
|
152
|
+
// First line only: sanitizeLine keeps newlines, so stripping splices lines.
|
|
116
153
|
const version = sanitizeLine(result.stdout ?? "")
|
|
154
|
+
.split("\n")[0]
|
|
117
155
|
.replace(/[^\x20-\x7E]/g, "")
|
|
118
156
|
.trim();
|
|
119
157
|
return version ? version.slice(0, 32) : undefined;
|
|
@@ -138,6 +176,8 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
138
176
|
const attemptId = opts?.attemptId ?? seeded ?? randomUUID();
|
|
139
177
|
const endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
|
|
140
178
|
const pending = [];
|
|
179
|
+
let lastTrackAt;
|
|
180
|
+
let harnessVersion;
|
|
141
181
|
async function deliver(body) {
|
|
142
182
|
// Ahead of the key read; postConnectEvent's own opt-out check is too late to skip it.
|
|
143
183
|
if (telemetryDisabled())
|
|
@@ -154,7 +194,14 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
154
194
|
return {
|
|
155
195
|
attemptId,
|
|
156
196
|
// Fire-and-forget; awaiting each event stalls setup when telemetry down.
|
|
197
|
+
setHarnessVersion(version) {
|
|
198
|
+
harnessVersion = version;
|
|
199
|
+
},
|
|
157
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;
|
|
158
205
|
// Absorbed here, not in flush: flush runs in runGuarded's finally, so a rejection would
|
|
159
206
|
// replace the error the flow is already reporting.
|
|
160
207
|
pending.push(deliver(JSON.stringify({
|
|
@@ -164,9 +211,10 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
164
211
|
failed_stage: detail?.failedStage,
|
|
165
212
|
phase: detail?.phase,
|
|
166
213
|
failure_reason: detail?.failureReason,
|
|
167
|
-
harness_version: detail?.harnessVersion,
|
|
214
|
+
harness_version: detail?.harnessVersion ?? harnessVersion,
|
|
168
215
|
auth_mode: detail?.authMode,
|
|
169
216
|
harness_degraded: detail?.harnessDegraded,
|
|
217
|
+
stage_duration_ms: stageDurationMs,
|
|
170
218
|
runtime_platform: process.platform,
|
|
171
219
|
node_version: process.version,
|
|
172
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",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Where this CLI is installed: `prefix` for npm, `nodeModules` for reading the version back. */
|
|
2
|
+
export type InstallRoot = {
|
|
3
|
+
prefix: string;
|
|
4
|
+
nodeModules: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* The prefix to upgrade in place, or null when the layout is not npm's.
|
|
8
|
+
*
|
|
9
|
+
* Guarded on purpose: npx, pnpm and bun all nest the package under a bare `node_modules`, and
|
|
10
|
+
* installing into their trees would report a success the PATH binary never sees.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveInstallRoot(packageRoot: string, platform: typeof process.platform, onDisk?: (candidate: string) => boolean): InstallRoot | null;
|
|
13
|
+
export declare function installRoot(): InstallRoot | null;
|
|
14
|
+
/** The version on disk under a prefix, which is the only account of what `@latest` resolved to. */
|
|
15
|
+
export declare function readInstalledVersion(nodeModules: string): string | null;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { TINYFISH_CLI_PACKAGE } from "./constants.js";
|
|
5
|
+
function isDirectory(candidate) {
|
|
6
|
+
try {
|
|
7
|
+
return fs.statSync(candidate).isDirectory();
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The prefix to upgrade in place, or null when the layout is not npm's.
|
|
15
|
+
*
|
|
16
|
+
* Guarded on purpose: npx, pnpm and bun all nest the package under a bare `node_modules`, and
|
|
17
|
+
* installing into their trees would report a success the PATH binary never sees.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveInstallRoot(packageRoot, platform, onDisk = isDirectory) {
|
|
20
|
+
const p = platform === "win32" ? path.win32 : path.posix;
|
|
21
|
+
// npx's win32 cache uses the same bare layout as a global install.
|
|
22
|
+
if (packageRoot.split(p.sep).includes("_npx"))
|
|
23
|
+
return null;
|
|
24
|
+
const nodeModules = p.resolve(packageRoot, "..", "..");
|
|
25
|
+
if (p.basename(nodeModules) !== "node_modules")
|
|
26
|
+
return null;
|
|
27
|
+
const container = p.dirname(nodeModules);
|
|
28
|
+
// Only win32 puts node_modules directly under the prefix.
|
|
29
|
+
if (platform !== "win32" && p.basename(container) !== "lib")
|
|
30
|
+
return null;
|
|
31
|
+
if (!onDisk(nodeModules))
|
|
32
|
+
return null;
|
|
33
|
+
return { prefix: platform === "win32" ? container : p.dirname(container), nodeModules };
|
|
34
|
+
}
|
|
35
|
+
export function installRoot() {
|
|
36
|
+
// Two levels up from dist/lib/ is the package root, and tsc mirrors src/ into dist/.
|
|
37
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
38
|
+
return resolveInstallRoot(packageRoot, process.platform);
|
|
39
|
+
}
|
|
40
|
+
/** The version on disk under a prefix, which is the only account of what `@latest` resolved to. */
|
|
41
|
+
export function readInstalledVersion(nodeModules) {
|
|
42
|
+
try {
|
|
43
|
+
const manifest = fs.readFileSync(path.join(nodeModules, ...TINYFISH_CLI_PACKAGE.split("/"), "package.json"), "utf8");
|
|
44
|
+
return JSON.parse(manifest).version ?? null;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -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