@tiny-fish/cli 0.47.2-next.375 → 0.47.2-next.377

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.
@@ -3,9 +3,9 @@ import * as os from 'os';
3
3
  import * as path from 'path';
4
4
  import { createRequire } from 'module';
5
5
  import { z } from 'zod';
6
- import { getApiKey } from '../lib/auth.js';
6
+ import { getResolvedApiKey, rejectedKeyHint } from '../lib/auth.js';
7
7
  import { deriveChromeKey, listAuthRelevantHostKeys, openCookiesDb, readCookiesForHost, } from '../lib/chrome-cookies.js';
8
- import { profileCreate, profileUpload } from '../lib/client.js';
8
+ import { profileCreate, profileGet, profileList, profileUpload } from '../lib/client.js';
9
9
  import { err, errLine, handleApiError, out, outLine } from '../lib/output.js';
10
10
  // ── Helpers ───────────────────────────────────────────────────────────────────
11
11
  const chromeLocalStateSchema = z.object({
@@ -19,7 +19,7 @@ function getChromeRoot() {
19
19
  if (platform === 'linux') {
20
20
  return path.join(os.homedir(), '.config', 'google-chrome');
21
21
  }
22
- // Windows guard — handled in withCookiesDb
22
+ // Windows guard — handled in resolveCookiesPath
23
23
  return '';
24
24
  }
25
25
  function sanitizeChromeProfile(profile) {
@@ -28,14 +28,53 @@ function sanitizeChromeProfile(profile) {
28
28
  }
29
29
  return profile;
30
30
  }
31
+ class ChromePathOutsideError extends Error {
32
+ constructor() {
33
+ super('Chrome data path resolves outside the Chrome data directory');
34
+ }
35
+ }
31
36
  function assertChromePathContained(targetPath) {
32
37
  const chromeRoot = fs.realpathSync(getChromeRoot());
33
38
  const resolvedTarget = fs.realpathSync(targetPath);
34
39
  const relative = path.relative(chromeRoot, resolvedTarget);
35
40
  if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
36
- throw new Error('Chrome data path resolves outside the Chrome data directory');
41
+ throw new ChromePathOutsideError();
37
42
  }
38
43
  }
44
+ function isContainedCookiesFile(cookiesPath) {
45
+ try {
46
+ if (!fs.lstatSync(cookiesPath).isFile())
47
+ return false;
48
+ assertChromePathContained(cookiesPath);
49
+ return true;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ function listChromeProfilesWithCookies(chromeRoot) {
56
+ try {
57
+ return (fs
58
+ .readdirSync(chromeRoot, { withFileTypes: true })
59
+ .filter((entry) => entry.isDirectory())
60
+ .map((entry) => entry.name)
61
+ // nosemgrep: path-join-resolve-traversal -- readdir names, result containment-checked
62
+ .filter((name) => isContainedCookiesFile(path.join(chromeRoot, name, 'Cookies')))
63
+ .sort());
64
+ }
65
+ catch {
66
+ return [];
67
+ }
68
+ }
69
+ function availableProfilesHint(chromeRoot) {
70
+ const names = listChromeProfilesWithCookies(chromeRoot);
71
+ return names.length > 0
72
+ ? `Available Chrome profiles: ${names.join(', ')}.`
73
+ : 'No Chrome profiles with a Cookies file were found.';
74
+ }
75
+ function chromeProfileError(chromeRoot, reason) {
76
+ return new Error(`${reason} Use --chrome-profile to select a profile. ${availableProfilesHint(chromeRoot)}`);
77
+ }
39
78
  function getChromeCookiesPath(requestedProfile) {
40
79
  const chromeRoot = getChromeRoot();
41
80
  if (requestedProfile !== undefined) {
@@ -49,26 +88,28 @@ function getChromeCookiesPath(requestedProfile) {
49
88
  if (error.code === 'ENOENT') {
50
89
  return path.join(chromeRoot, 'Default', 'Cookies');
51
90
  }
52
- throw error;
91
+ throw chromeProfileError(chromeRoot, 'Could not read Chrome profile state.');
53
92
  }
93
+ let lastUsed;
54
94
  try {
55
95
  assertChromePathContained(localStatePath);
96
+ lastUsed = chromeLocalStateSchema.parse(JSON.parse(fs.readFileSync(localStatePath, 'utf8')))
97
+ .profile?.last_used;
56
98
  }
57
99
  catch (error) {
58
- if (error.code === 'ENOENT') {
59
- throw new Error('Could not read Chrome profile state. Use --chrome-profile to select a profile.');
60
- }
61
- throw error;
100
+ const reason = error instanceof ChromePathOutsideError
101
+ ? `${error.message}.`
102
+ : 'Could not read Chrome profile state.';
103
+ throw chromeProfileError(chromeRoot, reason);
62
104
  }
105
+ // Chrome writes last_used only after a profile switch.
106
+ if (!lastUsed)
107
+ return path.join(chromeRoot, 'Default', 'Cookies');
63
108
  try {
64
- const localState = chromeLocalStateSchema.parse(JSON.parse(fs.readFileSync(localStatePath, 'utf8')));
65
- const lastUsed = localState.profile?.last_used;
66
- if (!lastUsed)
67
- throw new Error('missing last-used profile');
68
109
  return path.join(chromeRoot, sanitizeChromeProfile(lastUsed), 'Cookies');
69
110
  }
70
111
  catch {
71
- throw new Error('Could not read Chrome profile state. Use --chrome-profile to select a profile.');
112
+ throw chromeProfileError(chromeRoot, 'Chrome profile state names an invalid profile.');
72
113
  }
73
114
  }
74
115
  function validateDomain(domain) {
@@ -77,16 +118,22 @@ function validateDomain(domain) {
77
118
  process.exit(1);
78
119
  }
79
120
  }
80
- // Opens a private copy of Chrome's cookie DB, derives the decryption key, and calls fn.
81
- function withCookiesDb(chromeProfile, fn) {
121
+ // Local checks only; must not touch the keychain or read cookies.
122
+ function resolveCookiesPath(chromeProfile) {
82
123
  if (process.platform === 'win32') {
83
124
  throw new Error('Cookie extraction is not supported on Windows yet');
84
125
  }
85
126
  const cookiesPath = getChromeCookiesPath(chromeProfile);
86
127
  if (!fs.existsSync(cookiesPath)) {
87
- throw new Error(`Chrome cookies file not found at ${cookiesPath}. Use --chrome-profile to select another Chrome profile.`);
128
+ throw chromeProfileError(getChromeRoot(), `Chrome cookies file not found at ${cookiesPath}.`);
88
129
  }
89
130
  assertChromePathContained(cookiesPath);
131
+ return cookiesPath;
132
+ }
133
+ // Opens a private copy of Chrome's cookie DB, derives the decryption key, and calls fn.
134
+ function withCookiesDb(cookiesPath, fn) {
135
+ // Recheck: the preflight await lets the path be swapped.
136
+ assertChromePathContained(cookiesPath);
90
137
  // Copy the DB out of the profile directory so we avoid the Chrome file lock.
91
138
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tinyfish-chrome-cookies-'));
92
139
  let db;
@@ -124,11 +171,11 @@ function withCookiesDb(chromeProfile, fn) {
124
171
  }
125
172
  }
126
173
  }
127
- function extractChromeCookies(domain, chromeProfile, stats) {
128
- return withCookiesDb(chromeProfile, (db, key) => readCookiesForHost(db, key, domain, stats));
174
+ function extractChromeCookies(domain, cookiesPath, stats) {
175
+ return withCookiesDb(cookiesPath, (db, key) => readCookiesForHost(db, key, domain, stats));
129
176
  }
130
- function extractAllChromeCookies(chromeProfile, stats) {
131
- return withCookiesDb(chromeProfile, (db, key) => {
177
+ function extractAllChromeCookies(cookiesPath, stats) {
178
+ return withCookiesDb(cookiesPath, (db, key) => {
132
179
  let hostKeys;
133
180
  try {
134
181
  hostKeys = listAuthRelevantHostKeys(db);
@@ -214,13 +261,13 @@ function printPrettyProfileUpload(result) {
214
261
  outLine(`Cookies uploaded: ${result.cookie_count}`);
215
262
  outLine(`Domains updated: ${result.domains_updated.join(', ')}`);
216
263
  }
217
- function extractCookiesOrThrow(opts) {
264
+ function extractCookiesOrThrow(opts, cookiesPath) {
218
265
  // Extract cookies before creating profile to avoid orphan profiles
219
266
  // if extraction fails (Windows, no Chrome, no cookies).
220
267
  const stats = { keyringSkipped: 0 };
221
268
  const cookies = opts.allDomains
222
- ? extractAllChromeCookies(opts.chromeProfile, stats)
223
- : extractChromeCookies(opts.domain, opts.chromeProfile, stats);
269
+ ? extractAllChromeCookies(cookiesPath, stats)
270
+ : extractChromeCookies(opts.domain, cookiesPath, stats);
224
271
  // Warn even on partial success — surviving plaintext rows can mask
225
272
  // that every auth cookie was keyring-encrypted and skipped.
226
273
  if (stats.keyringSkipped > 0) {
@@ -235,6 +282,13 @@ function extractCookiesOrThrow(opts) {
235
282
  }
236
283
  return cookies;
237
284
  }
285
+ // Fails a bad key or profile before the keychain prompt.
286
+ async function verifyImportTarget(profileId, apiKey) {
287
+ if (profileId === undefined)
288
+ await profileList(apiKey);
289
+ else
290
+ await profileGet(profileId, apiKey);
291
+ }
238
292
  // Exported for testing
239
293
  export async function uploadAllDomains(profileId, cookies, apiKey) {
240
294
  // Batch upload — never split a canonical domain across chunks
@@ -270,7 +324,7 @@ export function registerProfile(program) {
270
324
  .requiredOption('--name <name>', 'Profile name')
271
325
  .option('--pretty', 'Human-readable output')
272
326
  .action(async (opts) => {
273
- const apiKey = getApiKey();
327
+ const { key: apiKey, source } = getResolvedApiKey();
274
328
  try {
275
329
  const response = await profileCreate(opts.name, apiKey);
276
330
  if (opts.pretty) {
@@ -281,7 +335,7 @@ export function registerProfile(program) {
281
335
  }
282
336
  }
283
337
  catch (error) {
284
- handleApiError(error);
338
+ handleApiError(error, rejectedKeyHint(source));
285
339
  }
286
340
  });
287
341
  profileCmd
@@ -289,7 +343,7 @@ export function registerProfile(program) {
289
343
  .description('Import Chrome cookies into a profile. Use --domain for a single domain or --all-domains for every domain in your Chrome profile. Exactly one must be provided.')
290
344
  .option('--domain <domain>', 'Domain to import cookies for (e.g. github.com)')
291
345
  .option('--all-domains', 'Import cookies for all registrable domains (e.g. github.com) found in your Chrome profile. IPs and non-resolvable hostnames (e.g. localhost) are skipped.')
292
- .option('--chrome-profile <name>', 'Chrome profile directory (auto-detects the last-used profile by default)')
346
+ .option('--chrome-profile <name>', 'Chrome profile directory, e.g. "Profile 1" (default: last-used profile, else Default)')
293
347
  .option('--profile-id <id>', 'Profile ID to import cookies into (creates new profile if omitted)')
294
348
  .option('--pretty', 'Human-readable output')
295
349
  .action(async (opts) => {
@@ -302,13 +356,15 @@ export function registerProfile(program) {
302
356
  err({ error: 'Provide exactly one of --domain or --all-domains' });
303
357
  process.exit(1);
304
358
  }
305
- const apiKey = getApiKey();
359
+ const { key: apiKey, source } = getResolvedApiKey();
306
360
  if (opts.domain) {
307
361
  validateDomain(opts.domain);
308
362
  }
309
363
  let profileId = opts.profileId;
310
364
  try {
311
- const cookies = extractCookiesOrThrow(opts);
365
+ const cookiesPath = resolveCookiesPath(opts.chromeProfile);
366
+ await verifyImportTarget(profileId, apiKey);
367
+ const cookies = extractCookiesOrThrow(opts, cookiesPath);
312
368
  if (profileId === undefined) {
313
369
  const name = opts.allDomains ? 'chrome-import' : opts.domain;
314
370
  const created = await profileCreate(name, apiKey);
@@ -329,7 +385,7 @@ export function registerProfile(program) {
329
385
  }
330
386
  }
331
387
  catch (error) {
332
- handleApiError(error);
388
+ handleApiError(error, rejectedKeyHint(source));
333
389
  }
334
390
  });
335
391
  }
@@ -70,4 +70,7 @@ export declare function validatedApiKey(explicitApiKey?: string): string | undef
70
70
  * rather than exit like getApiKey does.
71
71
  */
72
72
  export declare function apiKeyStatus(explicitApiKey?: string): ResolvedKey | UnresolvedKey;
73
+ export declare function getResolvedApiKey(): ResolvedKey;
73
74
  export declare function getApiKey(): string;
75
+ /** 401 repair hint that names where the rejected key came from. */
76
+ export declare function rejectedKeyHint(source: ResolvedKey['source']): string | undefined;
package/dist/lib/auth.js CHANGED
@@ -319,10 +319,23 @@ export function apiKeyStatus(explicitApiKey) {
319
319
  }
320
320
  return { key: value, source };
321
321
  }
322
- export function getApiKey() {
322
+ export function getResolvedApiKey() {
323
323
  const resolved = apiKeyStatus();
324
324
  if ('key' in resolved)
325
- return resolved.key;
325
+ return resolved;
326
326
  err({ error: resolved.error });
327
327
  process.exit(1);
328
328
  }
329
+ export function getApiKey() {
330
+ return getResolvedApiKey().key;
331
+ }
332
+ /** 401 repair hint that names where the rejected key came from. */
333
+ export function rejectedKeyHint(source) {
334
+ if (source === 'env') {
335
+ return `The rejected key came from the ${TINYFISH_API_KEY_VAR} environment variable, which overrides the key saved by \`tinyfish auth login\`. Unset it (check your shell profile) or set it to a valid key, then try again.`;
336
+ }
337
+ if (source === 'config') {
338
+ return 'The rejected key is the one saved by `tinyfish auth login`. Run `tinyfish auth login` to save a new key, then try again.';
339
+ }
340
+ return undefined;
341
+ }
@@ -1,5 +1,5 @@
1
1
  import { type AgentRunAsyncResponse, type AgentRunResponse, type AgentRunWithStreamingResponse, type BrowserSession, type FetchGetContentsParams, type FetchResponse, type Run, type RunListParams, type RunListResponse, type SearchQueryParams, type SearchQueryResponse, type WalletResponse } from '@tiny-fish/sdk';
2
- import type { BatchCancelResponse, BatchGetResponse, BatchRunRequest, BatchRunResponse, CancelRunResponse, CliAgentRunParams, CliBrowserSessionCreateParams, CookieRecord, ProfileCreateResponse, ProfileUploadResponse, RunStepsResponse, VaultConnectRequest, VaultConnectResponse, VaultConnectionsListResponse, VaultDisconnectResponse, VaultItemsListResponse, VaultItemsSyncResponse } from './types.js';
2
+ import type { BatchCancelResponse, BatchGetResponse, BatchRunRequest, BatchRunResponse, CancelRunResponse, CliAgentRunParams, CliBrowserSessionCreateParams, CookieRecord, ProfileCreateResponse, ProfileSummary, ProfileUploadResponse, RunStepsResponse, VaultConnectRequest, VaultConnectResponse, VaultConnectionsListResponse, VaultDisconnectResponse, VaultItemsListResponse, VaultItemsSyncResponse } from './types.js';
3
3
  type ConnectMap = Record<string, {
4
4
  attempt_id: string;
5
5
  }> | undefined;
@@ -21,6 +21,8 @@ export declare function submitBatch(req: BatchRunRequest, apiKey: string): Promi
21
21
  export declare function getBatchRuns(runIds: string[], apiKey: string): Promise<BatchGetResponse>;
22
22
  export declare function cancelBatchRuns(runIds: string[], apiKey: string): Promise<BatchCancelResponse>;
23
23
  export declare function profileCreate(name: string, apiKey: string): Promise<ProfileCreateResponse>;
24
+ export declare function profileGet(profileId: string, apiKey: string): Promise<ProfileSummary>;
25
+ export declare function profileList(apiKey: string): Promise<ProfileSummary[]>;
24
26
  export declare function profileUpload(profileId: string, cookies: CookieRecord[], apiKey: string): Promise<ProfileUploadResponse>;
25
27
  export declare function vaultConnections(apiKey: string): Promise<VaultConnectionsListResponse>;
26
28
  export declare function vaultConnect(req: VaultConnectRequest, apiKey: string): Promise<VaultConnectResponse>;
@@ -372,6 +372,8 @@ const profileCreateResponseSchema = z.object({
372
372
  name: z.string(),
373
373
  created_at: z.string(),
374
374
  });
375
+ const profileSummarySchema = z.object({ id: z.string(), name: z.string() });
376
+ const profileListResponseSchema = z.object({ profiles: z.array(profileSummarySchema) });
375
377
  const profileUploadResponseSchema = z.object({
376
378
  profile_id: z.string().optional(),
377
379
  cookie_count: z.number(),
@@ -386,6 +388,19 @@ export function profileCreate(name, apiKey) {
386
388
  return parseWithSchema(profileCreateResponseSchema, response, 'Invalid profile create response');
387
389
  });
388
390
  }
391
+ export function profileGet(profileId, apiKey) {
392
+ return sdk(apiKey, async (client) => {
393
+ const response = await client.get(`/v1/profiles/${encodeURIComponent(profileId)}`);
394
+ return parseWithSchema(profileSummarySchema, response, 'Invalid profile response');
395
+ });
396
+ }
397
+ export function profileList(apiKey) {
398
+ return sdk(apiKey, async (client) => {
399
+ const response = await client.get('/v1/profiles');
400
+ return parseWithSchema(profileListResponseSchema, response, 'Invalid profile list response')
401
+ .profiles;
402
+ });
403
+ }
389
404
  export function profileUpload(profileId, cookies, apiKey) {
390
405
  return sdk(apiKey, async (client) => {
391
406
  const response = await client.post(`/v1/profiles/${encodeURIComponent(profileId)}/upload`, { json: { cookies } });
@@ -15,6 +15,6 @@ export declare function errLine(line: string): void;
15
15
  /** Advisory stderr line, yellow on a terminal and plain everywhere else so piped output stays clean. */
16
16
  export declare function warnLine(line: string): void;
17
17
  export declare function err(data: unknown): void;
18
- export declare function handleApiError(e: unknown): never;
18
+ export declare function handleApiError(e: unknown, unauthorizedHint?: string): never;
19
19
  /** Undefined, not a throw: callers hand the value to a Zod safeParse. */
20
20
  export declare function parseJson(text: string): unknown;
@@ -53,7 +53,7 @@ export function err(data) {
53
53
  const serialized = JSON.stringify(payload) ?? 'undefined';
54
54
  process.stderr.write(sanitizeLine(serialized) + '\n');
55
55
  }
56
- export function handleApiError(e) {
56
+ export function handleApiError(e, unauthorizedHint) {
57
57
  if (e instanceof ApiError) {
58
58
  const payload = { error: e.message, status: e.status };
59
59
  if (e.code)
@@ -64,7 +64,8 @@ export function handleApiError(e) {
64
64
  }
65
65
  else if (e.status === 401) {
66
66
  payload.hint =
67
- 'Clear the TINYFISH_API_KEY environment variable, run `tinyfish auth login`, then try again.';
67
+ unauthorizedHint ??
68
+ 'Clear the TINYFISH_API_KEY environment variable, run `tinyfish auth login`, then try again.';
68
69
  }
69
70
  err(payload);
70
71
  }
@@ -150,6 +150,10 @@ export interface ProfileCreateResponse {
150
150
  name: string;
151
151
  created_at: string;
152
152
  }
153
+ export interface ProfileSummary {
154
+ id: string;
155
+ name: string;
156
+ }
153
157
  export interface CookieRecord {
154
158
  name: string;
155
159
  value: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.47.2-next.375",
3
+ "version": "0.47.2-next.377",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {