@tiny-fish/cli 0.30.2-next.259 → 0.30.2-next.264

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.
@@ -65,167 +65,166 @@ function parseCsv(content) {
65
65
  return { url: urlField.value, goal: goalField.value };
66
66
  });
67
67
  }
68
- // ── register ──────────────────────────────────────────────────────────────────
68
+ async function batchRunAction(opts) {
69
+ // Read CSV file
70
+ let csvContent;
71
+ try {
72
+ csvContent = fs.readFileSync(opts.input, 'utf8');
73
+ }
74
+ catch {
75
+ err({ error: `Cannot read file: ${opts.input}` });
76
+ process.exit(1);
77
+ }
78
+ // Parse CSV — throws on any malformed input
79
+ let runs;
80
+ try {
81
+ runs = parseCsv(csvContent);
82
+ }
83
+ catch (e) {
84
+ err({
85
+ error: String(e instanceof Error ? e.message : e),
86
+ recommended_action: 'Fix the CSV file and try again',
87
+ });
88
+ process.exit(1);
89
+ }
90
+ const apiKey = getApiKey();
91
+ const response = await submitBatch({ runs }, apiKey).catch(handleApiError);
92
+ // Surface application-level errors from the API before persisting
93
+ if (response.error)
94
+ handleApiError(response.error);
95
+ const runIds = response.run_ids;
96
+ if (!runIds || runIds.length === 0) {
97
+ err({
98
+ error: 'Batch submission returned no run IDs',
99
+ recommended_action: 'Check the API key and try again',
100
+ });
101
+ process.exit(1);
102
+ }
103
+ const submitted = runIds.length;
104
+ const batchId = randomUUID();
105
+ const createdAt = new Date().toISOString();
106
+ const batch = {
107
+ batch_id: batchId,
108
+ run_ids: runIds,
109
+ total: runs.length,
110
+ submitted,
111
+ created_at: createdAt,
112
+ };
113
+ saveBatch(batch);
114
+ const resultsUrl = `${BASE_URL}/runs`;
115
+ if (opts.pretty) {
116
+ outLine('Batch submitted');
117
+ outLine(`ID: ${batchId}`);
118
+ outLine(`Total: ${runs.length}`);
119
+ outLine(`Submitted: ${submitted}`);
120
+ outLine(`Runs URL: ${resultsUrl}`);
121
+ }
122
+ else {
123
+ out({
124
+ data: { batch_id: batchId, total: runs.length, submitted, results_url: resultsUrl },
125
+ });
126
+ }
127
+ }
128
+ function batchListAction(opts) {
129
+ const batches = loadBatches();
130
+ if (opts.pretty) {
131
+ if (batches.length === 0) {
132
+ outLine('No batches found.');
133
+ return;
134
+ }
135
+ outLine(`${'BATCH ID'.padEnd(38)} ${'TOTAL'.padEnd(6)} ${'SUBMITTED'.padEnd(9)} CREATED AT`);
136
+ outLine(`${'-'.repeat(38)} ${'-'.repeat(6)} ${'-'.repeat(9)} ${'-'.repeat(24)}`);
137
+ for (const b of batches) {
138
+ outLine(`${b.batch_id.padEnd(38)} ${String(b.total).padEnd(6)} ${String(b.submitted).padEnd(9)} ${b.created_at}`);
139
+ }
140
+ }
141
+ else {
142
+ out(batches);
143
+ }
144
+ }
145
+ async function batchGetAction(batchId, opts) {
146
+ const batch = findBatch(batchId);
147
+ if (!batch) {
148
+ err({ error: `Batch not found: ${batchId}` });
149
+ process.exit(1);
150
+ }
151
+ const apiKey = getApiKey();
152
+ const response = await getBatchRuns(batch.run_ids, apiKey).catch(handleApiError);
153
+ if (opts.pretty) {
154
+ outLine(`Batch: ${batchId}`);
155
+ outLine(`Total: ${batch.total}`);
156
+ outLine(`Found: ${response.data.length}`);
157
+ if (response.not_found && response.not_found.length > 0) {
158
+ outLine(`Not found: ${response.not_found.length}`);
159
+ }
160
+ for (const run of response.data) {
161
+ outLine('');
162
+ outLine(`Run: ${run.run_id}`);
163
+ outLine(`Status: ${run.status}${run.num_of_steps != null ? ` (${run.num_of_steps} steps)` : ''}`);
164
+ outLine(`Goal: ${run.goal}`);
165
+ if (run.result != null) {
166
+ outLine(`Result: ${JSON.stringify(run.result, null, 2).replace(/\n/g, '\n ')}`);
167
+ }
168
+ else if (run.error) {
169
+ outLine(`Error: ${JSON.stringify(run.error)}`);
170
+ }
171
+ }
172
+ }
173
+ else {
174
+ out({ batch_id: batchId, ...response });
175
+ }
176
+ }
177
+ async function batchCancelAction(batchId, opts) {
178
+ const batch = findBatch(batchId);
179
+ if (!batch) {
180
+ err({ error: `Batch not found: ${batchId}` });
181
+ process.exit(1);
182
+ }
183
+ const apiKey = getApiKey();
184
+ const response = await cancelBatchRuns(batch.run_ids, apiKey).catch(handleApiError);
185
+ if (opts.pretty) {
186
+ const cancelled = response.results.filter((r) => r.status === 'CANCELLED').length;
187
+ outLine(`Batch ${batchId}: ${cancelled}/${response.results.length} run(s) cancelled.`);
188
+ if (response.results.length > 0) {
189
+ outLine('');
190
+ outLine(`${'RUN ID'.padEnd(36)} ${'STATUS'.padEnd(10)} CANCELLED AT`);
191
+ outLine(`${'-'.repeat(36)} ${'-'.repeat(10)} ${'-'.repeat(24)}`);
192
+ for (const r of response.results) {
193
+ outLine(`${r.run_id.padEnd(36)} ${r.status.padEnd(10)} ${r.cancelled_at ?? '-'}`);
194
+ }
195
+ }
196
+ if (response.not_found && response.not_found.length > 0) {
197
+ outLine(`\nNot found: ${response.not_found.join(', ')}`);
198
+ }
199
+ }
200
+ else {
201
+ out(response);
202
+ }
203
+ }
69
204
  export function registerBatch(agentCmd) {
70
205
  const batchCmd = agentCmd
71
206
  .command('batch')
72
207
  .description('Batch run operations')
73
208
  .enablePositionalOptions();
74
- // ── batch run ───────────────────────────────────────────────────────────────
75
209
  batchCmd
76
210
  .command('run')
77
211
  .description('Submit a batch of runs from a CSV file (columns: url,goal)')
78
212
  .requiredOption('--input <file>', 'Path to CSV file with columns: url,goal')
79
213
  .option('--pretty', 'Human-readable output')
80
- .action(async (opts) => {
81
- // Read CSV file
82
- let csvContent;
83
- try {
84
- csvContent = fs.readFileSync(opts.input, 'utf8');
85
- }
86
- catch {
87
- err({ error: `Cannot read file: ${opts.input}` });
88
- process.exit(1);
89
- }
90
- // Parse CSV — throws on any malformed input
91
- let runs;
92
- try {
93
- runs = parseCsv(csvContent);
94
- }
95
- catch (e) {
96
- err({
97
- error: String(e instanceof Error ? e.message : e),
98
- recommended_action: 'Fix the CSV file and try again',
99
- });
100
- process.exit(1);
101
- }
102
- const apiKey = getApiKey();
103
- const response = await submitBatch({ runs }, apiKey).catch(handleApiError);
104
- // Surface application-level errors from the API before persisting
105
- if (response.error)
106
- handleApiError(response.error);
107
- const runIds = response.run_ids;
108
- if (!runIds || runIds.length === 0) {
109
- err({
110
- error: 'Batch submission returned no run IDs',
111
- recommended_action: 'Check the API key and try again',
112
- });
113
- process.exit(1);
114
- }
115
- const submitted = runIds.length;
116
- const batchId = randomUUID();
117
- const createdAt = new Date().toISOString();
118
- const batch = {
119
- batch_id: batchId,
120
- run_ids: runIds,
121
- total: runs.length,
122
- submitted,
123
- created_at: createdAt,
124
- };
125
- saveBatch(batch);
126
- const resultsUrl = `${BASE_URL}/runs`;
127
- if (opts.pretty) {
128
- outLine('Batch submitted');
129
- outLine(`ID: ${batchId}`);
130
- outLine(`Total: ${runs.length}`);
131
- outLine(`Submitted: ${submitted}`);
132
- outLine(`Runs URL: ${resultsUrl}`);
133
- }
134
- else {
135
- out({
136
- data: { batch_id: batchId, total: runs.length, submitted, results_url: resultsUrl },
137
- });
138
- }
139
- });
140
- // ── batch list ──────────────────────────────────────────────────────────────
214
+ .action(batchRunAction);
141
215
  batchCmd
142
216
  .command('list')
143
217
  .description('List locally tracked batches')
144
218
  .option('--pretty', 'Human-readable output')
145
- .action((opts) => {
146
- const batches = loadBatches();
147
- if (opts.pretty) {
148
- if (batches.length === 0) {
149
- outLine('No batches found.');
150
- return;
151
- }
152
- outLine(`${'BATCH ID'.padEnd(38)} ${'TOTAL'.padEnd(6)} ${'SUBMITTED'.padEnd(9)} CREATED AT`);
153
- outLine(`${'-'.repeat(38)} ${'-'.repeat(6)} ${'-'.repeat(9)} ${'-'.repeat(24)}`);
154
- for (const b of batches) {
155
- outLine(`${b.batch_id.padEnd(38)} ${String(b.total).padEnd(6)} ${String(b.submitted).padEnd(9)} ${b.created_at}`);
156
- }
157
- }
158
- else {
159
- out(batches);
160
- }
161
- });
162
- // ── batch get ───────────────────────────────────────────────────────────────
219
+ .action(batchListAction);
163
220
  batchCmd
164
221
  .command('get <batch_id>')
165
222
  .description('Get run results for a batch')
166
223
  .option('--pretty', 'Human-readable output')
167
- .action(async (batchId, opts) => {
168
- const batch = findBatch(batchId);
169
- if (!batch) {
170
- err({ error: `Batch not found: ${batchId}` });
171
- process.exit(1);
172
- }
173
- const apiKey = getApiKey();
174
- const response = await getBatchRuns(batch.run_ids, apiKey).catch(handleApiError);
175
- if (opts.pretty) {
176
- outLine(`Batch: ${batchId}`);
177
- outLine(`Total: ${batch.total}`);
178
- outLine(`Found: ${response.data.length}`);
179
- if (response.not_found && response.not_found.length > 0) {
180
- outLine(`Not found: ${response.not_found.length}`);
181
- }
182
- for (const run of response.data) {
183
- outLine('');
184
- outLine(`Run: ${run.run_id}`);
185
- outLine(`Status: ${run.status}${run.num_of_steps != null ? ` (${run.num_of_steps} steps)` : ''}`);
186
- outLine(`Goal: ${run.goal}`);
187
- if (run.result != null) {
188
- outLine(`Result: ${JSON.stringify(run.result, null, 2).replace(/\n/g, '\n ')}`);
189
- }
190
- else if (run.error) {
191
- outLine(`Error: ${JSON.stringify(run.error)}`);
192
- }
193
- }
194
- }
195
- else {
196
- out({ batch_id: batchId, ...response });
197
- }
198
- });
199
- // ── batch cancel ─────────────────────────────────────────────────────────────
224
+ .action(batchGetAction);
200
225
  batchCmd
201
226
  .command('cancel <batch_id>')
202
227
  .description('Cancel all runs in a batch')
203
228
  .option('--pretty', 'Human-readable output')
204
- .action(async (batchId, opts) => {
205
- const batch = findBatch(batchId);
206
- if (!batch) {
207
- err({ error: `Batch not found: ${batchId}` });
208
- process.exit(1);
209
- }
210
- const apiKey = getApiKey();
211
- const response = await cancelBatchRuns(batch.run_ids, apiKey).catch(handleApiError);
212
- if (opts.pretty) {
213
- const cancelled = response.results.filter((r) => r.status === 'CANCELLED').length;
214
- outLine(`Batch ${batchId}: ${cancelled}/${response.results.length} run(s) cancelled.`);
215
- if (response.results.length > 0) {
216
- outLine('');
217
- outLine(`${'RUN ID'.padEnd(36)} ${'STATUS'.padEnd(10)} CANCELLED AT`);
218
- outLine(`${'-'.repeat(36)} ${'-'.repeat(10)} ${'-'.repeat(24)}`);
219
- for (const r of response.results) {
220
- outLine(`${r.run_id.padEnd(36)} ${r.status.padEnd(10)} ${r.cancelled_at ?? '-'}`);
221
- }
222
- }
223
- if (response.not_found && response.not_found.length > 0) {
224
- outLine(`\nNot found: ${response.not_found.join(', ')}`);
225
- }
226
- }
227
- else {
228
- out(response);
229
- }
230
- });
229
+ .action(batchCancelAction);
231
230
  }
