@tiny-fish/cli 0.12.1-next.137 → 0.12.1-next.138
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 +9 -28
- package/dist/lib/client.d.ts +1 -1
- package/dist/lib/client.js +2 -2
- 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,12 @@
|
|
|
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
6
|
import { cursorMcpPath, writeCursorMcpConfig } from "../lib/cursor-config.js";
|
|
7
7
|
import { errLine } from "../lib/output.js";
|
|
8
|
+
import { telemetryDisabled, telemetryHeaders } from "../lib/setup-telemetry.js";
|
|
9
|
+
import { recordInstalledVersion } from "../lib/version-marker.js";
|
|
8
10
|
export const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
|
|
9
11
|
const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
|
|
10
12
|
const SKILL_INSTALL_TIMEOUT_MS = 120_000;
|
|
@@ -220,27 +222,6 @@ const OPENCLAW = {
|
|
|
220
222
|
unavailableMessage: OPENCLAW_SKILL_UNAVAILABLE_MESSAGE,
|
|
221
223
|
},
|
|
222
224
|
};
|
|
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
225
|
function storedApiKey() {
|
|
245
226
|
try {
|
|
246
227
|
return loadConfig().api_key;
|
|
@@ -253,14 +234,11 @@ function createConnectTelemetry(mcpUrl, client, apiKey) {
|
|
|
253
234
|
const attemptId = randomUUID();
|
|
254
235
|
const endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
|
|
255
236
|
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
|
-
}
|
|
237
|
+
const headers = telemetryHeaders(endpoint, telemetryKey);
|
|
262
238
|
const pending = [];
|
|
263
239
|
async function deliver(body) {
|
|
240
|
+
if (telemetryDisabled())
|
|
241
|
+
return;
|
|
264
242
|
// One retry: stale keep-alive sockets eat post-install POSTs. 5xx retries;
|
|
265
243
|
// 4xx never will (schema mismatch, e.g. new stages hitting an old server).
|
|
266
244
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -598,6 +576,7 @@ async function connectNativeMcpClient(client, options) {
|
|
|
598
576
|
// Persist once here (skill installed + authenticated) so both success paths, launch
|
|
599
577
|
// or not, record the install; buildConnectHeaders reads it on subsequent requests.
|
|
600
578
|
saveConnectContext(client.connectClient, telemetry.attemptId);
|
|
579
|
+
recordInstalledVersion();
|
|
601
580
|
errLine(UPGRADE_HINT);
|
|
602
581
|
if (!options.launch) {
|
|
603
582
|
errLine(`TinyFish is connected. Open ${client.displayName} to start using it.`);
|
|
@@ -676,6 +655,7 @@ export async function connectOpenClaw(options) {
|
|
|
676
655
|
}
|
|
677
656
|
telemetry.track("checkpoint", { phase: "authenticated" });
|
|
678
657
|
saveConnectContext("openclaw", telemetry.attemptId);
|
|
658
|
+
recordInstalledVersion();
|
|
679
659
|
errLine(UPGRADE_HINT);
|
|
680
660
|
if (!options.launch) {
|
|
681
661
|
errLine("TinyFish is connected. Open OpenClaw to start using it.");
|
|
@@ -714,6 +694,7 @@ export async function connectCursor(options) {
|
|
|
714
694
|
ensureCliAuthenticated("cursor", options.apiKey);
|
|
715
695
|
telemetry.track("checkpoint", { phase: "authenticated" });
|
|
716
696
|
saveConnectContext("cursor", telemetry.attemptId);
|
|
697
|
+
recordInstalledVersion();
|
|
717
698
|
// A server written into mcp.json lands unauthenticated: only Cursor's own "Add to
|
|
718
699
|
// Cursor" deep link auto-starts OAuth, so name the click or the install never completes.
|
|
719
700
|
errLine("Added to Cursor. Restart Cursor (or reload the window), then open");
|
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 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
|
+
}
|