@tiny-fish/cli 0.10.1 → 0.10.2-next.131

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,14 +1,14 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import * as path from "node:path";
3
3
  import spawn from "cross-spawn";
4
- import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConfig, saveConnectContext, validateKeyFormat, } from "../lib/auth.js";
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 { errLine } from "../lib/output.js";
7
7
  const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
8
8
  const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
9
9
  const SKILL_INSTALL_TIMEOUT_MS = 120_000;
10
10
  const HERMES_SEED_TIMEOUT_MS = 120_000;
11
- const TELEMETRY_TIMEOUT_MS = 500;
11
+ const TELEMETRY_TIMEOUT_MS = 2_000;
12
12
  const MCP_LOGIN_UNAVAILABLE_MESSAGE = "This Claude Code installation does not expose `claude mcp login`, which is required for " +
13
13
  "one-command MCP authentication. Update Claude Code and retry, or authenticate TinyFish " +
14
14
  "manually from /mcp.";
@@ -32,6 +32,72 @@ export const OPENCLAW_ONBOARDING_PROMPT = "I just installed the TinyFish skill.
32
32
  "want to search and wait for my reply before using TinyFish Search. Show me the results, ask " +
33
33
  "which result I want to read, and wait before using TinyFish Fetch. Then ask what browser task " +
34
34
  "I want to complete and wait before using TinyFish Agent. Never skip ahead or choose for me.";
35
+ /** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
36
+ class ConnectInterruptedError extends Error {
37
+ }
38
+ function throwIfInterrupted(result) {
39
+ if (result.signal === "SIGINT" || result.signal === "SIGTERM") {
40
+ throw new ConnectInterruptedError("Setup interrupted");
41
+ }
42
+ }
43
+ /** Terminal events exactly once, whichever of flow or signal handler wins. */
44
+ function settle(state, telemetry, stage, detail) {
45
+ if (state.settled)
46
+ return;
47
+ state.settled = true;
48
+ telemetry.track(stage, detail);
49
+ }
50
+ // Without a parent handler, terminal Ctrl+C kills this process inside spawn.sync
51
+ // before the child result exists — no aborted event, no flush. With one, the parent
52
+ // survives; raw-mode children still surface via throwIfInterrupted, and cooked-mode
53
+ // stages (npm/npx/OAuth waits) land here.
54
+ // 128 + signal number
55
+ const SIGNAL_EXIT_CODES = { SIGHUP: 129, SIGINT: 130, SIGTERM: 143 };
56
+ function installSignalGuard(state, telemetry) {
57
+ const onSignal = (signal) => {
58
+ if (state.settled) {
59
+ process.exit(SIGNAL_EXIT_CODES[signal]);
60
+ }
61
+ settle(state, telemetry, "aborted", { failedStage: state.stage });
62
+ errLine("Setup interrupted — run the command again to finish.");
63
+ void telemetry.flush().finally(() => process.exit(SIGNAL_EXIT_CODES[signal]));
64
+ };
65
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
66
+ const handlers = signals.map((s) => {
67
+ const handler = () => onSignal(s);
68
+ process.on(s, handler);
69
+ return [s, handler];
70
+ });
71
+ return () => {
72
+ for (const [s, handler] of handlers)
73
+ process.off(s, handler);
74
+ };
75
+ }
76
+ /** Shared terminal handling: interrupted → aborted (or completed post-install), else failed. */
77
+ async function runGuarded(state, telemetry, body) {
78
+ const uninstallSignalGuard = installSignalGuard(state, telemetry);
79
+ try {
80
+ body();
81
+ }
82
+ catch (error) {
83
+ if (error instanceof ConnectInterruptedError) {
84
+ if (state.stage === "walkthrough_launch") {
85
+ settle(state, telemetry, "completed");
86
+ return;
87
+ }
88
+ settle(state, telemetry, "aborted", { failedStage: state.stage });
89
+ errLine("Setup interrupted — run the command again to finish.");
90
+ process.exitCode = 130;
91
+ return;
92
+ }
93
+ settle(state, telemetry, "failed", { failedStage: state.stage });
94
+ throw error;
95
+ }
96
+ finally {
97
+ uninstallSignalGuard();
98
+ await telemetry.flush();
99
+ }
100
+ }
35
101
  const CLAUDE_CODE = {
36
102
  command: "claude",
37
103
  connectClient: "claude-code",
@@ -96,6 +162,7 @@ function launchHermesWalkthrough() {
96
162
  timeout: HERMES_SEED_TIMEOUT_MS,
97
163
  });