@@ -1,7 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { type TakeHoistedCliInstall } from '../lib/cli-install.js';
3
3
  import { type AgentClient, type ConnectAuthMode } from '../lib/connect-runtime.js';
4
- export declare const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
5
4
  export interface NativeConnectOptions {
6
5
  apiKey?: string;
7
6
  mcpUrl: string;
@@ -1,11 +1,11 @@
1
1
  import spawn from 'cross-spawn';
2
2
  import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, validatedApiKey, } from '../lib/auth.js';
3
3
  import { CLAUDE_CODE, CODEX, CURSOR_SKILL_TARGET, GROK, HERMES, OPENCLAW, OPENCLAW_SKILL_INSTALL_ARGS, OPENCODE, launchNativeMcpClient, launchOpenClawWalkthrough, openExternalUrl, } from '../lib/connect-clients.js';
4
- import { captureStdio, capturedOutput, HoistedFailureReportedError, installTinyFishCli, replay, } from '../lib/cli-install.js';
4
+ import { captureStdio, capturedOutput, HoistedFailureReportedError, installTinyFishCli, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from '../lib/cli-install.js';
5
5
  import { ensureCliAuthenticated } from '../lib/connect-auth.js';
6
6
  import { createStdinPrompt, runCliFallback } from '../lib/connect-fallback.js';
7
7
  import { installWebSkill } from '../lib/skill-install.js';
8
- import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, finishSetupCommand, requireCommandSupport, runGuarded, settle, SignInTimeoutError, spawnStepError, spawnRemoval, } from '../lib/connect-runtime.js';
8
+ import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, finishSetupCommand, requireCommandSupport, runGuarded, settle, SignInTimeoutError, spawnStepError, stageDurationOf, withStageDuration, spawnRemoval, } from '../lib/connect-runtime.js';
9
9
  import { cursorInstallDeeplink, cursorMcpPath, readCursorTinyfishEntry, writeCursorMcpConfig, } from '../lib/cursor-config.js';
10
10
  import { runConnectAll } from '../lib/connect-all.js';
11
11
  import { AuthMode, RELOAD_ACTION } from '../lib/harness-detect.js';
@@ -16,7 +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
- export const DEFAULT_MCP_URL = 'https://agent.tinyfish.ai/mcp';
19
+ import { DEFAULT_MCP_URL, isDefaultEndpoint } from '../lib/mcp-endpoint.js';
20
20
  function removeExistingRegistration(client, removal, verbose) {
21
21
  const result = spawnRemoval(client.command, removal.args);
22
22
  if (result.status === 0)
@@ -48,6 +48,19 @@ function exportApiKey(apiKey, displayName, deferOutro) {
48
48
  }
49
49
  return true;
50
50
  }
51
+ /** Runs before any write: a refused key strands nothing. */
52
+ async function verifyKeyBeforeSeeding(apiKey, displayName) {
53
+ const verified = await verifyMcpAuth(apiKey);
54
+ if (verified.ok)
55
+ return;
56
+ // No status means the network failed, not the key.
57
+ if (verified.status === undefined) {
58
+ throw new ConnectStepError(`Could not reach TinyFish to check your API key (${verified.code ?? 'unknown'}), so ` +
59
+ `nothing was written to ${displayName}. Check your connection and retry.`, 'timeout');
60
+ }
61
+ throw new ConnectStepError(`Your TinyFish API key was rejected (${verified.code ?? 'unknown'}), so it was not ` +
62
+ `written to ${displayName}. Run: tinyfish auth login`, 'invalid_config');
63
+ }
51
64
  /** A bounded spawn's timeout means the sign-in never finished. */
52
65
  function signInStepError(message, result, bounded) {
53
66
  const error = spawnStepError(message, result);
@@ -125,7 +138,7 @@ async function connectNativeMcpClient(client, options) {
125
138
  }
126
139
  const useKeyAuth = !!keyedAdd;
127
140
  if (options.keyAuthOnly && !useKeyAuth) {
128
- // No keyAuth at all is structural (Hermes); a missing flag is just an old install.
141
+ // All native clients have keyAuth; missing flags mean an old install.
129
142
  if (client.keyAuth) {
130
143
  throw new ConnectStepError(`This ${client.displayName} install cannot take a TinyFish API key, and signing in ` +
131
144
  `needs a terminal. Update it, then re-run: tinyfish connect ${client.connectClient}`, 'harness_too_old');
@@ -133,6 +146,10 @@ async function connectNativeMcpClient(client, options) {
133
146
  throw new ConnectStepError(`${client.displayName} cannot take a TinyFish API key, and signing in needs a ` +
134
147
  `terminal. Run: tinyfish connect ${client.connectClient}`, 'key_auth_unsupported');
135
148
  }
149
+ // Off-default endpoints skip it: the check hits BASE_URL, not `--url`.
150
+ if (keyedAdd?.spec.seedKey && isDefaultEndpoint(options.mcpUrl)) {
151
+ await verifyKeyBeforeSeeding(keyedAdd.key, client.displayName);
152
+ }
136
153
  telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
137
154
  state.stage = 'registration_cleanup';
138
155
  for (const removal of client.removals)
@@ -154,14 +171,36 @@ async function connectNativeMcpClient(client, options) {
154
171
  : client.addArgs(mcpUrl.toString(), options.mcpUrl);
155
172
  // Capturing an inline-OAuth add hides the sign-in URL and hangs.
156
173
  const interactiveAdd = !(client.nonInteractiveAdd || keyedAdd);
157
- const addResult = spawn.sync(client.command, addArgs, interactiveAdd
174
+ // After the removals: an interrupt there leaves no key behind.
175
+ const seeded = keyedAdd?.spec.seedKey?.(keyedAdd.key);
176
+ const addOptions = interactiveAdd
158
177
  ? { stdio: 'inherit', timeout: options.authTimeoutMs }
159
- : captureStdio(options.verbose ?? false));
160
- if (addResult.error || addResult.status !== 0) {
161
- // Built first: its interrupt check must run before any replay.
162
- const error = signInStepError(`Could not add TinyFish to ${client.displayName}`, addResult, interactiveAdd && options.authTimeoutMs !== undefined);
178
+ : seeded
179
+ ? {
180
+ encoding: 'utf8',
181
+ // Without it, the 1 MiB default overflows into a misread Ctrl+C.
182
+ maxBuffer: STEP_MAX_BUFFER,
183
+ // The unattended path had no bound at all before; captureStdio's is the floor.
184
+ timeout: options.authTimeoutMs ?? SKILL_INSTALL_TIMEOUT_MS,
185
+ input: seeded.stdinInput,
186
+ env: seeded.env,
187
+ }
188
+ : captureStdio(options.verbose ?? false);
189
+ const addResult = spawn.sync(client.command, addArgs, addOptions);
190
+ // Rolls back first: signInStepError throws on a Ctrl+C.
191
+ const failAdd = (build) => {
192
+ seeded?.rollback();
193
+ const error = build();
163
194
  replay(capturedOutput(addResult));
164
195
  throw error;
196
+ };
197
+ if (addResult.error || addResult.status !== 0) {
198
+ failAdd(() => signInStepError(`Could not add TinyFish to ${client.displayName}`, addResult, interactiveAdd && options.authTimeoutMs !== undefined));
199
+ }
200
+ const registration = seeded?.confirmRegistered();
201
+ if (registration && !registration.ok) {
202
+ failAdd(() => new ConnectStepError(`${client.displayName} reported success but TinyFish is not enabled there` +
203
+ `${registration.detail ? `: ${registration.detail}` : ''}.`, 'invalid_config'));
165
204
  }
166
205
  telemetry.track('checkpoint', { phase: 'registered' });
167
206
  // Codex runs OAuth inside `mcp add` and exits 0 either way, so the recorded state decides
@@ -172,6 +211,9 @@ async function connectNativeMcpClient(client, options) {
172
211
  const degradedNotes = loginDegraded ? client.degradedAuthNotes : undefined;
173
212
  if (useKeyAuth) {
174
213
  errLine(degradedNotes?.keyed ?? 'Using your stored TinyFish API key — no browser sign-in needed.');
214
+ // Every other credential the CLI writes into a user-owned file says so too.
215
+ if (seeded)
216
+ errLine(seeded.note);
175
217
  }
176
218
  else if (degradedNotes) {
177
219
  // Printed here, not with the closing line, so a `--launch` run sees it before the handover.
@@ -285,20 +327,26 @@ async function runPostInstallSteps(client, options, state, telemetry, signInDefe
285
327
  options.onPostInstallFailed?.();
286
328
  }
287
329
  }
330
+ /** Fails on a carried failure; only the first take reports anything. */
331
+ function takeHoistedCliInstall(take, telemetry) {
332
+ const { outcome, firstTake } = take();
333
+ if (!outcome.ok) {
334
+ // Late takers fail too; only the first reports the install.
335
+ if (!firstTake)
336
+ throw new HoistedFailureReportedError(outcome.error.message);
337
+ throw withStageDuration(outcome.error, outcome.durationMs);
338
+ }
339
+ if (firstTake) {
340
+ telemetry.track('checkpoint', {
341
+ phase: 'cli_installed',
342
+ stageDurationMs: outcome.durationMs,
343
+ });
344
+ }
345
+ }
288
346
  /** Consumes the `--all` hoist when present; installs inline otherwise. */
289
347
  function runCliInstallStep(options, telemetry) {
290
348
  if (options.hoistedCliInstall) {
291
- const { outcome, firstTake } = options.hoistedCliInstall();
292
- if (!outcome.ok) {
293
- // Late takers fail too; only the first reports the install.
294
- throw firstTake ? outcome.error : new HoistedFailureReportedError(outcome.error.message);
295
- }
296
- if (firstTake) {
297
- telemetry.track('checkpoint', {
298
- phase: 'cli_installed',
299
- stageDurationMs: outcome.durationMs,
300
- });
301
- }
349
+ takeHoistedCliInstall(options.hoistedCliInstall, telemetry);
302
350
  return;
303
351
  }
304
352
  installTinyFishCli({ verbose: options.verbose ?? false, announce: true });
@@ -319,6 +367,7 @@ function trackPostInstallFailure(displayName, state, telemetry, error) {
319
367
  telemetry.track('post_install_failed', {
320
368
  failedStage: state.stage,
321
369
  failureReason: error instanceof ConnectStepError ? error.failureReason : 'unexpected_error',
370
+ stageDurationMs: stageDurationOf(error),
322
371
  });
323
372
  }
324
373
  errLine(`The ${displayName} MCP connection succeeded, but a finishing step ` +
@@ -356,16 +405,8 @@ export async function connectOpenClaw(options) {
356
405
  telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
357
406
  if (options.hoistedCliInstall) {
358
407
  state.stage = 'cli_install';
359
- const { outcome, firstTake } = options.hoistedCliInstall();
360
- // A carried failure fails openclaw exactly like the inline install.
361
- if (!outcome.ok)
362
- throw outcome.error;
363
- if (firstTake) {
364
- telemetry.track('checkpoint', {
365
- phase: 'cli_installed',
366
- stageDurationMs: outcome.durationMs,
367
- });
368
- }
408
+ // A carried first-take failure fails openclaw like the inline install.
409
+ takeHoistedCliInstall(options.hoistedCliInstall, telemetry);
369
410
  }
370
411
  else if (options.cliInstall !== false) {
371
412
  state.stage = 'cli_install';
@@ -8,7 +8,8 @@ import { err, errLine, out, outLine } from '../lib/output.js';
8
8
  import { detectRegistrations } from '../lib/registration-detect.js';
9
9
  import { sendDoctorCompleted } from '../lib/setup-telemetry.js';
10
10
  import { verifyMcpAuth, verifyMcpHealth } from '../lib/verify.js';
11
- import { connectClaudeCode, connectCodex, connectCursor, connectGrok, connectHermes, connectOpenClaw, connectOpencode, DEFAULT_MCP_URL, } from './connect.js';
11
+ import { DEFAULT_MCP_URL, endpointOf, isDefaultEndpoint } from '../lib/mcp-endpoint.js';
12
+ import { connectClaudeCode, connectCodex, connectCursor, connectGrok, connectHermes, connectOpenClaw, connectOpencode, } from './connect.js';
12
13
  // A green auth mode with a red auth call proves nothing, so the call's verdict gates the claim.
13
14
  function provesHarnessReach(status, keyAuth, healthOk, mcpUrl) {
14
15
  // An endpoint doctor could not reach proves nothing about a harness pointed at it.
@@ -20,6 +21,9 @@ function provesHarnessReach(status, keyAuth, healthOk, mcpUrl) {
20
21
  return false;
21
22
  if (status.connected === true)
22
23
  return true;
24
+ // Symmetric with registrationVerdict: a harness saying it cannot reach us is decisive.
25
+ if (status.connected === false)
26
+ return false;
23
27
  // The harness's own key must have verified; the CLI's verdict says nothing about it.
24
28
  return status.authMode === AuthMode.ApiKey && keyAuth?.ok === true;
25
29
  }
@@ -46,11 +50,6 @@ async function checkConnectivity(mcpUrl) {
46
50
  scope: 'harness',
47
51
  };
48
52
  }
49
- // Query strings carry the connect attempt id, so they differ on every healthy install.
50
- function endpointOf(url) {
51
- const parsed = new URL(url);
52
- return `${parsed.origin}${parsed.pathname.replace(/\/$/, '')}`;
53
- }
54
53
  /** Host alone let `/wrong-path` and plain `http://` through and claim the harness reaches us. */
55
54
  function pointsElsewhere(registeredUrl, mcpUrl) {
56
55
  if (!registeredUrl)
@@ -189,15 +188,6 @@ function checkAuthCall(auth) {
189
188
  scope: 'cli',
190
189
  };
191
190
  }
192
- // `verifyMcpAuth` hits BASE_URL; a sandbox key 401s against prod.
193
- function isDefaultEndpoint(mcpUrl) {
194
- try {
195
- return endpointOf(mcpUrl) === endpointOf(DEFAULT_MCP_URL);
196
- }
197
- catch {
198
- return false;
199
- }
200
- }
201
191
  function verifyHarnessKey(status, cliAuth, mcpUrl) {
202
192
  if (!isDefaultEndpoint(mcpUrl))
203
193
  return undefined;
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { type DecryptStats } from '../lib/chrome-cookies.js';
3
- import type { CookieRecord } from '../lib/types.js';
3
+ import type { CookieRecord, ProfileUploadResponse } from '../lib/types.js';
4
4
  export declare function extractChromeCookies(domain: string, stats?: DecryptStats): CookieRecord[];
5
5
  export declare function extractAllChromeCookies(stats?: DecryptStats): CookieRecord[];
6
6
  type TldtsParse = (host: string, opts: {
@@ -11,5 +11,6 @@ type TldtsParse = (host: string, opts: {
11
11
  export declare function groupByCanonicalDomain(cookies: CookieRecord[], parse: TldtsParse): Map<string, CookieRecord[]>;
12
12
  export declare const MAX_COOKIES_PER_UPLOAD = 3000;
13
13
  export declare function buildChunks(groups: Map<string, CookieRecord[]>): CookieRecord[][];
14
+ export declare function uploadAllDomains(profileId: string, cookies: CookieRecord[], apiKey: string): Promise<ProfileUploadResponse>;
14
15
  export declare function registerProfile(program: Command): void;
15
16
  export {};
@@ -168,6 +168,51 @@ function printPrettyProfileUpload(result) {
168
168
  outLine(`Cookies uploaded: ${result.cookie_count}`);
169
169
  outLine(`Domains updated: ${result.domains_updated.join(', ')}`);
170
170
  }
171
+ function extractCookiesOrThrow(opts) {
172
+ // Extract cookies before creating profile to avoid orphan profiles
173
+ // if extraction fails (Windows, no Chrome, no cookies).
174
+ const stats = { keyringSkipped: 0 };
175
+ const cookies = opts.allDomains
176
+ ? extractAllChromeCookies(stats)
177
+ : extractChromeCookies(opts.domain, stats);
178
+ // Warn even on partial success — surviving plaintext rows can mask
179
+ // that every auth cookie was keyring-encrypted and skipped.
180
+ if (stats.keyringSkipped > 0) {
181
+ errLine(`Skipped ${stats.keyringSkipped} keyring-encrypted cookies (not supported on Linux yet).`);
182
+ }
183
+ if (cookies.length === 0) {
184
+ throw new UserFacingError(stats.keyringSkipped > 0
185
+ ? 'Chrome on Linux encrypts cookies with the system keyring, which is not supported yet.'
186
+ : opts.allDomains
187
+ ? 'No Chrome cookies found. Is Google Chrome installed and have you logged in to any sites?'
188
+ : `No Chrome cookies found for ${opts.domain}. Are you logged in to this site in Chrome?`);
189
+ }
190
+ return cookies;
191
+ }
192
+ // Exported for testing
193
+ export async function uploadAllDomains(profileId, cookies, apiKey) {
194
+ // Batch upload — never split a canonical domain across chunks
195
+ const require = createRequire(import.meta.url);
196
+ const { parse } = require('tldts');
197
+ const groups = groupByCanonicalDomain(cookies, parse);
198
+ const chunks = buildChunks(groups);
199
+ let cookieCount = 0;
200
+ const domainsUpdated = [];
201
+ const domainsFailed = [];
202
+ for (const chunk of chunks) {
203
+ const r = await profileUpload(profileId, chunk, apiKey);
204
+ cookieCount += r.cookie_count;
205
+ domainsUpdated.push(...r.domains_updated);
206
+ if (r.domains_failed)
207
+ domainsFailed.push(...r.domains_failed);
208
+ }
209
+ return {
210
+ profile_id: profileId,
211
+ cookie_count: cookieCount,
212
+ domains_updated: domainsUpdated,
213
+ ...(domainsFailed.length > 0 ? { domains_failed: domainsFailed } : {}),
214
+ };
215
+ }
171
216
  export function registerProfile(program) {
172
217
  const profileCmd = program
173
218
  .command('profile')
@@ -216,56 +261,15 @@ export function registerProfile(program) {
216
261
  }
217
262
  let profileId = opts.profileId;
218
263
  try {
219
- // Extract cookies before creating profile to avoid orphan profiles
220
- // if extraction fails (Windows, no Chrome, no cookies).
221
- const stats = { keyringSkipped: 0 };
222
- const cookies = opts.allDomains
223
- ? extractAllChromeCookies(stats)
224
- : extractChromeCookies(opts.domain, stats);
225
- // Warn even on partial success — surviving plaintext rows can mask
226
- // that every auth cookie was keyring-encrypted and skipped.
227
- if (stats.keyringSkipped > 0) {
228
- errLine(`Skipped ${stats.keyringSkipped} keyring-encrypted cookies (not supported on Linux yet).`);
229
- }
230
- if (cookies.length === 0) {
231
- throw new UserFacingError(stats.keyringSkipped > 0
232
- ? 'Chrome on Linux encrypts cookies with the system keyring, which is not supported yet.'
233
- : opts.allDomains
234
- ? 'No Chrome cookies found. Is Google Chrome installed and have you logged in to any sites?'
235
- : `No Chrome cookies found for ${opts.domain}. Are you logged in to this site in Chrome?`);
236
- }
264
+ const cookies = extractCookiesOrThrow(opts);
237
265
  if (profileId === undefined) {
238
266
  const name = opts.allDomains ? 'chrome-import' : opts.domain;
239
267
  const created = await profileCreate(name, apiKey);
240
268
  profileId = created.id;
241
269
  }
242
- let result;
243
- if (opts.allDomains) {
244
- // Batch upload — never split a canonical domain across chunks
245
- const require = createRequire(import.meta.url);
246
- const { parse } = require('tldts');
247
- const groups = groupByCanonicalDomain(cookies, parse);
248
- const chunks = buildChunks(groups);
249
- let cookieCount = 0;
250
- const domainsUpdated = [];
251
- const domainsFailed = [];
252
- for (const chunk of chunks) {
253
- const r = await profileUpload(profileId, chunk, apiKey);
254
- cookieCount += r.cookie_count;
255
- domainsUpdated.push(...r.domains_updated);
256
- if (r.domains_failed)
257
- domainsFailed.push(...r.domains_failed);
258
- }
259
- result = {
260
- profile_id: profileId,
261
- cookie_count: cookieCount,
262
- domains_updated: domainsUpdated,
263
- ...(domainsFailed.length > 0 ? { domains_failed: domainsFailed } : {}),
264
- };
265
- }
266
- else {
267
- result = await profileUpload(profileId, cookies, apiKey);
268
- }
270
+ const result = opts.allDomains
271
+ ? await uploadAllDomains(profileId, cookies, apiKey)
272
+ : await profileUpload(profileId, cookies, apiKey);
269
273
  if (result.domains_failed && result.domains_failed.length > 0) {
270
274
  errLine(`Warning: failed to update cookies for domains: ${result.domains_failed.join(', ')}`);
271
275
  }