@tiny-fish/cli 0.29.1 → 0.29.2-next.246

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.
@@ -6,6 +6,7 @@ import { ALL_HARNESSES, AuthMode, Registered } from "../lib/harness-detect.js";
6
6
  import { detectHumanInitiated } from "../lib/harness.js";
7
7
  import { err, errLine, out, outLine } from "../lib/output.js";
8
8
  import { detectRegistrations } from "../lib/registration-detect.js";
9
+ import { sendDoctorCompleted } from "../lib/setup-telemetry.js";
9
10
  import { verifyMcpAuth, verifyMcpHealth } from "../lib/verify.js";
10
11
  import { connectClaudeCode, connectCodex, connectCursor, connectGrok, connectHermes, connectOpenClaw, connectOpencode, DEFAULT_MCP_URL, } from "./connect.js";
11
12
  // A green auth mode with a red auth call proves nothing, so the call's verdict gates the claim.
@@ -351,6 +352,30 @@ export async function applyRepairs(repairs, options) {
351
352
  }
352
353
  return outcomes;
353
354
  }
355
+ /** Aggregate counts only; check `detail` text and per-harness rows never leave the machine. */
356
+ function telemetryPayload(report, offered, outcomes, options) {
357
+ const count = (status) => report.checks.filter((check) => check.status === status).length;
358
+ return {
359
+ schema_version: report.schema_version,
360
+ cli_version: report.cli_version,
361
+ ok_harnesses: report.ok_harnesses,
362
+ ok_cli: report.ok_cli,
363
+ checks_pass: count("pass"),
364
+ checks_fail: count("fail"),
365
+ checks_warn: count("warn"),
366
+ checks_skip: count("skip"),
367
+ // From the pre-repair report; the verdicts above describe the machine after it.
368
+ repairs_offered: offered.length,
369
+ repair_actions: [...new Set(offered.map((repair) => repair.action))],
370
+ repairs_attempted: outcomes.filter((outcome) => outcome.status !== "skipped").length,
371
+ repairs_succeeded: outcomes.filter((outcome) => outcome.status === "repaired").length,
372
+ fix_mode: !options.fix ? "none" : options.yes ? "fix_yes" : "fix",
373
+ // --harness scopes detection, so the counts above cover that harness alone.
374
+ harness_scope: options.harness ?? "all",
375
+ // Same signal cli_connect_stage carries, so agent and human doctor runs split.
376
+ is_human_initiated: detectHumanInitiated(),
377
+ };
378
+ }
354
379
  export function registerDoctor(program) {
355
380
  program
356
381
  .command("doctor")
@@ -373,10 +398,14 @@ export function registerDoctor(program) {
373
398
  ...(options.harness ? { harness: options.harness } : {}),
374
399
  };
375
400
  let report;
401
+ let offered;
402
+ let outcomes = [];
376
403
  try {
377
404
  report = await runDoctor(doctorOptions);
405
+ offered = report.repairs;
378
406
  if (doctorOptions.fix && report.repairs.length > 0) {
379
- for (const outcome of await applyRepairs(report.repairs, doctorOptions)) {
407
+ outcomes = await applyRepairs(report.repairs, doctorOptions);
408
+ for (const outcome of outcomes) {
380
409
  const reason = outcome.reason ? ` (${outcome.reason})` : "";
381
410
  errLine(`${outcome.status}: ${outcome.repair.command}${reason}`);
382
411
  }
@@ -399,5 +428,8 @@ export function registerDoctor(program) {
399
428
  else
400
429
  out(report);
401
430
  process.exitCode = exitCodeFor(report.checks);
431
+ // After the exit code is set: telemetry never decides how doctor exits.
432
+ const resolved = apiKeyStatus();
433
+ await sendDoctorCompleted(doctorOptions.mcpUrl, telemetryPayload(report, offered, outcomes, doctorOptions), "key" in resolved ? resolved.key : undefined);
402
434
  });
403
435
  }
@@ -1,7 +1,8 @@
1
1
  import { Command } from "commander";
2
+ import { type DecryptStats } from "../lib/chrome-cookies.js";
2
3
  import type { CookieRecord } from "../lib/types.js";
3
- export declare function extractChromeCookies(domain: string): Promise<CookieRecord[]>;
4
- export declare function extractAllChromeCookies(): Promise<CookieRecord[]>;
4
+ export declare function extractChromeCookies(domain: string, stats?: DecryptStats): CookieRecord[];
5
+ export declare function extractAllChromeCookies(stats?: DecryptStats): CookieRecord[];
5
6
  type TldtsParse = (host: string, opts: {
6
7
  allowPrivateDomains: boolean;
7
8
  }) => {
@@ -3,6 +3,7 @@ import * as os from "os";
3
3
  import * as path from "path";
4
4
  import { createRequire } from "module";
5
5
  import { getApiKey } from "../lib/auth.js";
6
+ import { deriveChromeKey, listAuthRelevantHostKeys, openCookiesDb, readCookiesForHost, } from "../lib/chrome-cookies.js";
6
7
  import { profileCreate, profileUpload } from "../lib/client.js";
7
8
  import { err, errLine, handleApiError, out, outLine } from "../lib/output.js";
8
9
  // ── UserFacingError ───────────────────────────────────────────────────────────
@@ -32,13 +33,8 @@ function validateDomain(domain) {
32
33
  process.exit(1);
33
34
  }
34
35
  }
35
- // Chrome-internal timestamp (microseconds since 1601-01-01) → Unix seconds
36
- const CHROME_EPOCH_DELTA_SECONDS = 11644473600;
37
- function chromeTimeToUnix(chromeMicros) {
38
- return Math.floor(chromeMicros / 1e6) - CHROME_EPOCH_DELTA_SECONDS;
39
- }
40
- // Sets up chrome-cookies-secure in a temp dir and calls fn; cleans up after.
41
- async function withCookiesDb(fn) {
36
+ // Opens a private copy of Chrome's cookie DB, derives the decryption key, and calls fn.
37
+ function withCookiesDb(fn) {
42
38
  if (process.platform === "win32") {
43
39
  throw new UserFacingError("Cookie extraction is not supported on Windows yet");
44
40
  }
@@ -46,17 +42,32 @@ async function withCookiesDb(fn) {
46
42
  if (!fs.existsSync(cookiesPath)) {
47
43
  throw new UserFacingError(`Chrome cookies file not found at ${cookiesPath}. Is Google Chrome installed?`);
48
44
  }
49
- // chrome-cookies-secure expects a profile directory and appends "/Cookies".
50
- // Copy the file into a temp dir so we avoid the Chrome file lock.
45
+ // Copy the DB out of the profile directory so we avoid the Chrome file lock.
51
46
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinyfish-chrome-cookies-"));
47
+ let db;
52
48
  try {
53
- fs.copyFileSync(cookiesPath, path.join(tmpDir, "Cookies"));
54
- // chrome-cookies-secure is a CommonJS module — use createRequire to load it
55
- const require = createRequire(import.meta.url);
56
- const chromeCookies = require("chrome-cookies-secure");
57
- return await fn(tmpDir, chromeCookies);
49
+ const dbPath = path.join(tmpDir, "Cookies"); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
50
+ fs.copyFileSync(cookiesPath, dbPath);
51
+ // Committed rows can sit in Cookies-wal until Chrome checkpoints.
52
+ for (const ext of ["-wal", "-shm"]) {
53
+ if (fs.existsSync(cookiesPath + ext))
54
+ fs.copyFileSync(cookiesPath + ext, dbPath + ext);
55
+ }
56
+ try {
57
+ db = openCookiesDb(dbPath);
58
+ }
59
+ catch (e) {
60
+ throw new UserFacingError(`Failed to read Chrome cookies database: ${e instanceof Error ? e.message : String(e)}`);
61
+ }
62
+ return fn(db, deriveChromeKey());
58
63
  }
59
64
  finally {
65
+ try {
66
+ db?.close();
67
+ }
68
+ catch {
69
+ // Ignore
70
+ }
60
71
  try {
61
72
  fs.rmSync(tmpDir, { recursive: true, force: true });
62
73
  }
@@ -65,61 +76,21 @@ async function withCookiesDb(fn) {
65
76
  }
66
77
  }
67
78
  }
68
- // Extract cookies for a single domain using an already-open cookies DB.
69
- async function extractDomain(tmpDir, chromeCookies, domain) {
70
- // "puppeteer" format returns an array with capitalized boolean fields and
71
- // Chrome-internal microsecond timestamps — convert to our CookieRecord shape.
72
- const raw = await chromeCookies.getCookiesPromised(`https://${domain}`, "puppeteer", tmpDir);
73
- return raw.map((c) => ({
74
- name: c.name,
75
- value: c.value,
76
- domain: c.domain,
77
- path: c.path,
78
- secure: c.Secure ?? false,
79
- httpOnly: c.HttpOnly ?? false,
80
- ...(c.expires && c.expires > 0 ? { expires: chromeTimeToUnix(c.expires) } : {}),
81
- }));
82
- }
83
79
  // Exported for testing
84
- export async function extractChromeCookies(domain) {
85
- return withCookiesDb((tmpDir, chromeCookies) => extractDomain(tmpDir, chromeCookies, domain));
80
+ export function extractChromeCookies(domain, stats) {
81
+ return withCookiesDb((db, key) => readCookiesForHost(db, key, domain, stats));
86
82
  }
87
83
  // Exported for testing
88
- export async function extractAllChromeCookies() {
89
- return withCookiesDb(async (tmpDir, chromeCookies) => {
90
- const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
91
- let sqliteDb = null;
84
+ export function extractAllChromeCookies(stats) {
85
+ return withCookiesDb((db, key) => {
92
86
  let hostKeys;
93
87
  try {
94
- sqliteDb = new DatabaseSync(path.join(tmpDir, "Cookies")); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
95
- // Only import domains with auth-relevant cookies — skips tracking/analytics junk.
96
- // A domain qualifies if it has any HttpOnly cookie (session/auth indicator) or
97
- // a cookie whose name suggests authentication.
98
- const rows = sqliteDb
99
- .prepare(`SELECT DISTINCT host_key FROM cookies
100
- WHERE is_httponly = 1
101
- OR name LIKE '%session%'
102
- OR name LIKE '%token%'
103
- OR name LIKE '%auth%'
104
- OR name LIKE '%sid%'
105
- OR name LIKE '%login%'
106
- OR name LIKE '%user%'
107
- OR name LIKE '%account%'`)
108
- .all();
109
- hostKeys = rows.map((r) => r.host_key);
88
+ hostKeys = listAuthRelevantHostKeys(db);
110
89
  }
111
90
  catch (e) {
112
91
  throw new UserFacingError(`Failed to read Chrome cookies database: ${e instanceof Error ? e.message : String(e)}`);
113
92
  }
114
- finally {
115
- try {
116
- sqliteDb?.close();
117
- }
118
- catch {
119
- // Ignore
120
- }
121
- }
122
- // 2. Canonicalize host_keys → registrable domains (mirrors frontend/app/lib/services/domain-utils.ts)
93
+ // Canonicalize host_keys → registrable domains (mirrors frontend/app/lib/services/domain-utils.ts)
123
94
  const require = createRequire(import.meta.url);
124
95
  const { parse } = require("tldts");
125
96
  const registrableDomains = new Set();
@@ -130,26 +101,19 @@ export async function extractAllChromeCookies() {
130
101
  registrableDomains.add(result.domain.toLowerCase());
131
102
  }
132
103
  errLine(`Scanning ${registrableDomains.size} domains from Chrome...`);
133
- // 3. Extract per-domain and concat
104
+ // hostKeyCandidates drops public-suffix parents, so registrable domains
105
+ // have disjoint host_key sets and need no cross-domain dedup.
134
106
  const all = [];
135
107
  for (const domain of registrableDomains) {
108
+ // One bad domain must not discard the whole import.
136
109
  try {
137
- const cookies = await extractDomain(tmpDir, chromeCookies, domain);
138
- all.push(...cookies);
110
+ all.push(...readCookiesForHost(db, key, domain, stats));
139
111
  }
140
112
  catch {
141
- // skip domains chrome-cookies-secure can't parse (IPs, .local, etc.)
113
+ errLine(`Skipping ${domain}: could not read its cookies`);
142
114
  }
143
115
  }
144
- // 4. Dedup by name+domain+path
145
- const seen = new Set();
146
- return all.filter((c) => {
147
- const key = `${c.name}\t${c.domain}\t${c.path}`;
148
- if (seen.has(key))
149
- return false;
150
- seen.add(key);
151
- return true;
152
- });
116
+ return all;
153
117
  });
154
118
  }
155
119
  // Exported for testing
@@ -254,13 +218,21 @@ export function registerProfile(program) {
254
218
  try {
255
219
  // Extract cookies before creating profile to avoid orphan profiles
256
220
  // if extraction fails (Windows, no Chrome, no cookies).
221
+ const stats = { keyringSkipped: 0 };
257
222
  const cookies = opts.allDomains
258
- ? await extractAllChromeCookies()
259
- : await extractChromeCookies(opts.domain);
223
+ ? extractAllChromeCookies(stats)
224
+ : extractChromeCookies(opts.domain, stats);
225
+ // Warn even on partial success — surviving plaintext rows can mask
226
+ // that every auth cookie was keyring-encrypted and skipped.
227
+ if (stats.keyringSkipped > 0) {
228
+ errLine(`Skipped ${stats.keyringSkipped} keyring-encrypted cookies (not supported on Linux yet).`);
229
+ }
260
230
  if (cookies.length === 0) {
261
- throw new UserFacingError(opts.allDomains
262
- ? "No Chrome cookies found. Is Google Chrome installed and have you logged in to any sites?"
263
- : `No Chrome cookies found for ${opts.domain}. Are you logged in to this site in Chrome?`);
231
+ throw new UserFacingError(stats.keyringSkipped > 0
232
+ ? "Chrome on Linux encrypts cookies with the system keyring, which is not supported yet."
233
+ : opts.allDomains
234
+ ? "No Chrome cookies found. Is Google Chrome installed and have you logged in to any sites?"
235
+ : `No Chrome cookies found for ${opts.domain}. Are you logged in to this site in Chrome?`);
264
236
  }
265
237
  if (profileId === undefined) {
266
238
  const name = opts.allDomains ? "chrome-import" : opts.domain;
@@ -0,0 +1,15 @@
1
+ import type { CookieRecord } from "./types.js";
2
+ export interface CookiesDb {
3
+ prepare(sql: string): {
4
+ all(...params: string[]): unknown[];
5
+ };
6
+ close(): void;
7
+ }
8
+ export declare function openCookiesDb(dbPath: string): CookiesDb;
9
+ export declare function deriveChromeKey(): Buffer;
10
+ export interface DecryptStats {
11
+ keyringSkipped: number;
12
+ }
13
+ export declare function decryptCookieValue(encrypted: Uint8Array, key: Buffer, stats?: DecryptStats): string | null;
14
+ export declare function listAuthRelevantHostKeys(db: CookiesDb): string[];
15
+ export declare function readCookiesForHost(db: CookiesDb, key: Buffer, host: string, stats?: DecryptStats): CookieRecord[];
@@ -0,0 +1,164 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createDecipheriv, pbkdf2Sync } from "node:crypto";
3
+ import { createRequire } from "node:module";
4
+ import { z } from "zod";
5
+ // Reads Chrome's `Cookies` SQLite DB and decrypts values with the Safe Storage key.
6
+ // Scheme per platform (Windows is unsupported and guarded by the caller):
7
+ // macOS — password from the login keychain, pbkdf2(pw, "saltysalt", 1003, 16, sha1)
8
+ // Linux — hardcoded "peanuts" password, 1 pbkdf2 iteration (Chrome's v10 fallback)
9
+ // Values are AES-128-CBC with a 16-space IV, prefixed "v10"/"v11", and modern Chrome
10
+ // prepends a 32-byte domain hash to the plaintext.
11
+ const KEY_LENGTH = 16;
12
+ const SALT = "saltysalt";
13
+ const CBC_IV = Buffer.alloc(KEY_LENGTH, " ");
14
+ const DOMAIN_HASH_LENGTH = 32;
15
+ const SUPPORTED_PREFIXES = ["v10", "v11"];
16
+ // Chrome-internal timestamp (microseconds since 1601-01-01) → Unix seconds
17
+ const CHROME_EPOCH_DELTA_SECONDS = 11644473600;
18
+ export function openCookiesDb(dbPath) {
19
+ // node:sqlite is stable on the Node >=24 floor in `engines`; createRequire avoids
20
+ // needing @types/node >=22 for the import type.
21
+ const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
22
+ return new DatabaseSync(dbPath);
23
+ }
24
+ export function deriveChromeKey() {
25
+ if (process.platform !== "darwin") {
26
+ return pbkdf2Sync("peanuts", SALT, 1, KEY_LENGTH, "sha1");
27
+ }
28
+ let password;
29
+ try {
30
+ password = execFileSync("/usr/bin/security", ["find-generic-password", "-w", "-s", "Chrome Safe Storage", "-a", "Chrome"],
31
+ // stderr is dropped so `security` diagnostics never pollute our JSON output.
32
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
33
+ }
34
+ catch {
35
+ // Deliberately not chaining the cause — it can carry keychain material.
36
+ throw new Error('Could not read Chrome\'s encryption key from the macOS keychain. Approve the Keychain prompt ("Always Allow") and retry.');
37
+ }
38
+ return pbkdf2Sync(password, SALT, 1003, KEY_LENGTH, "sha1");
39
+ }
40
+ // Returns null when the row cannot be decrypted — a wrong-key decrypt yields garbage,
41
+ // which would silently upload broken cookies.
42
+ export function decryptCookieValue(encrypted, key, stats) {
43
+ const prefix = Buffer.from(encrypted.subarray(0, 3)).toString("latin1");
44
+ if (!SUPPORTED_PREFIXES.includes(prefix))
45
+ return null;
46
+ // Off macOS the key is Chrome's v10 "peanuts" fallback; v11 rows are
47
+ // keyring-encrypted and would decrypt to garbage with it.
48
+ if (prefix === "v11" && process.platform !== "darwin") {
49
+ if (stats)
50
+ stats.keyringSkipped++;
51
+ return null;
52
+ }
53
+ let decoded;
54
+ try {
55
+ const decipher = createDecipheriv("aes-128-cbc", key, CBC_IV);
56
+ decipher.setAutoPadding(false);
57
+ decoded = Buffer.concat([decipher.update(encrypted.subarray(3)), decipher.final()]);
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ if (decoded.length === 0)
63
+ return null;
64
+ const padding = decoded[decoded.length - 1];
65
+ // A zero final byte means a legacy row with no padding and no domain-hash prefix.
66
+ if (!padding)
67
+ return decoded.toString("utf8");
68
+ // Wrong-key decrypts yield random bytes; require valid PKCS#7 padding or skip the row.
69
+ if (padding > KEY_LENGTH || decoded.length - padding < DOMAIN_HASH_LENGTH)
70
+ return null;
71
+ for (let i = decoded.length - padding; i < decoded.length; i++) {
72
+ if (decoded[i] !== padding)
73
+ return null;
74
+ }
75
+ return decoded.subarray(DOMAIN_HASH_LENGTH, decoded.length - padding).toString("utf8");
76
+ }
77
+ // A cookie applies to a host when its host_key is the host itself or one of its
78
+ // dot-delimited parents (RFC 6265 §5.1.3), with Chrome's optional leading dot.
79
+ // Public-suffix parents (github.io, co.uk) are dropped — a row there would leak
80
+ // into every registrable domain under the suffix.
81
+ function hostKeyCandidates(host) {
82
+ const { parse } = createRequire(import.meta.url)("tldts");
83
+ const labels = host.split(".");
84
+ const parents = labels
85
+ .slice(1, -1)
86
+ .map((_, i) => labels.slice(i + 1).join("."))
87
+ .filter((p) => parse(p, { allowPrivateDomains: true }).domain);
88
+ return [host, ...parents].flatMap((h) => [h, `.${h}`]);
89
+ }
90
+ const hostKeyRowSchema = z.object({ host_key: z.string() });
91
+ const cookieRowSchema = z.object({
92
+ host_key: z.string(),
93
+ name: z.string(),
94
+ value: z.string(),
95
+ encrypted_value: z.instanceof(Uint8Array),
96
+ is_secure: z.number(),
97
+ is_httponly: z.number(),
98
+ expires_utc: z.number(),
99
+ });
100
+ // Only hosts with auth-relevant cookies — skips tracking/analytics junk. A host
101
+ // qualifies via any HttpOnly cookie or an auth-suggesting cookie name.
102
+ export function listAuthRelevantHostKeys(db) {
103
+ const rows = db
104
+ .prepare(`SELECT DISTINCT host_key FROM cookies
105
+ WHERE is_httponly = 1
106
+ OR name LIKE '%session%'
107
+ OR name LIKE '%token%'
108
+ OR name LIKE '%auth%'
109
+ OR name LIKE '%sid%'
110
+ OR name LIKE '%login%'
111
+ OR name LIKE '%user%'
112
+ OR name LIKE '%account%'`)
113
+ .all();
114
+ return rows.flatMap((row) => {
115
+ const parsed = hostKeyRowSchema.safeParse(row);
116
+ return parsed.success ? [parsed.data.host_key] : [];
117
+ });
118
+ }
119
+ export function readCookiesForHost(db, key, host, stats) {
120
+ const candidates = hostKeyCandidates(host);
121
+ const rawRows = db
122
+ .prepare(
123
+ // Only root-path cookies are requested, matching the "/" request path this has
124
+ // always used. expires_utc is cast to REAL because Chrome's microsecond epoch
125
+ // exceeds Number.MAX_SAFE_INTEGER, which node:sqlite rejects as an exact integer.
126
+ `SELECT host_key, name, value, encrypted_value, is_secure, is_httponly,
127
+ CAST(expires_utc AS REAL) AS expires_utc
128
+ FROM cookies
129
+ WHERE path = '/' AND host_key IN (${candidates.map(() => "?").join(", ")})
130
+ ORDER BY creation_utc DESC`)
131
+ .all(...candidates);
132
+ // Malformed rows are skipped, matching the decrypt-failure policy.
133
+ const rows = rawRows.flatMap((raw) => {
134
+ const parsed = cookieRowSchema.safeParse(raw);
135
+ return parsed.success ? [parsed.data] : [];
136
+ });
137
+ const cookies = [];
138
+ const seen = new Set();
139
+ for (const row of rows) {
140
+ // Newest row wins for a repeated name, matching chrome-cookies-secure.
141
+ if (seen.has(row.name))
142
+ continue;
143
+ let value = row.value;
144
+ if (value === "" && row.encrypted_value.length > 0) {
145
+ const decrypted = decryptCookieValue(row.encrypted_value, key, stats);
146
+ if (decrypted === null)
147
+ continue;
148
+ value = decrypted;
149
+ }
150
+ seen.add(row.name);
151
+ cookies.push({
152
+ name: row.name,
153
+ value,
154
+ domain: row.host_key,
155
+ path: "/",
156
+ secure: row.is_secure === 1,
157
+ httpOnly: row.is_httponly === 1,
158
+ ...(row.expires_utc > 0
159
+ ? { expires: Math.floor(row.expires_utc / 1e6) - CHROME_EPOCH_DELTA_SECONDS }
160
+ : {}),
161
+ });
162
+ }
163
+ return cookies.reverse();
164
+ }
@@ -75,4 +75,40 @@ export type UpgradeScope = "both" | "cli" | "skill";
75
75
  /** `toVersion` is null when the run was interrupted or the installed version could not be read. */
76
76
  export declare function sendUpgradeCompleted(fromVersion: string, toVersion: string | null, outcome: UpgradeOutcome, scope: UpgradeScope, apiKey?: string): Promise<void>;
77
77
  export declare function sendSetupCompleted(mcpUrl: string, harnesses: HarnessResult[], apiKey?: string, mode?: SetupMode): Promise<void>;
78
+ export declare const DOCTOR_HARNESS_SCOPES: readonly ["all", "claude-code", "codex", "cursor", "grok", "hermes", "openclaw", "opencode"];
79
+ export declare const doctorCompletedPayloadSchema: z.ZodObject<{
80
+ schema_version: z.ZodInt;
81
+ cli_version: z.ZodString;
82
+ ok_harnesses: z.ZodBoolean;
83
+ ok_cli: z.ZodBoolean;
84
+ checks_pass: z.ZodInt;
85
+ checks_fail: z.ZodInt;
86
+ checks_warn: z.ZodInt;
87
+ checks_skip: z.ZodInt;
88
+ repairs_offered: z.ZodInt;
89
+ repair_actions: z.ZodArray<z.ZodEnum<{
90
+ connect: "connect";
91
+ "auth-login": "auth-login";
92
+ }>>;
93
+ repairs_attempted: z.ZodInt;
94
+ repairs_succeeded: z.ZodInt;
95
+ fix_mode: z.ZodEnum<{
96
+ none: "none";
97
+ fix: "fix";
98
+ fix_yes: "fix_yes";
99
+ }>;
100
+ harness_scope: z.ZodEnum<{
101
+ openclaw: "openclaw";
102
+ grok: "grok";
103
+ cursor: "cursor";
104
+ codex: "codex";
105
+ hermes: "hermes";
106
+ opencode: "opencode";
107
+ "claude-code": "claude-code";
108
+ all: "all";
109
+ }>;
110
+ is_human_initiated: z.ZodBoolean;
111
+ }, z.core.$strip>;
112
+ export type DoctorCompletedPayload = z.infer<typeof doctorCompletedPayloadSchema>;
113
+ export declare function sendDoctorCompleted(mcpUrl: string, payload: DoctorCompletedPayload, apiKey?: string): Promise<void>;
78
114
  export {};
@@ -152,3 +152,47 @@ export async function sendSetupCompleted(mcpUrl, harnesses, apiKey, mode) {
152
152
  }
153
153
  await postConnectEvent({ endpoint, body, attempts: 2, label: "setup", apiKey });
154
154
  }
155
+ export const DOCTOR_HARNESS_SCOPES = ["all", ...ALL_HARNESSES];
156
+ // Aggregate only: per-harness rows and check `detail` strings stay local, so nothing
157
+ // user-authored or credential-shaped can ride a doctor run to PostHog.
158
+ // Caps mirror the route's schema: a payload the server would 400 is dropped here instead,
159
+ // because postConnectEvent never retries a 4xx and the event would vanish silently.
160
+ export const doctorCompletedPayloadSchema = z.object({
161
+ schema_version: z.int().positive(),
162
+ cli_version: z.string().max(32),
163
+ ok_harnesses: z.boolean(),
164
+ ok_cli: z.boolean(),
165
+ checks_pass: z.int().nonnegative().max(256),
166
+ checks_fail: z.int().nonnegative().max(256),
167
+ checks_warn: z.int().nonnegative().max(256),
168
+ checks_skip: z.int().nonnegative().max(256),
169
+ /** Offered by the pre-repair report; the verdicts describe the machine after it. */
170
+ repairs_offered: z.int().nonnegative().max(256),
171
+ repair_actions: z.array(z.enum(["connect", "auth-login"])).max(8),
172
+ repairs_attempted: z.int().nonnegative().max(256),
173
+ repairs_succeeded: z.int().nonnegative().max(256),
174
+ fix_mode: z.enum(["none", "fix", "fix_yes"]),
175
+ /** "all" unless --harness scoped the run; the counts above cover only that scope. */
176
+ harness_scope: z.enum(DOCTOR_HARNESS_SCOPES),
177
+ is_human_initiated: z.boolean(),
178
+ });
179
+ export async function sendDoctorCompleted(mcpUrl, payload, apiKey) {
180
+ if (telemetryDisabled())
181
+ return;
182
+ let endpoint;
183
+ let body;
184
+ try {
185
+ // Parse inside try — a schema violation must not fail a diagnosis that already ran.
186
+ const parsed = doctorCompletedPayloadSchema.parse(payload);
187
+ endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
188
+ body = JSON.stringify({ event: "doctor_completed", ...parsed });
189
+ }
190
+ catch (e) {
191
+ if (process.env["TINYFISH_DEBUG"]) {
192
+ errLine(`doctor telemetry skipped: ${e instanceof Error ? e.message : String(e)}`);
193
+ }
194
+ return;
195
+ }
196
+ // One attempt: doctor just probed this origin, so a stale keep-alive socket is not the risk.
197
+ await postConnectEvent({ endpoint, body, attempts: 1, label: "doctor", apiKey });
198
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.29.1",
3
+ "version": "0.29.2-next.246",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,10 +33,10 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@tiny-fish/sdk": "^0.5.0",
36
- "chrome-cookies-secure": "^3.0.2",
37
36
  "commander": "^12.0.0",
38
37
  "cross-spawn": "^7.0.6",
39
38
  "globals": "^17.4.0",
39
+ "tldts": "^6.1.86",
40
40
  "which": "^2.0.2",
41
41
  "zod": "^4.3.6"
42
42
  },