98
164
  if (seedResult.error || seedResult.status !== 0) {
165
+ throwIfInterrupted(seedResult);
99
166
  throw new Error("Could not start the TinyFish walkthrough in Hermes", {
100
167
  cause: seedResult.error,
101
168
  });
@@ -159,44 +226,69 @@ function isTrustedTelemetryOrigin(endpoint) {
159
226
  return false;
160
227
  return TRUSTED_TELEMETRY_HOST_SUFFIXES.some((suffix) => host === suffix.slice(1) || host.endsWith(suffix));
161
228
  }
229
+ function storedApiKey() {
230
+ try {
231
+ return loadConfig().api_key;
232
+ }
233
+ catch {
234
+ return undefined;
235
+ }
236
+ }
162
237
  function createConnectTelemetry(mcpUrl, client, apiKey) {
163
238
  const attemptId = randomUUID();
164
239
  const endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
165
- const telemetryKey = apiKey ?? process.env["TINYFISH_API_KEY"];
240
+ const telemetryKey = apiKey || process.env["TINYFISH_API_KEY"] || storedApiKey();
166
241
  const headers = { "content-type": "application/json" };
167
- // Attach the key so the endpoint can attribute the event to the signed-in user, but only
168
- // when it is well-formed AND the endpoint is a trusted TinyFish origin. A user-supplied
169
- // --url pointing elsewhere gets anonymous telemetry, never the key.
242
+ // SHA-256 lookup key only — the raw key never rides a telemetry request
243
+ // (OWASP: keep credentials out of anything logging infra might capture).
170
244
  if (telemetryKey && validateKeyFormat(telemetryKey) && isTrustedTelemetryOrigin(endpoint)) {
171
- headers["x-api-key"] = telemetryKey;
245
+ headers["x-api-key-lookup"] = createHash("sha256").update(telemetryKey).digest("hex");
172
246
  }
173
- let enabled = true;
174
- return {
175
- attemptId,
176
- async track(stage, failedStage) {
177
- if (!enabled)
178
- return;
247
+ const pending = [];
248
+ async function deliver(body) {
249
+ // One retry: stale keep-alive sockets eat post-install POSTs. 5xx retries;
250
+ // 4xx never will (schema mismatch, e.g. new stages hitting an old server).
251
+ for (let attempt = 0; attempt < 2; attempt++) {
179
252
  try {
180
253
  const response = await fetch(endpoint, {
181
254
  method: "POST",
182
255
  headers,
183
- body: JSON.stringify({
184
- attempt_id: attemptId,
185
- client,
186
- stage,
187
- failed_stage: failedStage,
188
- runtime_platform: process.platform,
189
- node_version: process.version,
190
- cli_version: CLI_VERSION,
191
- }),
256
+ body,
257
+ redirect: "error",
192
258
  signal: AbortSignal.timeout(TELEMETRY_TIMEOUT_MS),
193
259
  });
194
- if (!response.ok)
195
- enabled = false;
260
+ if (response.ok)
261
+ return;
262
+ if (response.status < 500) {
263
+ if (process.env["TINYFISH_DEBUG"]) {
264
+ errLine(`telemetry rejected: HTTP ${response.status}`);
265
+ }
266
+ return;
267
+ }
196
268
  }
197
269
  catch {
198
- enabled = false;
270
+ // best-effort; never blocks setup
199
271
  }
272
+ }
273
+ }
274
+ return {
275
+ attemptId,
276
+ // Fire-and-forget; awaiting each event stalls setup when telemetry down.
277
+ track(stage, detail) {
278
+ pending.push(deliver(JSON.stringify({
279
+ attempt_id: attemptId,
280
+ client,
281
+ stage,
282
+ failed_stage: detail?.failedStage,
283
+ phase: detail?.phase,
284
+ runtime_platform: process.platform,
285
+ node_version: process.version,
286
+ cli_version: CLI_VERSION,
287
+ })));
288
+ },
289
+ // Awaited in finally so in-flight events land before exit.
290
+ async flush() {
291
+ await Promise.all(pending);
200
292
  },
201
293
  };
202
294
  }
@@ -211,6 +303,7 @@ function requireCommandSupport(client) {
211
303
  });
212
304
  }
213
305
  if (result.error || result.status !== 0) {
306
+ throwIfInterrupted(result);
214
307
  throw new Error(client.supportCheck.unavailableMessage, { cause: result.error });
215
308
  }
216
309
  const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
@@ -228,6 +321,7 @@ function installTinyFishCli() {
228
321
  timeout: SKILL_INSTALL_TIMEOUT_MS,
229
322
  });
