@tiny-fish/cli 0.37.1-next.300 → 0.37.1-next.301
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/auth.js +4 -1
- package/dist/commands/connect.js +12 -0
- package/dist/commands/doctor.d.ts +1 -14
- package/dist/commands/doctor.js +3 -173
- package/dist/lib/auth.d.ts +2 -1
- package/dist/lib/auth.js +3 -3
- package/dist/lib/connect-all.js +29 -2
- package/dist/lib/connect-preflight.d.ts +15 -0
- package/dist/lib/connect-preflight.js +67 -0
- package/dist/lib/doctor-repairs.d.ts +9 -0
- package/dist/lib/doctor-repairs.js +118 -0
- package/dist/lib/doctor-report.d.ts +7 -1
- package/dist/lib/doctor-telemetry.d.ts +9 -0
- package/dist/lib/doctor-telemetry.js +61 -0
- package/dist/lib/setup-telemetry.d.ts +12 -0
- package/dist/lib/setup-telemetry.js +24 -0
- package/package.json +1 -1
package/dist/commands/auth.js
CHANGED
|
@@ -128,7 +128,10 @@ export function registerAuth(program) {
|
|
|
128
128
|
// Read from stdin pipe (CI/CD) or hidden TTY prompt (interactive)
|
|
129
129
|
const key = await readKeyFromStdinOrPrompt();
|
|
130
130
|
if (!key) {
|
|
131
|
-
|
|
131
|
+
// `echo $KEY | tinyfish auth set` with KEY unset pipes a blank line, not a key.
|
|
132
|
+
err(process.stdin.isTTY
|
|
133
|
+
? 'API key cannot be empty'
|
|
134
|
+
: "No key received on stdin. Example: echo 'sk-tinyfish-...' | tinyfish auth set");
|
|
132
135
|
process.exit(1);
|
|
133
136
|
}
|
|
134
137
|
if (!validateKeyFormat(key)) {
|
package/dist/commands/connect.js
CHANGED
|
@@ -16,6 +16,7 @@ import { SIGN_IN_PROBES } from '../lib/registration-detect.js';
|
|
|
16
16
|
import { errLine, warnLine } from '../lib/output.js';
|
|
17
17
|
import { z } from 'zod';
|
|
18
18
|
import { verifyMcpAuth } from '../lib/verify.js';
|
|
19
|
+
import { gateApiKey } from '../lib/connect-preflight.js';
|
|
19
20
|
import { apiBaseFromMcpUrl, DEFAULT_MCP_URL } from '../lib/mcp-endpoint.js';
|
|
20
21
|
function removeExistingRegistration(client, removal, verbose) {
|
|
21
22
|
const result = spawnRemoval(client.command, removal.args);
|
|
@@ -746,6 +747,17 @@ async function connectSingleClient(client, options, mcpUrl, attemptId) {
|
|
|
746
747
|
if (!ALL_HARNESSES.includes(client)) {
|
|
747
748
|
throw new Error('Unsupported client: ' + client + '. Supported clients: ' + ALL_HARNESSES.join(', '));
|
|
748
749
|
}
|
|
750
|
+
const gate = await gateApiKey({
|
|
751
|
+
apiKey: options.apiKey,
|
|
752
|
+
mcpUrl,
|
|
753
|
+
retryCommand: `tinyfish connect ${client}`,
|
|
754
|
+
mode: 'single',
|
|
755
|
+
attemptId,
|
|
756
|
+
});
|
|
757
|
+
if (gate === 'blocked') {
|
|
758
|
+
process.exitCode = 1;
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
749
761
|
await connectHarness(client, connectOptions);
|
|
750
762
|
}
|
|
751
763
|
export function registerConnect(program) {
|
|
@@ -1,17 +1,4 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
|
-
import { type
|
|
3
|
-
import { type Harness } from '../lib/harness-detect.js';
|
|
4
|
-
export interface DoctorOptions {
|
|
5
|
-
mcpUrl: string;
|
|
6
|
-
harness?: Harness;
|
|
7
|
-
fix?: boolean;
|
|
8
|
-
yes?: boolean;
|
|
9
|
-
}
|
|
2
|
+
import { type DoctorOptions, type DoctorReport } from '../lib/doctor-report.js';
|
|
10
3
|
export declare function runDoctor(options: DoctorOptions): Promise<DoctorReport>;
|
|
11
|
-
export interface RepairOutcome {
|
|
12
|
-
repair: DoctorRepair;
|
|
13
|
-
status: 'repaired' | 'skipped' | 'failed';
|
|
14
|
-
reason?: string;
|
|
15
|
-
}
|
|
16
|
-
export declare function applyRepairs(repairs: DoctorRepair[], options: DoctorOptions): Promise<RepairOutcome[]>;
|
|
17
4
|
export declare function registerDoctor(program: Command): void;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
import spawn from 'cross-spawn';
|
|
2
1
|
import { apiKeyStatus } from '../lib/auth.js';
|
|
3
2
|
import { CLI_VERSION } from '../lib/constants.js';
|
|
4
3
|
import { DOCTOR_COULD_NOT_RUN, DOCTOR_INVALID_INPUT, DOCTOR_SCHEMA_VERSION, doctorReportSchema, exitCodeFor, renderPretty, verdictFor, } from '../lib/doctor-report.js';
|
|
4
|
+
import { applyRepairs, repairsFor } from '../lib/doctor-repairs.js';
|
|
5
|
+
import { couldNotRunPayload, telemetryKey, telemetryPayload } from '../lib/doctor-telemetry.js';
|
|
5
6
|
import { ALL_HARNESSES, AuthMode, Registered } from '../lib/harness-detect.js';
|
|
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 {
|
|
9
|
+
import { sendDoctorCompleted } from '../lib/setup-telemetry.js';
|
|
10
10
|
import { verifyMcpAuth, verifyMcpHealth } from '../lib/verify.js';
|
|
11
11
|
import { resolveHermesHome } from '../lib/hermes-env.js';
|
|
12
12
|
import { hermesWebBackendsOurs, readHermesPluginStatus, } from '../lib/hermes-plugin.js';
|
|
13
13
|
import { DEFAULT_MCP_URL, endpointOf, isDefaultEndpoint } from '../lib/mcp-endpoint.js';
|
|
14
|
-
import { connectHarness } from './connect.js';
|
|
15
14
|
// A green auth mode with a red auth call proves nothing, so the call's verdict gates the claim.
|
|
16
15
|
function provesHarnessReach(status, keyAuth, healthOk, mcpUrl) {
|
|
17
16
|
// An endpoint doctor could not reach proves nothing about a harness pointed at it.
|
|
@@ -270,57 +269,6 @@ function checkHermesPlugin(status) {
|
|
|
270
269
|
}
|
|
271
270
|
return { ...check, ...hermesPluginVerdict(home) };
|
|
272
271
|
}
|
|
273
|
-
// Only Cursor's repair is a pure local file write; every other one needs a browser sign-in.
|
|
274
|
-
const UNATTENDED_SAFE = new Set(['cursor']);
|
|
275
|
-
function repairsFor(checks, statuses) {
|
|
276
|
-
const repairs = [];
|
|
277
|
-
// A revoked-but-well-formed key passes the credential check and fails the call; both need login.
|
|
278
|
-
const credentialBroken = checks.some((c) => (c.id === 'cli-credential' || c.id === 'cli-auth-call') && c.status === 'fail');
|
|
279
|
-
// connect writes the stored key, so replace a dead one first.
|
|
280
|
-
if (credentialBroken) {
|
|
281
|
-
repairs.push({
|
|
282
|
-
for: 'cli-credential-invalid',
|
|
283
|
-
action: 'auth-login',
|
|
284
|
-
harness: null,
|
|
285
|
-
command: 'tinyfish auth login',
|
|
286
|
-
unattended_safe: false,
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
const authCallPassed = checks.some((c) => c.id === 'cli-auth-call' && c.status === 'pass');
|
|
290
|
-
for (const status of statuses) {
|
|
291
|
-
// `unknown` earns nothing: connect would contradict the check's detail.
|
|
292
|
-
// A registered entry that still fails earns one too.
|
|
293
|
-
// `connected: false` is the harness's own no; it only warns, so it earns a repair here.
|
|
294
|
-
const brokenRegistration = status.connected === false ||
|
|
295
|
-
checks.some((c) => c.harness === status.harness && c.id !== 'hermes-plugin' && c.status === 'fail');
|
|
296
|
-
if (!status.detected || (status.registered !== Registered.No && !brokenRegistration))
|
|
297
|
-
continue;
|
|
298
|
-
repairs.push({
|
|
299
|
-
for: status.registered === Registered.Yes
|
|
300
|
-
? `${status.harness}-registration-broken`
|
|
301
|
-
: status.connectedBefore
|
|
302
|
-
? `${status.harness}-registration-lost`
|
|
303
|
-
: `${status.harness}-not-connected`,
|
|
304
|
-
action: 'connect',
|
|
305
|
-
harness: status.harness,
|
|
306
|
-
command: `tinyfish connect ${status.harness}`,
|
|
307
|
-
// A dead well-formed key passes the format check.
|
|
308
|
-
unattended_safe: UNATTENDED_SAFE.has(status.harness) && authCallPassed,
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
// connect hermes reinstalls the plugin; a second row would double it.
|
|
312
|
-
const pluginBroken = checks.some((c) => c.id === 'hermes-plugin' && c.status === 'fail');
|
|
313
|
-
if (pluginBroken && !repairs.some((repair) => repair.harness === 'hermes')) {
|
|
314
|
-
repairs.push({
|
|
315
|
-
for: 'hermes-plugin-broken',
|
|
316
|
-
action: 'connect',
|
|
317
|
-
harness: 'hermes',
|
|
318
|
-
command: 'tinyfish connect hermes',
|
|
319
|
-
unattended_safe: false,
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
return repairs;
|
|
323
|
-
}
|
|
324
272
|
export async function runDoctor(options) {
|
|
325
273
|
const statuses = detectRegistrations(options.harness ? [options.harness] : undefined);
|
|
326
274
|
const credential = checkCredential();
|
|
@@ -359,124 +307,6 @@ export async function runDoctor(options) {
|
|
|
359
307
|
// Nothing to scrub: every string above is authored here or enumerated at its source.
|
|
360
308
|
return doctorReportSchema.parse(body);
|
|
361
309
|
}
|
|
362
|
-
// Shell out rather than import the login flow: `auth login` owns its own browser and prompts.
|
|
363
|
-
// Re-invoke this same entry script, never a global `tinyfish` — under `npx` there is no such
|
|
364
|
-
// binary on PATH, and npx is the install-nothing path doctor exists to serve.
|
|
365
|
-
function runAuthLogin() {
|
|
366
|
-
const entry = process.argv[1];
|
|
367
|
-
if (!entry)
|
|
368
|
-
throw new Error('Could not locate the TinyFish CLI entry point to re-invoke');
|
|
369
|
-
const result = spawn.sync(process.execPath, [entry, 'auth', 'login', '--source', 'doctor'], {
|
|
370
|
-
stdio: 'inherit',
|
|
371
|
-
});
|
|
372
|
-
if (result.error)
|
|
373
|
-
throw new Error('Could not run `tinyfish auth login`', { cause: result.error });
|
|
374
|
-
if (result.status !== 0) {
|
|
375
|
-
throw new Error(`\`tinyfish auth login\` exited ${result.status ?? 'unknown'}`);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
/** Dispatches on `action`, never on a null harness: that conflation made the credential fix a no-op. */
|
|
379
|
-
async function runRepair(repair, mcpUrl) {
|
|
380
|
-
if (repair.action === 'auth-login')
|
|
381
|
-
return runAuthLogin();
|
|
382
|
-
if (repair.harness)
|
|
383
|
-
await connectHarness(repair.harness, { mcpUrl, launch: false });
|
|
384
|
-
}
|
|
385
|
-
export async function applyRepairs(repairs, options) {
|
|
386
|
-
const interactive = detectHumanInitiated();
|
|
387
|
-
const outcomes = [];
|
|
388
|
-
for (const repair of repairs) {
|
|
389
|
-
if (!interactive && !options.yes) {
|
|
390
|
-
outcomes.push({
|
|
391
|
-
repair,
|
|
392
|
-
status: 'skipped',
|
|
393
|
-
reason: 'no terminal; re-run with --yes to repair unattended',
|
|
394
|
-
});
|
|
395
|
-
continue;
|
|
396
|
-
}
|
|
397
|
-
if (!interactive && !repair.unattended_safe) {
|
|
398
|
-
outcomes.push({
|
|
399
|
-
repair,
|
|
400
|
-
status: 'skipped',
|
|
401
|
-
reason: 'needs a browser sign-in, which has no unattended path',
|
|
402
|
-
});
|
|
403
|
-
continue;
|
|
404
|
-
}
|
|
405
|
-
try {
|
|
406
|
-
await runRepair(repair, options.mcpUrl);
|
|
407
|
-
outcomes.push({ repair, status: 'repaired' });
|
|
408
|
-
}
|
|
409
|
-
catch (e) {
|
|
410
|
-
// Connect quotes absolute config paths in its errors, so the raw text stays behind
|
|
411
|
-
// --debug and the user is told where to find it rather than handed a generic dead end.
|
|
412
|
-
if (process.env['TINYFISH_DEBUG']) {
|
|
413
|
-
errLine(e instanceof Error ? (e.stack ?? e.message) : String(e));
|
|
414
|
-
}
|
|
415
|
-
outcomes.push({
|
|
416
|
-
repair,
|
|
417
|
-
status: 'failed',
|
|
418
|
-
reason: `\`${repair.command}\` failed; re-run it directly, or --debug for the raw error`,
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
return outcomes;
|
|
423
|
-
}
|
|
424
|
-
/** Mirrors the route's cap; over it the payload would 400 and never be retried. */
|
|
425
|
-
const MAX_DURATION_MS = 3_600_000;
|
|
426
|
-
function fixModeOf(options) {
|
|
427
|
-
return !options.fix ? 'none' : options.yes ? 'fix_yes' : 'fix';
|
|
428
|
-
}
|
|
429
|
-
/** Enumerated fields only; check `detail` text never leaves the machine. */
|
|
430
|
-
function telemetryPayload(report, offered, outcomes, options, durationMs) {
|
|
431
|
-
const count = (status) => report.checks.filter((check) => check.status === status).length;
|
|
432
|
-
const failed = report.checks.filter((check) => check.status === 'fail');
|
|
433
|
-
return {
|
|
434
|
-
outcome: 'completed',
|
|
435
|
-
schema_version: report.schema_version,
|
|
436
|
-
cli_version: report.cli_version,
|
|
437
|
-
ok_harnesses: report.ok_harnesses,
|
|
438
|
-
ok_cli: report.ok_cli,
|
|
439
|
-
checks_pass: count('pass'),
|
|
440
|
-
checks_fail: count('fail'),
|
|
441
|
-
checks_warn: count('warn'),
|
|
442
|
-
checks_skip: count('skip'),
|
|
443
|
-
// From the pre-repair report; the verdicts above describe the machine after it.
|
|
444
|
-
repairs_offered: offered.length,
|
|
445
|
-
repair_actions: [...new Set(offered.map((repair) => repair.action))],
|
|
446
|
-
repairs_attempted: outcomes.filter((outcome) => outcome.status !== 'skipped').length,
|
|
447
|
-
repairs_succeeded: outcomes.filter((outcome) => outcome.status === 'repaired').length,
|
|
448
|
-
fix_mode: fixModeOf(options),
|
|
449
|
-
// --harness scopes detection, so the counts above cover that harness alone.
|
|
450
|
-
harness_scope: options.harness ?? 'all',
|
|
451
|
-
// Same signal cli_connect_stage carries, so agent and human doctor runs split.
|
|
452
|
-
is_human_initiated: detectHumanInitiated(),
|
|
453
|
-
// Two flat arrays rather than one of pairs: PostHog groups by element, not by field.
|
|
454
|
-
failed_check_ids: [...new Set(failed.map((check) => check.id))],
|
|
455
|
-
failed_harnesses: [...new Set(failed.map((check) => check.harness).filter((h) => h !== null))],
|
|
456
|
-
harnesses: report.harnesses,
|
|
457
|
-
// Clamped, not validated: a --fix parked at a prompt must not fail the parse and drop the run.
|
|
458
|
-
duration_ms: Math.min(durationMs, MAX_DURATION_MS),
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
/** Both paths send keyless when there is no key; the route records those anonymously. */
|
|
462
|
-
function telemetryKey() {
|
|
463
|
-
const resolved = apiKeyStatus();
|
|
464
|
-
return 'key' in resolved ? resolved.key : undefined;
|
|
465
|
-
}
|
|
466
|
-
/** Enumerated outcome and timing only; the raw error stays behind --debug. */
|
|
467
|
-
function couldNotRunPayload(error, options, durationMs) {
|
|
468
|
-
return {
|
|
469
|
-
outcome: 'could_not_run',
|
|
470
|
-
error_class: doctorErrorClass(error),
|
|
471
|
-
schema_version: DOCTOR_SCHEMA_VERSION,
|
|
472
|
-
cli_version: CLI_VERSION,
|
|
473
|
-
fix_mode: fixModeOf(options),
|
|
474
|
-
harness_scope: options.harness ?? 'all',
|
|
475
|
-
is_human_initiated: detectHumanInitiated(),
|
|
476
|
-
// Clamped like the completed row: over the cap the payload would 400 un-retried.
|
|
477
|
-
duration_ms: Math.min(durationMs, MAX_DURATION_MS),
|
|
478
|
-
};
|
|
479
|
-
}
|
|
480
310
|
export function registerDoctor(program) {
|
|
481
311
|
program
|
|
482
312
|
.command('doctor')
|
package/dist/lib/auth.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { DASHBOARD_URL, getDashboardUrl } from './constants.js';
|
|
2
2
|
export declare function configDir(): string;
|
|
3
|
+
export declare function configFile(): string;
|
|
3
4
|
/** Install channel baked onto connect context; the connect flow is always CLI-driven. */
|
|
4
5
|
export declare const CONNECT_SOURCE = "tinyfish_cli";
|
|
5
6
|
export declare const CONNECT_ATTEMPT_ENV = "TINYFISH_CONNECT_ATTEMPT_ID";
|
|
@@ -65,5 +66,5 @@ export declare function validatedApiKey(explicitApiKey?: string): string | undef
|
|
|
65
66
|
* The diagnosis, not the key: doctor must report a bad credential and say where it came from
|
|
66
67
|
* rather than exit like getApiKey does.
|
|
67
68
|
*/
|
|
68
|
-
export declare function apiKeyStatus(): ResolvedKey | UnresolvedKey;
|
|
69
|
+
export declare function apiKeyStatus(explicitApiKey?: string): ResolvedKey | UnresolvedKey;
|
|
69
70
|
export declare function getApiKey(): string;
|
package/dist/lib/auth.js
CHANGED
|
@@ -11,7 +11,7 @@ export function configDir() {
|
|
|
11
11
|
// os.homedir() is cross-platform; process.env.HOME is undefined on Windows
|
|
12
12
|
return path.join(os.homedir(), '.tinyfish');
|
|
13
13
|
}
|
|
14
|
-
function configFile() {
|
|
14
|
+
export function configFile() {
|
|
15
15
|
return path.join(configDir(), 'config.json');
|
|
16
16
|
}
|
|
17
17
|
/** Install channel baked onto connect context; the connect flow is always CLI-driven. */
|
|
@@ -283,8 +283,8 @@ export function validatedApiKey(explicitApiKey) {
|
|
|
283
283
|
* The diagnosis, not the key: doctor must report a bad credential and say where it came from
|
|
284
284
|
* rather than exit like getApiKey does.
|
|
285
285
|
*/
|
|
286
|
-
export function apiKeyStatus() {
|
|
287
|
-
const { value, source } = lookupApiKey();
|
|
286
|
+
export function apiKeyStatus(explicitApiKey) {
|
|
287
|
+
const { value, source } = lookupApiKey(explicitApiKey);
|
|
288
288
|
if (!value || source === 'none') {
|
|
289
289
|
return { error: 'No API key found. Run: tinyfish auth login', source: 'none' };
|
|
290
290
|
}
|
package/dist/lib/connect-all.js
CHANGED
|
@@ -9,6 +9,7 @@ import { actionable, computeExitCode, reloadHint, renderSummary } from './connec
|
|
|
9
9
|
import { planText, uninstallHarness, uninstallPlanText } from './connect-all-uninstall.js';
|
|
10
10
|
import { NATIVE_BY_HARNESS } from './connect-clients.js';
|
|
11
11
|
import { createStdinPrompt, runCliFallback } from './connect-fallback.js';
|
|
12
|
+
import { gateApiKey } from './connect-preflight.js';
|
|
12
13
|
import { ConnectInterruptedError } from './connect-runtime.js';
|
|
13
14
|
import { pickHarnesses } from './connect-picker.js';
|
|
14
15
|
import { commandOnPath, detectInstalledHarnesses, HARNESS_COMMANDS, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, ALL_HARNESSES, } from './harness-detect.js';
|
|
@@ -212,8 +213,13 @@ async function connectLoop(detections, opts, deselected, io) {
|
|
|
212
213
|
try {
|
|
213
214
|
setErrIndent(STEP_INDENT);
|
|
214
215
|
result = await processHarness(detection, opts, io.prompt, oauthBudget, io.takeHoistedCli, (line) => io.outro.add(line));
|
|
216
|
+
// A failed harness prints nothing of its own, so its step reads as skipped.
|
|
217
|
+
if (result.outcome === 'failed' && result.errorMessage)
|
|
218
|
+
errLine(result.errorMessage);
|
|
215
219
|
}
|
|
216
220
|
catch (e) {
|
|
221
|
+
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
222
|
+
errLine(errorMessage);
|
|
217
223
|
// Isolation: one harness throwing never stops the others.
|
|
218
224
|
result = {
|
|
219
225
|
harness,
|
|
@@ -221,7 +227,7 @@ async function connectLoop(detections, opts, deselected, io) {
|
|
|
221
227
|
configPath: detection.configPath,
|
|
222
228
|
installed: false,
|
|
223
229
|
outcome: 'failed',
|
|
224
|
-
errorMessage
|
|
230
|
+
errorMessage,
|
|
225
231
|
fixCommand: `tinyfish connect ${harness}`,
|
|
226
232
|
};
|
|
227
233
|
}
|
|
@@ -295,11 +301,32 @@ async function finishRun(results, opts, isTTY) {
|
|
|
295
301
|
}
|
|
296
302
|
return exitCode;
|
|
297
303
|
}
|
|
304
|
+
/** Neither path needs a live key, and neither writes a registration. */
|
|
305
|
+
function keyGateApplies(opts) {
|
|
306
|
+
return !opts.uninstall && !opts.dryRun;
|
|
307
|
+
}
|
|
308
|
+
async function blockedByKeyGate(opts) {
|
|
309
|
+
if (!keyGateApplies(opts))
|
|
310
|
+
return false;
|
|
311
|
+
const pick = opts.pick === true;
|
|
312
|
+
const gate = await gateApiKey({
|
|
313
|
+
apiKey: opts.apiKey,
|
|
314
|
+
mcpUrl: opts.mcpUrl,
|
|
315
|
+
// The picker path is reached by a bare `tinyfish connect`.
|
|
316
|
+
retryCommand: pick ? 'tinyfish connect' : 'tinyfish connect --all',
|
|
317
|
+
mode: pick ? 'pick' : 'all',
|
|
318
|
+
attemptId: opts.attemptId,
|
|
319
|
+
});
|
|
320
|
+
return gate === 'blocked';
|
|
321
|
+
}
|
|
298
322
|
export async function runConnectAll(opts) {
|
|
299
323
|
const isTTY = detectHumanInitiated();
|
|
324
|
+
// Above the setup event and every write: a blocked run is not a setup attempt.
|
|
325
|
+
if (await blockedByKeyGate(opts))
|
|
326
|
+
return 1;
|
|
300
327
|
// Ahead of detection and the hoisted install; either can end the process silently.
|
|
301
328
|
// Not on the picker path: an explicit cancel is not a setup.
|
|
302
|
-
if (
|
|
329
|
+
if (keyGateApplies(opts) && !opts.pick) {
|
|
303
330
|
// validatedApiKey falls back to env/config; opts.apiKey alone splits the run's two ends.
|
|
304
331
|
await sendSetupStarted(opts.mcpUrl, opts.attemptId, validatedApiKey(opts.apiKey));
|
|
305
332
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type SetupBlockedMode } from './setup-telemetry.js';
|
|
2
|
+
export type PreflightOutcome = 'ok' | 'blocked';
|
|
3
|
+
interface PreflightOptions {
|
|
4
|
+
apiKey?: string;
|
|
5
|
+
mcpUrl: string;
|
|
6
|
+
retryCommand: string;
|
|
7
|
+
mode: SetupBlockedMode;
|
|
8
|
+
attemptId?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Checks the key every harness would install with, once, before anything is written.
|
|
12
|
+
* A bad key otherwise fails all seven installs the slow way, and Hermes refuses outright.
|
|
13
|
+
*/
|
|
14
|
+
export declare function gateApiKey(options: PreflightOptions): Promise<PreflightOutcome>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { apiKeyStatus, configFile, validateKeyFormat } from './auth.js';
|
|
2
|
+
import { TINYFISH_API_KEY_VAR } from './constants.js';
|
|
3
|
+
import { apiBaseFromMcpUrl } from './mcp-endpoint.js';
|
|
4
|
+
import { errLine, warnLine } from './output.js';
|
|
5
|
+
import { sendSetupBlocked, } from './setup-telemetry.js';
|
|
6
|
+
import { verifyMcpAuth } from './verify.js';
|
|
7
|
+
function sourceAdvice(source) {
|
|
8
|
+
switch (source) {
|
|
9
|
+
case 'env':
|
|
10
|
+
return `The key came from ${TINYFISH_API_KEY_VAR}, which overrides a saved key — fix or unset that export.`;
|
|
11
|
+
case 'explicit':
|
|
12
|
+
return 'The key came from --api-key.';
|
|
13
|
+
default:
|
|
14
|
+
return `The key came from ${configFile()}. Save a working one: tinyfish auth login, or pipe a key to tinyfish auth set.`;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function block(detail, options) {
|
|
18
|
+
errLine(`${detail.message} Nothing was installed.`);
|
|
19
|
+
errLine(sourceAdvice(detail.source));
|
|
20
|
+
errLine(`Then re-run: ${options.retryCommand}`);
|
|
21
|
+
await sendSetupBlocked(options.mcpUrl, {
|
|
22
|
+
blockReason: detail.reason,
|
|
23
|
+
keySource: detail.source,
|
|
24
|
+
...(detail.status === undefined ? {} : { status: detail.status }),
|
|
25
|
+
mode: options.mode,
|
|
26
|
+
attemptId: options.attemptId,
|
|
27
|
+
}, detail.key);
|
|
28
|
+
return 'blocked';
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Checks the key every harness would install with, once, before anything is written.
|
|
32
|
+
* A bad key otherwise fails all seven installs the slow way, and Hermes refuses outright.
|
|
33
|
+
*/
|
|
34
|
+
export async function gateApiKey(options) {
|
|
35
|
+
const status = apiKeyStatus(options.apiKey);
|
|
36
|
+
// No key at all is a valid answer: the OAuth harnesses still connect without one.
|
|
37
|
+
if (status.source === 'none')
|
|
38
|
+
return 'ok';
|
|
39
|
+
const source = status.source;
|
|
40
|
+
if (!('key' in status)) {
|
|
41
|
+
return block({ reason: 'key_unreadable', source, message: `${status.error}.` }, options);
|
|
42
|
+
}
|
|
43
|
+
// Downstream this key is dropped as unusable, which reads as "no key" and silently
|
|
44
|
+
// downgrades every install to a browser sign-in Hermes cannot take.
|
|
45
|
+
if (!validateKeyFormat(status.key)) {
|
|
46
|
+
return block({
|
|
47
|
+
reason: 'key_malformed',
|
|
48
|
+
source,
|
|
49
|
+
message: 'Your TinyFish API key is not shaped like one (expected sk-tinyfish-... or sk-mino-...).',
|
|
50
|
+
}, options);
|
|
51
|
+
}
|
|
52
|
+
const verified = await verifyMcpAuth(status.key, apiBaseFromMcpUrl(options.mcpUrl));
|
|
53
|
+
if (verified.ok)
|
|
54
|
+
return 'ok';
|
|
55
|
+
// No status means the network failed, and an outage must not block a whole setup.
|
|
56
|
+
if (verified.status === undefined) {
|
|
57
|
+
warnLine(`Could not check your TinyFish API key (${verified.code ?? 'unknown'}); continuing.`);
|
|
58
|
+
return 'ok';
|
|
59
|
+
}
|
|
60
|
+
return block({
|
|
61
|
+
reason: 'key_rejected',
|
|
62
|
+
source,
|
|
63
|
+
message: `Your TinyFish API key was rejected (${verified.code ?? 'unknown'}).`,
|
|
64
|
+
key: status.key,
|
|
65
|
+
status: verified.status,
|
|
66
|
+
}, options);
|
|
67
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DoctorCheck, DoctorOptions, DoctorRepair } from './doctor-report.js';
|
|
2
|
+
import type { RegistrationStatus } from './registration-detect.js';
|
|
3
|
+
export declare function repairsFor(checks: DoctorCheck[], statuses: RegistrationStatus[]): DoctorRepair[];
|
|
4
|
+
export interface RepairOutcome {
|
|
5
|
+
repair: DoctorRepair;
|
|
6
|
+
status: 'repaired' | 'skipped' | 'failed';
|
|
7
|
+
reason?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function applyRepairs(repairs: DoctorRepair[], options: DoctorOptions): Promise<RepairOutcome[]>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import spawn from 'cross-spawn';
|
|
2
|
+
import { connectHarness } from '../commands/connect.js';
|
|
3
|
+
import { Registered } from './harness-detect.js';
|
|
4
|
+
import { detectHumanInitiated } from './harness.js';
|
|
5
|
+
import { errLine } from './output.js';
|
|
6
|
+
// Only Cursor's repair is a pure local file write; every other one needs a browser sign-in.
|
|
7
|
+
const UNATTENDED_SAFE = new Set(['cursor']);
|
|
8
|
+
export function repairsFor(checks, statuses) {
|
|
9
|
+
const repairs = [];
|
|
10
|
+
// A revoked-but-well-formed key passes the credential check and fails the call; both need login.
|
|
11
|
+
const credentialBroken = checks.some((c) => (c.id === 'cli-credential' || c.id === 'cli-auth-call') && c.status === 'fail');
|
|
12
|
+
// connect writes the stored key, so replace a dead one first.
|
|
13
|
+
if (credentialBroken) {
|
|
14
|
+
repairs.push({
|
|
15
|
+
for: 'cli-credential-invalid',
|
|
16
|
+
action: 'auth-login',
|
|
17
|
+
harness: null,
|
|
18
|
+
command: 'tinyfish auth login',
|
|
19
|
+
unattended_safe: false,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const authCallPassed = checks.some((c) => c.id === 'cli-auth-call' && c.status === 'pass');
|
|
23
|
+
for (const status of statuses) {
|
|
24
|
+
// `unknown` earns nothing: connect would contradict the check's detail.
|
|
25
|
+
// A registered entry that still fails earns one too.
|
|
26
|
+
// `connected: false` is the harness's own no; it only warns, so it earns a repair here.
|
|
27
|
+
const brokenRegistration = status.connected === false ||
|
|
28
|
+
checks.some((c) => c.harness === status.harness && c.id !== 'hermes-plugin' && c.status === 'fail');
|
|
29
|
+
if (!status.detected || (status.registered !== Registered.No && !brokenRegistration))
|
|
30
|
+
continue;
|
|
31
|
+
repairs.push({
|
|
32
|
+
for: status.registered === Registered.Yes
|
|
33
|
+
? `${status.harness}-registration-broken`
|
|
34
|
+
: status.connectedBefore
|
|
35
|
+
? `${status.harness}-registration-lost`
|
|
36
|
+
: `${status.harness}-not-connected`,
|
|
37
|
+
action: 'connect',
|
|
38
|
+
harness: status.harness,
|
|
39
|
+
command: `tinyfish connect ${status.harness}`,
|
|
40
|
+
// A dead well-formed key passes the format check.
|
|
41
|
+
unattended_safe: UNATTENDED_SAFE.has(status.harness) && authCallPassed,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
// connect hermes reinstalls the plugin; a second row would double it.
|
|
45
|
+
const pluginBroken = checks.some((c) => c.id === 'hermes-plugin' && c.status === 'fail');
|
|
46
|
+
if (pluginBroken && !repairs.some((repair) => repair.harness === 'hermes')) {
|
|
47
|
+
repairs.push({
|
|
48
|
+
for: 'hermes-plugin-broken',
|
|
49
|
+
action: 'connect',
|
|
50
|
+
harness: 'hermes',
|
|
51
|
+
command: 'tinyfish connect hermes',
|
|
52
|
+
unattended_safe: false,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return repairs;
|
|
56
|
+
}
|
|
57
|
+
// Shell out rather than import the login flow: `auth login` owns its own browser and prompts.
|
|
58
|
+
// Re-invoke this same entry script, never a global `tinyfish` — under `npx` there is no such
|
|
59
|
+
// binary on PATH, and npx is the install-nothing path doctor exists to serve.
|
|
60
|
+
function runAuthLogin() {
|
|
61
|
+
const entry = process.argv[1];
|
|
62
|
+
if (!entry)
|
|
63
|
+
throw new Error('Could not locate the TinyFish CLI entry point to re-invoke');
|
|
64
|
+
const result = spawn.sync(process.execPath, [entry, 'auth', 'login', '--source', 'doctor'], {
|
|
65
|
+
stdio: 'inherit',
|
|
66
|
+
});
|
|
67
|
+
if (result.error)
|
|
68
|
+
throw new Error('Could not run `tinyfish auth login`', { cause: result.error });
|
|
69
|
+
if (result.status !== 0) {
|
|
70
|
+
throw new Error(`\`tinyfish auth login\` exited ${result.status ?? 'unknown'}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Dispatches on `action`, never on a null harness: that conflation made the credential fix a no-op. */
|
|
74
|
+
async function runRepair(repair, mcpUrl) {
|
|
75
|
+
if (repair.action === 'auth-login')
|
|
76
|
+
return runAuthLogin();
|
|
77
|
+
if (repair.harness)
|
|
78
|
+
await connectHarness(repair.harness, { mcpUrl, launch: false });
|
|
79
|
+
}
|
|
80
|
+
export async function applyRepairs(repairs, options) {
|
|
81
|
+
const interactive = detectHumanInitiated();
|
|
82
|
+
const outcomes = [];
|
|
83
|
+
for (const repair of repairs) {
|
|
84
|
+
if (!interactive && !options.yes) {
|
|
85
|
+
outcomes.push({
|
|
86
|
+
repair,
|
|
87
|
+
status: 'skipped',
|
|
88
|
+
reason: 'no terminal; re-run with --yes to repair unattended',
|
|
89
|
+
});
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (!interactive && !repair.unattended_safe) {
|
|
93
|
+
outcomes.push({
|
|
94
|
+
repair,
|
|
95
|
+
status: 'skipped',
|
|
96
|
+
reason: 'needs a browser sign-in, which has no unattended path',
|
|
97
|
+
});
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
await runRepair(repair, options.mcpUrl);
|
|
102
|
+
outcomes.push({ repair, status: 'repaired' });
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
// Connect quotes absolute config paths in its errors, so the raw text stays behind
|
|
106
|
+
// --debug and the user is told where to find it rather than handed a generic dead end.
|
|
107
|
+
if (process.env['TINYFISH_DEBUG']) {
|
|
108
|
+
errLine(e instanceof Error ? (e.stack ?? e.message) : String(e));
|
|
109
|
+
}
|
|
110
|
+
outcomes.push({
|
|
111
|
+
repair,
|
|
112
|
+
status: 'failed',
|
|
113
|
+
reason: `\`${repair.command}\` failed; re-run it directly, or --debug for the raw error`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return outcomes;
|
|
118
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { AuthMode, Registered } from './harness-detect.js';
|
|
2
|
+
import { AuthMode, type Harness, Registered } from './harness-detect.js';
|
|
3
3
|
/** Bumped whenever a consumer could misread the payload; the cookbook skill releases separately. */
|
|
4
4
|
export declare const DOCTOR_SCHEMA_VERSION = 3;
|
|
5
5
|
declare const checkStatusSchema: z.ZodEnum<{
|
|
@@ -151,6 +151,12 @@ export type DoctorCheck = z.infer<typeof doctorCheckSchema>;
|
|
|
151
151
|
export type DoctorHarness = z.infer<typeof doctorHarnessSchema>;
|
|
152
152
|
export type DoctorRepair = z.infer<typeof doctorRepairSchema>;
|
|
153
153
|
export type DoctorReport = z.infer<typeof doctorReportSchema>;
|
|
154
|
+
export interface DoctorOptions {
|
|
155
|
+
mcpUrl: string;
|
|
156
|
+
harness?: Harness;
|
|
157
|
+
fix?: boolean;
|
|
158
|
+
yes?: boolean;
|
|
159
|
+
}
|
|
154
160
|
/** Doctor itself broke, which is a different claim from "your setup is broken". */
|
|
155
161
|
export declare const DOCTOR_COULD_NOT_RUN = 2;
|
|
156
162
|
/** The invocation was wrong, not the machine; exit 2 stays "doctor broke". */
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type DoctorOptions, type DoctorRepair, type DoctorReport } from './doctor-report.js';
|
|
2
|
+
import type { RepairOutcome } from './doctor-repairs.js';
|
|
3
|
+
import { type DoctorCompletedPayload, type DoctorCouldNotRunPayload } from './setup-telemetry.js';
|
|
4
|
+
/** Enumerated fields only; check `detail` text never leaves the machine. */
|
|
5
|
+
export declare function telemetryPayload(report: DoctorReport, offered: DoctorRepair[], outcomes: RepairOutcome[], options: DoctorOptions, durationMs: number): DoctorCompletedPayload;
|
|
6
|
+
/** Both paths send keyless when there is no key; the route records those anonymously. */
|
|
7
|
+
export declare function telemetryKey(): string | undefined;
|
|
8
|
+
/** Enumerated outcome and timing only; the raw error stays behind --debug. */
|
|
9
|
+
export declare function couldNotRunPayload(error: unknown, options: DoctorOptions, durationMs: number): DoctorCouldNotRunPayload;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { apiKeyStatus } from './auth.js';
|
|
2
|
+
import { CLI_VERSION } from './constants.js';
|
|
3
|
+
import { DOCTOR_SCHEMA_VERSION, } from './doctor-report.js';
|
|
4
|
+
import { detectHumanInitiated } from './harness.js';
|
|
5
|
+
import { doctorErrorClass, } from './setup-telemetry.js';
|
|
6
|
+
/** Mirrors the route's cap; over it the payload would 400 and never be retried. */
|
|
7
|
+
const MAX_DURATION_MS = 3_600_000;
|
|
8
|
+
function fixModeOf(options) {
|
|
9
|
+
return !options.fix ? 'none' : options.yes ? 'fix_yes' : 'fix';
|
|
10
|
+
}
|
|
11
|
+
/** Enumerated fields only; check `detail` text never leaves the machine. */
|
|
12
|
+
export function telemetryPayload(report, offered, outcomes, options, durationMs) {
|
|
13
|
+
const count = (status) => report.checks.filter((check) => check.status === status).length;
|
|
14
|
+
const failed = report.checks.filter((check) => check.status === 'fail');
|
|
15
|
+
return {
|
|
16
|
+
outcome: 'completed',
|
|
17
|
+
schema_version: report.schema_version,
|
|
18
|
+
cli_version: report.cli_version,
|
|
19
|
+
ok_harnesses: report.ok_harnesses,
|
|
20
|
+
ok_cli: report.ok_cli,
|
|
21
|
+
checks_pass: count('pass'),
|
|
22
|
+
checks_fail: count('fail'),
|
|
23
|
+
checks_warn: count('warn'),
|
|
24
|
+
checks_skip: count('skip'),
|
|
25
|
+
// From the pre-repair report; the verdicts above describe the machine after it.
|
|
26
|
+
repairs_offered: offered.length,
|
|
27
|
+
repair_actions: [...new Set(offered.map((repair) => repair.action))],
|
|
28
|
+
repairs_attempted: outcomes.filter((outcome) => outcome.status !== 'skipped').length,
|
|
29
|
+
repairs_succeeded: outcomes.filter((outcome) => outcome.status === 'repaired').length,
|
|
30
|
+
fix_mode: fixModeOf(options),
|
|
31
|
+
// --harness scopes detection, so the counts above cover that harness alone.
|
|
32
|
+
harness_scope: options.harness ?? 'all',
|
|
33
|
+
// Same signal cli_connect_stage carries, so agent and human doctor runs split.
|
|
34
|
+
is_human_initiated: detectHumanInitiated(),
|
|
35
|
+
// Two flat arrays rather than one of pairs: PostHog groups by element, not by field.
|
|
36
|
+
failed_check_ids: [...new Set(failed.map((check) => check.id))],
|
|
37
|
+
failed_harnesses: [...new Set(failed.map((check) => check.harness).filter((h) => h !== null))],
|
|
38
|
+
harnesses: report.harnesses,
|
|
39
|
+
// Clamped, not validated: a --fix parked at a prompt must not fail the parse and drop the run.
|
|
40
|
+
duration_ms: Math.min(durationMs, MAX_DURATION_MS),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Both paths send keyless when there is no key; the route records those anonymously. */
|
|
44
|
+
export function telemetryKey() {
|
|
45
|
+
const resolved = apiKeyStatus();
|
|
46
|
+
return 'key' in resolved ? resolved.key : undefined;
|
|
47
|
+
}
|
|
48
|
+
/** Enumerated outcome and timing only; the raw error stays behind --debug. */
|
|
49
|
+
export function couldNotRunPayload(error, options, durationMs) {
|
|
50
|
+
return {
|
|
51
|
+
outcome: 'could_not_run',
|
|
52
|
+
error_class: doctorErrorClass(error),
|
|
53
|
+
schema_version: DOCTOR_SCHEMA_VERSION,
|
|
54
|
+
cli_version: CLI_VERSION,
|
|
55
|
+
fix_mode: fixModeOf(options),
|
|
56
|
+
harness_scope: options.harness ?? 'all',
|
|
57
|
+
is_human_initiated: detectHumanInitiated(),
|
|
58
|
+
// Clamped like the completed row: over the cap the payload would 400 un-retried.
|
|
59
|
+
duration_ms: Math.min(durationMs, MAX_DURATION_MS),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -43,6 +43,18 @@ declare const harnessResultSchema: z.ZodObject<{
|
|
|
43
43
|
}, z.core.$strip>;
|
|
44
44
|
/** First act of a `--all` run; everything upstream of it stays invisible. */
|
|
45
45
|
export declare function sendSetupStarted(mcpUrl: string, attemptId?: string, apiKey?: string): Promise<void>;
|
|
46
|
+
export type SetupBlockReason = 'key_rejected' | 'key_malformed' | 'key_unreadable';
|
|
47
|
+
export type SetupBlockedMode = 'all' | 'pick' | 'single';
|
|
48
|
+
export interface SetupBlockedDetail {
|
|
49
|
+
blockReason: SetupBlockReason;
|
|
50
|
+
keySource: 'env' | 'config' | 'explicit';
|
|
51
|
+
/** Only a server answer has one; a malformed key never reaches the API. */
|
|
52
|
+
status?: number;
|
|
53
|
+
mode: SetupBlockedMode;
|
|
54
|
+
attemptId?: string;
|
|
55
|
+
}
|
|
56
|
+
/** Terminal state for a run the key gate stopped; fires instead of setup_started. */
|
|
57
|
+
export declare function sendSetupBlocked(mcpUrl: string, detail: SetupBlockedDetail, apiKey?: string): Promise<void>;
|
|
46
58
|
export declare const setupCompletedPayloadSchema: z.ZodObject<{
|
|
47
59
|
harnesses: z.ZodArray<z.ZodObject<{
|
|
48
60
|
harness: z.ZodEnum<{
|
|
@@ -123,6 +123,30 @@ export async function sendSetupStarted(mcpUrl, attemptId, apiKey) {
|
|
|
123
123
|
apiKey,
|
|
124
124
|
});
|
|
125
125
|
}
|
|
126
|
+
/** Terminal state for a run the key gate stopped; fires instead of setup_started. */
|
|
127
|
+
export async function sendSetupBlocked(mcpUrl, detail, apiKey) {
|
|
128
|
+
if (telemetryDisabled())
|
|
129
|
+
return;
|
|
130
|
+
const stamped = detail.attemptId && z.uuid().safeParse(detail.attemptId).success ? detail.attemptId : undefined;
|
|
131
|
+
await postConnectEvent({
|
|
132
|
+
endpoint: new URL('/api/cli/connect-event', mcpUrl).toString(),
|
|
133
|
+
body: JSON.stringify({
|
|
134
|
+
event: 'setup_blocked',
|
|
135
|
+
cli_version: CLI_VERSION,
|
|
136
|
+
block_reason: detail.blockReason,
|
|
137
|
+
key_source: detail.keySource,
|
|
138
|
+
...(detail.status !== undefined ? { status: detail.status } : {}),
|
|
139
|
+
mode: detail.mode,
|
|
140
|
+
runtime_platform: process.platform,
|
|
141
|
+
...(stamped ? { connect_attempt_id: stamped } : {}),
|
|
142
|
+
cli_invocation_id: CLI_INVOCATION_ID,
|
|
143
|
+
runtime_env: detectRuntimeEnv(),
|
|
144
|
+
}),
|
|
145
|
+
attempts: 1,
|
|
146
|
+
label: 'setup',
|
|
147
|
+
apiKey,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
126
150
|
export const setupCompletedPayloadSchema = z.object({
|
|
127
151
|
harnesses: z
|
|
128
152
|
.array(harnessResultSchema)
|