@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.
- package/dist/commands/connect.js +25 -67
- package/dist/commands/upgrade.d.ts +2 -0
- package/dist/commands/upgrade.js +58 -5
- package/dist/lib/auth.d.ts +2 -0
- package/dist/lib/auth.js +17 -1
- package/dist/lib/connect-all.js +9 -16
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/setup-telemetry.d.ts +17 -1
- package/dist/lib/setup-telemetry.js +70 -30
- package/dist/lib/signals.d.ts +12 -0
- package/dist/lib/signals.js +27 -0
- package/package.json +1 -1
package/dist/commands/connect.js
CHANGED
|
@@ -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 {
|
|
9
|
+
import { emitNotice } from "../lib/notice.js";
|
|
10
10
|
import { errLine } from "../lib/output.js";
|
|
11
|
-
import {
|
|
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
|
|
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
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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",
|
|
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;
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -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
|
|
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(
|
|
34
|
-
process.exitCode =
|
|
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
|
-
|
|
96
|
+
.action(async () => {
|
|
97
|
+
await runUpgradeCommand();
|
|
45
98
|
});
|
|
46
99
|
}
|
package/dist/lib/auth.d.ts
CHANGED
|
@@ -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({
|
|
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;
|
package/dist/lib/connect-all.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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 &&
|
|
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;
|
package/dist/lib/constants.d.ts
CHANGED
|
@@ -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 */
|
package/dist/lib/constants.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
+
}
|