230
323
  if (result.error || result.status !== 0) {
324
+ throwIfInterrupted(result);
231
325
  throw new Error("Could not install the TinyFish CLI", { cause: result.error });
232
326
  }
233
327
  }
@@ -253,6 +347,7 @@ function installWebSkill(client) {
253
347
  timeout: SKILL_INSTALL_TIMEOUT_MS,
254
348
  });
255
349
  if (result.error || result.status !== 0) {
350
+ throwIfInterrupted(result);
256
351
  throw new Error(`Could not install the TinyFish web skill in ${client.displayName}`, {
257
352
  cause: result.error,
258
353
  });
@@ -264,7 +359,8 @@ function ensureCliAuthenticated(source, apiKey) {
264
359
  if (!validateKeyFormat(envKey)) {
265
360
  throw new Error("TINYFISH_API_KEY has invalid format");
266
361
  }
267
- saveConfig(envKey);
362
+ // Throwing variant: a write failure must reach connect's catch/flush, not exit(1) past it.
363
+ writeConfig(envKey);
268
364
  persistApiKeyToEnvironment(envKey);
269
365
  return;
270
366
  }
@@ -285,6 +381,7 @@ function ensureCliAuthenticated(source, apiKey) {
285
381
  throw new Error(TINYFISH_CLI_NOT_FOUND_MESSAGE, { cause: loginResult.error });
286
382
  }
287
383
  if (loginResult.error || loginResult.status !== 0) {
384
+ throwIfInterrupted(loginResult);
288
385
  throw new Error("Could not authenticate the TinyFish CLI", { cause: loginResult.error });
289
386
  }
290
387
  }
@@ -295,6 +392,7 @@ function removeExistingRegistration(client, removal) {
295
392
  });
296
393
  if (result.status === 0)
297
394
  return;
395
+ throwIfInterrupted(result);
298
396
  const details = result.stderr?.trim() || result.error?.message || "unknown error";
299
397
  if (/No MCP server named "tinyfish"/i.test(details))
300
398
  return;
@@ -339,62 +437,66 @@ function launchOpenClawWalkthrough() {
339
437
  }
340
438
  async function connectNativeMcpClient(client, options) {
341
439
  const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options.apiKey);
