@tiny-fish/cli 0.30.2-next.256 → 0.30.2-next.258
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.d.ts +1 -1
- package/dist/commands/auth.js +52 -52
- package/dist/commands/batch.d.ts +1 -1
- package/dist/commands/batch.js +54 -46
- package/dist/commands/browser.d.ts +1 -1
- package/dist/commands/browser.js +14 -14
- package/dist/commands/config-claude.d.ts +1 -1
- package/dist/commands/config-claude.js +22 -22
- package/dist/commands/connect.d.ts +3 -3
- package/dist/commands/connect.js +150 -150
- package/dist/commands/doctor.d.ts +4 -4
- package/dist/commands/doctor.js +104 -104
- package/dist/commands/fetch.d.ts +1 -1
- package/dist/commands/fetch.js +24 -27
- package/dist/commands/onboard.d.ts +1 -1
- package/dist/commands/onboard.js +50 -50
- package/dist/commands/profile.d.ts +3 -3
- package/dist/commands/profile.js +45 -45
- package/dist/commands/run.d.ts +1 -1
- package/dist/commands/run.js +78 -65
- package/dist/commands/runs.d.ts +1 -1
- package/dist/commands/runs.js +63 -50
- package/dist/commands/search.d.ts +1 -1
- package/dist/commands/search.js +16 -16
- package/dist/commands/upgrade.d.ts +2 -2
- package/dist/commands/upgrade.js +36 -36
- package/dist/commands/vault.d.ts +1 -1
- package/dist/commands/vault.js +48 -45
- package/dist/commands/wallet.d.ts +1 -1
- package/dist/commands/wallet.js +14 -14
- package/dist/index.js +2 -2
- package/dist/lib/auth.d.ts +5 -5
- package/dist/lib/auth.js +56 -56
- package/dist/lib/batch-store.d.ts +1 -1
- package/dist/lib/batch-store.js +4 -4
- package/dist/lib/chrome-cookies.d.ts +1 -1
- package/dist/lib/chrome-cookies.js +24 -24
- package/dist/lib/claude-config.js +37 -37
- package/dist/lib/cli-install.js +12 -12
- package/dist/lib/client.d.ts +2 -2
- package/dist/lib/client.js +61 -61
- package/dist/lib/connect-all.js +140 -134
- package/dist/lib/connect-auth.d.ts +2 -2
- package/dist/lib/connect-auth.js +15 -15
- package/dist/lib/connect-clients.d.ts +6 -6
- package/dist/lib/connect-clients.js +143 -143
- package/dist/lib/connect-fallback.d.ts +1 -1
- package/dist/lib/connect-fallback.js +38 -38
- package/dist/lib/connect-picker.d.ts +4 -4
- package/dist/lib/connect-picker.js +28 -28
- package/dist/lib/connect-runtime.d.ts +7 -7
- package/dist/lib/connect-runtime.js +61 -61
- package/dist/lib/constants.js +13 -13
- package/dist/lib/cursor-config.d.ts +1 -1
- package/dist/lib/cursor-config.js +30 -30
- package/dist/lib/doctor-report.d.ts +2 -2
- package/dist/lib/doctor-report.js +22 -22
- package/dist/lib/harness-detect.js +39 -39
- package/dist/lib/harness.js +9 -9
- package/dist/lib/hermes-env.js +26 -23
- package/dist/lib/install-root.js +12 -12
- package/dist/lib/notice.d.ts +1 -1
- package/dist/lib/notice.js +12 -12
- package/dist/lib/output.js +14 -14
- package/dist/lib/registration-detect.d.ts +3 -3
- package/dist/lib/registration-detect.js +82 -82
- package/dist/lib/setup-telemetry.d.ts +4 -4
- package/dist/lib/setup-telemetry.js +34 -34
- package/dist/lib/signals.js +1 -1
- package/dist/lib/skill-install.d.ts +3 -3
- package/dist/lib/skill-install.js +40 -40
- package/dist/lib/types.d.ts +8 -8
- package/dist/lib/verify.d.ts +1 -1
- package/dist/lib/verify.js +28 -28
- package/dist/program.d.ts +1 -1
- package/dist/program.js +28 -28
- package/package.json +18 -9
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
import * as readline from
|
|
2
|
-
import spawn from
|
|
3
|
-
import { resolvedApiKey, validateKeyFormat } from
|
|
4
|
-
import { installTinyFishCli } from
|
|
5
|
-
import { ensureCliAuthenticated, TINYFISH_CLI_NOT_FOUND_MESSAGE } from
|
|
6
|
-
import { TINYFISH_API_KEY_VAR } from
|
|
7
|
-
import { commandNotFound, ConnectInterruptedError, ConnectStepError, throwIfInterrupted, } from
|
|
8
|
-
import { errLine, outLine } from
|
|
1
|
+
import * as readline from 'node:readline/promises';
|
|
2
|
+
import spawn from 'cross-spawn';
|
|
3
|
+
import { resolvedApiKey, validateKeyFormat } from './auth.js';
|
|
4
|
+
import { installTinyFishCli } from './cli-install.js';
|
|
5
|
+
import { ensureCliAuthenticated, TINYFISH_CLI_NOT_FOUND_MESSAGE } from './connect-auth.js';
|
|
6
|
+
import { TINYFISH_API_KEY_VAR } from './constants.js';
|
|
7
|
+
import { commandNotFound, ConnectInterruptedError, ConnectStepError, throwIfInterrupted, } from './connect-runtime.js';
|
|
8
|
+
import { errLine, outLine } from './output.js';
|
|
9
9
|
export const SEARCH_RETRY = 'tinyfish search query --pretty -- "<your query>"';
|
|
10
10
|
// Echoes the finale suggestion, so headless rescues rehearse the same task.
|
|
11
11
|
export const DEFAULT_VERIFICATION_QUERY = "today's top Hacker News story";
|
|
12
12
|
// Must undercut agent tool-call budgets (Claude Code Bash defaults to 120s).
|
|
13
13
|
const HEADLESS_VERIFY_TIMEOUT_MS = 90_000;
|
|
14
14
|
// An exported var must be unset, not re-logged-in.
|
|
15
|
-
export const MALFORMED_ENV_KEY_MESSAGE =
|
|
16
|
-
export const MALFORMED_STORED_KEY_MESSAGE =
|
|
15
|
+
export const MALFORMED_ENV_KEY_MESSAGE = 'TINYFISH_API_KEY is set but malformed; it will override any stored key. Fix or unset it, then re-run.';
|
|
16
|
+
export const MALFORMED_STORED_KEY_MESSAGE = 'The stored TinyFish API key is malformed. Run: tinyfish auth login';
|
|
17
17
|
function searchRetry(query) {
|
|
18
|
-
if (process.platform ===
|
|
18
|
+
if (process.platform === 'win32')
|
|
19
19
|
return SEARCH_RETRY;
|
|
20
20
|
return `tinyfish search query --pretty -- '${query.replaceAll("'", "'\\''")}'`;
|
|
21
21
|
}
|
|
@@ -24,14 +24,14 @@ export function createStdinPrompt() {
|
|
|
24
24
|
let stdinClosed = false;
|
|
25
25
|
return async (question) => {
|
|
26
26
|
if (stdinClosed)
|
|
27
|
-
throw new Error(
|
|
27
|
+
throw new Error('stdin is closed');
|
|
28
28
|
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
29
29
|
// Without a close-linked signal, question() never settles on Ctrl+D.
|
|
30
30
|
const closed = new AbortController();
|
|
31
|
-
rl.on(
|
|
31
|
+
rl.on('close', () => closed.abort());
|
|
32
32
|
// Raw mode swallows Ctrl+C here; resurface it as the interrupt.
|
|
33
33
|
let interrupted = false;
|
|
34
|
-
rl.on(
|
|
34
|
+
rl.on('SIGINT', () => {
|
|
35
35
|
interrupted = true;
|
|
36
36
|
rl.close();
|
|
37
37
|
});
|
|
@@ -40,7 +40,7 @@ export function createStdinPrompt() {
|
|
|
40
40
|
}
|
|
41
41
|
catch (error) {
|
|
42
42
|
if (interrupted)
|
|
43
|
-
throw new ConnectInterruptedError(
|
|
43
|
+
throw new ConnectInterruptedError('Setup interrupted');
|
|
44
44
|
stdinClosed = true;
|
|
45
45
|
throw error;
|
|
46
46
|
}
|
|
@@ -52,7 +52,7 @@ export function createStdinPrompt() {
|
|
|
52
52
|
/** Steps print retry lines and return outcomes; only interrupts throw. */
|
|
53
53
|
export async function runCliFallback(opts) {
|
|
54
54
|
const { hooks, prompt } = opts;
|
|
55
|
-
hooks?.onStepStart?.(
|
|
55
|
+
hooks?.onStepStart?.('cli_install');
|
|
56
56
|
try {
|
|
57
57
|
installTinyFishCli({ verbose: opts.verbose, announce: true });
|
|
58
58
|
}
|
|
@@ -61,26 +61,26 @@ export async function runCliFallback(opts) {
|
|
|
61
61
|
throw error;
|
|
62
62
|
errLine(error instanceof Error ? error.message : String(error));
|
|
63
63
|
outLine(`Retry: ${opts.retryCommand}`);
|
|
64
|
-
return
|
|
64
|
+
return 'cli_install_failed';
|
|
65
65
|
}
|
|
66
|
-
hooks?.onStepDone?.(
|
|
67
|
-
hooks?.onStepStart?.(
|
|
66
|
+
hooks?.onStepDone?.('cli_installed');
|
|
67
|
+
hooks?.onStepStart?.('authentication');
|
|
68
68
|
// Resolved raw: validatedApiKey collapses a malformed key into "no key".
|
|
69
69
|
const rawKey = resolvedApiKey(opts.apiKey);
|
|
70
70
|
if (rawKey && !validateKeyFormat(rawKey)) {
|
|
71
71
|
// Terminal even in a TTY: login writes config; env shadows it.
|
|
72
72
|
errLine(process.env[TINYFISH_API_KEY_VAR] ? MALFORMED_ENV_KEY_MESSAGE : MALFORMED_STORED_KEY_MESSAGE);
|
|
73
73
|
outLine(`Retry: ${opts.retryCommand}`);
|
|
74
|
-
return
|
|
74
|
+
return 'invalid_credentials';
|
|
75
75
|
}
|
|
76
76
|
if (!prompt && !rawKey) {
|
|
77
77
|
outLine(`Retry in a terminal: ${opts.retryCommand}`);
|
|
78
|
-
return
|
|
78
|
+
return 'no_credentials';
|
|
79
79
|
}
|
|
80
80
|
try {
|
|
81
81
|
// Gate-only widening: a config-only key must not re-persist anywhere.
|
|
82
82
|
// Headless has no one to answer a login prompt; never inherit stdio there.
|
|
83
|
-
ensureCliAuthenticated(
|
|
83
|
+
ensureCliAuthenticated('cli', opts.apiKey ?? process.env[TINYFISH_API_KEY_VAR], {
|
|
84
84
|
interactiveLogin: !!prompt,
|
|
85
85
|
});
|
|
86
86
|
}
|
|
@@ -88,49 +88,49 @@ export async function runCliFallback(opts) {
|
|
|
88
88
|
if (error instanceof ConnectInterruptedError)
|
|
89
89
|
throw error;
|
|
90
90
|
errLine(error instanceof Error ? error.message : String(error));
|
|
91
|
-
outLine(error instanceof ConnectStepError && error.failureReason ===
|
|
92
|
-
?
|
|
93
|
-
:
|
|
94
|
-
return
|
|
91
|
+
outLine(error instanceof ConnectStepError && error.failureReason === 'command_not_found'
|
|
92
|
+
? 'Retry in a new terminal: tinyfish auth login'
|
|
93
|
+
: 'Retry: tinyfish auth login');
|
|
94
|
+
return 'authentication_failed';
|
|
95
95
|
}
|
|
96
|
-
hooks?.onStepDone?.(
|
|
97
|
-
hooks?.onStepStart?.(
|
|
96
|
+
hooks?.onStepDone?.('authenticated');
|
|
97
|
+
hooks?.onStepStart?.('cli_verification');
|
|
98
98
|
let query;
|
|
99
99
|
if (prompt) {
|
|
100
100
|
try {
|
|
101
|
-
query = (await prompt(
|
|
101
|
+
query = (await prompt('What would you like to search? ')).trim();
|
|
102
102
|
}
|
|
103
103
|
catch (error) {
|
|
104
104
|
if (error instanceof ConnectInterruptedError)
|
|
105
105
|
throw error;
|
|
106
106
|
outLine(`Retry: ${SEARCH_RETRY}`);
|
|
107
|
-
return
|
|
107
|
+
return 'abandoned';
|
|
108
108
|
}
|
|
109
109
|
if (!query) {
|
|
110
110
|
outLine(`Retry: ${SEARCH_RETRY}`);
|
|
111
|
-
return
|
|
111
|
+
return 'abandoned';
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
else {
|
|
115
115
|
// Headless with a key: a canned query still proves the pipe.
|
|
116
116
|
query = DEFAULT_VERIFICATION_QUERY;
|
|
117
117
|
}
|
|
118
|
-
const search = spawn.sync(
|
|
119
|
-
stdio:
|
|
120
|
-
env: { ...process.env, TF_SETUP_VERIFICATION:
|
|
118
|
+
const search = spawn.sync('tinyfish', ['search', 'query', '--pretty', '--', query], {
|
|
119
|
+
stdio: 'inherit',
|
|
120
|
+
env: { ...process.env, TF_SETUP_VERIFICATION: '1' },
|
|
121
121
|
// A user-driven search must never be cut off.
|
|
122
122
|
timeout: prompt ? undefined : HEADLESS_VERIFY_TIMEOUT_MS,
|
|
123
123
|
});
|
|
124
124
|
if (commandNotFound(search.error)) {
|
|
125
125
|
errLine(TINYFISH_CLI_NOT_FOUND_MESSAGE);
|
|
126
126
|
outLine(`Retry in a new terminal: ${searchRetry(query)}`);
|
|
127
|
-
return
|
|
127
|
+
return 'verification_failed';
|
|
128
128
|
}
|
|
129
129
|
throwIfInterrupted(search);
|
|
130
130
|
if (search.error || search.status !== 0) {
|
|
131
131
|
outLine(`Retry: ${searchRetry(query)}`);
|
|
132
|
-
return
|
|
132
|
+
return 'verification_failed';
|
|
133
133
|
}
|
|
134
|
-
hooks?.onStepDone?.(
|
|
135
|
-
return
|
|
134
|
+
hooks?.onStepDone?.('cli_verified');
|
|
135
|
+
return 'cli_verified';
|
|
136
136
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { Readable } from
|
|
2
|
-
import { type Harness, type HarnessDetection } from
|
|
3
|
-
export type PickSelection = Harness[] |
|
|
1
|
+
import type { Readable } from 'node:stream';
|
|
2
|
+
import { type Harness, type HarnessDetection } from './harness-detect.js';
|
|
3
|
+
export type PickSelection = Harness[] | 'cancel';
|
|
4
4
|
export interface PickResult {
|
|
5
5
|
selection: PickSelection;
|
|
6
|
-
mode:
|
|
6
|
+
mode: 'pick' | 'pick_numeric';
|
|
7
7
|
}
|
|
8
8
|
export type PickerStdin = Readable & {
|
|
9
9
|
setRawMode?: (raw: boolean) => unknown;
|
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
import * as readline from
|
|
2
|
-
import { HARNESS_DISPLAY_NAMES } from
|
|
3
|
-
const KEY_HELP =
|
|
1
|
+
import * as readline from 'node:readline';
|
|
2
|
+
import { HARNESS_DISPLAY_NAMES } from './harness-detect.js';
|
|
3
|
+
const KEY_HELP = ' ↑↓ move space toggle one a none/all q cancel';
|
|
4
4
|
function header(count) {
|
|
5
|
-
return `Found ${count} agent config${count === 1 ?
|
|
5
|
+
return `Found ${count} agent config${count === 1 ? '' : 's'} on this machine.\n\n`;
|
|
6
6
|
}
|
|
7
7
|
function connectedNote(item) {
|
|
8
|
-
return item.connectedBefore ?
|
|
8
|
+
return item.connectedBefore ? ' (previously connected)' : '';
|
|
9
9
|
}
|
|
10
10
|
function enterHint(checkedCount, total) {
|
|
11
11
|
if (checkedCount === 0)
|
|
12
|
-
return
|
|
12
|
+
return 'press enter to cancel';
|
|
13
13
|
if (checkedCount === total)
|
|
14
14
|
return `press enter to connect all ${total}`;
|
|
15
15
|
return `press enter to connect ${checkedCount} of ${total}`;
|
|
16
16
|
}
|
|
17
17
|
function frame(items, checked, cursor) {
|
|
18
|
-
const rows = items.map((item, i) => ` ${i === cursor ?
|
|
18
|
+
const rows = items.map((item, i) => ` ${i === cursor ? '>' : ' '} [${checked[i] ? 'x' : ' '}] ${item.name}${connectedNote(item)}`);
|
|
19
19
|
const count = checked.filter(Boolean).length;
|
|
20
|
-
return [...rows,
|
|
20
|
+
return [...rows, '', ` ${enterHint(count, items.length)}`, KEY_HELP, ''].join('\n');
|
|
21
21
|
}
|
|
22
22
|
async function pickWithKeys(items, stdin, write) {
|
|
23
23
|
const checked = items.map(() => true);
|
|
@@ -25,7 +25,7 @@ async function pickWithKeys(items, stdin, write) {
|
|
|
25
25
|
let painted = 0;
|
|
26
26
|
const paint = () => {
|
|
27
27
|
const text = frame(items, checked, cursor);
|
|
28
|
-
write((painted > 0 ? `\u001b[${painted}A\u001b[0J` :
|
|
28
|
+
write((painted > 0 ? `\u001b[${painted}A\u001b[0J` : '') + text);
|
|
29
29
|
painted = items.length + 3;
|
|
30
30
|
};
|
|
31
31
|
readline.emitKeypressEvents(stdin);
|
|
@@ -38,24 +38,24 @@ async function pickWithKeys(items, stdin, write) {
|
|
|
38
38
|
return await new Promise((resolve, reject) => {
|
|
39
39
|
onKeypress = (_chunk, key = {}) => {
|
|
40
40
|
try {
|
|
41
|
-
const name = key.name ??
|
|
41
|
+
const name = key.name ?? '';
|
|
42
42
|
// Raw mode eats terminal SIGINT, so Ctrl+C arrives only here.
|
|
43
|
-
if ((key.ctrl && (name ===
|
|
44
|
-
resolve(
|
|
43
|
+
if ((key.ctrl && (name === 'c' || name === 'd')) || name === 'q' || name === 'escape') {
|
|
44
|
+
resolve('cancel');
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
47
|
-
if (name ===
|
|
47
|
+
if (name === 'return' || name === 'enter') {
|
|
48
48
|
const picked = items.filter((_, i) => checked[i]).map((item) => item.harness);
|
|
49
|
-
resolve(picked.length > 0 ? picked :
|
|
49
|
+
resolve(picked.length > 0 ? picked : 'cancel');
|
|
50
50
|
return;
|
|
51
51
|
}
|
|
52
|
-
if (name ===
|
|
52
|
+
if (name === 'up' || name === 'k')
|
|
53
53
|
cursor = (cursor + items.length - 1) % items.length;
|
|
54
|
-
else if (name ===
|
|
54
|
+
else if (name === 'down' || name === 'j')
|
|
55
55
|
cursor = (cursor + 1) % items.length;
|
|
56
|
-
else if (name ===
|
|
56
|
+
else if (name === 'space')
|
|
57
57
|
checked[cursor] = !checked[cursor];
|
|
58
|
-
else if (name ===
|
|
58
|
+
else if (name === 'a')
|
|
59
59
|
checked.fill(!checked.every(Boolean));
|
|
60
60
|
paint();
|
|
61
61
|
}
|
|
@@ -63,19 +63,19 @@ async function pickWithKeys(items, stdin, write) {
|
|
|
63
63
|
reject(error instanceof Error ? error : new Error(String(error)));
|
|
64
64
|
}
|
|
65
65
|
};
|
|
66
|
-
stdin.on(
|
|
66
|
+
stdin.on('keypress', onKeypress);
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
69
|
finally {
|
|
70
70
|
// Restore before any child spawn can inherit raw stdin (PF-3635).
|
|
71
71
|
if (onKeypress)
|
|
72
|
-
stdin.removeListener(
|
|
72
|
+
stdin.removeListener('keypress', onKeypress);
|
|
73
73
|
stdin.setRawMode(false);
|
|
74
74
|
stdin.pause();
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
function numberedRows(items) {
|
|
78
|
-
return (items.map((item, i) => ` [${i + 1}] ${item.name}${connectedNote(item)}`).join(
|
|
78
|
+
return (items.map((item, i) => ` [${i + 1}] ${item.name}${connectedNote(item)}`).join('\n') + '\n');
|
|
79
79
|
}
|
|
80
80
|
function parseSubset(answer, items) {
|
|
81
81
|
const tokens = answer.split(/[\s,]+/).filter(Boolean);
|
|
@@ -103,17 +103,17 @@ async function pickWithNumbers(items, prompt, write) {
|
|
|
103
103
|
}
|
|
104
104
|
catch {
|
|
105
105
|
// Closed stdin: every later question would throw too.
|
|
106
|
-
return
|
|
106
|
+
return 'cancel';
|
|
107
107
|
}
|
|
108
|
-
if (answer ===
|
|
108
|
+
if (answer === '')
|
|
109
109
|
return items.map((item) => item.harness);
|
|
110
110
|
if (/^[nq]$/i.test(answer))
|
|
111
|
-
return
|
|
111
|
+
return 'cancel';
|
|
112
112
|
const subset = parseSubset(answer, items);
|
|
113
113
|
if (subset)
|
|
114
114
|
return subset;
|
|
115
115
|
}
|
|
116
|
-
return
|
|
116
|
+
return 'cancel';
|
|
117
117
|
}
|
|
118
118
|
export async function pickHarnesses(detections, connectedBefore, io = {}) {
|
|
119
119
|
const items = detections
|
|
@@ -125,12 +125,12 @@ export async function pickHarnesses(detections, connectedBefore, io = {}) {
|
|
|
125
125
|
}));
|
|
126
126
|
const stdin = io.stdin ?? process.stdin;
|
|
127
127
|
const write = io.write ?? ((text) => void process.stderr.write(text));
|
|
128
|
-
if (typeof stdin.setRawMode !==
|
|
128
|
+
if (typeof stdin.setRawMode !== 'function') {
|
|
129
129
|
// No prompt means nothing to ask; keep --all semantics.
|
|
130
130
|
const selection = io.prompt
|
|
131
131
|
? await pickWithNumbers(items, io.prompt, write)
|
|
132
132
|
: items.map((item) => item.harness);
|
|
133
|
-
return { selection, mode:
|
|
133
|
+
return { selection, mode: 'pick_numeric' };
|
|
134
134
|
}
|
|
135
|
-
return { selection: await pickWithKeys(items, stdin, write), mode:
|
|
135
|
+
return { selection: await pickWithKeys(items, stdin, write), mode: 'pick' };
|
|
136
136
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { AuthMode } from
|
|
1
|
+
import { AuthMode } from './harness-detect.js';
|
|
2
2
|
export declare const NON_INTERACTIVE_TIMEOUT_MS = 10000;
|
|
3
|
-
export type AgentClient =
|
|
4
|
-
type ConnectStage =
|
|
5
|
-
export type ConnectFailureStage =
|
|
3
|
+
export type AgentClient = 'claude-code' | 'codex' | 'cursor' | 'grok' | 'hermes' | 'openclaw' | 'opencode';
|
|
4
|
+
type ConnectStage = 'started' | 'checkpoint' | 'completed' | 'failed' | 'aborted' | 'post_install_failed';
|
|
5
|
+
export type ConnectFailureStage = 'prerequisite_check' | 'registration_cleanup' | 'registration' | 'registration_or_authentication' | 'client_oauth' | 'cli_install' | 'skill_install' | 'authentication' | 'walkthrough_launch' | 'cli_verification';
|
|
6
6
|
export declare const CONNECT_FAILURE_REASONS: readonly ["harness_not_installed", "harness_command_unsupported", "harness_too_old", "key_auth_unsupported", "command_not_found", "timeout", "spawn_error", "nonzero_exit", "invalid_config", "user_abandoned", "process_terminated", "unexpected_error"];
|
|
7
7
|
export type ConnectFailureReason = (typeof CONNECT_FAILURE_REASONS)[number];
|
|
8
|
-
export type ConnectAuthMode = Exclude<AuthMode, AuthMode.Unknown> |
|
|
9
|
-
export type ConnectCheckpoint =
|
|
10
|
-
export type CliFallbackOutcome =
|
|
8
|
+
export type ConnectAuthMode = Exclude<AuthMode, AuthMode.Unknown> | 'deferred';
|
|
9
|
+
export type ConnectCheckpoint = 'prerequisite_ok' | 'registered' | 'oauth_done' | 'cli_installed' | 'skill_installed' | 'authenticated' | 'cli_verified' | 'walkthrough_launched' | 'walkthrough_prompt_printed';
|
|
10
|
+
export type CliFallbackOutcome = 'cli_verified' | 'cli_install_failed' | 'no_credentials' | 'invalid_credentials' | 'authentication_failed' | 'verification_failed' | 'abandoned';
|
|
11
11
|
/** Ctrl+C killed a setup child — abandonment, not error. */
|
|
12
12
|
export declare class ConnectInterruptedError extends Error {
|
|
13
13
|
}
|
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
import { randomUUID } from
|
|
2
|
-
import spawn from
|
|
3
|
-
import { CONNECT_ATTEMPT_ENV, isAttemptId, resolvedApiKey, takePendingConnectAttempt, } from
|
|
4
|
-
import { CLI_VERSION } from
|
|
5
|
-
import { detectHarness, detectHumanInitiated } from
|
|
6
|
-
import { errLine, sanitizeLine } from
|
|
7
|
-
import { postConnectEvent, telemetryDisabled } from
|
|
8
|
-
import { installSignalGuard } from
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import spawn from 'cross-spawn';
|
|
3
|
+
import { CONNECT_ATTEMPT_ENV, isAttemptId, resolvedApiKey, takePendingConnectAttempt, } from './auth.js';
|
|
4
|
+
import { CLI_VERSION } from './constants.js';
|
|
5
|
+
import { detectHarness, detectHumanInitiated } from './harness.js';
|
|
6
|
+
import { errLine, sanitizeLine } from './output.js';
|
|
7
|
+
import { postConnectEvent, telemetryDisabled } from './setup-telemetry.js';
|
|
8
|
+
import { installSignalGuard } from './signals.js';
|
|
9
9
|
export const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
|
|
10
10
|
// Mirrors the route's Zod enum; tests both sides pin it.
|
|
11
11
|
export const CONNECT_FAILURE_REASONS = [
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
12
|
+
'harness_not_installed',
|
|
13
|
+
'harness_command_unsupported',
|
|
14
|
+
'harness_too_old',
|
|
15
|
+
'key_auth_unsupported',
|
|
16
|
+
'command_not_found',
|
|
17
|
+
'timeout',
|
|
18
|
+
'spawn_error',
|
|
19
|
+
'nonzero_exit',
|
|
20
|
+
'invalid_config',
|
|
21
|
+
'user_abandoned',
|
|
22
|
+
'process_terminated',
|
|
23
|
+
'unexpected_error',
|
|
24
24
|
];
|
|
25
25
|
/** Ctrl+C killed a setup child — abandonment, not error. */
|
|
26
26
|
export class ConnectInterruptedError extends Error {
|
|
@@ -40,25 +40,25 @@ class PrerequisiteError extends ConnectStepError {
|
|
|
40
40
|
/** A bounded sign-in wait elapsed — unfinished, not broken. */
|
|
41
41
|
export class SignInTimeoutError extends ConnectStepError {
|
|
42
42
|
constructor(message, cause) {
|
|
43
|
-
super(message,
|
|
43
|
+
super(message, 'timeout', { cause });
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
// A maxBuffer overflow is also killed with SIGTERM, and is a failure too.
|
|
47
|
-
const NOT_INTERRUPTIONS = new Set([
|
|
47
|
+
const NOT_INTERRUPTIONS = new Set(['ETIMEDOUT', 'ENOBUFS']);
|
|
48
48
|
export function throwIfInterrupted(result) {
|
|
49
49
|
// spawn.sync kills a timed-out child with SIGTERM, so signal alone would misread a slow
|
|
50
50
|
// network as the user walking away. A timeout is a failure and must report as one.
|
|
51
51
|
const code = result.error?.code;
|
|
52
52
|
if (code && NOT_INTERRUPTIONS.has(code))
|
|
53
53
|
return;
|
|
54
|
-
if (result.signal ===
|
|
55
|
-
throw new ConnectInterruptedError(
|
|
54
|
+
if (result.signal === 'SIGINT') {
|
|
55
|
+
throw new ConnectInterruptedError('Setup interrupted');
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
/** Bounded removal spawn shared by install cleanup and --uninstall; Ctrl+C throws. */
|
|
59
59
|
export function spawnRemoval(command, args, timeoutMs = NON_INTERACTIVE_TIMEOUT_MS) {
|
|
60
60
|
const result = spawn.sync(command, [...args], {
|
|
61
|
-
encoding:
|
|
61
|
+
encoding: 'utf8',
|
|
62
62
|
timeout: timeoutMs,
|
|
63
63
|
});
|
|
64
64
|
throwIfInterrupted(result);
|
|
@@ -69,16 +69,16 @@ export function spawnStepError(message, result) {
|
|
|
69
69
|
throwIfInterrupted(result);
|
|
70
70
|
const code = result.error?.code;
|
|
71
71
|
const reason = !result.error
|
|
72
|
-
?
|
|
73
|
-
: code ===
|
|
74
|
-
?
|
|
75
|
-
: code ===
|
|
76
|
-
?
|
|
77
|
-
:
|
|
72
|
+
? 'nonzero_exit'
|
|
73
|
+
: code === 'ENOENT'
|
|
74
|
+
? 'command_not_found'
|
|
75
|
+
: code === 'ETIMEDOUT'
|
|
76
|
+
? 'timeout'
|
|
77
|
+
: 'spawn_error';
|
|
78
78
|
return new ConnectStepError(message, reason, { cause: result.error });
|
|
79
79
|
}
|
|
80
80
|
export function commandNotFound(error) {
|
|
81
|
-
return error?.code ===
|
|
81
|
+
return error?.code === 'ENOENT';
|
|
82
82
|
}
|
|
83
83
|
/** One command redoes every post-install step for a client. */
|
|
84
84
|
export function finishSetupCommand(client) {
|
|
@@ -96,11 +96,11 @@ export function settle(state, telemetry, stage, detail) {
|
|
|
96
96
|
function installConnectSignalGuard(state, telemetry) {
|
|
97
97
|
return installSignalGuard(async (signal) => {
|
|
98
98
|
if (!state.settled) {
|
|
99
|
-
settle(state, telemetry,
|
|
99
|
+
settle(state, telemetry, 'aborted', {
|
|
100
100
|
failedStage: state.stage,
|
|
101
|
-
failureReason: signal ===
|
|
101
|
+
failureReason: signal === 'SIGINT' ? 'user_abandoned' : 'process_terminated',
|
|
102
102
|
});
|
|
103
|
-
errLine(
|
|
103
|
+
errLine('Setup interrupted — run the command again to finish.');
|
|
104
104
|
}
|
|
105
105
|
// Settled spans the whole post-install phase; the `completed` POST may still be in flight.
|
|
106
106
|
await telemetry.flush();
|
|
@@ -114,22 +114,22 @@ export async function runGuarded(state, telemetry, body) {
|
|
|
114
114
|
}
|
|
115
115
|
catch (error) {
|
|
116
116
|
if (error instanceof ConnectInterruptedError) {
|
|
117
|
-
settle(state, telemetry,
|
|
117
|
+
settle(state, telemetry, 'aborted', {
|
|
118
118
|
failedStage: state.stage,
|
|
119
|
-
failureReason:
|
|
119
|
+
failureReason: 'user_abandoned',
|
|
120
120
|
});
|
|
121
|
-
errLine(
|
|
121
|
+
errLine('Setup interrupted — run the command again to finish.');
|
|
122
122
|
process.exitCode = 130;
|
|
123
123
|
return;
|
|
124
124
|
}
|
|
125
125
|
// An elapsed sign-in wait is abandonment, not breakage.
|
|
126
126
|
if (error instanceof SignInTimeoutError) {
|
|
127
|
-
settle(state, telemetry,
|
|
127
|
+
settle(state, telemetry, 'aborted', { failedStage: state.stage, failureReason: 'timeout' });
|
|
128
128
|
throw error;
|
|
129
129
|
}
|
|
130
|
-
settle(state, telemetry,
|
|
130
|
+
settle(state, telemetry, 'failed', {
|
|
131
131
|
failedStage: state.stage,
|
|
132
|
-
failureReason: error instanceof ConnectStepError ? error.failureReason :
|
|
132
|
+
failureReason: error instanceof ConnectStepError ? error.failureReason : 'unexpected_error',
|
|
133
133
|
...(error instanceof ConnectStepError ? { harnessVersion: error.harnessVersion } : {}),
|
|
134
134
|
});
|
|
135
135
|
throw error;
|
|
@@ -141,38 +141,38 @@ export async function runGuarded(state, telemetry, body) {
|
|
|
141
141
|
}
|
|
142
142
|
export function requireCommandSupport(client) {
|
|
143
143
|
const result = spawn.sync(client.command, client.supportCheck.args, {
|
|
144
|
-
encoding:
|
|
144
|
+
encoding: 'utf8',
|
|
145
145
|
// Agent harnesses export FORCE_COLOR, which makes clients colourise even a piped --help.
|
|
146
|
-
env: { ...process.env, FORCE_COLOR:
|
|
146
|
+
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
|
|
147
147
|
timeout: NON_INTERACTIVE_TIMEOUT_MS,
|
|
148
148
|
});
|
|
149
149
|
// 127 is the shell's not-found code; a wrapper shim with no real binary behind it exits that.
|
|
150
150
|
if (commandNotFound(result.error) || result.status === 127) {
|
|
151
|
-
throw new PrerequisiteError(`${client.displayName} is not installed or not available on PATH.`,
|
|
151
|
+
throw new PrerequisiteError(`${client.displayName} is not installed or not available on PATH.`, 'harness_not_installed', { cause: result.error });
|
|
152
152
|
}
|
|
153
153
|
if (result.error || result.status !== 0) {
|
|
154
154
|
throwIfInterrupted(result);
|
|
155
155
|
// A hung probe is a timeout, not a missing capability.
|
|
156
|
-
if (result.error?.code ===
|
|
157
|
-
throw new ConnectStepError(client.supportCheck.unavailableMessage,
|
|
156
|
+
if (result.error?.code === 'ETIMEDOUT') {
|
|
157
|
+
throw new ConnectStepError(client.supportCheck.unavailableMessage, 'timeout', {
|
|
158
158
|
cause: result.error,
|
|
159
159
|
});
|
|
160
160
|
}
|
|
161
161
|
// It ran and exited non-zero, so the version is still answerable.
|
|
162
|
-
throw new PrerequisiteError(client.supportCheck.unavailableMessage,
|
|
162
|
+
throw new PrerequisiteError(client.supportCheck.unavailableMessage, 'harness_command_unsupported', { cause: result.error, harnessVersion: probeHarnessVersion(client.command) });
|
|
163
163
|
}
|
|
164
164
|
// Belt and braces with the colour env: a client that ignores NO_COLOR still has to match.
|
|
165
|
-
const output = sanitizeLine(`${result.stdout ??
|
|
165
|
+
const output = sanitizeLine(`${result.stdout ?? ''}\n${result.stderr ?? ''}`);
|
|
166
166
|
const listed = (patterns) => patterns.every((pattern) => pattern.test(output));
|
|
167
167
|
const essential = listed(client.supportCheck.patterns);
|
|
168
168
|
const optionalSupported = listed(client.supportCheck.optionalPatterns ?? []);
|
|
169
169
|
const keyAuthPattern = client.supportCheck.keyAuthPattern;
|
|
170
170
|
const keyAuthSupported = keyAuthPattern ? keyAuthPattern.test(output) : true;
|
|
171
|
-
if ((!essential || !optionalSupported || !keyAuthSupported) && process.env[
|
|
172
|
-
errLine(`${client.command} ${client.supportCheck.args.join(
|
|
171
|
+
if ((!essential || !optionalSupported || !keyAuthSupported) && process.env['TINYFISH_DEBUG']) {
|
|
172
|
+
errLine(`${client.command} ${client.supportCheck.args.join(' ')} printed:\n${output.trim()}`);
|
|
173
173
|
}
|
|
174
174
|
if (!essential) {
|
|
175
|
-
throw new PrerequisiteError(client.supportCheck.unavailableMessage,
|
|
175
|
+
throw new PrerequisiteError(client.supportCheck.unavailableMessage, 'harness_too_old', {
|
|
176
176
|
harnessVersion: probeHarnessVersion(client.command),
|
|
177
177
|
});
|
|
178
178
|
}
|
|
@@ -184,16 +184,16 @@ export function requireCommandSupport(client) {
|
|
|
184
184
|
}
|
|
185
185
|
/** Best-effort; the server rejects non-printable characters and >32 chars. */
|
|
186
186
|
function probeHarnessVersion(command) {
|
|
187
|
-
const result = spawn.sync(command, [
|
|
188
|
-
encoding:
|
|
187
|
+
const result = spawn.sync(command, ['--version'], {
|
|
188
|
+
encoding: 'utf8',
|
|
189
189
|
timeout: NON_INTERACTIVE_TIMEOUT_MS,
|
|
190
190
|
});
|
|
191
191
|
if (result.error || result.status !== 0)
|
|
192
192
|
return undefined;
|
|
193
193
|
// First line only: sanitizeLine keeps newlines, so stripping splices lines.
|
|
194
|
-
const version = sanitizeLine(result.stdout ??
|
|
195
|
-
.split(
|
|
196
|
-
.replace(/[^\x20-\x7E]/g,
|
|
194
|
+
const version = sanitizeLine(result.stdout ?? '')
|
|
195
|
+
.split('\n')[0]
|
|
196
|
+
.replace(/[^\x20-\x7E]/g, '')
|
|
197
197
|
.trim();
|
|
198
198
|
return version ? version.slice(0, 32) : undefined;
|
|
199
199
|
}
|
|
@@ -215,7 +215,7 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
215
215
|
// A setup-page id (via --url, the env var, or one parked by login) joins "copied the
|
|
216
216
|
// command" to this install attempt.
|
|
217
217
|
const attemptId = opts?.attemptId ?? seeded ?? opts?.fallbackAttemptId ?? randomUUID();
|
|
218
|
-
const endpoint = new URL(
|
|
218
|
+
const endpoint = new URL('/api/cli/connect-event', mcpUrl).toString();
|
|
219
219
|
const pending = [];
|
|
220
220
|
let lastTrackAt;
|
|
221
221
|
let harnessVersion;
|
|
@@ -230,7 +230,7 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
230
230
|
body,
|
|
231
231
|
attempts: 2,
|
|
232
232
|
apiKey: resolvedApiKey(opts?.apiKey),
|
|
233
|
-
label:
|
|
233
|
+
label: 'connect',
|
|
234
234
|
});
|
|
235
235
|
}
|
|
236
236
|
return {
|
|
@@ -258,7 +258,7 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
258
258
|
phase: detail?.phase,
|
|
259
259
|
failure_reason: detail?.failureReason,
|
|
260
260
|
// Terminal rows only: rescue checkpoints stay wire-identical to normal ones.
|
|
261
|
-
fallback_outcome: stage ===
|
|
261
|
+
fallback_outcome: stage === 'failed' || stage === 'aborted' ? fallbackOutcome : undefined,
|
|
262
262
|
harness_version: detail?.harnessVersion ?? harnessVersion,
|
|
263
263
|
auth_mode: detail?.authMode,
|
|
264
264
|
harness_degraded: detail?.harnessDegraded,
|
|
@@ -269,11 +269,11 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
269
269
|
// Same signal the usage events carry, so TTY and headless connects can be split.
|
|
270
270
|
is_human_initiated: detectHumanInitiated(),
|
|
271
271
|
// "none", not absent — absent has to keep meaning "CLI too old".
|
|
272
|
-
calling_harness: detectHarness() ??
|
|
272
|
+
calling_harness: detectHarness() ?? 'none',
|
|
273
273
|
})).catch((error) => {
|
|
274
274
|
// postConnectEvent is total, so this only fires if something inside deliver starts
|
|
275
275
|
// throwing. Same channel postConnectEvent uses for its own failures.
|
|
276
|
-
if (!process.env[
|
|
276
|
+
if (!process.env['TINYFISH_DEBUG'])
|
|
277
277
|
return;
|
|
278
278
|
errLine(`connect telemetry failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
279
279
|
}));
|