@tiny-fish/cli 0.52.5-next.436 → 0.52.5-next.438
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/README.md +2 -1
- package/dist/commands/connect.js +8 -0
- package/dist/lib/config/connect-config-file.d.ts +1 -0
- package/dist/lib/connect-all-auth.js +16 -1
- package/dist/lib/connect-all.d.ts +2 -0
- package/dist/lib/connect-all.js +2 -1
- package/dist/lib/connect-clients.d.ts +1 -2
- package/dist/lib/connect-clients.js +3 -9
- package/dist/lib/connect-native.js +62 -26
- package/dist/lib/connect-runtime.d.ts +1 -0
- package/dist/lib/connect-runtime.js +2 -0
- package/dist/lib/connect-steps.d.ts +2 -0
- package/dist/lib/harness/harness-spec.d.ts +7 -1
- package/dist/lib/harness/harness-spec.js +4 -1
- package/dist/lib/setup-telemetry.d.ts +1 -1
- package/dist/lib/setup-telemetry.js +2 -1
- package/dist/lib/sign-in-url.d.ts +8 -0
- package/dist/lib/sign-in-url.js +35 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,11 +26,12 @@ anything. Terminals without raw-key support get a numbered prompt instead.
|
|
|
26
26
|
Try Search without an account; run `tinyfish auth login` and reconnect to unlock all tools:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
npx -y @tiny-fish/cli@latest connect opencode --launch
|
|
30
29
|
npx -y @tiny-fish/cli@latest connect hermes --launch
|
|
31
30
|
npx -y @tiny-fish/cli@latest connect omp --launch
|
|
32
31
|
```
|
|
33
32
|
|
|
33
|
+
`connect opencode` signs you in to a TinyFish account instead: it opens your browser, or prints a sign-in link when an agent runs it.
|
|
34
|
+
|
|
34
35
|
Without a terminal (agents, CI), bare `connect` behaves exactly like `tinyfish connect --all`
|
|
35
36
|
and says so on its first line: harnesses that can take your API key (via `TINYFISH_API_KEY` or
|
|
36
37
|
`--api-key`) connect first, then the first harness that needs a browser sign-in opens your
|
package/dist/commands/connect.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
+
import { Option } from 'commander';
|
|
3
4
|
import { saveConnectConfigDir, validateKeyFormat, validatedApiKey } from '../lib/auth.js';
|
|
4
5
|
import { runConnectAll } from '../lib/connect-all.js';
|
|
5
6
|
import { ALL_HARNESSES } from '../lib/harness/harness-detect.js';
|
|
@@ -10,6 +11,8 @@ import { z } from 'zod';
|
|
|
10
11
|
import { gateApiKey } from '../lib/connect-preflight.js';
|
|
11
12
|
import { DEFAULT_MCP_URL } from '../lib/mcp-endpoint.js';
|
|
12
13
|
import { connectHarness } from '../lib/connect-harness.js';
|
|
14
|
+
// The route 400s anything else, losing the event; drop it instead.
|
|
15
|
+
const TRIGGER_FORMAT = /^[a-z0-9-]{1,32}$/;
|
|
13
16
|
/** Cline reads CLINE_DIR itself, so setting it steers the add, the remove and our own probe. */
|
|
14
17
|
function applyConfigDir(client, configDir) {
|
|
15
18
|
if (client !== 'cline') {
|
|
@@ -60,6 +63,7 @@ async function connectManyPath(options, mcpUrl, attemptId) {
|
|
|
60
63
|
apiKey: options.apiKey,
|
|
61
64
|
mcpUrl,
|
|
62
65
|
attemptId,
|
|
66
|
+
trigger: options.trigger,
|
|
63
67
|
dryRun: options.dryRun ?? false,
|
|
64
68
|
uninstall: options.uninstall ?? false,
|
|
65
69
|
noLaunch: options.skipLaunch === true,
|
|
@@ -80,6 +84,7 @@ async function connectSingleClient(client, options, mcpUrl, attemptId) {
|
|
|
80
84
|
launch: options.launch ?? false,
|
|
81
85
|
configDir: options.configDir ? applyConfigDir(client, options.configDir) : undefined,
|
|
82
86
|
attemptId,
|
|
87
|
+
trigger: options.trigger,
|
|
83
88
|
// A key that the install cannot use leaves only a browser hop no agent can finish.
|
|
84
89
|
keyAuthOnly: !detectHumanInitiated() && !!validatedApiKey(options.apiKey),
|
|
85
90
|
authTimeoutMs: detectHumanInitiated() ? undefined : 90_000,
|
|
@@ -117,10 +122,13 @@ export function registerConnect(program) {
|
|
|
117
122
|
.option('--url <mcpUrl>', 'MCP endpoint override')
|
|
118
123
|
.option('--verbose', 'Print the full npm and skills output instead of only on failure')
|
|
119
124
|
.option('--config-dir <path>', 'Cline only: config dir of a relocated install (~/.cline)')
|
|
125
|
+
.addOption(new Option('--trigger <trigger>', 'What prompted this connect, for attribution').hideHelp())
|
|
120
126
|
.action(async (client, options) => {
|
|
121
127
|
if (options.apiKey && !validateKeyFormat(options.apiKey)) {
|
|
122
128
|
throw new Error('Invalid --api-key value');
|
|
123
129
|
}
|
|
130
|
+
if (options.trigger && !TRIGGER_FORMAT.test(options.trigger))
|
|
131
|
+
options.trigger = undefined;
|
|
124
132
|
const { mcpUrl, attemptId } = parseMcpUrlOption(options);
|
|
125
133
|
if (options.all) {
|
|
126
134
|
if (client) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NATIVE_BY_HARNESS } from './connect-clients.js';
|
|
2
2
|
import { harnessSpec } from './harness/harness-spec.js';
|
|
3
|
-
import { ConnectInterruptedError, ConnectStepError, SignInTimeoutError, } from './connect-runtime.js';
|
|
3
|
+
import { ConnectInterruptedError, ConnectStepError, SignInTimeoutError, probeSupportVariant, } from './connect-runtime.js';
|
|
4
4
|
import { ALL_HARNESSES, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, } from './harness/harness-detect.js';
|
|
5
5
|
import { errLine } from './output.js';
|
|
6
6
|
// Must undercut agent tool-call budgets (Claude Code Bash defaults to 120s).
|
|
@@ -23,6 +23,19 @@ const BROWSER_SIGN_IN = new Set(ALL_HARNESSES.filter((harness) => {
|
|
|
23
23
|
}));
|
|
24
24
|
// Derived: these refuse a keyless install, so no sign-in helps.
|
|
25
25
|
const KEY_REQUIRED = new Set(ALL_HARNESSES.filter((harness) => !!harnessSpec(harness).keyRequired));
|
|
26
|
+
/** OpenCode 2.x prints a sign-in link and exits, so it waits on no browser. */
|
|
27
|
+
function printsSignInLink(harness) {
|
|
28
|
+
const native = NATIVE_BY_HARNESS.get(harness);
|
|
29
|
+
if (!native?.detachedLoginVariant)
|
|
30
|
+
return false;
|
|
31
|
+
try {
|
|
32
|
+
return probeSupportVariant(native) === native.detachedLoginVariant;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Routing only: connect itself reports the probe failure.
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
26
39
|
function headlessGate(base, oauthBudget, fixCommand) {
|
|
27
40
|
const { harness } = base;
|
|
28
41
|
// A CLI-login sign-in is interactive-only — unboundable headless.
|
|
@@ -33,6 +46,8 @@ function headlessGate(base, oauthBudget, fixCommand) {
|
|
|
33
46
|
spentBrowserSlot: false,
|
|
34
47
|
};
|
|
35
48
|
}
|
|
49
|
+
if (printsSignInLink(harness))
|
|
50
|
+
return { headlessAuth: true, spentBrowserSlot: false };
|
|
36
51
|
let spentBrowserSlot = false;
|
|
37
52
|
if (BROWSER_SIGN_IN.has(harness)) {
|
|
38
53
|
// One browser sign-in per run; the rest wait for a re-run.
|
|
@@ -33,6 +33,8 @@ export interface ConnectAllOptions {
|
|
|
33
33
|
verbose?: boolean;
|
|
34
34
|
/** Setup-page id riding in on --url; joins "copied the command" to this run. */
|
|
35
35
|
attemptId?: string;
|
|
36
|
+
/** What prompted the connect (`--trigger`), stamped on every event. */
|
|
37
|
+
trigger?: string;
|
|
36
38
|
}
|
|
37
39
|
/** The harness has never created its dir, so connecting would build one it does not own. */
|
|
38
40
|
export declare function needsFirstRun(detection: HarnessDetection): boolean;
|
package/dist/lib/connect-all.js
CHANGED
|
@@ -94,6 +94,7 @@ async function processHarness(detection, opts, prompt, oauthBudget, hoistedCliIn
|
|
|
94
94
|
mcpUrl: opts.mcpUrl,
|
|
95
95
|
launch: false,
|
|
96
96
|
verbose: opts.verbose,
|
|
97
|
+
trigger: opts.trigger,
|
|
97
98
|
keyAuthOnly: keyed && !isTTY,
|
|
98
99
|
authTimeoutMs: headlessAuth ? OAUTH_SIGN_IN_TIMEOUT_MS : undefined,
|
|
99
100
|
hoistedCliInstall,
|
|
@@ -379,7 +380,7 @@ export async function runConnectAll(opts) {
|
|
|
379
380
|
verify_depth: r.verifyDepth ?? null,
|
|
380
381
|
verify_ok: r.verifyOk ?? null,
|
|
381
382
|
}));
|
|
382
|
-
await sendSetupCompleted(opts.mcpUrl, harnessTelemetry, validatedApiKey(opts.apiKey), mode, opts.attemptId);
|
|
383
|
+
await sendSetupCompleted(opts.mcpUrl, harnessTelemetry, validatedApiKey(opts.apiKey), mode, opts.attemptId, opts.trigger);
|
|
383
384
|
// After the summary and before the launch, which is where the user is reading.
|
|
384
385
|
emitNotice();
|
|
385
386
|
}
|
|
@@ -39,7 +39,7 @@ interface SeededInstall {
|
|
|
39
39
|
tag?: string;
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
-
interface BaseMcpClient extends SupportedCommand, Pick<HarnessSpec, 'skillAgent' | 'nonInteractiveAdd' | 'signInHint' | 'degradedAuthNotes' | 'postConnectNote' | 'verifyListed'> {
|
|
42
|
+
interface BaseMcpClient extends SupportedCommand, Pick<HarnessSpec, 'skillAgent' | 'nonInteractiveAdd' | 'signInHint' | 'detachedLoginVariant' | 'cliKeyOptional' | 'degradedAuthNotes' | 'postConnectNote' | 'verifyListed'> {
|
|
43
43
|
connectClient: Exclude<AgentClient, 'openclaw'>;
|
|
44
44
|
/** Replaces `addArgs` when a stored key is available: key auth removes the OAuth hop. */
|
|
45
45
|
keyAuth?: {
|
|
@@ -49,7 +49,6 @@ interface BaseMcpClient extends SupportedCommand, Pick<HarnessSpec, 'skillAgent'
|
|
|
49
49
|
/** Writes into the harness's store, so connect verifies the key first. */
|
|
50
50
|
seedKey?: (apiKey: string) => SeededInstall;
|
|
51
51
|
};
|
|
52
|
-
keylessAddArgs?: (mcpUrl: string) => string[];
|
|
53
52
|
prepareKeylessInstall?: () => DirectInstall;
|
|
54
53
|
loginArgs?: string[];
|
|
55
54
|
removals: {
|
|
@@ -4,7 +4,7 @@ import { detectHumanInitiated } from './harness.js';
|
|
|
4
4
|
import { harnessConfigPath } from './harness/harness-detect.js';
|
|
5
5
|
import { errLine, sanitizeLine, warnLine } from './output.js';
|
|
6
6
|
import { ConnectInterruptedError, ConnectStepError, spawnStepError, } from './connect-runtime.js';
|
|
7
|
-
import {
|
|
7
|
+
import { TINYFISH_API_KEY_VAR } from './constants.js';
|
|
8
8
|
import { HARNESS_SPECS, NATIVE_HARNESSES, harnessSpec, } from './harness/harness-spec.js';
|
|
9
9
|
import { HERMES_KEY_VAR, captureHermesKeyRestore, hermesEnvPath, resolveHermesHome, writeHermesKey, } from './hermes-env.js';
|
|
10
10
|
import { hermesConfigPath, readHermesEntry } from './config/hermes-config.js';
|
|
@@ -256,13 +256,6 @@ function specKeyAuth(spec) {
|
|
|
256
256
|
addArgs: (mcpUrl, apiKey) => [...specAddArgs(spec, mcpUrl), '--header', value(apiKey)],
|
|
257
257
|
};
|
|
258
258
|
}
|
|
259
|
-
function specKeylessAddArgs(spec) {
|
|
260
|
-
const { header } = spec;
|
|
261
|
-
if (!spec.keylessFallback || !header)
|
|
262
|
-
return undefined;
|
|
263
|
-
const value = `${TINYFISH_ACCESS_MODE_HEADER}${header.sep}${TINYFISH_KEYLESS_ACCESS_MODE}`;
|
|
264
|
-
return (mcpUrl) => specAddArgs(spec, mcpUrl).concat('--header', value);
|
|
265
|
-
}
|
|
266
259
|
/** Descriptor behaviour the spec cannot hold; every other field is spec data. */
|
|
267
260
|
const HARNESS_OVERRIDES = {
|
|
268
261
|
cline: { launchWalkthrough: launchCline },
|
|
@@ -291,9 +284,10 @@ function toNativeClient(harness) {
|
|
|
291
284
|
skillAgent: spec.skillAgent,
|
|
292
285
|
nonInteractiveAdd: spec.nonInteractiveAdd,
|
|
293
286
|
keyAuth,
|
|
294
|
-
keylessAddArgs: specKeylessAddArgs(spec),
|
|
295
287
|
prepareKeylessInstall: overrides.prepareKeylessInstall,
|
|
296
288
|
signInHint: spec.signInHint,
|
|
289
|
+
detachedLoginVariant: spec.detachedLoginVariant,
|
|
290
|
+
cliKeyOptional: spec.cliKeyOptional,
|
|
297
291
|
degradedAuthNotes: spec.degradedAuthNotes,
|
|
298
292
|
removals: spec.removals ?? [
|
|
299
293
|
{
|
|
@@ -4,6 +4,7 @@ import { KEYLESS_ONBOARDING_PROMPT, launchNativeMcpClient, } from './connect-cli
|
|
|
4
4
|
import { captureStdio, capturedOutput, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from './cli-install.js';
|
|
5
5
|
import { ensureCliAuthenticated } from './connect-auth.js';
|
|
6
6
|
import { installWebSkill } from './skill-install.js';
|
|
7
|
+
import { waitForSignInUrl } from './sign-in-url.js';
|
|
7
8
|
import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, runGuarded, settle, SignInTimeoutError, spawnRemoval, spawnStepError, } from './connect-runtime.js';
|
|
8
9
|
import { DASHBOARD_URL, TINYFISH_API_KEY_VAR } from './constants.js';
|
|
9
10
|
import { strayProjectKeyFiles } from './config/opencode-config.js';
|
|
@@ -194,20 +195,12 @@ async function prepareInstallAuth(client, options, keyAuthSupported, matchedVari
|
|
|
194
195
|
// Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
|
|
195
196
|
const storedKey = validatedApiKey(options.apiKey);
|
|
196
197
|
const keyedAdd = resolveKeyedAdd(client, storedKey, keyAuthSupported, options.keyAuthOnly === true, options.deferOutro);
|
|
197
|
-
const keylessArgs = !storedKey ? client.keylessAddArgs : undefined;
|
|
198
198
|
const directInstall = !storedKey ? client.prepareKeylessInstall?.() : undefined;
|
|
199
|
-
|
|
199
|
+
const keyless = !!directInstall;
|
|
200
200
|
let buildAddArgs;
|
|
201
201
|
if (keyedAdd) {
|
|
202
202
|
buildAddArgs = (mcpUrl) => keyedAdd.spec.addArgs(mcpUrl, keyedAdd.key);
|
|
203
203
|
}
|
|
204
|
-
else if (keylessArgs && keyAuthSupported) {
|
|
205
|
-
buildAddArgs = keylessArgs;
|
|
206
|
-
keyless = true;
|
|
207
|
-
}
|
|
208
|
-
else if (keylessArgs && options.authTimeoutMs !== undefined) {
|
|
209
|
-
refuseKeylessInstall(client, false, true);
|
|
210
|
-
}
|
|
211
204
|
else if (!directInstall) {
|
|
212
205
|
buildAddArgs = keylessAddArgs(client, keyAuthSupported);
|
|
213
206
|
}
|
|
@@ -302,6 +295,24 @@ function performLoginStep(client, pendingLogin, options) {
|
|
|
302
295
|
throw signInStepError(`Could not authenticate TinyFish in ${client.displayName}`, loginResult, options.authTimeoutMs !== undefined);
|
|
303
296
|
}
|
|
304
297
|
}
|
|
298
|
+
// Upper bound only: the URL normally prints within two seconds.
|
|
299
|
+
const SIGN_IN_URL_WAIT_MS = 15_000;
|
|
300
|
+
// 2.x's service lags `mcp add` by under a second (measured on 2.1.4).
|
|
301
|
+
const STALE_REGISTRATION_RETRIES = 3;
|
|
302
|
+
const STALE_REGISTRATION_DELAY_MS = 1_000;
|
|
303
|
+
/** Relays the sign-in link; the harness service finishes it after we exit. */
|
|
304
|
+
async function printDetachedLoginUrl(client, pendingLogin) {
|
|
305
|
+
errLine('Starting TinyFish sign-in...');
|
|
306
|
+
let run = await waitForSignInUrl(client.command, pendingLogin, SIGN_IN_URL_WAIT_MS);
|
|
307
|
+
for (let retry = 0; retry < STALE_REGISTRATION_RETRIES && run.output.includes('Integration not found'); retry++) {
|
|
308
|
+
await new Promise((resolve) => setTimeout(resolve, STALE_REGISTRATION_DELAY_MS));
|
|
309
|
+
run = await waitForSignInUrl(client.command, pendingLogin, SIGN_IN_URL_WAIT_MS);
|
|
310
|
+
}
|
|
311
|
+
if (!run.url) {
|
|
312
|
+
throw signInStepError(`Could not start TinyFish sign-in in ${client.displayName}`, run.result, true);
|
|
313
|
+
}
|
|
314
|
+
errLine(`Sign in to TinyFish: ${run.url}`);
|
|
315
|
+
}
|
|
305
316
|
function announceAuthNotes(useKeyAuth, degradedNotes, seededNote) {
|
|
306
317
|
if (useKeyAuth) {
|
|
307
318
|
errLine(degradedNotes?.keyed ?? 'Using your stored TinyFish API key — no browser sign-in needed.');
|
|
@@ -314,7 +325,20 @@ function announceAuthNotes(useKeyAuth, degradedNotes, seededNote) {
|
|
|
314
325
|
errLine(degradedNotes.deferred);
|
|
315
326
|
}
|
|
316
327
|
}
|
|
317
|
-
|
|
328
|
+
/** Returns whether sign-in is still deferred afterwards. */
|
|
329
|
+
async function runPendingLogin(client, pendingLogin, run) {
|
|
330
|
+
run.state.stage = 'client_oauth';
|
|
331
|
+
if (run.ctx.detachedLogin) {
|
|
332
|
+
await printDetachedLoginUrl(client, pendingLogin);
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
performLoginStep(client, pendingLogin, run.options);
|
|
336
|
+
if (run.ctx.probeAuthenticated?.() === false)
|
|
337
|
+
return true;
|
|
338
|
+
run.telemetry.track('checkpoint', { phase: 'oauth_done' });
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
async function completeAuthFlow(client, options, state, telemetry, ctx) {
|
|
318
342
|
const { useKeyAuth, useKeyless, loginDegraded, loginArgs, probeAuthenticated } = ctx;
|
|
319
343
|
if (useKeyless) {
|
|
320
344
|
errLine('Using keyless TinyFish Search — no account or browser sign-in needed.');
|
|
@@ -329,15 +353,9 @@ function completeAuthFlow(client, options, state, telemetry, ctx) {
|
|
|
329
353
|
// A probed client signs in again only when its inline OAuth is known to have been abandoned.
|
|
330
354
|
const retryLogin = probeAuthenticated ? addSignedIn === false : true;
|
|
331
355
|
const pendingLogin = !useKeyAuth && retryLogin ? loginArgs : undefined;
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
performLoginStep(client, pendingLogin, options);
|
|
336
|
-
if (probeAuthenticated?.() === false)
|
|
337
|
-
signInDeferred = true;
|
|
338
|
-
else
|
|
339
|
-
telemetry.track('checkpoint', { phase: 'oauth_done' });
|
|
340
|
-
}
|
|
356
|
+
const signInDeferred = pendingLogin
|
|
357
|
+
? await runPendingLogin(client, pendingLogin, { options, state, telemetry, ctx })
|
|
358
|
+
: !useKeyAuth && !addSignedIn;
|
|
341
359
|
const authMode = useKeyAuth
|
|
342
360
|
? AuthMode.ApiKey
|
|
343
361
|
: signInDeferred
|
|
@@ -380,11 +398,12 @@ export async function connectNativeMcpClient(unpinned, options) {
|
|
|
380
398
|
for (const stray of twoPointX ? strayProjectKeyFiles() : []) {
|
|
381
399
|
warnLine(opencodeStrayKeyNote(stray, DASHBOARD_URL));
|
|
382
400
|
}
|
|
383
|
-
const { authMode, signInDeferred } = completeAuthFlow(client, options, state, telemetry, {
|
|
401
|
+
const { authMode, signInDeferred } = await completeAuthFlow(client, options, state, telemetry, {
|
|
384
402
|
useKeyAuth,
|
|
385
403
|
useKeyless,
|
|
386
404
|
loginDegraded,
|
|
387
405
|
loginArgs,
|
|
406
|
+
detachedLogin: !!client.detachedLoginVariant && matchedVariant === client.detachedLoginVariant,
|
|
388
407
|
probeAuthenticated,
|
|
389
408
|
seededNote,
|
|
390
409
|
});
|
|
@@ -411,6 +430,28 @@ function connectedLine(client, signInDeferred) {
|
|
|
411
430
|
return (`TinyFish is connected. Open ${client.displayName}; if it is already running, ` +
|
|
412
431
|
`${RELOAD_ACTION[client.connectClient]} to pick up the tools.`);
|
|
413
432
|
}
|
|
433
|
+
/** Right after OAuth, a key prompt or failure reads as a second sign-in. */
|
|
434
|
+
function offerCliSignIn(client, options, telemetry) {
|
|
435
|
+
try {
|
|
436
|
+
ensureCliAuthenticated(client.connectClient, options.apiKey, { interactiveLogin: false });
|
|
437
|
+
telemetry.track('checkpoint', { phase: 'authenticated' });
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
if (error instanceof ConnectInterruptedError)
|
|
441
|
+
throw error;
|
|
442
|
+
errLine('To use TinyFish from the terminal too, run: tinyfish auth login');
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function authenticateCli(client, options, state, telemetry) {
|
|
446
|
+
if (client.cliKeyOptional && state.authMode !== AuthMode.ApiKey) {
|
|
447
|
+
offerCliSignIn(client, options, telemetry);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
ensureCliAuthenticated(client.connectClient, options.apiKey, {
|
|
451
|
+
interactiveLogin: options.authTimeoutMs === undefined,
|
|
452
|
+
});
|
|
453
|
+
telemetry.track('checkpoint', { phase: 'authenticated' });
|
|
454
|
+
}
|
|
414
455
|
// Runs after `completed` settled: steps fail alone, the rest still run.
|
|
415
456
|
async function runPostInstallSteps(client, options, state, telemetry, signInDeferred, seededHome) {
|
|
416
457
|
let failed = false;
|
|
@@ -450,12 +491,7 @@ async function runPostInstallSteps(client, options, state, telemetry, signInDefe
|
|
|
450
491
|
});
|
|
451
492
|
}
|
|
452
493
|
if (state.authMode !== AuthMode.Keyless) {
|
|
453
|
-
await attempt('authentication', () =>
|
|
454
|
-
ensureCliAuthenticated(client.connectClient, options.apiKey, {
|
|
455
|
-
interactiveLogin: options.authTimeoutMs === undefined,
|
|
456
|
-
});
|
|
457
|
-
telemetry.track('checkpoint', { phase: 'authenticated' });
|
|
458
|
-
});
|
|
494
|
+
await attempt('authentication', () => authenticateCli(client, options, state, telemetry));
|
|
459
495
|
}
|
|
460
496
|
if (client.postConnectNote)
|
|
461
497
|
errLine(client.postConnectNote);
|
|
@@ -334,6 +334,7 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
334
334
|
// command" to this install attempt.
|
|
335
335
|
const attemptId = opts?.attemptId ?? seeded ?? opts?.fallbackAttemptId ?? randomUUID();
|
|
336
336
|
const endpoint = new URL('/api/cli/connect-event', mcpUrl).toString();
|
|
337
|
+
const trigger = opts?.trigger;
|
|
337
338
|
const pending = [];
|
|
338
339
|
let lastTrackAt;
|
|
339
340
|
let harnessVersion;
|
|
@@ -378,6 +379,7 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
378
379
|
calling_harness: detectHarness() ?? 'none',
|
|
379
380
|
cli_invocation_id: CLI_INVOCATION_ID,
|
|
380
381
|
runtime_env: detectRuntimeEnv(),
|
|
382
|
+
connect_trigger: trigger,
|
|
381
383
|
});
|
|
382
384
|
}
|
|
383
385
|
return {
|
|
@@ -6,6 +6,8 @@ export interface NativeConnectOptions {
|
|
|
6
6
|
mcpUrl: string;
|
|
7
7
|
launch: boolean;
|
|
8
8
|
attemptId?: string;
|
|
9
|
+
/** What prompted the connect (`--trigger`), stamped on every event. */
|
|
10
|
+
trigger?: string;
|
|
9
11
|
/** Headless: refuse rather than fall back to an unfinishable browser sign-in. */
|
|
10
12
|
keyAuthOnly?: boolean;
|
|
11
13
|
/** Headless --all: bounds sign-in spawns and bars interactive CLI login. */
|
|
@@ -56,6 +56,10 @@ export interface HarnessSpec {
|
|
|
56
56
|
keyRequired?: true;
|
|
57
57
|
keylessFallback?: true;
|
|
58
58
|
loginArgs?: string[];
|
|
59
|
+
/** After this harness's OAuth, connect never prompts for or requires the CLI key. */
|
|
60
|
+
cliKeyOptional?: true;
|
|
61
|
+
/** With this support variant, the sign-in callback outlives `loginArgs`. */
|
|
62
|
+
detachedLoginVariant?: string;
|
|
59
63
|
/** Default: one `mcp remove tinyfish` labeled from displayName. */
|
|
60
64
|
removals?: {
|
|
61
65
|
args: string[];
|
|
@@ -295,7 +299,6 @@ export declare const HARNESS_SPECS: {
|
|
|
295
299
|
reloadAction: string;
|
|
296
300
|
skillAgent: "opencode";
|
|
297
301
|
nonInteractiveAdd: true;
|
|
298
|
-
keylessFallback: true;
|
|
299
302
|
supportCheck: {
|
|
300
303
|
args: string[];
|
|
301
304
|
patterns: RegExp[];
|
|
@@ -310,6 +313,9 @@ export declare const HARNESS_SPECS: {
|
|
|
310
313
|
sep: "=";
|
|
311
314
|
};
|
|
312
315
|
loginArgs: string[];
|
|
316
|
+
detachedLoginVariant: string;
|
|
317
|
+
cliKeyOptional: true;
|
|
318
|
+
signInHint: string;
|
|
313
319
|
removals: never[];
|
|
314
320
|
postConnectNote: string;
|
|
315
321
|
verifyListed: true;
|
|
@@ -221,7 +221,6 @@ export const HARNESS_SPECS = {
|
|
|
221
221
|
reloadAction: 'restart it',
|
|
222
222
|
skillAgent: 'opencode',
|
|
223
223
|
nonInteractiveAdd: true,
|
|
224
|
-
keylessFallback: true,
|
|
225
224
|
supportCheck: {
|
|
226
225
|
args: ['mcp', 'add', '--help'],
|
|
227
226
|
patterns: [/--url(?:\s|$)/m],
|
|
@@ -237,6 +236,10 @@ export const HARNESS_SPECS = {
|
|
|
237
236
|
header: { name: 'X-API-Key', sep: '=' },
|
|
238
237
|
// OpenCode writes its config itself; `mcp auth` is the separate OAuth step.
|
|
239
238
|
loginArgs: ['mcp', 'auth', 'tinyfish'],
|
|
239
|
+
// 2.x's background service, not `mcp auth`, catches the OAuth callback.
|
|
240
|
+
detachedLoginVariant: '--global',
|
|
241
|
+
cliKeyOptional: true,
|
|
242
|
+
signInHint: 'TinyFish is configured in OpenCode. Open the sign-in link above, then restart OpenCode.',
|
|
240
243
|
// `opencode mcp add` upserts by name and ships no `mcp remove`.
|
|
241
244
|
removals: [],
|
|
242
245
|
postConnectNote: OPENCODE_MODEL_NOTE,
|
|
@@ -97,7 +97,7 @@ export type UpgradeOutcome = 'updated' | 'failed' | 'interrupted';
|
|
|
97
97
|
export type UpgradeScope = 'both' | 'cli' | 'skill';
|
|
98
98
|
/** `toVersion` is null when the run was interrupted or the installed version could not be read. */
|
|
99
99
|
export declare function sendUpgradeCompleted(fromVersion: string, toVersion: string | null, outcome: UpgradeOutcome, scope: UpgradeScope, apiKey?: string): Promise<void>;
|
|
100
|
-
export declare function sendSetupCompleted(mcpUrl: string, harnesses: HarnessResult[], apiKey?: string, mode?: SetupMode, attemptId?: string): Promise<void>;
|
|
100
|
+
export declare function sendSetupCompleted(mcpUrl: string, harnesses: HarnessResult[], apiKey?: string, mode?: SetupMode, attemptId?: string, trigger?: string): Promise<void>;
|
|
101
101
|
export declare const DOCTOR_ERROR_CLASSES: readonly ["command_not_found", "timeout", "spawn_error", "nonzero_exit", "unexpected_error"];
|
|
102
102
|
export type DoctorErrorClass = (typeof DOCTOR_ERROR_CLASSES)[number];
|
|
103
103
|
/** Derived, not read: the catch is generic, so only the code is trustworthy. */
|
|
@@ -183,7 +183,7 @@ export async function sendUpgradeCompleted(fromVersion, toVersion, outcome, scop
|
|
|
183
183
|
apiKey,
|
|
184
184
|
});
|
|
185
185
|
}
|
|
186
|
-
export async function sendSetupCompleted(mcpUrl, harnesses, apiKey, mode, attemptId) {
|
|
186
|
+
export async function sendSetupCompleted(mcpUrl, harnesses, apiKey, mode, attemptId, trigger) {
|
|
187
187
|
// Ahead of the parse so an opt-out run prints no telemetry debug.
|
|
188
188
|
if (telemetryDisabled())
|
|
189
189
|
return;
|
|
@@ -204,6 +204,7 @@ export async function sendSetupCompleted(mcpUrl, harnesses, apiKey, mode, attemp
|
|
|
204
204
|
...payload,
|
|
205
205
|
cli_invocation_id: CLI_INVOCATION_ID,
|
|
206
206
|
runtime_env: detectRuntimeEnv(),
|
|
207
|
+
...(trigger ? { connect_trigger: trigger } : {}),
|
|
207
208
|
});
|
|
208
209
|
}
|
|
209
210
|
catch (e) {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SpawnStepResult } from './connect-runtime.js';
|
|
2
|
+
export interface SignInUrlRun {
|
|
3
|
+
url?: string;
|
|
4
|
+
output: string;
|
|
5
|
+
result: SpawnStepResult;
|
|
6
|
+
}
|
|
7
|
+
/** Resolves on the first printed URL; SIGKILL, because SIGTERM cancels OpenCode's sign-in. */
|
|
8
|
+
export declare function waitForSignInUrl(command: string, args: readonly string[], timeoutMs: number): Promise<SignInUrlRun>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import spawn from 'cross-spawn';
|
|
2
|
+
// Trailing whitespace proves the URL arrived whole, not cut mid-chunk.
|
|
3
|
+
const SIGN_IN_URL = /https:\/\/\S+(?=\s)/;
|
|
4
|
+
/** Resolves on the first printed URL; SIGKILL, because SIGTERM cancels OpenCode's sign-in. */
|
|
5
|
+
export function waitForSignInUrl(command, args, timeoutMs) {
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
const child = spawn(command, [...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
8
|
+
let output = '';
|
|
9
|
+
let settled = false;
|
|
10
|
+
const settle = (result) => {
|
|
11
|
+
if (settled)
|
|
12
|
+
return;
|
|
13
|
+
settled = true;
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
child.kill('SIGKILL');
|
|
16
|
+
resolve({
|
|
17
|
+
url: output.match(SIGN_IN_URL)?.[0],
|
|
18
|
+
output,
|
|
19
|
+
result: { ...result, stdout: output },
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
settle({ status: null, error: Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }) });
|
|
24
|
+
}, timeoutMs);
|
|
25
|
+
const onData = (chunk) => {
|
|
26
|
+
output += chunk.toString();
|
|
27
|
+
if (SIGN_IN_URL.test(output))
|
|
28
|
+
settle({ status: 0 });
|
|
29
|
+
};
|
|
30
|
+
child.stdout?.on('data', onData);
|
|
31
|
+
child.stderr?.on('data', onData);
|
|
32
|
+
child.on('error', (error) => settle({ status: null, error }));
|
|
33
|
+
child.on('close', (status, signal) => settle({ status, signal }));
|
|
34
|
+
});
|
|
35
|
+
}
|