342
- let failureStage = "prerequisite_check";
343
- try {
344
- await telemetry.track("started");
440
+ const state = { stage: "prerequisite_check", settled: false };
441
+ await runGuarded(state, telemetry, () => {
442
+ telemetry.track("started");
345
443
  requireCommandSupport(client);
346
- failureStage = "registration_cleanup";
444
+ telemetry.track("checkpoint", { phase: "prerequisite_ok" });
445
+ state.stage = "registration_cleanup";
347
446
  for (const removal of client.removals)
348
447
  removeExistingRegistration(client, removal);
349
448
  const mcpUrl = new URL(options.mcpUrl);
350
449
  mcpUrl.searchParams.set("source", CONNECT_SOURCE);
351
450
  mcpUrl.searchParams.set("client", client.connectClient);
352
451
  mcpUrl.searchParams.set("connect_attempt_id", telemetry.attemptId);
353
- failureStage = client.loginArgs ? "registration" : "registration_or_authentication";
452
+ state.stage = client.loginArgs ? "registration" : "registration_or_authentication";
354
453
  errLine(`Adding TinyFish to ${client.displayName}...`);
355
454
  const addResult = spawn.sync(client.command, client.addArgs(mcpUrl.toString(), options.mcpUrl), {
356
455
  stdio: "inherit",
357
456
  });
358
457
  if (addResult.error || addResult.status !== 0) {
458
+ throwIfInterrupted(addResult);
359
459
  throw new Error(`Could not add TinyFish to ${client.displayName}`, {
360
460
  cause: addResult.error,
361
461
  });
362
462
  }
463
+ telemetry.track("checkpoint", { phase: "registered" });
363
464
  if (client.loginArgs) {
364
- failureStage = "authentication";
465
+ state.stage = "client_oauth";
365
466
  errLine("Signing in to TinyFish...");
366
467
  const loginResult = spawn.sync(client.command, client.loginArgs, {
367
468
  stdio: "inherit",
368
469
  });
369
470
  if (loginResult.error || loginResult.status !== 0) {
471
+ throwIfInterrupted(loginResult);
370
472
  throw new Error(`Could not authenticate TinyFish in ${client.displayName}`, {
371
473
  cause: loginResult.error,
372
474
  });
373
475
  }
476
+ telemetry.track("checkpoint", { phase: "oauth_done" });
374
477
  }
375
- failureStage = "cli_install";
478
+ state.stage = "cli_install";
376
479
  installTinyFishCli();
377
- failureStage = "skill_install";
480
+ telemetry.track("checkpoint", { phase: "cli_installed" });
481
+ state.stage = "skill_install";
378
482
  installWebSkill(client);
379
- failureStage = "authentication";
483
+ telemetry.track("checkpoint", { phase: "skill_installed" });
484
+ state.stage = "authentication";
380
485
  ensureCliAuthenticated(client.connectClient, options.apiKey);
486
+ telemetry.track("checkpoint", { phase: "authenticated" });
381
487
  // Persist once here (skill installed + authenticated) so both success paths, launch
382
488
  // or not, record the install; buildConnectHeaders reads it on subsequent requests.
383
489
  saveConnectContext(client.connectClient, telemetry.attemptId);
384
490
  if (!options.launch) {
385
491
  errLine(`TinyFish is connected. Open ${client.displayName} to start using it.`);
386
- await telemetry.track("completed");
492
+ settle(state, telemetry, "completed");
387
493
  return;
388
494
  }
389
- failureStage = "walkthrough_launch";
390
- if (!launchNativeMcpClient(client))
391
- return;
392
- await telemetry.track("completed");
393
- }
394
- catch (error) {
395
- await telemetry.track("failed", failureStage);
396
- throw error;
397
- }
495
+ state.stage = "walkthrough_launch";
496
+ // Install already done; walkthrough Ctrl+C still counts completed.
497
+ launchNativeMcpClient(client);
498
+ settle(state, telemetry, "completed");
499
+ });
398
500
  }
