@tiny-fish/cli 0.12.1-next.137 → 0.12.1-next.139
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 -0
- package/dist/commands/connect.js +97 -51
- package/dist/lib/client.d.ts +1 -1
- package/dist/lib/client.js +2 -2
- package/dist/lib/connect-all.d.ts +9 -0
- package/dist/lib/connect-all.js +345 -0
- package/dist/lib/cursor-config.d.ts +6 -2
- package/dist/lib/cursor-config.js +19 -9
- package/dist/lib/harness-detect.d.ts +9 -0
- package/dist/lib/harness-detect.js +28 -0
- package/dist/lib/setup-telemetry.d.ts +50 -0
- package/dist/lib/setup-telemetry.js +107 -0
- package/dist/lib/verify.d.ts +10 -0
- package/dist/lib/verify.js +36 -0
- package/dist/lib/version-marker.d.ts +10 -0
- package/dist/lib/version-marker.js +109 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -277,6 +277,14 @@ TINYFISH_DEBUG=1 tinyfish agent run "..." --url https://example.com
|
|
|
277
277
|
tinyfish --debug agent run "..." --url https://example.com
|
|
278
278
|
```
|
|
279
279
|
|
|
280
|
+
### Telemetry
|
|
281
|
+
|
|
282
|
+
The CLI reports setup outcomes (which harnesses connected, whether verification passed) and
|
|
283
|
+
checks npm once a day for a newer CLI. Set `TINYFISH_NO_TELEMETRY` to any value other than
|
|
284
|
+
`0`/`false` to disable both. API keys are never sent — only a SHA-256 lookup hash, and only to
|
|
285
|
+
TinyFish-owned origins (`*.tinyfish.ai`, `*.tinyfish.io`) or a local development endpoint
|
|
286
|
+
(`localhost`, `127.0.0.1`, `[::1]`).
|
|
287
|
+
|
|
280
288
|
## CI/CD
|
|
281
289
|
|
|
282
290
|
```yaml
|
package/dist/commands/connect.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import spawn from "cross-spawn";
|
|
4
4
|
import { CONNECT_SOURCE, loadConfig, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, writeConfig, } from "../lib/auth.js";
|
|
5
5
|
import { CLI_VERSION } from "../lib/constants.js";
|
|
6
|
-
import { cursorMcpPath, writeCursorMcpConfig } from "../lib/cursor-config.js";
|
|
6
|
+
import { cursorInstallDeeplink, cursorMcpPath, writeCursorMcpConfig } from "../lib/cursor-config.js";
|
|
7
|
+
import { runConnectAll } from "../lib/connect-all.js";
|
|
8
|
+
import { detectHumanInitiated } from "../lib/harness.js";
|
|
7
9
|
import { errLine } from "../lib/output.js";
|
|
10
|
+
import { telemetryDisabled, telemetryHeaders } from "../lib/setup-telemetry.js";
|
|
11
|
+
import { verifyMcpAuth } from "../lib/verify.js";
|
|
12
|
+
import { recordInstalledVersion } from "../lib/version-marker.js";
|
|
8
13
|
export const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
|
|
9
14
|
const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
|
|
10
15
|
const SKILL_INSTALL_TIMEOUT_MS = 120_000;
|
|
@@ -92,7 +97,7 @@ function installSignalGuard(state, telemetry) {
|
|
|
92
97
|
async function runGuarded(state, telemetry, body) {
|
|
93
98
|
const uninstallSignalGuard = installSignalGuard(state, telemetry);
|
|
94
99
|
try {
|
|
95
|
-
body();
|
|
100
|
+
await body();
|
|
96
101
|
}
|
|
97
102
|
catch (error) {
|
|
98
103
|
if (error instanceof ConnectInterruptedError) {
|
|
@@ -123,6 +128,7 @@ const CLAUDE_CODE = {
|
|
|
123
128
|
patterns: [/^\s*login(?:\s|\[)/m],
|
|
124
129
|
unavailableMessage: MCP_LOGIN_UNAVAILABLE_MESSAGE,
|
|
125
130
|
},
|
|
131
|
+
headerAuthSupported: true,
|
|
126
132
|
loginArgs: ["mcp", "login", "tinyfish"],
|
|
127
133
|
// Project scope is shared in .mcp.json; a user setup command must not rewrite it.
|
|
128
134
|
removals: [
|
|
@@ -220,27 +226,6 @@ const OPENCLAW = {
|
|
|
220
226
|
unavailableMessage: OPENCLAW_SKILL_UNAVAILABLE_MESSAGE,
|
|
221
227
|
},
|
|
222
228
|
};
|
|
223
|
-
// TinyFish-owned domains plus loopback are the only origins the API key may be sent to.
|
|
224
|
-
// `--url` accepts any http(s) URL, so a user-supplied origin must never receive the key.
|
|
225
|
-
const TRUSTED_TELEMETRY_HOST_SUFFIXES = [".tinyfish.ai", ".tinyfish.io"];
|
|
226
|
-
const TRUSTED_TELEMETRY_LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
227
|
-
function isTrustedTelemetryOrigin(endpoint) {
|
|
228
|
-
let url;
|
|
229
|
-
try {
|
|
230
|
-
url = new URL(endpoint);
|
|
231
|
-
}
|
|
232
|
-
catch {
|
|
233
|
-
return false;
|
|
234
|
-
}
|
|
235
|
-
const host = url.hostname.toLowerCase();
|
|
236
|
-
// Loopback may use http for local dev; every other trusted origin must be https so the
|
|
237
|
-
// key is never sent over cleartext.
|
|
238
|
-
if (TRUSTED_TELEMETRY_LOOPBACK_HOSTS.includes(host))
|
|
239
|
-
return true;
|
|
240
|
-
if (url.protocol !== "https:")
|
|
241
|
-
return false;
|
|
242
|
-
return TRUSTED_TELEMETRY_HOST_SUFFIXES.some((suffix) => host === suffix.slice(1) || host.endsWith(suffix));
|
|
243
|
-
}
|
|
244
229
|
function storedApiKey() {
|
|
245
230
|
try {
|
|
246
231
|
return loadConfig().api_key;
|
|
@@ -253,14 +238,11 @@ function createConnectTelemetry(mcpUrl, client, apiKey) {
|
|
|
253
238
|
const attemptId = randomUUID();
|
|
254
239
|
const endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
|
|
255
240
|
const telemetryKey = apiKey || process.env["TINYFISH_API_KEY"] || storedApiKey();
|
|
256
|
-
const headers =
|
|
257
|
-
// SHA-256 lookup key only — the raw key never rides a telemetry request
|
|
258
|
-
// (OWASP: keep credentials out of anything logging infra might capture).
|
|
259
|
-
if (telemetryKey && validateKeyFormat(telemetryKey) && isTrustedTelemetryOrigin(endpoint)) {
|
|
260
|
-
headers["x-api-key-lookup"] = createHash("sha256").update(telemetryKey).digest("hex");
|
|
261
|
-
}
|
|
241
|
+
const headers = telemetryHeaders(endpoint, telemetryKey);
|
|
262
242
|
const pending = [];
|
|
263
243
|
async function deliver(body) {
|
|
244
|
+
if (telemetryDisabled())
|
|
245
|
+
return;
|
|
264
246
|
// One retry: stale keep-alive sockets eat post-install POSTs. 5xx retries;
|
|
265
247
|
// 4xx never will (schema mismatch, e.g. new stages hitting an old server).
|
|
266
248
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -560,9 +542,17 @@ async function connectNativeMcpClient(client, options) {
|
|
|
560
542
|
mcpUrl.searchParams.set("source", CONNECT_SOURCE);
|
|
561
543
|
mcpUrl.searchParams.set("client", client.connectClient);
|
|
562
544
|
mcpUrl.searchParams.set("connect_attempt_id", telemetry.attemptId);
|
|
545
|
+
// Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
|
|
546
|
+
const storedKey = options.apiKey ?? process.env["TINYFISH_API_KEY"] ?? loadConfig().api_key;
|
|
547
|
+
const useKeyAuth = client.headerAuthSupported === true && !!storedKey && validateKeyFormat(storedKey);
|
|
563
548
|
state.stage = client.loginArgs ? "registration" : "registration_or_authentication";
|
|
564
549
|
errLine(`Adding TinyFish to ${client.displayName}...`);
|
|
565
|
-
const addResult = spawn.sync(client.command,
|
|
550
|
+
const addResult = spawn.sync(client.command, [
|
|
551
|
+
...client.addArgs(mcpUrl.toString(), options.mcpUrl),
|
|
552
|
+
// Key rides argv — readable via /proc/<pid>/cmdline. Accepted because these CLIs
|
|
553
|
+
// take headers no other way.
|
|
554
|
+
...(useKeyAuth ? ["--header", `X-API-Key: ${storedKey}`] : []),
|
|
555
|
+
], {
|
|
566
556
|
stdio: "inherit",
|
|
567
557
|
});
|
|
568
558
|
if (addResult.error || addResult.status !== 0) {
|
|
@@ -572,7 +562,10 @@ async function connectNativeMcpClient(client, options) {
|
|
|
572
562
|
});
|
|
573
563
|
}
|
|
574
564
|
telemetry.track("checkpoint", { phase: "registered" });
|
|
575
|
-
if (
|
|
565
|
+
if (useKeyAuth) {
|
|
566
|
+
errLine("Using your stored TinyFish API key — no browser sign-in needed.");
|
|
567
|
+
}
|
|
568
|
+
if (client.loginArgs && !useKeyAuth) {
|
|
576
569
|
state.stage = "client_oauth";
|
|
577
570
|
errLine("Signing in to TinyFish...");
|
|
578
571
|
const loginResult = spawn.sync(client.command, client.loginArgs, {
|
|
@@ -598,6 +591,7 @@ async function connectNativeMcpClient(client, options) {
|
|
|
598
591
|
// Persist once here (skill installed + authenticated) so both success paths, launch
|
|
599
592
|
// or not, record the install; buildConnectHeaders reads it on subsequent requests.
|
|
600
593
|
saveConnectContext(client.connectClient, telemetry.attemptId);
|
|
594
|
+
recordInstalledVersion();
|
|
601
595
|
errLine(UPGRADE_HINT);
|
|
602
596
|
if (!options.launch) {
|
|
603
597
|
errLine(`TinyFish is connected. Open ${client.displayName} to start using it.`);
|
|
@@ -628,15 +622,7 @@ export async function connectOpenClaw(options) {
|
|
|
628
622
|
telemetry.track("checkpoint", { phase: "prerequisite_ok" });
|
|
629
623
|
if (options.installCli !== false) {
|
|
630
624
|
state.stage = "cli_install";
|
|
631
|
-
|
|
632
|
-
const cliInstallResult = spawn.sync("npm", ["install", "--global", "@tiny-fish/cli@latest"], {
|
|
633
|
-
stdio: "inherit",
|
|
634
|
-
timeout: SKILL_INSTALL_TIMEOUT_MS,
|
|
635
|
-
});
|
|
636
|
-
if (cliInstallResult.error || cliInstallResult.status !== 0) {
|
|
637
|
-
throwIfInterrupted(cliInstallResult);
|
|
638
|
-
throw new Error("Could not install the TinyFish CLI", { cause: cliInstallResult.error });
|
|
639
|
-
}
|
|
625
|
+
installTinyFishCli();
|
|
640
626
|
telemetry.track("checkpoint", { phase: "cli_installed" });
|
|
641
627
|
}
|
|
642
628
|
state.stage = "skill_install";
|
|
@@ -676,6 +662,7 @@ export async function connectOpenClaw(options) {
|
|
|
676
662
|
}
|
|
677
663
|
telemetry.track("checkpoint", { phase: "authenticated" });
|
|
678
664
|
saveConnectContext("openclaw", telemetry.attemptId);
|
|
665
|
+
recordInstalledVersion();
|
|
679
666
|
errLine(UPGRADE_HINT);
|
|
680
667
|
if (!options.launch) {
|
|
681
668
|
errLine("TinyFish is connected. Open OpenClaw to start using it.");
|
|
@@ -692,16 +679,21 @@ export async function connectOpenClaw(options) {
|
|
|
692
679
|
export async function connectCursor(options) {
|
|
693
680
|
const telemetry = createConnectTelemetry(options.mcpUrl, "cursor", options.apiKey);
|
|
694
681
|
const state = { stage: "prerequisite_check", settled: false };
|
|
695
|
-
await runGuarded(state, telemetry, () => {
|
|
682
|
+
await runGuarded(state, telemetry, async () => {
|
|
696
683
|
telemetry.track("started");
|
|
697
684
|
telemetry.track("checkpoint", { phase: "prerequisite_ok" });
|
|
685
|
+
// Auth first: a key lets the config carry an X-API-Key header, removing the sign-in hand-back.
|
|
686
|
+
state.stage = "authentication";
|
|
687
|
+
ensureCliAuthenticated("cursor", options.apiKey);
|
|
688
|
+
telemetry.track("checkpoint", { phase: "authenticated" });
|
|
689
|
+
const resolvedKey = options.apiKey ?? process.env["TINYFISH_API_KEY"] ?? loadConfig().api_key;
|
|
698
690
|
state.stage = "registration";
|
|
699
691
|
errLine("Adding TinyFish to Cursor...");
|
|
700
692
|
const mcpUrl = new URL(options.mcpUrl);
|
|
701
693
|
mcpUrl.searchParams.set("source", CONNECT_SOURCE);
|
|
702
694
|
mcpUrl.searchParams.set("client", "cursor");
|
|
703
695
|
// Attempt id rides telemetry only; persisting it would break idempotency.
|
|
704
|
-
const result = writeCursorMcpConfig(mcpUrl.toString());
|
|
696
|
+
const result = writeCursorMcpConfig(mcpUrl.toString(), resolvedKey);
|
|
705
697
|
if (result.status === "corrupt_skip") {
|
|
706
698
|
throw new Error(`Could not update ${cursorMcpPath()}: existing file could not be read or is not valid JSON (${result.error}). ` +
|
|
707
699
|
`Fix the file, then re-run: tinyfish connect cursor`);
|
|
@@ -709,18 +701,43 @@ export async function connectCursor(options) {
|
|
|
709
701
|
if (result.backupPath) {
|
|
710
702
|
errLine(`Backed up existing Cursor MCP config to ${result.backupPath}`);
|
|
711
703
|
}
|
|
704
|
+
if (result.repaired) {
|
|
705
|
+
errLine("Repaired the existing TinyFish entry (updated auth/config to current).");
|
|
706
|
+
}
|
|
712
707
|
telemetry.track("checkpoint", { phase: "registered" });
|
|
713
|
-
state.stage = "authentication";
|
|
714
|
-
ensureCliAuthenticated("cursor", options.apiKey);
|
|
715
|
-
telemetry.track("checkpoint", { phase: "authenticated" });
|
|
716
708
|
saveConnectContext("cursor", telemetry.attemptId);
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
709
|
+
recordInstalledVersion();
|
|
710
|
+
if (resolvedKey) {
|
|
711
|
+
// Deeplink would embed the key in a URL (process args, LaunchServices logs) — reload instead.
|
|
712
|
+
const verify = await verifyMcpAuth(resolvedKey);
|
|
713
|
+
if (verify.ok) {
|
|
714
|
+
errLine("TinyFish is connected and verified (health+auth). Reload the Cursor window (or restart cursor-agent) and it's live.");
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
errLine(`TinyFish is configured, but the authenticated check failed (${verify.reason ?? "unknown"}). ` +
|
|
718
|
+
"Fix: rotate/re-enter your key with `tinyfish auth login`, then re-run: tinyfish connect cursor");
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
else if (detectHumanInitiated() && launchCursorDeeplink(mcpUrl.toString())) {
|
|
722
|
+
errLine("Cursor should pop a TinyFish install confirmation — approve it, then sign in when prompted.");
|
|
723
|
+
}
|
|
724
|
+
else {
|
|
725
|
+
errLine("TinyFish is connected. Reload the Cursor window, then approve/sign in under Settings → MCP.");
|
|
726
|
+
}
|
|
721
727
|
settle(state, telemetry, "completed");
|
|
722
728
|
});
|
|
723
729
|
}
|
|
730
|
+
/** Best-effort: false when no handler/open fails — caller falls back to reload copy. */
|
|
731
|
+
function launchCursorDeeplink(mcpUrl) {
|
|
732
|
+
const deepLink = cursorInstallDeeplink(mcpUrl);
|
|
733
|
+
const [command, args] = process.platform === "darwin"
|
|
734
|
+
? ["open", [deepLink]]
|
|
735
|
+
: process.platform === "win32"
|
|
736
|
+
? ["cmd", ["/c", "start", "", deepLink]]
|
|
737
|
+
: ["xdg-open", [deepLink]];
|
|
738
|
+
const result = spawn.sync(command, args, { stdio: "ignore" });
|
|
739
|
+
return !result.error && result.status === 0;
|
|
740
|
+
}
|
|
724
741
|
export function launchAgent(client) {
|
|
725
742
|
if (client === "claude-code") {
|
|
726
743
|
launchNativeMcpClient(CLAUDE_CODE);
|
|
@@ -747,7 +764,11 @@ export function registerConnect(program) {
|
|
|
747
764
|
program
|
|
748
765
|
.command("connect")
|
|
749
766
|
.description("Connect TinyFish to an AI agent")
|
|
750
|
-
.argument("
|
|
767
|
+
.argument("[client]", "Agent client to connect (claude-code, codex, cursor, hermes, or openclaw)")
|
|
768
|
+
.option("--all", "Detect and connect every supported harness found on this machine")
|
|
769
|
+
.option("--dry-run", "Print planned writes per harness without touching anything (--all only)")
|
|
770
|
+
.option("--uninstall", "Remove TinyFish entries written by connect --all")
|
|
771
|
+
.option("--skip-launch", "Skip the end-of-install agent launch (--all only)")
|
|
751
772
|
.option("--api-key <apiKey>", "TinyFish API key to persist for the installed skill")
|
|
752
773
|
.option("--launch", "Launch the agent and start the TinyFish walkthrough")
|
|
753
774
|
.option("--url <mcpUrl>", "MCP endpoint override")
|
|
@@ -769,6 +790,31 @@ export function registerConnect(program) {
|
|
|
769
790
|
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
|
770
791
|
throw new Error(`Invalid --url value: ${mcpUrl}`);
|
|
771
792
|
}
|
|
793
|
+
if (options.all) {
|
|
794
|
+
if (client) {
|
|
795
|
+
throw new Error("Pass either a client or --all, not both.");
|
|
796
|
+
}
|
|
797
|
+
if (options.launch) {
|
|
798
|
+
throw new Error("--launch is not supported with --all. Connect a single client to launch it.");
|
|
799
|
+
}
|
|
800
|
+
const exitCode = await runConnectAll({
|
|
801
|
+
apiKey: options.apiKey,
|
|
802
|
+
mcpUrl,
|
|
803
|
+
dryRun: options.dryRun ?? false,
|
|
804
|
+
uninstall: options.uninstall ?? false,
|
|
805
|
+
noLaunch: options.skipLaunch === true,
|
|
806
|
+
});
|
|
807
|
+
// Preserve the 130 runGuarded set on Ctrl+C.
|
|
808
|
+
if (!process.exitCode)
|
|
809
|
+
process.exitCode = exitCode;
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
if (options.dryRun || options.uninstall) {
|
|
813
|
+
throw new Error("--dry-run and --uninstall are only supported with --all.");
|
|
814
|
+
}
|
|
815
|
+
if (!client) {
|
|
816
|
+
throw new Error("Pass a client (claude-code, codex, cursor, hermes, openclaw) or --all.");
|
|
817
|
+
}
|
|
772
818
|
const connectOptions = { apiKey: options.apiKey, mcpUrl, launch: options.launch ?? false };
|
|
773
819
|
if (client === "claude-code") {
|
|
774
820
|
await connectClaudeCode(connectOptions);
|
package/dist/lib/client.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export declare function buildConnectHeaders(connect: ConnectMap, caller: string
|
|
|
7
7
|
export declare function runSync(req: CliAgentRunParams, apiKey: string): Promise<AgentRunResponse>;
|
|
8
8
|
export declare function runAsync(req: CliAgentRunParams, apiKey: string): Promise<AgentRunAsyncResponse>;
|
|
9
9
|
export declare function runStream(req: CliAgentRunParams, apiKey: string, signal?: AbortSignal): AsyncGenerator<AgentRunWithStreamingResponse>;
|
|
10
|
-
export declare function listRuns(opts: RunListParams, apiKey: string): Promise<RunListResponse>;
|
|
10
|
+
export declare function listRuns(opts: RunListParams, apiKey: string, timeout?: number): Promise<RunListResponse>;
|
|
11
11
|
export declare function getRun(runId: string, apiKey: string): Promise<Run>;
|
|
12
12
|
export declare function getRunSteps(runId: string, apiKey: string): Promise<RunStepsResponse>;
|
|
13
13
|
export declare function searchQuery(params: SearchQueryParams, apiKey: string): Promise<SearchQueryResponse>;
|
package/dist/lib/client.js
CHANGED
|
@@ -256,9 +256,9 @@ export async function* runStream(req, apiKey, signal) {
|
|
|
256
256
|
}
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
|
-
export async function listRuns(opts, apiKey) {
|
|
259
|
+
export async function listRuns(opts, apiKey, timeout) {
|
|
260
260
|
try {
|
|
261
|
-
return await createSdkClient(apiKey).runs.list(opts);
|
|
261
|
+
return await createSdkClient(apiKey, timeout).runs.list(opts);
|
|
262
262
|
}
|
|
263
263
|
catch (error) {
|
|
264
264
|
rethrowSdkError(error);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface ConnectAllOptions {
|
|
2
|
+
apiKey?: string;
|
|
3
|
+
mcpUrl: string;
|
|
4
|
+
dryRun: boolean;
|
|
5
|
+
uninstall: boolean;
|
|
6
|
+
/** Skip the end-of-install agent launch (finale still prints the ask). */
|
|
7
|
+
noLaunch?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function runConnectAll(opts: ConnectAllOptions): Promise<number>;
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import * as readline from "node:readline/promises";
|
|
2
|
+
import spawn from "cross-spawn";
|
|
3
|
+
import { connectClaudeCode, connectCodex, connectCursor, connectHermes, connectOpenClaw, } from "../commands/connect.js";
|
|
4
|
+
import { loadConfig, validateKeyFormat } from "./auth.js";
|
|
5
|
+
import { detectInstalledHarnesses, FIVE_HARNESSES, } from "./harness-detect.js";
|
|
6
|
+
import { detectHumanInitiated } from "./harness.js";
|
|
7
|
+
import { removeCursorMcpServer, planCursorWrite } from "./cursor-config.js";
|
|
8
|
+
import { errLine, outLine } from "./output.js";
|
|
9
|
+
import { sendSetupCompleted } from "./setup-telemetry.js";
|
|
10
|
+
import { verifyMcpAuth, verifyMcpHealth } from "./verify.js";
|
|
11
|
+
import { printUpdateNoticeIfDrifted, recordInstalledVersion } from "./version-marker.js";
|
|
12
|
+
const DISPLAY_NAMES = {
|
|
13
|
+
"claude-code": "Claude Code",
|
|
14
|
+
codex: "Codex",
|
|
15
|
+
cursor: "Cursor",
|
|
16
|
+
hermes: "Hermes",
|
|
17
|
+
openclaw: "OpenClaw",
|
|
18
|
+
};
|
|
19
|
+
// `codex mcp add` succeeds unauthenticated; OAuth lands at first tool use.
|
|
20
|
+
const AUTH_DEFERRED_AT_INSTALL = new Set(["codex"]);
|
|
21
|
+
// Harnesses that drive their own OAuth at install — a stored TinyFish key says nothing
|
|
22
|
+
// about their session, so it must never unlock a headless run for them.
|
|
23
|
+
const HARNESS_OWN_AUTH = new Set(["claude-code", "codex", "hermes"]);
|
|
24
|
+
const AUTH_CHECK_CAPABLE = new Set(["cursor", "openclaw"]);
|
|
25
|
+
// Real removal commands — harness id is not the binary name.
|
|
26
|
+
const UNINSTALL_POINTERS = {
|
|
27
|
+
"claude-code": "claude mcp remove tinyfish --scope user",
|
|
28
|
+
codex: "codex mcp remove tinyfish",
|
|
29
|
+
hermes: "hermes mcp remove tinyfish",
|
|
30
|
+
openclaw: "openclaw skills uninstall @tinyfish/tinyfish --global",
|
|
31
|
+
};
|
|
32
|
+
function resolvedApiKey(explicitApiKey) {
|
|
33
|
+
if (explicitApiKey)
|
|
34
|
+
return explicitApiKey;
|
|
35
|
+
const envKey = process.env["TINYFISH_API_KEY"];
|
|
36
|
+
if (envKey)
|
|
37
|
+
return envKey;
|
|
38
|
+
try {
|
|
39
|
+
return loadConfig().api_key;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function hasStoredAuth(explicitApiKey) {
|
|
46
|
+
const key = resolvedApiKey(explicitApiKey);
|
|
47
|
+
return !!key && validateKeyFormat(key);
|
|
48
|
+
}
|
|
49
|
+
async function runHarnessConnect(harness, opts) {
|
|
50
|
+
const base = { apiKey: opts.apiKey, mcpUrl: opts.mcpUrl, launch: false };
|
|
51
|
+
if (harness === "claude-code")
|
|
52
|
+
return connectClaudeCode(base);
|
|
53
|
+
if (harness === "codex")
|
|
54
|
+
return connectCodex(base);
|
|
55
|
+
if (harness === "hermes")
|
|
56
|
+
return connectHermes(base);
|
|
57
|
+
if (harness === "openclaw")
|
|
58
|
+
return connectOpenClaw({ mcpUrl: opts.mcpUrl, launch: false });
|
|
59
|
+
return connectCursor({ apiKey: opts.apiKey, mcpUrl: opts.mcpUrl });
|
|
60
|
+
}
|
|
61
|
+
async function verifyHarness(harness, mcpUrl, apiKey) {
|
|
62
|
+
const health = await verifyMcpHealth(mcpUrl);
|
|
63
|
+
// claude-code is auth-checkable only in key mode (header-authed config).
|
|
64
|
+
const capable = AUTH_CHECK_CAPABLE.has(harness) || (harness === "claude-code" && !!apiKey);
|
|
65
|
+
if (!capable || !health.ok)
|
|
66
|
+
return health;
|
|
67
|
+
if (!apiKey)
|
|
68
|
+
return { depth: "health+auth", ok: false, reason: "no stored API key" };
|
|
69
|
+
return verifyMcpAuth(apiKey);
|
|
70
|
+
}
|
|
71
|
+
function planText(harness, mcpUrl, apiKey) {
|
|
72
|
+
if (harness === "cursor")
|
|
73
|
+
return planCursorWrite(mcpUrl, apiKey);
|
|
74
|
+
return `would run \`tinyfish connect ${harness}\` (harness-owned MCP write; no local file touched by the CLI)`;
|
|
75
|
+
}
|
|
76
|
+
function uninstallPlanText(harness) {
|
|
77
|
+
if (harness === "cursor") {
|
|
78
|
+
return "would remove the tinyfish entry from ~/.cursor/mcp.json";
|
|
79
|
+
}
|
|
80
|
+
return `would print the removal command \`${UNINSTALL_POINTERS[harness]}\` (config is harness-owned; the CLI removes nothing)`;
|
|
81
|
+
}
|
|
82
|
+
function uninstallHarness(harness, configPath) {
|
|
83
|
+
const detection = { harness, detected: true, configPath, installed: false };
|
|
84
|
+
if (harness !== "cursor") {
|
|
85
|
+
// Harness-owned config — removal happens via each tool's own CLI.
|
|
86
|
+
return { ...detection, outcome: "uninstall_pointer", fixCommand: UNINSTALL_POINTERS[harness] };
|
|
87
|
+
}
|
|
88
|
+
let result;
|
|
89
|
+
try {
|
|
90
|
+
result = removeCursorMcpServer();
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
result = { status: "corrupt_skip", error: e instanceof Error ? e.message : String(e) };
|
|
94
|
+
}
|
|
95
|
+
if (result.status === "corrupt_skip") {
|
|
96
|
+
return {
|
|
97
|
+
...detection,
|
|
98
|
+
outcome: "uninstalled",
|
|
99
|
+
errorMessage: `could not update Cursor's mcp.json (${result.error}) — nothing removed.`,
|
|
100
|
+
fixCommand: "Fix ~/.cursor/mcp.json by hand, then re-run: tinyfish connect --all --uninstall",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (result.status === "unchanged") {
|
|
104
|
+
return { ...detection, outcome: "uninstall_noop" };
|
|
105
|
+
}
|
|
106
|
+
return { ...detection, outcome: "uninstalled" };
|
|
107
|
+
}
|
|
108
|
+
async function processHarness(detection, opts, prompt) {
|
|
109
|
+
const { harness, configPath } = detection;
|
|
110
|
+
if (!detection.detected) {
|
|
111
|
+
return { harness, detected: false, configPath, installed: false, outcome: "not_detected" };
|
|
112
|
+
}
|
|
113
|
+
const base = { harness, detected: true, configPath, installed: false };
|
|
114
|
+
// dryRun first: --dry-run --uninstall must plan, never reach removeCursorMcpServer().
|
|
115
|
+
if (opts.dryRun) {
|
|
116
|
+
const plan = opts.uninstall ? uninstallPlanText(harness) : planText(harness, opts.mcpUrl, resolvedApiKey(opts.apiKey));
|
|
117
|
+
return { ...base, outcome: "dry_run", fixCommand: plan };
|
|
118
|
+
}
|
|
119
|
+
if (opts.uninstall)
|
|
120
|
+
return uninstallHarness(harness, configPath);
|
|
121
|
+
const isTTY = detectHumanInitiated();
|
|
122
|
+
// claude-code leaves the own-OAuth set when a key is stored: `mcp add --header
|
|
123
|
+
// X-API-Key` replaces its login. codex/hermes have no header path, so they keep the skip.
|
|
124
|
+
const keyed = hasStoredAuth(opts.apiKey);
|
|
125
|
+
const ownAuthHarness = HARNESS_OWN_AUTH.has(harness) && !(harness === "claude-code" && keyed);
|
|
126
|
+
const alreadyAuthed = !ownAuthHarness && keyed;
|
|
127
|
+
if (!isTTY && !alreadyAuthed) {
|
|
128
|
+
return { ...base, outcome: "no_tty_auth_skip", fixCommand: `tinyfish connect ${harness}` };
|
|
129
|
+
}
|
|
130
|
+
if (isTTY && !alreadyAuthed && prompt) {
|
|
131
|
+
errLine(`step: waiting for browser sign-in to TinyFish for ${DISPLAY_NAMES[harness]}`);
|
|
132
|
+
try {
|
|
133
|
+
await prompt(`Press enter to open the browser and sign in to TinyFish for ${DISPLAY_NAMES[harness]}... `);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Ctrl+D closed stdin — treat like the no-TTY skip.
|
|
137
|
+
return { ...base, outcome: "no_tty_auth_skip", fixCommand: `tinyfish connect ${harness}` };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// runGuarded swallows Ctrl+C and sets exitCode 130 instead of throwing.
|
|
141
|
+
const interruptedBefore = process.exitCode === 130;
|
|
142
|
+
try {
|
|
143
|
+
await runHarnessConnect(harness, opts);
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
return {
|
|
147
|
+
...base,
|
|
148
|
+
outcome: "failed",
|
|
149
|
+
errorMessage: e instanceof Error ? e.message : String(e),
|
|
150
|
+
fixCommand: `tinyfish connect ${harness}`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (!interruptedBefore && process.exitCode === 130) {
|
|
154
|
+
return { ...base, outcome: "interrupted", fixCommand: `tinyfish connect ${harness}` };
|
|
155
|
+
}
|
|
156
|
+
recordInstalledVersion();
|
|
157
|
+
const verify = await verifyHarness(harness, opts.mcpUrl, resolvedApiKey(opts.apiKey));
|
|
158
|
+
return {
|
|
159
|
+
...base,
|
|
160
|
+
installed: true,
|
|
161
|
+
outcome: "installed",
|
|
162
|
+
verifyDepth: verify.depth,
|
|
163
|
+
verifyOk: verify.ok,
|
|
164
|
+
verifyReason: verify.reason,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function summaryLine(result, uninstall) {
|
|
168
|
+
const name = DISPLAY_NAMES[result.harness];
|
|
169
|
+
if (!result.detected) {
|
|
170
|
+
const next = uninstall
|
|
171
|
+
? "nothing to remove"
|
|
172
|
+
: `Install ${name}, then run: tinyfish connect ${result.harness}`;
|
|
173
|
+
return `- ${name} — not detected (searched ${result.configPath}). ${next}`;
|
|
174
|
+
}
|
|
175
|
+
if (result.outcome === "not_reached") {
|
|
176
|
+
return `… ${name} — not reached, the run stopped early. Fix: ${result.fixCommand}`;
|
|
177
|
+
}
|
|
178
|
+
if (result.outcome === "dry_run") {
|
|
179
|
+
return `… ${name} — ${result.fixCommand}`;
|
|
180
|
+
}
|
|
181
|
+
if (result.outcome === "no_tty_auth_skip") {
|
|
182
|
+
return `… ${name} — skipped, no stored credentials and no terminal to sign in. Fix: ${result.fixCommand}`;
|
|
183
|
+
}
|
|
184
|
+
if (result.outcome === "uninstall_pointer") {
|
|
185
|
+
return `… ${name} — not removed automatically (config is owned by ${name}). Run: ${result.fixCommand}`;
|
|
186
|
+
}
|
|
187
|
+
if (result.outcome === "uninstall_noop") {
|
|
188
|
+
return `… ${name} — no tinyfish entry found, nothing removed`;
|
|
189
|
+
}
|
|
190
|
+
if (result.outcome === "uninstalled") {
|
|
191
|
+
return result.errorMessage
|
|
192
|
+
? `✗ ${name} — ${result.errorMessage} Fix: ${result.fixCommand}`
|
|
193
|
+
: `✓ ${name} — removed`;
|
|
194
|
+
}
|
|
195
|
+
if (result.outcome === "interrupted") {
|
|
196
|
+
return `✗ ${name} — interrupted before finishing. Fix: ${result.fixCommand}`;
|
|
197
|
+
}
|
|
198
|
+
if (result.outcome === "failed") {
|
|
199
|
+
return `✗ ${name} — install failed: ${result.errorMessage}. Fix: ${result.fixCommand}`;
|
|
200
|
+
}
|
|
201
|
+
const authDeferred = AUTH_DEFERRED_AT_INSTALL.has(result.harness);
|
|
202
|
+
const verifyLabel = result.verifyDepth ? `verified: ${result.verifyDepth}` : "not verified";
|
|
203
|
+
if (authDeferred || result.verifyOk === false) {
|
|
204
|
+
const reason = authDeferred
|
|
205
|
+
? "one step left: sign in when your agent first runs"
|
|
206
|
+
: `verify warning (${verifyLabel}): ${result.verifyReason ?? "unknown"}`;
|
|
207
|
+
return `⚠ ${name} — installed, ${reason}. Fix: run \`/tinyfish:doctor\` in ${name} once it's open.`;
|
|
208
|
+
}
|
|
209
|
+
return `✓ ${name} — installed, ${verifyLabel}`;
|
|
210
|
+
}
|
|
211
|
+
function computeExitCode(results) {
|
|
212
|
+
const detected = results.filter((r) => r.detected);
|
|
213
|
+
if (detected.length === 0)
|
|
214
|
+
return 1;
|
|
215
|
+
// Only real install attempts count toward the success/failure denominator.
|
|
216
|
+
const attempted = detected.filter((r) => r.outcome === "installed" || r.outcome === "failed" || r.outcome === "interrupted");
|
|
217
|
+
if (attempted.length === 0)
|
|
218
|
+
return 0;
|
|
219
|
+
return attempted.some((r) => r.outcome === "installed") ? 0 : 1;
|
|
220
|
+
}
|
|
221
|
+
export async function runConnectAll(opts) {
|
|
222
|
+
const isTTY = detectHumanInitiated();
|
|
223
|
+
const rl = isTTY
|
|
224
|
+
? readline.createInterface({ input: process.stdin, output: process.stderr })
|
|
225
|
+
: undefined;
|
|
226
|
+
let prompt;
|
|
227
|
+
if (rl) {
|
|
228
|
+
// Without a close-linked signal, question() never settles on Ctrl+D.
|
|
229
|
+
const closed = new AbortController();
|
|
230
|
+
rl.on("close", () => closed.abort());
|
|
231
|
+
prompt = (question) => rl.question(question, { signal: closed.signal });
|
|
232
|
+
}
|
|
233
|
+
const detections = detectInstalledHarnesses();
|
|
234
|
+
const results = [];
|
|
235
|
+
try {
|
|
236
|
+
for (const [index, detection] of detections.entries()) {
|
|
237
|
+
const { harness } = detection;
|
|
238
|
+
errLine(`step ${index + 1} of ${detections.length} — ${DISPLAY_NAMES[harness]}`);
|
|
239
|
+
let result;
|
|
240
|
+
try {
|
|
241
|
+
result = await processHarness(detection, opts, prompt);
|
|
242
|
+
}
|
|
243
|
+
catch (e) {
|
|
244
|
+
// Isolation: one harness throwing never stops the others.
|
|
245
|
+
result = {
|
|
246
|
+
harness,
|
|
247
|
+
detected: detection.detected,
|
|
248
|
+
configPath: detection.configPath,
|
|
249
|
+
installed: false,
|
|
250
|
+
outcome: "failed",
|
|
251
|
+
errorMessage: e instanceof Error ? e.message : String(e),
|
|
252
|
+
fixCommand: `tinyfish connect ${harness}`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
results.push(result);
|
|
256
|
+
// Ctrl+C means stop, not continue to the next harness.
|
|
257
|
+
if (result.outcome === "interrupted")
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
rl?.close();
|
|
263
|
+
}
|
|
264
|
+
// The interrupt break truncates `results`; pad so the 5-entry telemetry schema still parses.
|
|
265
|
+
for (const detection of detections) {
|
|
266
|
+
if (results.some((r) => r.harness === detection.harness))
|
|
267
|
+
continue;
|
|
268
|
+
results.push({
|
|
269
|
+
...detection,
|
|
270
|
+
installed: false,
|
|
271
|
+
outcome: detection.detected ? "not_reached" : "not_detected",
|
|
272
|
+
fixCommand: `tinyfish connect ${detection.harness}`,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
outLine("");
|
|
276
|
+
outLine("TinyFish setup summary:");
|
|
277
|
+
for (const result of results)
|
|
278
|
+
outLine(summaryLine(result, opts.uninstall));
|
|
279
|
+
if (results.every((r) => !r.detected)) {
|
|
280
|
+
outLine("");
|
|
281
|
+
outLine("No supported harness found. Install one of: " + FIVE_HARNESSES.join(", "));
|
|
282
|
+
if (!opts.uninstall)
|
|
283
|
+
outLine("Then run: npx @tiny-fish/cli connect --all");
|
|
284
|
+
return 1;
|
|
285
|
+
}
|
|
286
|
+
if (!opts.dryRun && !opts.uninstall) {
|
|
287
|
+
const harnessTelemetry = results.map((r) => ({
|
|
288
|
+
harness: r.harness,
|
|
289
|
+
detected: r.detected,
|
|
290
|
+
installed: r.installed,
|
|
291
|
+
verify_depth: r.verifyDepth ?? null,
|
|
292
|
+
verify_ok: r.verifyOk ?? null,
|
|
293
|
+
}));
|
|
294
|
+
await sendSetupCompleted(opts.mcpUrl, harnessTelemetry, resolvedApiKey(opts.apiKey));
|
|
295
|
+
await printUpdateNoticeIfDrifted();
|
|
296
|
+
}
|
|
297
|
+
const exitCode = computeExitCode(results);
|
|
298
|
+
const interrupted = results.some((r) => r.outcome === "interrupted");
|
|
299
|
+
if (exitCode === 0 && !interrupted && !opts.dryRun && !opts.uninstall && results.some((r) => r.installed)) {
|
|
300
|
+
outLine("");
|
|
301
|
+
if (isTTY && !opts.noLaunch && (await launchFirstTask(results))) {
|
|
302
|
+
return exitCode;
|
|
303
|
+
}
|
|
304
|
+
outLine('Now ask your agent: "use TinyFish to find today\'s top Hacker News story"');
|
|
305
|
+
}
|
|
306
|
+
return exitCode;
|
|
307
|
+
}
|
|
308
|
+
const FIRST_TASK_PROMPT = "My TinyFish setup just finished — verify it with a real run: use the TinyFish search tool to " +
|
|
309
|
+
"find today's top Hacker News story, then fetch that page with the TinyFish fetch tool and give " +
|
|
310
|
+
"me a two-line summary. Show which tools you call.";
|
|
311
|
+
// Launchable seeded agents, best-first — cursor-agent shares ~/.cursor/mcp.json with the IDE.
|
|
312
|
+
const LAUNCHERS = [
|
|
313
|
+
{ harness: "cursor", command: "cursor-agent", args: [FIRST_TASK_PROMPT] },
|
|
314
|
+
{ harness: "claude-code", command: "claude", args: [FIRST_TASK_PROMPT] },
|
|
315
|
+
];
|
|
316
|
+
async function launchFirstTask(results) {
|
|
317
|
+
const candidates = LAUNCHERS.filter((l) => results.some((r) => r.harness === l.harness && r.installed));
|
|
318
|
+
if (candidates.length === 0)
|
|
319
|
+
return false;
|
|
320
|
+
// Several installs: ask rather than pick a favorite; plain enter skips.
|
|
321
|
+
let chosen = candidates[0];
|
|
322
|
+
if (candidates.length > 1) {
|
|
323
|
+
const menu = candidates.map((l, i) => `[${i + 1}] ${DISPLAY_NAMES[l.harness]}`).join(" ");
|
|
324
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
325
|
+
let answer;
|
|
326
|
+
try {
|
|
327
|
+
answer = await rl.question(`Try your first TinyFish task now? ${menu} [enter] skip: `);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
finally {
|
|
333
|
+
rl.close();
|
|
334
|
+
}
|
|
335
|
+
const pick = Number.parseInt(answer.trim(), 10);
|
|
336
|
+
if (!pick || pick < 1 || pick > candidates.length)
|
|
337
|
+
return false;
|
|
338
|
+
chosen = candidates[pick - 1];
|
|
339
|
+
}
|
|
340
|
+
if (!chosen)
|
|
341
|
+
return false;
|
|
342
|
+
outLine(`Starting ${DISPLAY_NAMES[chosen.harness]} with your first TinyFish task...`);
|
|
343
|
+
const result = spawn.sync(chosen.command, chosen.args, { stdio: "inherit" });
|
|
344
|
+
return !result.error;
|
|
345
|
+
}
|
|
@@ -4,10 +4,14 @@ export interface CursorMcpWriteResult {
|
|
|
4
4
|
status: "written" | "unchanged" | "corrupt_skip";
|
|
5
5
|
backupPath?: string;
|
|
6
6
|
error?: string;
|
|
7
|
+
/** A pre-existing tinyfish entry was replaced — a repair, not a fresh install. */
|
|
8
|
+
repaired?: boolean;
|
|
7
9
|
}
|
|
8
10
|
/** Dry-run description of the pending write; touches nothing. */
|
|
9
|
-
export declare function planCursorWrite(mcpUrl: string): string;
|
|
11
|
+
export declare function planCursorWrite(mcpUrl: string, apiKey?: string): string;
|
|
10
12
|
/** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
|
|
11
|
-
export declare function writeCursorMcpConfig(mcpUrl: string): CursorMcpWriteResult;
|
|
13
|
+
export declare function writeCursorMcpConfig(mcpUrl: string, apiKey?: string): CursorMcpWriteResult;
|
|
14
|
+
/** Cursor deeplink format: cursor://anysphere.cursor-deeplink/mcp/install?name=$NAME&config=$BASE64 */
|
|
15
|
+
export declare function cursorInstallDeeplink(mcpUrl: string): string;
|
|
12
16
|
/** Removes only the `tinyfish` key. */
|
|
13
17
|
export declare function removeCursorMcpServer(): CursorMcpWriteResult;
|
|
@@ -12,8 +12,9 @@ export function cursorMcpPath() {
|
|
|
12
12
|
function isPlainRecord(value) {
|
|
13
13
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
14
14
|
}
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// Footgun: the key lands plaintext in mcp.json (0600) — per-device keys are the GA hardening.
|
|
16
|
+
function buildTinyfishServerEntry(mcpUrl, apiKey) {
|
|
17
|
+
return apiKey ? { url: mcpUrl, headers: { "X-API-Key": apiKey } } : { url: mcpUrl };
|
|
17
18
|
}
|
|
18
19
|
function parseMcpJson(raw) {
|
|
19
20
|
try {
|
|
@@ -46,20 +47,23 @@ function readExisting() {
|
|
|
46
47
|
return { raw, parsed: parsed.value };
|
|
47
48
|
}
|
|
48
49
|
/** Dry-run description of the pending write; touches nothing. */
|
|
49
|
-
export function planCursorWrite(mcpUrl) {
|
|
50
|
+
export function planCursorWrite(mcpUrl, apiKey) {
|
|
50
51
|
const existing = readExisting();
|
|
51
52
|
const filePath = cursorMcpPath();
|
|
52
53
|
if ("error" in existing) {
|
|
53
54
|
return `${filePath}: existing file is corrupt (${existing.error}) — would skip and leave it untouched`;
|
|
54
55
|
}
|
|
56
|
+
const authNote = apiKey
|
|
57
|
+
? " with an API-key header — the key is stored in plaintext in that file (value not shown here)"
|
|
58
|
+
: "";
|
|
55
59
|
const servers = existing.parsed.mcpServers;
|
|
56
60
|
const current = isPlainRecord(servers) ? servers[TINYFISH_SERVER_KEY] : undefined;
|
|
57
|
-
if (JSON.stringify(current) === JSON.stringify(buildTinyfishServerEntry(mcpUrl))) {
|
|
61
|
+
if (JSON.stringify(current) === JSON.stringify(buildTinyfishServerEntry(mcpUrl, apiKey))) {
|
|
58
62
|
return `${filePath}: already has the tinyfish MCP entry — no change`;
|
|
59
63
|
}
|
|
60
64
|
return existing.raw === undefined
|
|
61
|
-
? `${filePath}: would create with a "tinyfish" MCP server entry`
|
|
62
|
-
: `${filePath}: would back up to a timestamped copy, then merge in the "tinyfish" MCP server entry`;
|
|
65
|
+
? `${filePath}: would create with a "tinyfish" MCP server entry${authNote}`
|
|
66
|
+
: `${filePath}: would back up to a timestamped copy, then merge in the "tinyfish" MCP server entry${authNote}`;
|
|
63
67
|
}
|
|
64
68
|
// Backup, then atomic temp+rename; pid avoids same-millisecond backup collisions.
|
|
65
69
|
function commitServers(filePath, existing, servers) {
|
|
@@ -82,7 +86,7 @@ function commitServers(filePath, existing, servers) {
|
|
|
82
86
|
return { status: "written", backupPath };
|
|
83
87
|
}
|
|
84
88
|
/** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
|
|
85
|
-
export function writeCursorMcpConfig(mcpUrl) {
|
|
89
|
+
export function writeCursorMcpConfig(mcpUrl, apiKey) {
|
|
86
90
|
const filePath = cursorMcpPath();
|
|
87
91
|
const existing = readExisting();
|
|
88
92
|
if ("error" in existing)
|
|
@@ -90,12 +94,18 @@ export function writeCursorMcpConfig(mcpUrl) {
|
|
|
90
94
|
const servers = isPlainRecord(existing.parsed.mcpServers)
|
|
91
95
|
? { ...existing.parsed.mcpServers }
|
|
92
96
|
: {};
|
|
93
|
-
const nextEntry = buildTinyfishServerEntry(mcpUrl);
|
|
97
|
+
const nextEntry = buildTinyfishServerEntry(mcpUrl, apiKey);
|
|
94
98
|
if (JSON.stringify(servers[TINYFISH_SERVER_KEY]) === JSON.stringify(nextEntry)) {
|
|
95
99
|
return { status: "unchanged" };
|
|
96
100
|
}
|
|
101
|
+
const repaired = TINYFISH_SERVER_KEY in servers;
|
|
97
102
|
servers[TINYFISH_SERVER_KEY] = nextEntry;
|
|
98
|
-
return commitServers(filePath, existing, servers);
|
|
103
|
+
return { ...commitServers(filePath, existing, servers), repaired };
|
|
104
|
+
}
|
|
105
|
+
/** Cursor deeplink format: cursor://anysphere.cursor-deeplink/mcp/install?name=$NAME&config=$BASE64 */
|
|
106
|
+
export function cursorInstallDeeplink(mcpUrl) {
|
|
107
|
+
const config = Buffer.from(JSON.stringify(buildTinyfishServerEntry(mcpUrl))).toString("base64");
|
|
108
|
+
return `cursor://anysphere.cursor-deeplink/mcp/install?name=${TINYFISH_SERVER_KEY}&config=${encodeURIComponent(config)}`;
|
|
99
109
|
}
|
|
100
110
|
/** Removes only the `tinyfish` key. */
|
|
101
111
|
export function removeCursorMcpServer() {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const FIVE_HARNESSES: readonly ["claude-code", "codex", "cursor", "hermes", "openclaw"];
|
|
2
|
+
export type FiveHarness = (typeof FIVE_HARNESSES)[number];
|
|
3
|
+
export declare function harnessConfigPath(harness: FiveHarness): string;
|
|
4
|
+
export interface HarnessDetection {
|
|
5
|
+
harness: FiveHarness;
|
|
6
|
+
detected: boolean;
|
|
7
|
+
configPath: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function detectInstalledHarnesses(): HarnessDetection[];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
export const FIVE_HARNESSES = ["claude-code", "codex", "cursor", "hermes", "openclaw"];
|
|
5
|
+
// Presence detection only — dir existence means "installed", nothing more.
|
|
6
|
+
const CONFIG_DIRS = {
|
|
7
|
+
"claude-code": ".claude",
|
|
8
|
+
codex: ".codex",
|
|
9
|
+
cursor: ".cursor",
|
|
10
|
+
hermes: ".hermes",
|
|
11
|
+
openclaw: ".openclaw",
|
|
12
|
+
};
|
|
13
|
+
export function harnessConfigPath(harness) {
|
|
14
|
+
return path.join(os.homedir(), CONFIG_DIRS[harness]); // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
|
|
15
|
+
}
|
|
16
|
+
export function detectInstalledHarnesses() {
|
|
17
|
+
return FIVE_HARNESSES.map((harness) => {
|
|
18
|
+
const configPath = harnessConfigPath(harness);
|
|
19
|
+
let detected;
|
|
20
|
+
try {
|
|
21
|
+
detected = fs.statSync(configPath).isDirectory();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
detected = false;
|
|
25
|
+
}
|
|
26
|
+
return { harness, detected, configPath };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* `TINYFISH_NO_TELEMETRY` suppresses ALL CLI-emitted network analytics
|
|
4
|
+
* (setup_completed, connect-event, update check). Any value except `0`/`false`
|
|
5
|
+
* opts out — a privacy switch must not fail closed on `true`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function telemetryDisabled(): boolean;
|
|
8
|
+
export declare function isTrustedTelemetryOrigin(endpoint: string): boolean;
|
|
9
|
+
/** SHA-256 lookup key only, and only to trusted origins — the raw key never rides telemetry. */
|
|
10
|
+
export declare function telemetryHeaders(endpoint: string, apiKey?: string): Record<string, string>;
|
|
11
|
+
declare const harnessResultSchema: z.ZodObject<{
|
|
12
|
+
harness: z.ZodEnum<{
|
|
13
|
+
openclaw: "openclaw";
|
|
14
|
+
cursor: "cursor";
|
|
15
|
+
codex: "codex";
|
|
16
|
+
hermes: "hermes";
|
|
17
|
+
"claude-code": "claude-code";
|
|
18
|
+
}>;
|
|
19
|
+
detected: z.ZodBoolean;
|
|
20
|
+
installed: z.ZodBoolean;
|
|
21
|
+
verify_depth: z.ZodNullable<z.ZodEnum<{
|
|
22
|
+
health: "health";
|
|
23
|
+
"health+auth": "health+auth";
|
|
24
|
+
}>>;
|
|
25
|
+
verify_ok: z.ZodNullable<z.ZodBoolean>;
|
|
26
|
+
}, z.core.$strip>;
|
|
27
|
+
export declare const setupCompletedPayloadSchema: z.ZodObject<{
|
|
28
|
+
harnesses: z.ZodArray<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
|
+
installed: z.ZodBoolean;
|
|
38
|
+
verify_depth: z.ZodNullable<z.ZodEnum<{
|
|
39
|
+
health: "health";
|
|
40
|
+
"health+auth": "health+auth";
|
|
41
|
+
}>>;
|
|
42
|
+
verify_ok: z.ZodNullable<z.ZodBoolean>;
|
|
43
|
+
}, z.core.$strip>>;
|
|
44
|
+
cli_version: z.ZodString;
|
|
45
|
+
}, z.core.$strip>;
|
|
46
|
+
export type SetupCompletedPayload = z.infer<typeof setupCompletedPayloadSchema>;
|
|
47
|
+
export type HarnessResult = z.infer<typeof harnessResultSchema>;
|
|
48
|
+
/** Fire-and-forget, 2s cap; raw keys never ride telemetry. */
|
|
49
|
+
export declare function sendSetupCompleted(mcpUrl: string, harnesses: HarnessResult[], apiKey?: string): Promise<void>;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { validateKeyFormat } from "./auth.js";
|
|
4
|
+
import { CLI_VERSION } from "./constants.js";
|
|
5
|
+
import { FIVE_HARNESSES } from "./harness-detect.js";
|
|
6
|
+
import { errLine } from "./output.js";
|
|
7
|
+
const SETUP_TELEMETRY_TIMEOUT_MS = 2_000;
|
|
8
|
+
/**
|
|
9
|
+
* `TINYFISH_NO_TELEMETRY` suppresses ALL CLI-emitted network analytics
|
|
10
|
+
* (setup_completed, connect-event, update check). Any value except `0`/`false`
|
|
11
|
+
* opts out — a privacy switch must not fail closed on `true`.
|
|
12
|
+
*/
|
|
13
|
+
export function telemetryDisabled() {
|
|
14
|
+
const value = (process.env["TINYFISH_NO_TELEMETRY"] ?? "").trim().toLowerCase();
|
|
15
|
+
return value !== "" && value !== "0" && value !== "false";
|
|
16
|
+
}
|
|
17
|
+
// TinyFish-owned domains plus loopback are the only origins the API key may be sent to.
|
|
18
|
+
// `--url` accepts any http(s) URL, so a user-supplied origin must never receive the key.
|
|
19
|
+
const TRUSTED_TELEMETRY_HOST_SUFFIXES = [".tinyfish.ai", ".tinyfish.io"];
|
|
20
|
+
const TRUSTED_TELEMETRY_LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
21
|
+
export function isTrustedTelemetryOrigin(endpoint) {
|
|
22
|
+
let url;
|
|
23
|
+
try {
|
|
24
|
+
url = new URL(endpoint);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const host = url.hostname.toLowerCase();
|
|
30
|
+
// Loopback may use http for local dev; every other trusted origin must be https so the
|
|
31
|
+
// key is never sent over cleartext.
|
|
32
|
+
if (TRUSTED_TELEMETRY_LOOPBACK_HOSTS.includes(host))
|
|
33
|
+
return true;
|
|
34
|
+
if (url.protocol !== "https:")
|
|
35
|
+
return false;
|
|
36
|
+
return TRUSTED_TELEMETRY_HOST_SUFFIXES.some((suffix) => host === suffix.slice(1) || host.endsWith(suffix));
|
|
37
|
+
}
|
|
38
|
+
/** SHA-256 lookup key only, and only to trusted origins — the raw key never rides telemetry. */
|
|
39
|
+
export function telemetryHeaders(endpoint, apiKey) {
|
|
40
|
+
const headers = { "content-type": "application/json" };
|
|
41
|
+
if (apiKey && validateKeyFormat(apiKey) && isTrustedTelemetryOrigin(endpoint)) {
|
|
42
|
+
headers["x-api-key-lookup"] = createHash("sha256").update(apiKey).digest("hex");
|
|
43
|
+
}
|
|
44
|
+
return headers;
|
|
45
|
+
}
|
|
46
|
+
const harnessResultSchema = z.object({
|
|
47
|
+
harness: z.enum(FIVE_HARNESSES),
|
|
48
|
+
detected: z.boolean(),
|
|
49
|
+
installed: z.boolean(),
|
|
50
|
+
verify_depth: z.enum(["health", "health+auth"]).nullable(),
|
|
51
|
+
verify_ok: z.boolean().nullable(),
|
|
52
|
+
});
|
|
53
|
+
export const setupCompletedPayloadSchema = z.object({
|
|
54
|
+
harnesses: z
|
|
55
|
+
.array(harnessResultSchema)
|
|
56
|
+
.length(FIVE_HARNESSES.length)
|
|
57
|
+
.refine((results) => new Set(results.map((r) => r.harness)).size === FIVE_HARNESSES.length, {
|
|
58
|
+
message: "expected one result per harness",
|
|
59
|
+
}),
|
|
60
|
+
cli_version: z.string(),
|
|
61
|
+
});
|
|
62
|
+
/** Fire-and-forget, 2s cap; raw keys never ride telemetry. */
|
|
63
|
+
export async function sendSetupCompleted(mcpUrl, harnesses, apiKey) {
|
|
64
|
+
if (telemetryDisabled())
|
|
65
|
+
return;
|
|
66
|
+
let endpoint;
|
|
67
|
+
let body;
|
|
68
|
+
try {
|
|
69
|
+
// Parse inside try — schema violation must not crash finished setup.
|
|
70
|
+
const payload = setupCompletedPayloadSchema.parse({ harnesses, cli_version: CLI_VERSION });
|
|
71
|
+
endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
|
|
72
|
+
body = JSON.stringify({ event: "setup_completed", ...payload });
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
if (process.env["TINYFISH_DEBUG"]) {
|
|
76
|
+
errLine(`setup telemetry skipped: ${e instanceof Error ? e.message : String(e)}`);
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const headers = telemetryHeaders(endpoint, apiKey);
|
|
81
|
+
// One retry: stale keep-alive sockets eat post-install POSTs, and this is the
|
|
82
|
+
// headline `--all` metric. 5xx retries; 4xx never will.
|
|
83
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetch(endpoint, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers,
|
|
88
|
+
body,
|
|
89
|
+
redirect: "error",
|
|
90
|
+
signal: AbortSignal.timeout(SETUP_TELEMETRY_TIMEOUT_MS),
|
|
91
|
+
});
|
|
92
|
+
if (response.ok)
|
|
93
|
+
return;
|
|
94
|
+
if (response.status < 500) {
|
|
95
|
+
if (process.env["TINYFISH_DEBUG"]) {
|
|
96
|
+
errLine(`setup telemetry rejected: HTTP ${response.status}`);
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
if (process.env["TINYFISH_DEBUG"]) {
|
|
103
|
+
errLine(`setup telemetry failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type VerifyDepth = "health" | "health+auth";
|
|
2
|
+
export interface VerifyResult {
|
|
3
|
+
depth: VerifyDepth;
|
|
4
|
+
ok: boolean;
|
|
5
|
+
reason?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Reachability check. Verify failure is a warning, never install failure. */
|
|
8
|
+
export declare function verifyMcpHealth(mcpUrl: string): Promise<VerifyResult>;
|
|
9
|
+
/** Authenticated check — only where the CLI holds the key (Cursor, OpenClaw). */
|
|
10
|
+
export declare function verifyMcpAuth(apiKey: string): Promise<VerifyResult>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { listRuns } from "./client.js";
|
|
2
|
+
const VERIFY_TIMEOUT_MS = 5_000;
|
|
3
|
+
/** Reachability check. Verify failure is a warning, never install failure. */
|
|
4
|
+
export async function verifyMcpHealth(mcpUrl) {
|
|
5
|
+
try {
|
|
6
|
+
const response = await fetch(mcpUrl, {
|
|
7
|
+
method: "GET",
|
|
8
|
+
// Redirect (captive portal, typo'd --url) is not healthy.
|
|
9
|
+
redirect: "error",
|
|
10
|
+
signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS),
|
|
11
|
+
});
|
|
12
|
+
// MCP rejects bare GET with 4xx — still proves endpoint routed.
|
|
13
|
+
if (response.status >= 500) {
|
|
14
|
+
return { depth: "health", ok: false, reason: `endpoint returned HTTP ${response.status}` };
|
|
15
|
+
}
|
|
16
|
+
return { depth: "health", ok: true };
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
return {
|
|
20
|
+
depth: "health",
|
|
21
|
+
ok: false,
|
|
22
|
+
reason: e instanceof Error ? e.message : "network error reaching MCP endpoint",
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Authenticated check — only where the CLI holds the key (Cursor, OpenClaw). */
|
|
27
|
+
export async function verifyMcpAuth(apiKey) {
|
|
28
|
+
try {
|
|
29
|
+
// Bound it: the SDK default is 600s, and a warning-only check must never hang an install.
|
|
30
|
+
await listRuns({ limit: 1 }, apiKey, VERIFY_TIMEOUT_MS);
|
|
31
|
+
return { depth: "health+auth", ok: true };
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
return { depth: "health+auth", ok: false, reason: e instanceof Error ? e.message : String(e) };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Install-time, offline-safe; best-effort — never fails a finished install. */
|
|
2
|
+
export declare function recordInstalledVersion(): void;
|
|
3
|
+
interface UpdateCheckResult {
|
|
4
|
+
updateAvailable: boolean;
|
|
5
|
+
latest?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Throttled npm-currency check; never auto-mutates the install. */
|
|
8
|
+
export declare function checkForCliUpdate(now?: number): Promise<UpdateCheckResult>;
|
|
9
|
+
export declare function printUpdateNoticeIfDrifted(): Promise<void>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { configDir } from "./auth.js";
|
|
5
|
+
import { CLI_VERSION } from "./constants.js";
|
|
6
|
+
import { errLine } from "./output.js";
|
|
7
|
+
import { telemetryDisabled } from "./setup-telemetry.js";
|
|
8
|
+
const MARKER_FILE = "skill-version-marker.json";
|
|
9
|
+
const THROTTLE_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
const CHECK_TIMEOUT_MS = 5_000;
|
|
11
|
+
// npm is the only currency signal with a publisher behind it: the cookbook repo has no
|
|
12
|
+
// releases and no release automation, so a tag check would report "up to date" forever.
|
|
13
|
+
const REGISTRY_URL = "https://registry.npmjs.org/@tiny-fish/cli/latest";
|
|
14
|
+
const markerSchema = z.object({
|
|
15
|
+
last_checked_at: z.number().optional(),
|
|
16
|
+
last_known_latest: z.string().optional(),
|
|
17
|
+
});
|
|
18
|
+
function markerPath() {
|
|
19
|
+
return path.join(configDir(), MARKER_FILE);
|
|
20
|
+
}
|
|
21
|
+
function readMarker() {
|
|
22
|
+
try {
|
|
23
|
+
const parsed = markerSchema.safeParse(JSON.parse(fs.readFileSync(markerPath(), "utf8")));
|
|
24
|
+
return parsed.success ? parsed.data : undefined;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function writeMarker(state) {
|
|
31
|
+
fs.mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
32
|
+
const tmpPath = `${markerPath()}.tmp-${process.pid}`;
|
|
33
|
+
fs.writeFileSync(tmpPath, JSON.stringify(state), { mode: 0o600 });
|
|
34
|
+
fs.renameSync(tmpPath, markerPath());
|
|
35
|
+
}
|
|
36
|
+
/** Install-time, offline-safe; best-effort — never fails a finished install. */
|
|
37
|
+
export function recordInstalledVersion() {
|
|
38
|
+
try {
|
|
39
|
+
writeMarker({ ...readMarker() });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// marker is a nice-to-have
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)$/;
|
|
46
|
+
/** Strictly newer; false on unparsable input. */
|
|
47
|
+
function isNewer(current, latest) {
|
|
48
|
+
const c = SEMVER.exec(current);
|
|
49
|
+
const l = SEMVER.exec(latest);
|
|
50
|
+
if (!c || !l)
|
|
51
|
+
return false;
|
|
52
|
+
for (let i = 1; i <= 3; i++) {
|
|
53
|
+
const cn = Number(c[i]);
|
|
54
|
+
const ln = Number(l[i]);
|
|
55
|
+
if (ln !== cn)
|
|
56
|
+
return ln > cn;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
/** Throttled npm-currency check; never auto-mutates the install. */
|
|
61
|
+
export async function checkForCliUpdate(now = Date.now()) {
|
|
62
|
+
const marker = readMarker();
|
|
63
|
+
// No marker means the CLI never installed anything — nothing to nag about.
|
|
64
|
+
if (!marker || telemetryDisabled())
|
|
65
|
+
return { updateAvailable: false };
|
|
66
|
+
const cached = () => ({
|
|
67
|
+
updateAvailable: marker.last_known_latest !== undefined && isNewer(CLI_VERSION, marker.last_known_latest),
|
|
68
|
+
...(marker.last_known_latest !== undefined ? { latest: marker.last_known_latest } : {}),
|
|
69
|
+
});
|
|
70
|
+
// Time-based only — failed fetch must not stall every invocation.
|
|
71
|
+
if (marker.last_checked_at !== undefined && now - marker.last_checked_at < THROTTLE_MS) {
|
|
72
|
+
return cached();
|
|
73
|
+
}
|
|
74
|
+
let latest;
|
|
75
|
+
try {
|
|
76
|
+
const response = await fetch(REGISTRY_URL, {
|
|
77
|
+
headers: { accept: "application/json" },
|
|
78
|
+
signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
|
|
79
|
+
});
|
|
80
|
+
if (response.ok) {
|
|
81
|
+
const body = (await response.json());
|
|
82
|
+
if (typeof body.version === "string")
|
|
83
|
+
latest = body.version;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Unreachable registry — don't fail setup, don't erase the cache.
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
writeMarker({
|
|
91
|
+
...marker,
|
|
92
|
+
last_checked_at: now,
|
|
93
|
+
last_known_latest: latest ?? marker.last_known_latest,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// best-effort
|
|
98
|
+
}
|
|
99
|
+
// Fall back to the cache so consecutive invocations can't disagree.
|
|
100
|
+
if (!latest)
|
|
101
|
+
return cached();
|
|
102
|
+
return { updateAvailable: isNewer(CLI_VERSION, latest), latest };
|
|
103
|
+
}
|
|
104
|
+
export async function printUpdateNoticeIfDrifted() {
|
|
105
|
+
const { updateAvailable } = await checkForCliUpdate();
|
|
106
|
+
if (updateAvailable) {
|
|
107
|
+
errLine("a newer TinyFish CLI is available — re-run `npx @tiny-fish/cli connect --all`");
|
|
108
|
+
}
|
|
109
|
+
}
|