@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/README.md +24 -5
- package/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.js +52 -3
- package/dist/commands/login.js +29 -9
- package/dist/commands/scraps.js +225 -95
- package/dist/commands/telemetry.js +9 -5
- package/dist/commands/token.js +7 -21
- package/dist/index.d.ts +19 -1
- package/dist/index.js +113 -42
- package/dist/lib/api.d.ts +14 -1
- package/dist/lib/api.js +133 -19
- package/dist/lib/config.d.ts +11 -0
- package/dist/lib/config.js +24 -0
- package/dist/lib/errors.d.ts +41 -0
- package/dist/lib/errors.js +59 -0
- package/dist/lib/jwt.d.ts +8 -0
- package/dist/lib/jwt.js +22 -0
- package/dist/lib/posthog.d.ts +9 -0
- package/dist/lib/posthog.js +47 -3
- package/dist/lib/prompt.js +13 -2
- package/dist/lib/validate.d.ts +9 -0
- package/dist/lib/validate.js +24 -5
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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,
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/config.js
CHANGED
|
@@ -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;
|
package/dist/lib/jwt.js
ADDED
|
@@ -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
|
+
}
|
package/dist/lib/posthog.d.ts
CHANGED
|
@@ -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 */
|