399
501
  export async function connectClaudeCode(options) {
400
502
  await connectNativeMcpClient(CLAUDE_CODE, options);
@@ -407,30 +509,35 @@ export async function connectHermes(options) {
407
509
  }
408
510
  export async function connectOpenClaw(options) {
409
511
  const telemetry = createConnectTelemetry(options.mcpUrl, "openclaw");
410
- let failureStage = "prerequisite_check";
411
- try {
412
- await telemetry.track("started");
512
+ const state = { stage: "prerequisite_check", settled: false };
513
+ await runGuarded(state, telemetry, () => {
514
+ telemetry.track("started");
413
515
  requireCommandSupport(OPENCLAW);
516
+ telemetry.track("checkpoint", { phase: "prerequisite_ok" });
414
517
  if (options.installCli !== false) {
415
- failureStage = "cli_install";
518
+ state.stage = "cli_install";
416
519
  errLine("Installing the TinyFish CLI...");
417
520
  const cliInstallResult = spawn.sync("npm", ["install", "--global", "@tiny-fish/cli@latest"], {
418
521
  stdio: "inherit",
419
522
  timeout: SKILL_INSTALL_TIMEOUT_MS,
420
523
  });
421
524
  if (cliInstallResult.error || cliInstallResult.status !== 0) {
525
+ throwIfInterrupted(cliInstallResult);
422
526
  throw new Error("Could not install the TinyFish CLI", { cause: cliInstallResult.error });
423
527
  }
528
+ telemetry.track("checkpoint", { phase: "cli_installed" });
424
529
  }
425
- failureStage = "skill_install";
530
+ state.stage = "skill_install";
426
531
  errLine("Installing the TinyFish skill in OpenClaw...");
427
532
  const skillInstallResult = spawn.sync("openclaw", ["skills", "install", OPENCLAW_SKILL, "--global", "--force", "--acknowledge-clawhub-risk"], { stdio: "inherit" });
428
533
  if (skillInstallResult.error || skillInstallResult.status !== 0) {
534
+ throwIfInterrupted(skillInstallResult);
429
535
  throw new Error("Could not install the TinyFish skill in OpenClaw", {
430
536
  cause: skillInstallResult.error,
431
537
  });
432
538
  }
433
- failureStage = "authentication";
539
+ telemetry.track("checkpoint", { phase: "skill_installed" });
540
+ state.stage = "authentication";
434
541
  const authStatus = spawn.sync("tinyfish", ["auth", "status"], {
435
542
  stdio: "ignore",
436
543
  timeout: NON_INTERACTIVE_TIMEOUT_MS,
@@ -447,26 +554,24 @@ export async function connectOpenClaw(options) {
447
554
  throw new Error(TINYFISH_CLI_NOT_FOUND_MESSAGE, { cause: loginResult.error });
448
555
  }
449
556
  if (loginResult.error || loginResult.status !== 0) {
557
+ throwIfInterrupted(loginResult);
450
558
  throw new Error("Could not authenticate TinyFish for OpenClaw", {
451
559
  cause: loginResult.error,
452
560
  });
453
561
  }
454
562
  }
563
+ telemetry.track("checkpoint", { phase: "authenticated" });
455
564
  saveConnectContext("openclaw", telemetry.attemptId);
456
565
  if (!options.launch) {
457
566
  errLine("TinyFish is connected. Open OpenClaw to start using it.");
458
- await telemetry.track("completed");
567
+ settle(state, telemetry, "completed");
459
568
  return;
460
569
  }
461
- failureStage = "walkthrough_launch";
462
- if (!launchOpenClawWalkthrough())
463
- return;
464
- await telemetry.track("completed");
465
- }
466
- catch (error) {
467
- await telemetry.track("failed", failureStage);
468
- throw error;
469
- }
570
+ state.stage = "walkthrough_launch";
571
+ // Install already done; walkthrough Ctrl+C still counts completed.
572
+ launchOpenClawWalkthrough();
573
+ settle(state, telemetry, "completed");
574
+ });
470
575
  }
471
576
  export function launchAgent(client) {
472
577
  if (client === "claude-code") {
@@ -10,6 +10,8 @@ interface TinyfishConfig {
10
10
  connect?: Record<string, ConnectEntry>;
11
11
  }
12
12
  export declare function loadConfig(): TinyfishConfig;
13
+ /** Throwing variant for callers with their own cleanup (connect's telemetry flush). */
14
+ export declare function writeConfig(apiKey: string): void;
13
15
  export declare function saveConfig(apiKey: string): void;
14
16
  export declare function saveConnectContext(client: string, attemptId: string): void;
15
17
  interface EnvironmentOptions {
package/dist/lib/auth.js CHANGED
@@ -44,16 +44,20 @@ function parseConnectMap(value) {
44
44
  }
45
45
  return Object.keys(out).length > 0 ? out : undefined;
46
46
  }
47
- export function saveConfig(apiKey) {
47
+ /** Throwing variant for callers with their own cleanup (connect's telemetry flush). */
48
+ export function writeConfig(apiKey) {
48
49
  const dir = configDir();
50
+ // 0o700: only owner can read/write/execute the directory
51
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
52
+ const existing = loadConfig();
53
+ // 0o600: only owner can read/write the config file (key is sensitive)
54
+ fs.writeFileSync(configFile(), JSON.stringify({ ...existing, api_key: apiKey }), {
55
+ mode: 0o600,
56
+ });
57
+ }
58
+ export function saveConfig(apiKey) {
49
59
  try {
50
- // 0o700: only owner can read/write/execute the directory
51
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
52
- const existing = loadConfig();
53
- // 0o600: only owner can read/write the config file (key is sensitive)
54
- fs.writeFileSync(configFile(), JSON.stringify({ ...existing, api_key: apiKey }), {
55
- mode: 0o600,
56
- });
60
+ writeConfig(apiKey);
57
61
  }
58
62
  catch (e) {
59
63
  err({ error: `Could not write config: ${e instanceof Error ? e.message : String(e)}` });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.10.1",
3
+ "version": "0.10.2-next.131",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {