@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 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
@@ -1,10 +1,12 @@
1
- import { createHash, randomUUID } from "node:crypto";
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 = { "content-type": "application/json" };
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");
@@ -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>;
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.12.1-next.137",
3
+ "version": "0.12.1-next.138",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {