@tiny-fish/cli 0.13.1-next.148 → 0.13.1-next.150

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.
@@ -1,20 +1,20 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import * as path from "node:path";
3
3
  import spawn from "cross-spawn";
4
- import { CONNECT_SOURCE, loadConfig, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, writeConfig, } from "../lib/auth.js";
5
- import { CLI_VERSION } from "../lib/constants.js";
6
- import { cursorInstallDeeplink, cursorMcpPath, writeCursorMcpConfig } from "../lib/cursor-config.js";
4
+ import { CONNECT_SOURCE, loadConfig, persistApiKeyToEnvironment, resolvedApiKey, saveConnectContext, validateKeyFormat, writeConfig, } from "../lib/auth.js";
5
+ import { CLI_VERSION, TINYFISH_CLI_PACKAGE } from "../lib/constants.js";
6
+ import { cursorInstallDeeplink, cursorMcpPath, writeCursorMcpConfig, } from "../lib/cursor-config.js";
7
7
  import { runConnectAll } from "../lib/connect-all.js";
8
8
  import { detectHumanInitiated } from "../lib/harness.js";
9
- import { captureNotice, emitNotice } from "../lib/notice.js";
9
+ import { emitNotice } from "../lib/notice.js";
10
10
  import { errLine } from "../lib/output.js";
11
- import { telemetryDisabled, telemetryHeaders } from "../lib/setup-telemetry.js";
11
+ import { installSignalGuard } from "../lib/signals.js";
12
+ import { postConnectEvent, telemetryDisabled } from "../lib/setup-telemetry.js";
12
13
  import { verifyMcpAuth } from "../lib/verify.js";
13
14
  export const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
14
15
  const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
15
16
  const SKILL_INSTALL_TIMEOUT_MS = 120_000;
16
17
  const HERMES_SEED_TIMEOUT_MS = 120_000;
17
- const TELEMETRY_TIMEOUT_MS = 2_000;
18
18
  const MCP_LOGIN_UNAVAILABLE_MESSAGE = "This Claude Code installation does not expose `claude mcp login`, which is required for " +
19
19
  "one-command MCP authentication. Update Claude Code and retry, or authenticate TinyFish " +
20
20
  "manually from /mcp.";
@@ -32,7 +32,7 @@ const OPENCODE_MODEL_NOTE = "Note: TinyFish runs on tool calls, so OpenCode need
32
32
  'models (e.g. Nano Banana Pro) will show "No endpoints found that support tool use" — switch ' +
33
33
  "OpenCode's model if the walkthrough can't start.";
34
34
  const TINYFISH_CLI_NOT_FOUND_MESSAGE = "TinyFish CLI installed but is not available on PATH. Open a new terminal and retry.";
35
- const TINYFISH_CLI_PACKAGE = "@tiny-fish/cli@latest";
35
+ const TINYFISH_CLI_INSTALL_SPEC = `${TINYFISH_CLI_PACKAGE}@latest`;
36
36
  export const UPGRADE_HINT = "Run `tinyfish upgrade` any time to update the CLI and skill.";
37
37
  // Supports Hermes without node:util.styleText, so the installer still runs on Node 20.11.
38
38
  const SKILLS_CLI_PACKAGE = "skills@1.5.15";
@@ -74,35 +74,20 @@ function settle(state, telemetry, stage, detail) {
74
74
  state.settled = true;
75
75
  telemetry.track(stage, detail);
76
76
  }
77
- // Without a parent handler, terminal Ctrl+C kills this process inside spawn.sync
78
- // before the child result exists — no aborted event, no flush. With one, the parent
79
- // survives; raw-mode children still surface via throwIfInterrupted, and cooked-mode
80
- // stages (npm/npx/OAuth waits) land here.
81
- // 128 + signal number
82
- const SIGNAL_EXIT_CODES = { SIGHUP: 129, SIGINT: 130, SIGTERM: 143 };
83
- function installSignalGuard(state, telemetry) {
84
- const onSignal = (signal) => {
85
- if (state.settled) {
86
- process.exit(SIGNAL_EXIT_CODES[signal]);
87
- }
77
+ // Raw-mode children still surface via throwIfInterrupted; cooked-mode stages
78
+ // (npm/npx/OAuth waits) land here.
79
+ function installConnectSignalGuard(state, telemetry) {
80
+ return installSignalGuard(async () => {
81
+ if (state.settled)
82
+ return;
88
83
  settle(state, telemetry, "aborted", { failedStage: state.stage });
89
84
  errLine("Setup interrupted — run the command again to finish.");
90
- void telemetry.flush().finally(() => process.exit(SIGNAL_EXIT_CODES[signal]));
91
- };
92
- const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
93
- const handlers = signals.map((s) => {
94
- const handler = () => onSignal(s);
95
- process.on(s, handler);
96
- return [s, handler];
85
+ await telemetry.flush();
97
86
  });
98
- return () => {
99
- for (const [s, handler] of handlers)
100
- process.off(s, handler);
101
- };
102
87
  }
103
88
  /** Shared terminal handling: interrupted → aborted (or completed post-install), else failed. */
104
89
  async function runGuarded(state, telemetry, body) {
105
- const uninstallSignalGuard = installSignalGuard(state, telemetry);
90
+ const uninstallSignalGuard = installConnectSignalGuard(state, telemetry);
106
91
  try {
107
92
  await body();
108
93
  }
@@ -267,49 +252,22 @@ const OPENCLAW = {
267
252
  unavailableMessage: OPENCLAW_SKILL_UNAVAILABLE_MESSAGE,
268
253
  },
269
254
  };
270
- function storedApiKey() {
271
- try {
272
- return loadConfig().api_key;
273
- }
274
- catch {
275
- return undefined;
276
- }
277
- }
278
255
  function createConnectTelemetry(mcpUrl, client, apiKey) {
279
256
  const attemptId = randomUUID();
280
257
  const endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
281
- const telemetryKey = apiKey || process.env["TINYFISH_API_KEY"] || storedApiKey();
282
- const headers = telemetryHeaders(endpoint, telemetryKey);
283
258
  const pending = [];
284
259
  async function deliver(body) {
260
+ // Ahead of the key read; postConnectEvent's own opt-out check is too late to skip it.
285
261
  if (telemetryDisabled())
286
262
  return;
287
- // One retry: stale keep-alive sockets eat post-install POSTs. 5xx retries;
288
- // 4xx never will (schema mismatch, e.g. new stages hitting an old server).
289
- for (let attempt = 0; attempt < 2; attempt++) {
290
- try {
291
- const response = await fetch(endpoint, {
292
- method: "POST",
293
- headers,
294
- body,
295
- redirect: "error",
296
- signal: AbortSignal.timeout(TELEMETRY_TIMEOUT_MS),
297
- });
298
- // Before the ok branch so an error response still yields a notice.
299
- captureNotice(response.headers);
300
- if (response.ok)
301
- return;
302
- if (response.status < 500) {
303
- if (process.env["TINYFISH_DEBUG"]) {
304
- errLine(`telemetry rejected: HTTP ${response.status}`);
305
- }
306
- return;
307
- }
308
- }
309
- catch {
310
- // best-effort; never blocks setup
311
- }
312
- }
263
+ // Resolved per event: `tinyfish auth login` writes the config from a subprocess mid-connect.
264
+ await postConnectEvent({
265
+ endpoint,
266
+ body,
267
+ attempts: 2,
268
+ apiKey: resolvedApiKey(apiKey),
269
+ label: "connect",
270
+ });
313
271
  }
314
272
  return {
315
273
  attemptId,
@@ -356,7 +314,7 @@ function commandNotFound(error) {
356
314
  }
357
315
  export function installTinyFishCli() {
358
316
  errLine("Installing the TinyFish CLI...");
359
- const result = spawn.sync("npm", ["install", "--global", TINYFISH_CLI_PACKAGE], {
317
+ const result = spawn.sync("npm", ["install", "--global", TINYFISH_CLI_INSTALL_SPEC], {
360
318
  stdio: "inherit",
361
319
  timeout: SKILL_INSTALL_TIMEOUT_MS,
362
320
  });
@@ -1,3 +1,5 @@
1
1
  import { Command } from "commander";
2
2
  export declare function runUpgrade(): void;
3
+ /** `runUpgrade` plus its outcome telemetry, which must never change what the upgrade does. */
4
+ export declare function runUpgradeCommand(): Promise<void>;
3
5
  export declare function registerUpgrade(program: Command): void;
@@ -1,6 +1,12 @@
1
+ import spawn from "cross-spawn";
2
+ import { resolvedApiKey } from "../lib/auth.js";
3
+ import { CLI_VERSION, TINYFISH_CLI_PACKAGE } from "../lib/constants.js";
1
4
  import { errLine } from "../lib/output.js";
5
+ import { sendUpgradeCompleted, telemetryDisabled, } from "../lib/setup-telemetry.js";
6
+ import { installSignalGuard, SIGNAL_EXIT_CODES } from "../lib/signals.js";
2
7
  import { ConnectInterruptedError, installTinyFishCli, updateWebSkill } from "./connect.js";
3
- const INTERRUPTED_EXIT_CODE = 130;
8
+ const VERSION_READ_TIMEOUT_MS = 2_000;
9
+ const INTERRUPTED_MESSAGE = "Upgrade interrupted — run `tinyfish upgrade` again to finish.";
4
10
  function attempt(label, run) {
5
11
  try {
6
12
  run();
@@ -30,17 +36,64 @@ export function runUpgrade() {
30
36
  catch (error) {
31
37
  if (!(error instanceof ConnectInterruptedError))
32
38
  throw error;
33
- errLine("Upgrade interrupted — run `tinyfish upgrade` again to finish.");
34
- process.exitCode = INTERRUPTED_EXIT_CODE;
39
+ errLine(INTERRUPTED_MESSAGE);
40
+ process.exitCode = SIGNAL_EXIT_CODES.SIGINT;
35
41
  return;
36
42
  }
37
43
  errLine("TinyFish is up to date. Restart your agent to pick up the refreshed skill.");
38
44
  }
45
+ /** The version now on disk. Only the upgrade's own npm call knows what "latest" resolved to. */
46
+ function installedCliVersion() {
47
+ try {
48
+ const result = spawn.sync("npm", ["ls", "--global", "--depth=0", "--json", TINYFISH_CLI_PACKAGE], { encoding: "utf8", timeout: VERSION_READ_TIMEOUT_MS });
49
+ const parsed = JSON.parse(result.stdout ?? "");
50
+ return parsed.dependencies?.[TINYFISH_CLI_PACKAGE]?.version ?? null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ /** `runUpgrade` plus its outcome telemetry, which must never change what the upgrade does. */
57
+ export async function runUpgradeCommand() {
58
+ let reported = false;
59
+ // Read on the failed path too: a failed skill refresh still leaves an upgraded CLI, so
60
+ // `outcome` reports whether every step ran and `to_version` whether the binary moved.
61
+ const report = async (outcome) => {
62
+ if (reported)
63
+ return;
64
+ reported = true;
65
+ // Ahead of the version and key reads; an opted-out run does neither.
66
+ if (telemetryDisabled())
67
+ return;
68
+ // Interrupted skips the read — racing the exit. Undercounts; see posthog-events.md.
69
+ const toVersion = outcome === "interrupted" ? null : installedCliVersion();
70
+ await sendUpgradeCompleted(CLI_VERSION, toVersion, outcome, resolvedApiKey());
71
+ };
72
+ const uninstallSignalGuard = installSignalGuard(async () => {
73
+ errLine(INTERRUPTED_MESSAGE);
74
+ await report("interrupted");
75
+ });
76
+ let outcome = "updated";
77
+ try {
78
+ runUpgrade();
79
+ if (process.exitCode === SIGNAL_EXIT_CODES.SIGINT)
80
+ outcome = "interrupted";
81
+ }
82
+ catch (error) {
83
+ outcome = "failed";
84
+ throw error;
85
+ }
86
+ finally {
87
+ uninstallSignalGuard();
88
+ // Swallowed: a throw here would replace the upgrade's own error, or fail a good upgrade.
89
+ await report(outcome).catch(() => { });
90
+ }
91
+ }
39
92
  export function registerUpgrade(program) {
40
93
  program
41
94
  .command("upgrade")
42
95
  .description("Update the TinyFish CLI and the use-tinyfish skill to the latest versions")
43
- .action(() => {
44
- runUpgrade();
96
+ .action(async () => {
97
+ await runUpgradeCommand();
45
98
  });
46
99
  }
@@ -22,4 +22,6 @@ export declare function persistApiKeyToEnvironment(apiKey: string, options?: Env
22
22
  export declare function clearConfig(): boolean;
23
23
  export declare function validateKeyFormat(key: string): boolean;
24
24
  export declare function maskKey(key: string): string;
25
+ /** Key for optional paths (telemetry, auth probes) — absent is a valid answer, so never exits. */
26
+ export declare function resolvedApiKey(explicitApiKey?: string): string | undefined;
25
27
  export declare function getApiKey(): string;
package/dist/lib/auth.js CHANGED
@@ -145,11 +145,27 @@ export function maskKey(key) {
145
145
  return "***";
146
146
  return key.slice(0, 12) + "..." + key.slice(-4);
147
147
  }
148
+ /** Key for optional paths (telemetry, auth probes) — absent is a valid answer, so never exits. */
149
+ export function resolvedApiKey(explicitApiKey) {
150
+ if (explicitApiKey)
151
+ return explicitApiKey;
152
+ const envKey = process.env["TINYFISH_API_KEY"];
153
+ if (envKey)
154
+ return envKey;
155
+ try {
156
+ return loadConfig().api_key;
157
+ }
158
+ catch {
159
+ return undefined;
160
+ }
161
+ }
148
162
  export function getApiKey() {
149
163
  const envKey = process.env.TINYFISH_API_KEY;
150
164
  if (envKey) {
151
165
  if (!validateKeyFormat(envKey)) {
152
- err({ error: "TINYFISH_API_KEY has invalid format. Expected sk-tinyfish-... or sk-mino-..." });
166
+ err({
167
+ error: "TINYFISH_API_KEY has invalid format. Expected sk-tinyfish-... or sk-mino-...",
168
+ });
153
169
  process.exit(1);
154
170
  }
155
171
  return envKey;
@@ -1,7 +1,7 @@
1
1
  import * as readline from "node:readline/promises";
2
2
  import spawn from "cross-spawn";
3
3
  import { connectClaudeCode, connectCodex, connectCursor, connectHermes, connectOpenClaw, } from "../commands/connect.js";
4
- import { loadConfig, validateKeyFormat } from "./auth.js";
4
+ import { resolvedApiKey, validateKeyFormat } from "./auth.js";
5
5
  import { detectInstalledHarnesses, FIVE_HARNESSES, } from "./harness-detect.js";
6
6
  import { detectHumanInitiated } from "./harness.js";
7
7
  import { emitNotice } from "./notice.js";
@@ -29,19 +29,6 @@ const UNINSTALL_POINTERS = {
29
29
  hermes: "hermes mcp remove tinyfish",
30
30
  openclaw: "openclaw skills uninstall @tinyfish/tinyfish --global",
31
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
32
  function hasStoredAuth(explicitApiKey) {
46
33
  const key = resolvedApiKey(explicitApiKey);
47
34
  return !!key && validateKeyFormat(key);
@@ -113,7 +100,9 @@ async function processHarness(detection, opts, prompt) {
113
100
  const base = { harness, detected: true, configPath, installed: false };
114
101
  // dryRun first: --dry-run --uninstall must plan, never reach removeCursorMcpServer().
115
102
  if (opts.dryRun) {
116
- const plan = opts.uninstall ? uninstallPlanText(harness) : planText(harness, opts.mcpUrl, resolvedApiKey(opts.apiKey));
103
+ const plan = opts.uninstall
104
+ ? uninstallPlanText(harness)
105
+ : planText(harness, opts.mcpUrl, resolvedApiKey(opts.apiKey));
117
106
  return { ...base, outcome: "dry_run", fixCommand: plan };
118
107
  }
119
108
  if (opts.uninstall)
@@ -296,7 +285,11 @@ export async function runConnectAll(opts) {
296
285
  }
297
286
  const exitCode = computeExitCode(results);
298
287
  const interrupted = results.some((r) => r.outcome === "interrupted");
299
- if (exitCode === 0 && !interrupted && !opts.dryRun && !opts.uninstall && results.some((r) => r.installed)) {
288
+ if (exitCode === 0 &&
289
+ !interrupted &&
290
+ !opts.dryRun &&
291
+ !opts.uninstall &&
292
+ results.some((r) => r.installed)) {
300
293
  outLine("");
301
294
  if (isTTY && !opts.noLaunch && (await launchFirstTask(results))) {
302
295
  return exitCode;
@@ -1,4 +1,6 @@
1
1
  export declare const CLI_VERSION: string;
2
+ /** Unversioned: the install site appends `@latest`, the version read must not. */
3
+ export declare const TINYFISH_CLI_PACKAGE = "@tiny-fish/cli";
2
4
  export declare const BASE_URL: string;
3
5
  export declare const API_URL_OVERRIDE: string | undefined;
4
6
  /** URL where users can create and manage API keys */
@@ -2,6 +2,8 @@ import { createRequire } from "module";
2
2
  const _require = createRequire(import.meta.url);
3
3
  const _pkg = _require("../../package.json");
4
4
  export const CLI_VERSION = _pkg.version;
5
+ /** Unversioned: the install site appends `@latest`, the version read must not. */
6
+ export const TINYFISH_CLI_PACKAGE = "@tiny-fish/cli";
5
7
  /** Base URL for the TinyFish API. Override with TINYFISH_API_URL for staging/self-hosted. */
6
8
  const apiUrlOverride = process.env["TINYFISH_API_URL"]?.trim() || undefined;
7
9
  if (apiUrlOverride) {
@@ -8,6 +8,20 @@ export declare function telemetryDisabled(): boolean;
8
8
  export declare function isTrustedTelemetryOrigin(endpoint: string): boolean;
9
9
  /** SHA-256 lookup key only, and only to trusted origins — the raw key never rides telemetry. */
10
10
  export declare function telemetryHeaders(endpoint: string, apiKey?: string): Record<string, string>;
11
+ /**
12
+ * The one POST to `/api/cli/connect-event`, and the only place its security-relevant
13
+ * properties live: trusted-origin key gating, `redirect: "error"`, the 2s cap, notice
14
+ * capture. Never throws. `endpoint` is the caller's — connect and setup target the `--url`
15
+ * being installed against, upgrade has none and uses BASE_URL.
16
+ */
17
+ export declare function postConnectEvent(options: {
18
+ endpoint: string;
19
+ body: string;
20
+ /** 2 where a lost event costs a headline metric — stale keep-alive sockets eat post-install POSTs. */
21
+ attempts: number;
22
+ label: string;
23
+ apiKey?: string;
24
+ }): Promise<void>;
11
25
  declare const harnessResultSchema: z.ZodObject<{
12
26
  harness: z.ZodEnum<{
13
27
  openclaw: "openclaw";
@@ -45,6 +59,8 @@ export declare const setupCompletedPayloadSchema: z.ZodObject<{
45
59
  }, z.core.$strip>;
46
60
  export type SetupCompletedPayload = z.infer<typeof setupCompletedPayloadSchema>;
47
61
  export type HarnessResult = z.infer<typeof harnessResultSchema>;
48
- /** Fire-and-forget, 2s cap; raw keys never ride telemetry. */
62
+ export type UpgradeOutcome = "updated" | "failed" | "interrupted";
63
+ /** `toVersion` is null when the run was interrupted or the installed version could not be read. */
64
+ export declare function sendUpgradeCompleted(fromVersion: string, toVersion: string | null, outcome: UpgradeOutcome, apiKey?: string): Promise<void>;
49
65
  export declare function sendSetupCompleted(mcpUrl: string, harnesses: HarnessResult[], apiKey?: string): Promise<void>;
50
66
  export {};
@@ -1,10 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import { validateKeyFormat } from "./auth.js";
4
- import { CLI_VERSION } from "./constants.js";
4
+ import { BASE_URL, CLI_VERSION } from "./constants.js";
5
5
  import { FIVE_HARNESSES } from "./harness-detect.js";
6
+ import { captureNotice } from "./notice.js";
6
7
  import { errLine } from "./output.js";
7
- const SETUP_TELEMETRY_TIMEOUT_MS = 2_000;
8
+ const CONNECT_EVENT_TIMEOUT_MS = 2_000;
8
9
  /**
9
10
  * `TINYFISH_NO_TELEMETRY` suppresses ALL CLI-emitted network analytics
10
11
  * (setup_completed, connect-event, update check). Any value except `0`/`false`
@@ -43,6 +44,56 @@ export function telemetryHeaders(endpoint, apiKey) {
43
44
  }
44
45
  return headers;
45
46
  }
47
+ /**
48
+ * The one POST to `/api/cli/connect-event`, and the only place its security-relevant
49
+ * properties live: trusted-origin key gating, `redirect: "error"`, the 2s cap, notice
50
+ * capture. Never throws. `endpoint` is the caller's — connect and setup target the `--url`
51
+ * being installed against, upgrade has none and uses BASE_URL.
52
+ */
53
+ export async function postConnectEvent(options) {
54
+ const { endpoint, body, attempts, label, apiKey } = options;
55
+ if (telemetryDisabled())
56
+ return;
57
+ // Swallows its own write failure so callers can treat this function as total.
58
+ const debug = (line) => {
59
+ if (!process.env["TINYFISH_DEBUG"])
60
+ return;
61
+ try {
62
+ errLine(`${label} telemetry ${line}`);
63
+ }
64
+ catch {
65
+ // no stderr to report to
66
+ }
67
+ };
68
+ const headers = telemetryHeaders(endpoint, apiKey);
69
+ const trusted = isTrustedTelemetryOrigin(endpoint);
70
+ for (let attempt = 0; attempt < attempts; attempt++) {
71
+ try {
72
+ const response = await fetch(endpoint, {
73
+ method: "POST",
74
+ headers,
75
+ body,
76
+ redirect: "error",
77
+ signal: AbortSignal.timeout(CONNECT_EVENT_TIMEOUT_MS),
78
+ });
79
+ // Before the ok branch so a rejected event still yields a notice; gated on origin so a
80
+ // `--url` host cannot print its own text as a TinyFish advisory.
81
+ if (trusted)
82
+ captureNotice(response.headers);
83
+ if (response.ok)
84
+ return;
85
+ // 4xx will reject the same body again (schema mismatch, e.g. new stages on an old server).
86
+ const willRetry = response.status >= 500 && attempt < attempts - 1;
87
+ if (!willRetry)
88
+ debug(`rejected: HTTP ${response.status}`);
89
+ if (response.status < 500)
90
+ return;
91
+ }
92
+ catch (e) {
93
+ debug(`failed: ${e instanceof Error ? e.message : String(e)}`);
94
+ }
95
+ }
96
+ }
46
97
  const harnessResultSchema = z.object({
47
98
  harness: z.enum(FIVE_HARNESSES),
48
99
  detected: z.boolean(),
@@ -59,8 +110,23 @@ export const setupCompletedPayloadSchema = z.object({
59
110
  }),
60
111
  cli_version: z.string(),
61
112
  });
62
- /** Fire-and-forget, 2s cap; raw keys never ride telemetry. */
113
+ /** `toVersion` is null when the run was interrupted or the installed version could not be read. */
114
+ export async function sendUpgradeCompleted(fromVersion, toVersion, outcome, apiKey) {
115
+ await postConnectEvent({
116
+ endpoint: new URL("/api/cli/connect-event", BASE_URL).toString(),
117
+ body: JSON.stringify({
118
+ event: "upgrade_completed",
119
+ from_version: fromVersion,
120
+ to_version: toVersion,
121
+ outcome,
122
+ }),
123
+ attempts: 1,
124
+ label: "upgrade",
125
+ apiKey,
126
+ });
127
+ }
63
128
  export async function sendSetupCompleted(mcpUrl, harnesses, apiKey) {
129
+ // Ahead of the parse so an opt-out run prints no telemetry debug.
64
130
  if (telemetryDisabled())
65
131
  return;
66
132
  let endpoint;
@@ -77,31 +143,5 @@ export async function sendSetupCompleted(mcpUrl, harnesses, apiKey) {
77
143
  }
78
144
  return;
79
145
  }
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
- }
146
+ await postConnectEvent({ endpoint, body, attempts: 2, label: "setup", apiKey });
107
147
  }
@@ -0,0 +1,12 @@
1
+ export declare const SIGNAL_EXIT_CODES: {
2
+ readonly SIGHUP: 129;
3
+ readonly SIGINT: 130;
4
+ readonly SIGTERM: 143;
5
+ };
6
+ export type TerminalSignal = keyof typeof SIGNAL_EXIT_CODES;
7
+ /**
8
+ * Without a parent handler, terminal Ctrl+C kills this process inside spawn.sync before the
9
+ * child result exists — no event, no flush. `onSignal` runs at most once; a second signal
10
+ * exits rather than waiting on it.
11
+ */
12
+ export declare function installSignalGuard(onSignal: (signal: TerminalSignal) => Promise<void> | void): () => void;
@@ -0,0 +1,27 @@
1
+ // 128 + signal number
2
+ export const SIGNAL_EXIT_CODES = { SIGHUP: 129, SIGINT: 130, SIGTERM: 143 };
3
+ /**
4
+ * Without a parent handler, terminal Ctrl+C kills this process inside spawn.sync before the
5
+ * child result exists — no event, no flush. `onSignal` runs at most once; a second signal
6
+ * exits rather than waiting on it.
7
+ */
8
+ export function installSignalGuard(onSignal) {
9
+ let handling = false;
10
+ const handlers = ["SIGINT", "SIGTERM", "SIGHUP"].map((signal) => {
11
+ const handler = () => {
12
+ const exit = () => process.exit(SIGNAL_EXIT_CODES[signal]);
13
+ if (handling)
14
+ return exit();
15
+ handling = true;
16
+ void Promise.resolve(onSignal(signal))
17
+ .catch(() => { })
18
+ .finally(exit);
19
+ };
20
+ process.on(signal, handler);
21
+ return [signal, handler];
22
+ });
23
+ return () => {
24
+ for (const [signal, handler] of handlers)
25
+ process.off(signal, handler);
26
+ };
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.13.1-next.148",
3
+ "version": "0.13.1-next.150",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {