@trawlme/cli 1.16.0 → 1.18.0

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/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { Command } from 'commander';
3
3
  import chalk from 'chalk';
4
4
  import { readFileSync } from 'node:fs';
5
- import { fileURLToPath } from 'node:url';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { login, logout } from './commands/login.js';
8
8
  import { scraps } from './commands/scraps.js';
@@ -10,49 +10,120 @@ import { skills } from './commands/skills.js';
10
10
  import { telemetry } from './commands/telemetry.js';
11
11
  import { token } from './commands/token.js';
12
12
  import { autoUpdateInstalledSkills } from './lib/skills.js';
13
- import { initPostHog, captureCommand, shutdown } from './lib/posthog.js';
14
- autoUpdateInstalledSkills();
15
- initPostHog();
13
+ import { initPostHog, captureCommand, shutdown, registerAllowedCommands } from './lib/posthog.js';
14
+ import { classifyError } from './lib/errors.js';
16
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
16
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
18
- const program = new Command()
19
- .name('trawl')
20
- .description('Trawl CLI manage scraps from the terminal')
21
- .version(pkg.version)
22
- .option('--debug', 'Show full error stack traces');
23
- // Track start times per command instance for duration measurement
24
- const startTimes = new WeakMap();
25
- program.hook('preAction', (thisCommand, actionCommand) => {
26
- startTimes.set(actionCommand, Date.now());
27
- });
28
- program.hook('postAction', (thisCommand, actionCommand) => {
29
- const start = startTimes.get(actionCommand);
30
- if (start !== undefined) {
31
- const name = actionCommand.parent
32
- ? `${actionCommand.parent.name()} ${actionCommand.name()}`
33
- : actionCommand.name();
34
- void captureCommand(name, { duration_ms: Date.now() - start, exit_code: 0 });
17
+ /**
18
+ * Derive a safe telemetry event name from a *resolved* commander Command —
19
+ * NEVER from raw argv. Flag values (e.g. the string after --password/--email/
20
+ * --url) don't start with '-' and would otherwise survive an argv filter and
21
+ * leak to PostHog (#67). Falls back to 'unknown' when no command resolved
22
+ * (e.g. an error thrown before any action ran).
23
+ */
24
+ export function resolveCommandName(actionCommand) {
25
+ if (!actionCommand)
26
+ return 'unknown';
27
+ return actionCommand.parent
28
+ ? `${actionCommand.parent.name()} ${actionCommand.name()}`
29
+ : actionCommand.name();
30
+ }
31
+ /**
32
+ * Walk the full command tree and produce every valid resolveCommandName()
33
+ * token the allowlist registered with posthog.ts so captureCommand can
34
+ * never be handed a free-form string.
35
+ */
36
+ export function collectCommandNames(root) {
37
+ const names = [];
38
+ const walk = (cmd) => {
39
+ for (const sub of cmd.commands) {
40
+ names.push(resolveCommandName(sub));
41
+ walk(sub);
42
+ }
43
+ };
44
+ walk(root);
45
+ return names;
46
+ }
47
+ export function createProgram() {
48
+ const program = new Command()
49
+ .name('trawl')
50
+ .description('Trawl CLI — manage scraps from the terminal')
51
+ .version(pkg.version)
52
+ .option('--debug', 'Show full error stack traces');
53
+ program.addCommand(login);
54
+ program.addCommand(logout);
55
+ program.addCommand(scraps);
56
+ program.addCommand(skills);
57
+ program.addCommand(telemetry);
58
+ program.addCommand(token);
59
+ return program;
60
+ }
61
+ /** True when this module is the process entrypoint (not merely imported by a test). */
62
+ export function isEntryPoint(argv1, moduleUrl) {
63
+ return argv1 !== undefined && moduleUrl === pathToFileURL(argv1).href;
64
+ }
65
+ export async function runCli(argv = process.argv) {
66
+ autoUpdateInstalledSkills();
67
+ initPostHog();
68
+ const program = createProgram();
69
+ registerAllowedCommands(collectCommandNames(program));
70
+ // Track start times + the currently-resolved command per instance, so the
71
+ // catch handler below can derive the exact same safe name the success path
72
+ // uses — it must never re-derive anything from argv.
73
+ const startTimes = new WeakMap();
74
+ let currentCommand;
75
+ program.hook('preAction', (_thisCommand, actionCommand) => {
76
+ currentCommand = actionCommand;
77
+ startTimes.set(actionCommand, Date.now());
78
+ });
79
+ program.hook('postAction', (_thisCommand, actionCommand) => {
80
+ const start = startTimes.get(actionCommand);
81
+ if (start !== undefined) {
82
+ // Actions can fail without throwing (process.exitCode set directly) —
83
+ // report the real outcome instead of hardcoding success.
84
+ const exitCode = typeof process.exitCode === 'number' ? process.exitCode : 0;
85
+ void captureCommand(resolveCommandName(actionCommand), {
86
+ duration_ms: Date.now() - start,
87
+ exit_code: exitCode,
88
+ });
89
+ }
90
+ });
91
+ try {
92
+ await program.parseAsync(argv);
35
93
  }
36
- });
37
- program.addCommand(login);
38
- program.addCommand(logout);
39
- program.addCommand(scraps);
40
- program.addCommand(skills);
41
- program.addCommand(telemetry);
42
- program.addCommand(token);
43
- process.on('exit', () => {
44
- void shutdown();
45
- });
46
- program.parseAsync().catch((err) => {
47
- // Capture error telemetry (best-effort: command name from process.argv)
48
- const name = process.argv.slice(2).filter((a) => !a.startsWith('-')).join(' ') || 'unknown';
49
- void captureCommand(name, { exit_code: 1, error: err.name });
50
- const { debug } = program.opts();
51
- if (debug || process.env['DEBUG']) {
52
- console.error(err);
94
+ catch (err) {
95
+ // Map the error to a distinct exit code + machine envelope instead of a
96
+ // uniform 1 — agents driving this CLI unattended need to tell
97
+ // auth-expired (3) from not-found (4) from network-down (5) from a bad
98
+ // flag (2) apart from an arbitrary bug (1). (#71)
99
+ const { exitCode, envelope } = classifyError(err);
100
+ // Capture error telemetry from the resolved command only — never argv.
101
+ void captureCommand(resolveCommandName(currentCommand), {
102
+ exit_code: exitCode,
103
+ error: err.name,
104
+ });
105
+ const { debug } = program.opts();
106
+ const isDebug = Boolean(debug || process.env['DEBUG']);
107
+ // A --json subcommand must keep stdout pure JSON even on failure — read
108
+ // the resolved command's own --json flag (never argv) so the error
109
+ // envelope lands on the same channel the success path would have used.
110
+ const wantsJson = Boolean(currentCommand?.opts()?.json);
111
+ if (isDebug)
112
+ console.error(err);
113
+ if (wantsJson) {
114
+ console.log(JSON.stringify({ error: envelope }));
115
+ }
116
+ else if (!isDebug) {
117
+ console.error(chalk.red('✗ ' + envelope.message));
118
+ }
119
+ process.exitCode = exitCode;
53
120
  }
54
- else {
55
- console.error(chalk.red('' + err.message));
121
+ finally {
122
+ // Flush + close telemetry before the process exits. A `process.on('exit')`
123
+ // handler cannot reliably run async work, so this must happen here.
124
+ await shutdown();
56
125
  }
57
- process.exitCode = 1;
58
- });
126
+ }
127
+ if (isEntryPoint(process.argv[1], import.meta.url)) {
128
+ void runCli();
129
+ }
package/dist/lib/api.d.ts CHANGED
@@ -1,3 +1,16 @@
1
+ export declare class ApiError extends Error {
2
+ status: number;
3
+ constructor(status: number, message: string);
4
+ }
5
+ /**
6
+ * A fetch-level failure — the request never got a response at all (DNS,
7
+ * connection refused, timeout, TLS, …). Distinguished from ApiError (which
8
+ * always carries a real HTTP status) so the top-level handler can map it to
9
+ * its own exit code instead of the generic uniform 1. (#71 findings 4/58)
10
+ */
11
+ export declare class NetworkError extends Error {
12
+ constructor(message: string);
13
+ }
1
14
  export declare const api: {
2
15
  get: <T>(path: string) => Promise<T>;
3
16
  getText: (path: string) => Promise<string>;
@@ -5,7 +18,7 @@ export declare const api: {
5
18
  put: <T>(path: string, body?: unknown) => Promise<T>;
6
19
  delete: <T>(path: string) => Promise<T>;
7
20
  upload: <T>(path: string, formData: FormData) => Promise<T>;
8
- publicPost: <T>(path: string, body?: unknown) => Promise<{
21
+ publicPost: <T>(path: string, body?: unknown, baseUrlOverride?: string) => Promise<{
9
22
  data: T;
10
23
  headers: Headers;
11
24
  }>;
package/dist/lib/api.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { dirname, resolve } from 'node:path';
4
- import config, { getApiUrl } from './config.js';
4
+ import { getApiUrl, getToken } from './config.js';
5
5
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
6
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8'));
7
7
  const USER_AGENT = `@trawlme/cli/${pkg.version}`;
8
- class ApiError extends Error {
8
+ export class ApiError extends Error {
9
9
  status;
10
10
  constructor(status, message) {
11
11
  super(message);
@@ -13,7 +13,57 @@ class ApiError extends Error {
13
13
  this.name = 'ApiError';
14
14
  }
15
15
  }
16
- function extractErrorMessage(raw) {
16
+ /**
17
+ * A fetch-level failure — the request never got a response at all (DNS,
18
+ * connection refused, timeout, TLS, …). Distinguished from ApiError (which
19
+ * always carries a real HTTP status) so the top-level handler can map it to
20
+ * its own exit code instead of the generic uniform 1. (#71 findings 4/58)
21
+ */
22
+ export class NetworkError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = 'NetworkError';
26
+ }
27
+ }
28
+ const DEFAULT_TIMEOUT_MS = 30_000;
29
+ /** Effective fetch timeout — TRAWL_TIMEOUT env override (ms), default 30s. (#71) */
30
+ function getTimeoutMs() {
31
+ const raw = process.env['TRAWL_TIMEOUT']?.trim();
32
+ if (!raw)
33
+ return DEFAULT_TIMEOUT_MS;
34
+ const n = Number(raw);
35
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_TIMEOUT_MS;
36
+ }
37
+ /**
38
+ * Wrap a `fetch()` call so connection-level failures (ECONNREFUSED, DNS,
39
+ * timeout, …) surface as a NetworkError carrying the effective URL + the
40
+ * unwrapped `err.cause` detail, instead of a bare "fetch failed" with no
41
+ * actionable information. (#71 findings 4/58)
42
+ */
43
+ async function safeFetch(url, options) {
44
+ try {
45
+ return await fetch(url, options);
46
+ }
47
+ catch (err) {
48
+ const e = err;
49
+ if (e?.name === 'TimeoutError' || e?.name === 'AbortError') {
50
+ throw new NetworkError(`Request to ${url} timed out after ${getTimeoutMs()}ms (override with TRAWL_TIMEOUT env var, ms)`);
51
+ }
52
+ const cause = e?.cause;
53
+ const causeDetail = cause?.code ? ` (${cause.code})` : cause?.message ? ` (${cause.message})` : '';
54
+ throw new NetworkError(`Network error reaching ${url}${causeDetail}: ${e?.message ?? String(err)}`);
55
+ }
56
+ }
57
+ /**
58
+ * Extract the honest client-facing error string from a raw response body.
59
+ * The server envelope (lib/helpers/responses.js) shape is
60
+ * `{ type, message, code, status, errorCode, description, error? }` — where
61
+ * `message` is sometimes a bare HTTP reason phrase (e.g. "Payment Required")
62
+ * duplicating `res.statusText`, producing a tautology like
63
+ * "402 Payment Required: Payment Required". When that happens, prefer the
64
+ * richer `description` field instead. (#71 finding 76 — error-copy part only)
65
+ */
66
+ function extractErrorMessage(raw, statusText) {
17
67
  if (!raw)
18
68
  return '';
19
69
  try {
@@ -34,8 +84,15 @@ function extractErrorMessage(raw) {
34
84
  return nested;
35
85
  }
36
86
  }
37
- if (typeof env.message === 'string')
38
- return env.message;
87
+ const message = typeof env.message === 'string' ? env.message : undefined;
88
+ const description = typeof env.description === 'string' && env.description ? env.description : undefined;
89
+ if (message && description && statusText && message.toLowerCase() === statusText.toLowerCase()) {
90
+ return description;
91
+ }
92
+ if (message)
93
+ return message;
94
+ if (description)
95
+ return description;
39
96
  }
40
97
  }
41
98
  catch {
@@ -43,26 +100,77 @@ function extractErrorMessage(raw) {
43
100
  }
44
101
  return raw;
45
102
  }
103
+ /**
104
+ * Best-effort extraction of an upgrade URL from a 402 response body. In
105
+ * production the envelope rarely carries it directly (billing.quota.service
106
+ * nests `upgradeUrl` inside AppError.details, which `responses.error` only
107
+ * serializes to the dev-only `error` string) — so this checks the top-level
108
+ * field, `details.upgradeUrl`, and the dev-only nested `error` JSON string,
109
+ * and returns null (never fabricates) when none are present. (#71 finding 76)
110
+ */
111
+ function extractUpgradeUrl(raw) {
112
+ try {
113
+ const parsed = JSON.parse(raw);
114
+ if (typeof parsed.upgradeUrl === 'string')
115
+ return parsed.upgradeUrl;
116
+ const details = parsed.details;
117
+ if (details && typeof details === 'object' && typeof details.upgradeUrl === 'string') {
118
+ return details.upgradeUrl;
119
+ }
120
+ if (typeof parsed.error === 'string') {
121
+ try {
122
+ const inner = JSON.parse(parsed.error);
123
+ if (typeof inner.upgradeUrl === 'string')
124
+ return inner.upgradeUrl;
125
+ const innerDetails = inner.details;
126
+ if (innerDetails &&
127
+ typeof innerDetails === 'object' &&
128
+ typeof innerDetails.upgradeUrl === 'string') {
129
+ return innerDetails.upgradeUrl;
130
+ }
131
+ }
132
+ catch {
133
+ // dev-only nested string wasn't JSON — nothing to extract
134
+ }
135
+ }
136
+ }
137
+ catch {
138
+ // not JSON — nothing to extract
139
+ }
140
+ return null;
141
+ }
46
142
  async function throwIfError(res, isPublic = false) {
47
143
  if (res.status === 401 && !isPublic) {
48
144
  throw new ApiError(401, 'Session expired or invalid. Run: trawl login');
49
145
  }
50
146
  if (!res.ok) {
51
147
  const raw = await res.text();
52
- const message = extractErrorMessage(raw);
148
+ const message = extractErrorMessage(raw, res.statusText);
53
149
  if (res.status === 401 && isPublic) {
54
150
  throw new ApiError(401, `Invalid credentials${message ? `: ${message}` : ''}`);
55
151
  }
56
- throw new ApiError(res.status, `${res.status} ${res.statusText}: ${message}`);
152
+ let full = message;
153
+ if (res.status === 402) {
154
+ const upgradeUrl = extractUpgradeUrl(raw);
155
+ if (upgradeUrl)
156
+ full += ` — upgrade: ${upgradeUrl}`;
157
+ }
158
+ if (res.status === 429) {
159
+ const retryAfter = res.headers?.get?.('retry-after');
160
+ if (retryAfter)
161
+ full += ` (retry after ${retryAfter}s)`;
162
+ }
163
+ throw new ApiError(res.status, `${res.status} ${res.statusText}: ${full}`);
57
164
  }
58
165
  }
59
166
  async function request(path, options = {}) {
60
- const token = config.get('token');
167
+ const token = getToken();
61
168
  if (!token)
62
169
  throw new Error('Not logged in. Run: trawl login');
63
170
  const url = `${getApiUrl()}${path}`;
64
- const res = await fetch(url, {
171
+ const res = await safeFetch(url, {
65
172
  ...options,
173
+ signal: AbortSignal.timeout(getTimeoutMs()),
66
174
  headers: {
67
175
  'Content-Type': 'application/json',
68
176
  'User-Agent': USER_AGENT,
@@ -87,14 +195,15 @@ async function request(path, options = {}) {
87
195
  }
88
196
  }
89
197
  async function upload(path, formData) {
90
- const token = config.get('token');
198
+ const token = getToken();
91
199
  if (!token)
92
200
  throw new Error('Not logged in. Run: trawl login');
93
201
  const url = `${getApiUrl()}${path}`;
94
202
  // Do NOT set Content-Type — fetch sets it automatically with the correct multipart boundary
95
- const res = await fetch(url, {
203
+ const res = await safeFetch(url, {
96
204
  method: 'POST',
97
205
  body: formData,
206
+ signal: AbortSignal.timeout(getTimeoutMs()),
98
207
  headers: {
99
208
  'User-Agent': USER_AGENT,
100
209
  Cookie: `TOKEN=${token}`,
@@ -116,12 +225,13 @@ async function upload(path, formData) {
116
225
  throw new Error('Invalid JSON in server response');
117
226
  }
118
227
  }
119
- async function publicPost(path, body) {
120
- const url = `${getApiUrl()}${path}`;
121
- const res = await fetch(url, {
228
+ async function publicPost(path, body, baseUrlOverride) {
229
+ const url = `${baseUrlOverride ?? getApiUrl()}${path}`;
230
+ const res = await safeFetch(url, {
122
231
  method: 'POST',
123
232
  headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
124
233
  body: body ? JSON.stringify(body) : undefined,
234
+ signal: AbortSignal.timeout(getTimeoutMs()),
125
235
  });
126
236
  await throwIfError(res, true);
127
237
  const text = await res.text();
@@ -134,15 +244,16 @@ async function publicPost(path, body) {
134
244
  }
135
245
  }
136
246
  async function getText(path) {
137
- const token = config.get('token');
247
+ const token = getToken();
138
248
  if (!token)
139
249
  throw new Error('Not logged in. Run: trawl login');
140
250
  const url = `${getApiUrl()}${path}`;
141
- const res = await fetch(url, {
251
+ const res = await safeFetch(url, {
142
252
  headers: {
143
253
  'User-Agent': USER_AGENT,
144
254
  Cookie: `TOKEN=${token}`,
145
255
  },
256
+ signal: AbortSignal.timeout(getTimeoutMs()),
146
257
  });
147
258
  await throwIfError(res);
148
259
  return res.text();
@@ -160,13 +271,16 @@ export const api = {
160
271
  }),
161
272
  delete: (path) => request(path, { method: 'DELETE' }),
162
273
  upload: (path, formData) => upload(path, formData),
163
- publicPost: (path, body) => publicPost(path, body),
274
+ publicPost: (path, body, baseUrlOverride) => publicPost(path, body, baseUrlOverride),
164
275
  stream: async function* (path) {
165
- const token = config.get('token');
276
+ const token = getToken();
166
277
  if (!token)
167
278
  throw new Error('Not logged in. Run: trawl login');
168
279
  const url = `${getApiUrl()}${path}`;
169
- const res = await fetch(url, {
280
+ // No AbortSignal.timeout here a long-running `watch`/`--watch` stream is
281
+ // expected to sit open indefinitely; only connection-level failures
282
+ // (never a timeout) should surface via safeFetch's cause-unwrapping. (#71)
283
+ const res = await safeFetch(url, {
170
284
  headers: {
171
285
  Accept: 'text/event-stream',
172
286
  'User-Agent': USER_AGENT,
@@ -14,4 +14,15 @@ declare const config: Conf<TrawlConfig>;
14
14
  * without mutating the operator's persisted config. (#56)
15
15
  */
16
16
  export declare function getApiUrl(): string;
17
+ /**
18
+ * Resolve the effective session token.
19
+ * Precedence: TRAWL_TOKEN env > stored `trawl login` token.
20
+ * Lets CI/agents authenticate headlessly (`TRAWL_TOKEN=<jwt> trawl scraps list`)
21
+ * without ever touching the on-disk config — and without a stored token being
22
+ * silently sent to whatever TRAWL_API_URL points at instead (cross-env
23
+ * credential misuse). Every request/upload/getText/stream call site in
24
+ * api.ts must read the token through this, never through `config.get('token')`
25
+ * directly. Mirrors getApiUrl(). (#68)
26
+ */
27
+ export declare function getToken(): string;
17
28
  export default config;
@@ -1,6 +1,16 @@
1
1
  import Conf from 'conf';
2
+ /**
3
+ * TRAWL_CONFIG_DIR overrides where Conf stores the config file (its `cwd`
4
+ * option). Without this, the CLI has zero config isolation on macOS — Conf's
5
+ * env-paths dependency hardcodes `~/Library/Preferences/...` and ignores
6
+ * XDG_CONFIG_HOME — so CI/agent runs and concurrent `trawl login`s race on
7
+ * one shared on-disk file. Point at an ephemeral dir for hermetic runs, e.g.
8
+ * `TRAWL_CONFIG_DIR=$(mktemp -d) trawl login --token …`. Rescope of #59. (#68)
9
+ */
10
+ const configDir = process.env['TRAWL_CONFIG_DIR']?.trim();
2
11
  const config = new Conf({
3
12
  projectName: 'trawl-cli',
13
+ ...(configDir ? { cwd: configDir } : {}),
4
14
  defaults: {
5
15
  apiUrl: 'https://api.trawl.me',
6
16
  token: '',
@@ -19,4 +29,18 @@ export function getApiUrl() {
19
29
  const override = process.env['TRAWL_API_URL']?.trim();
20
30
  return override ? override : config.get('apiUrl');
21
31
  }
32
+ /**
33
+ * Resolve the effective session token.
34
+ * Precedence: TRAWL_TOKEN env > stored `trawl login` token.
35
+ * Lets CI/agents authenticate headlessly (`TRAWL_TOKEN=<jwt> trawl scraps list`)
36
+ * without ever touching the on-disk config — and without a stored token being
37
+ * silently sent to whatever TRAWL_API_URL points at instead (cross-env
38
+ * credential misuse). Every request/upload/getText/stream call site in
39
+ * api.ts must read the token through this, never through `config.get('token')`
40
+ * directly. Mirrors getApiUrl(). (#68)
41
+ */
42
+ export function getToken() {
43
+ const override = process.env['TRAWL_TOKEN']?.trim();
44
+ return override ? override : config.get('token');
45
+ }
22
46
  export default config;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Thrown for CLI usage / input-validation failures (bad flag value, malformed
3
+ * JSON, invalid ObjectId, missing required prompt input, …). Distinguished
4
+ * from ApiError/NetworkError so the top-level handler can map it to its own
5
+ * exit code (2) instead of the generic uniform 1 every other bug collapses
6
+ * into. (#71)
7
+ */
8
+ export declare class UsageError extends Error {
9
+ constructor(message: string);
10
+ }
11
+ export interface ErrorEnvelope {
12
+ message: string;
13
+ status?: number;
14
+ kind: string;
15
+ }
16
+ export interface ClassifiedError {
17
+ exitCode: number;
18
+ envelope: ErrorEnvelope;
19
+ }
20
+ /**
21
+ * Central status → exit-code map (#71 findings 13/14/60). Agents driving this
22
+ * CLI unattended need to tell "you're not logged in" (3) from "that id
23
+ * doesn't exist" (4) from "the network/API is unreachable" (5) from "you
24
+ * passed a bad flag" (2) — a uniform exit 1 collapses all of these into one
25
+ * undifferentiable signal.
26
+ */
27
+ export declare function classifyError(err: unknown): ClassifiedError;
28
+ /**
29
+ * Print a classified error to the correct stream and return its exit code.
30
+ * stdout is reserved for payload — under --json the error itself IS the
31
+ * payload (`{"error":{message,status,kind}}`); otherwise the human-readable
32
+ * line goes to stderr, never stdout. (#71 findings 13/14/60)
33
+ *
34
+ * `quiet` skips the human-readable stderr line (used when the caller already
35
+ * printed a fuller diagnostic, e.g. a raw stack trace under --debug) while
36
+ * still emitting the --json payload when requested.
37
+ */
38
+ export declare function reportError(err: unknown, opts?: {
39
+ json?: boolean;
40
+ quiet?: boolean;
41
+ }): number;
@@ -0,0 +1,59 @@
1
+ import chalk from 'chalk';
2
+ import { ApiError, NetworkError } from './api.js';
3
+ /**
4
+ * Thrown for CLI usage / input-validation failures (bad flag value, malformed
5
+ * JSON, invalid ObjectId, missing required prompt input, …). Distinguished
6
+ * from ApiError/NetworkError so the top-level handler can map it to its own
7
+ * exit code (2) instead of the generic uniform 1 every other bug collapses
8
+ * into. (#71)
9
+ */
10
+ export class UsageError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = 'UsageError';
14
+ }
15
+ }
16
+ /**
17
+ * Central status → exit-code map (#71 findings 13/14/60). Agents driving this
18
+ * CLI unattended need to tell "you're not logged in" (3) from "that id
19
+ * doesn't exist" (4) from "the network/API is unreachable" (5) from "you
20
+ * passed a bad flag" (2) — a uniform exit 1 collapses all of these into one
21
+ * undifferentiable signal.
22
+ */
23
+ export function classifyError(err) {
24
+ const message = err instanceof Error ? err.message : String(err);
25
+ if (err instanceof ApiError) {
26
+ if (err.status === 401)
27
+ return { exitCode: 3, envelope: { message, status: 401, kind: 'auth' } };
28
+ if (err.status === 404)
29
+ return { exitCode: 4, envelope: { message, status: 404, kind: 'not_found' } };
30
+ return { exitCode: 1, envelope: { message, status: err.status, kind: 'api' } };
31
+ }
32
+ if (err instanceof NetworkError) {
33
+ return { exitCode: 5, envelope: { message, kind: 'network' } };
34
+ }
35
+ if (err instanceof UsageError) {
36
+ return { exitCode: 2, envelope: { message, kind: 'usage' } };
37
+ }
38
+ return { exitCode: 1, envelope: { message, kind: 'unknown' } };
39
+ }
40
+ /**
41
+ * Print a classified error to the correct stream and return its exit code.
42
+ * stdout is reserved for payload — under --json the error itself IS the
43
+ * payload (`{"error":{message,status,kind}}`); otherwise the human-readable
44
+ * line goes to stderr, never stdout. (#71 findings 13/14/60)
45
+ *
46
+ * `quiet` skips the human-readable stderr line (used when the caller already
47
+ * printed a fuller diagnostic, e.g. a raw stack trace under --debug) while
48
+ * still emitting the --json payload when requested.
49
+ */
50
+ export function reportError(err, opts = {}) {
51
+ const { exitCode, envelope } = classifyError(err);
52
+ if (opts.json) {
53
+ console.log(JSON.stringify({ error: envelope }));
54
+ }
55
+ else if (!opts.quiet) {
56
+ console.error(chalk.red('✗ ' + envelope.message));
57
+ }
58
+ return exitCode;
59
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Decode the `exp` claim from a JWT (middle segment, base64url-encoded JSON).
3
+ * Returns null if the payload cannot be decoded or has no `exp` field.
4
+ *
5
+ * Shared between `trawl token` (expiry advisory) and `trawl login` (reject
6
+ * already-expired tokens instead of silently storing them). (#68)
7
+ */
8
+ export declare function decodeExp(jwt: string): number | null;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Decode the `exp` claim from a JWT (middle segment, base64url-encoded JSON).
3
+ * Returns null if the payload cannot be decoded or has no `exp` field.
4
+ *
5
+ * Shared between `trawl token` (expiry advisory) and `trawl login` (reject
6
+ * already-expired tokens instead of silently storing them). (#68)
7
+ */
8
+ export function decodeExp(jwt) {
9
+ try {
10
+ const parts = jwt.split('.');
11
+ if (parts.length !== 3)
12
+ return null;
13
+ const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
14
+ const parsed = JSON.parse(payload);
15
+ if (typeof parsed.exp !== 'number')
16
+ return null;
17
+ return parsed.exp;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
@@ -1,4 +1,13 @@
1
1
  export declare const initPostHog: () => void;
2
+ /**
3
+ * Register the complete set of valid "<parent> <name>" telemetry tokens,
4
+ * derived from the actual commander command tree (see resolveCommandName /
5
+ * collectCommandNames in src/index.ts). captureCommand refuses any command
6
+ * string outside this set — defense in depth so free-form user input (argv
7
+ * operands, flag values) can never reach PostHog as the event name, even if a
8
+ * future change accidentally reintroduces argv-derived naming.
9
+ */
10
+ export declare const registerAllowedCommands: (names: readonly string[]) => void;
2
11
  export declare const captureCommand: (command: string, props?: Record<string, unknown>) => void;
3
12
  export declare const shutdown: () => Promise<void>;
4
13
  /** Reset singleton state — for testing only */