@trawlme/cli 0.1.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 ADDED
@@ -0,0 +1,57 @@
1
+ # trawl-cli
2
+
3
+ CLI for managing Trawl scraps from the terminal.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @trawl/cli
9
+ ```
10
+
11
+ ## Authentication
12
+
13
+ Three methods:
14
+
15
+ 1. **Interactive** — `trawl login` (prompts for email and password)
16
+ 2. **Env var** — `TRAWL_TOKEN=<jwt> trawl scraps list` (CI/CD, bypasses prompt)
17
+ 3. **Token flag** — `trawl login --token <jwt>` (CI/CD, direct JWT)
18
+
19
+ Custom API URL: `trawl login --url https://self-hosted.example.com`
20
+
21
+ ## Commands
22
+
23
+ ### Auth
24
+
25
+ ```
26
+ trawl login [--url <url>] [--token <jwt>] [--email <email>] [--password <pass>]
27
+ trawl logout
28
+ ```
29
+
30
+ ### Scraps
31
+
32
+ ```
33
+ trawl scraps list [--json] [--status <success|failure|never>]
34
+ trawl scraps get <id> [--json]
35
+ trawl scraps create -t <title> [-u <url>] [-r <request>] [-s <scrapper>]
36
+ trawl scraps update <id> [-t title] [-u <url>] [-r request] [-s scrapper] [--cron <expr>|--no-cron] [--alert <email>|--no-alert] [-p <json>|--params-file <path>]
37
+ trawl scraps run <id> [--watch]
38
+ trawl scraps trigger <id> [--watch]
39
+ trawl scraps watch <id>
40
+ trawl scraps data <id> [--json]
41
+ trawl scraps rm <id> [--force]
42
+ ```
43
+
44
+ ### Scrap accounts
45
+
46
+ ```
47
+ trawl scraps account set <id> [-u <username>] [-p <password>]
48
+ trawl scraps account delete <id> [--force]
49
+ trawl scraps account clear-session <id>
50
+ trawl scraps account status <id> [--json]
51
+ ```
52
+
53
+ ## Environment variables
54
+
55
+ | Variable | Description |
56
+ |---------------|------------------------------------------------------|
57
+ | `TRAWL_TOKEN` | JWT token — bypasses login prompt, useful for CI/CD |
@@ -0,0 +1,3 @@
1
+ import { Command } from 'commander';
2
+ export declare const login: Command;
3
+ export declare const logout: Command;
@@ -0,0 +1,74 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import config from '../lib/config.js';
4
+ import { api } from '../lib/api.js';
5
+ import { requireJwt, requireUrl } from '../lib/validate.js';
6
+ import { promptPassword } from '../lib/prompt.js';
7
+ async function promptEmail() {
8
+ const { createInterface } = await import('readline');
9
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
10
+ try {
11
+ return await new Promise((resolve) => {
12
+ rl.question('Email: ', (answer) => resolve(answer.trim()));
13
+ });
14
+ }
15
+ finally {
16
+ rl.close();
17
+ }
18
+ }
19
+ export const login = new Command('login')
20
+ .description('Authenticate with the Trawl API')
21
+ .option('-u, --url <url>', 'API base URL')
22
+ .option('-t, --token <token>', 'JWT token (for CI use only)')
23
+ .option('-e, --email <email>', 'Email address')
24
+ .option('-p, --password <password>', 'Password (CI only — visible in process list and shell history)')
25
+ .action(async (opts) => {
26
+ if (opts.url)
27
+ config.set('apiUrl', requireUrl(opts.url, '--url'));
28
+ // Check environment variable override first
29
+ const envToken = process.env['TRAWL_TOKEN'];
30
+ if (envToken) {
31
+ config.set('token', requireJwt(envToken, 'TRAWL_TOKEN'));
32
+ console.log(chalk.green('✓ Logged in'));
33
+ console.log(chalk.dim(` API: ${config.get('apiUrl')}`));
34
+ console.log(chalk.dim(` Config: ${config.path}`));
35
+ return;
36
+ }
37
+ // --token flag: direct JWT (CI / retrocompat)
38
+ if (opts.token) {
39
+ config.set('token', requireJwt(opts.token, '--token'));
40
+ console.log(chalk.green('✓ Logged in'));
41
+ console.log(chalk.dim(` API: ${config.get('apiUrl')}`));
42
+ console.log(chalk.dim(` Config: ${config.path}`));
43
+ return;
44
+ }
45
+ // Email/password flow
46
+ if (opts.password) {
47
+ console.warn(chalk.yellow('Warning: passing --password on the command line is insecure.'));
48
+ }
49
+ const email = opts.email ?? (await promptEmail());
50
+ const password = opts.password ?? (await promptPassword('Password: '));
51
+ const { data, headers } = await api.publicPost('/api/auth/signin', { email, password });
52
+ // Try token from response body first, then fall back to Set-Cookie header
53
+ let raw = typeof data === 'string' ? data : data.token;
54
+ if (!raw) {
55
+ const setCookie = headers.get('set-cookie') ?? '';
56
+ const match = /(?:^|,\s*)TOKEN=([^;,]+)/.exec(setCookie);
57
+ if (match)
58
+ raw = decodeURIComponent(match[1]);
59
+ }
60
+ if (!raw || typeof raw !== 'string') {
61
+ throw new Error(`Invalid response from API: no token received. Got: ${JSON.stringify(data)}`);
62
+ }
63
+ const token = requireJwt(raw, 'token');
64
+ config.set('token', token);
65
+ console.log(chalk.green(`✓ Logged in as ${email}`));
66
+ console.log(chalk.dim(` API: ${config.get('apiUrl')}`));
67
+ console.log(chalk.dim(` Config: ${config.path}`));
68
+ });
69
+ export const logout = new Command('logout')
70
+ .description('Clear stored credentials')
71
+ .action(() => {
72
+ config.set('token', '');
73
+ console.log(chalk.green('✓ Logged out'));
74
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare const scraps: Command;
@@ -0,0 +1,428 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { api } from '../lib/api.js';
5
+ import { table, json } from '../lib/format.js';
6
+ import { promptPassword } from '../lib/prompt.js';
7
+ import { validateObjectId } from '../lib/validate.js';
8
+ function lastStatus(scrap) {
9
+ const last = scrap.history?.[0];
10
+ if (!last || last.status === null || last.status === undefined)
11
+ return 'never';
12
+ return last.status === true ? 'success' : 'failure';
13
+ }
14
+ function lastRun(scrap) {
15
+ const last = scrap.history?.[0];
16
+ if (!last?.createdAt)
17
+ return '—';
18
+ const d = new Date(last.createdAt);
19
+ const dd = String(d.getDate()).padStart(2, '0');
20
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
21
+ const yy = String(d.getFullYear()).slice(2);
22
+ const hh = String(d.getHours()).padStart(2, '0');
23
+ const min = String(d.getMinutes()).padStart(2, '0');
24
+ return `${dd}/${mm}/${yy} ${hh}:${min}`;
25
+ }
26
+ function statusIcon(status) {
27
+ if (status === 'success')
28
+ return chalk.green('✓');
29
+ if (status === 'failure')
30
+ return chalk.red('✗');
31
+ return chalk.dim('—');
32
+ }
33
+ export const scraps = new Command('scraps').description('Manage scraps');
34
+ // shared SSE streaming helper
35
+ async function watchActivities(id) {
36
+ console.log(chalk.dim('Streaming activities (Ctrl+C to stop)…\n'));
37
+ for await (const event of api.stream(`/api/scraps/${id}/activities/stream`)) {
38
+ try {
39
+ const activity = JSON.parse(event);
40
+ const time = new Date(activity.createdAt).toLocaleTimeString();
41
+ console.log(`${chalk.dim(`[${time}]`)} ${activity.message}`);
42
+ }
43
+ catch {
44
+ // non-JSON lines are heartbeats or SSE comments — skip silently
45
+ if (process.env['DEBUG'] && event.trim()) {
46
+ console.error(chalk.dim(`[SSE] skipping non-JSON: ${event}`));
47
+ }
48
+ }
49
+ }
50
+ }
51
+ // list
52
+ scraps
53
+ .command('list')
54
+ .alias('ls')
55
+ .description('List all scraps')
56
+ .option('--json', 'Output as JSON')
57
+ .option('--status <status>', 'Filter by last run status (success|failure|never)')
58
+ .action(async (opts) => {
59
+ const spinner = ora('Fetching scraps…').start();
60
+ const data = await api.get('/api/scraps');
61
+ spinner.stop();
62
+ let rows = data;
63
+ if (opts.status)
64
+ rows = rows.filter((s) => lastStatus(s) === opts.status);
65
+ if (opts.json)
66
+ return json(rows);
67
+ const tableRows = rows.map((s) => ({
68
+ id: s._id,
69
+ title: s.title || '(untitled)',
70
+ cron: s.cron || '—',
71
+ status: statusIcon(lastStatus(s)),
72
+ 'last run': lastRun(s),
73
+ updated: new Date(s.updatedAt).toLocaleDateString(),
74
+ }));
75
+ table(tableRows, ['id', 'title', 'cron', 'status', 'last run', 'updated']);
76
+ });
77
+ // get
78
+ scraps
79
+ .command('get <id>')
80
+ .description('Get scrap details')
81
+ .option('--json', 'Output as JSON')
82
+ .action(async (id, opts) => {
83
+ validateObjectId(id);
84
+ const data = await api.get(`/api/scraps/${id}`);
85
+ if (opts.json)
86
+ return json(data);
87
+ console.log(chalk.bold(data.title || '(untitled)'));
88
+ console.log(chalk.dim(` ID: `) + data._id);
89
+ console.log(chalk.dim(` Cron: `) + (data.cron || '—'));
90
+ console.log(chalk.dim(` Status: `) + statusIcon(lastStatus(data)));
91
+ console.log(chalk.dim(` Last run: `) + lastRun(data));
92
+ console.log(chalk.dim(` Updated: `) + new Date(data.updatedAt).toLocaleString());
93
+ });
94
+ // create
95
+ scraps
96
+ .command('create')
97
+ .description('Create a new scrap')
98
+ .requiredOption('-t, --title <title>', 'Scrap title')
99
+ .option('-u, --url <url>', 'Target URL')
100
+ .option('-r, --request <request>', 'Request/query')
101
+ .option('-s, --scrapper <scrapper>', 'Scrapper type', 'puppeteer')
102
+ .action(async (opts) => {
103
+ const spinner = ora('Creating scrap…').start();
104
+ const data = await api.post('/api/scraps', {
105
+ title: opts.title,
106
+ ...(opts.url && { url: opts.url }),
107
+ request: opts.request || '',
108
+ scrapper: opts.scrapper,
109
+ });
110
+ spinner.succeed(`Scrap created: ${chalk.bold(data._id)}`);
111
+ console.log(chalk.dim(` Title: ${data.title}`));
112
+ });
113
+ // update
114
+ scraps
115
+ .command('update <id>')
116
+ .description('Update an existing scrap')
117
+ .option('-t, --title <title>', 'New title')
118
+ .option('-u, --url <url>', 'New target URL')
119
+ .option('-r, --request <request>', 'New request')
120
+ .option('-s, --scrapper <scrapper>', 'New scrapper type')
121
+ .option('--cron <expression>', 'Cron expression (empty string to disable)')
122
+ .option('--no-cron', 'Disable cron (set to null)')
123
+ .option('--alert <email>', 'Failure alert email (empty string to clear)')
124
+ .option('--no-alert', 'Disable failure alert email (set to null)')
125
+ .option('-p, --params <json>', 'Runtime params as JSON array of objects (e.g. \'[{"@slug":"..."}]\')')
126
+ .option('--params-file <path>', 'Runtime params from a JSON file')
127
+ .action(async (id, opts) => {
128
+ validateObjectId(id);
129
+ const body = {};
130
+ if (opts.title !== undefined)
131
+ body.title = opts.title;
132
+ if (opts.url !== undefined)
133
+ body.url = opts.url;
134
+ if (opts.request !== undefined)
135
+ body.request = opts.request;
136
+ if (opts.scrapper !== undefined)
137
+ body.scrapper = opts.scrapper;
138
+ if (opts.cron === false)
139
+ body.cron = null;
140
+ else if (typeof opts.cron === 'string')
141
+ body.cron = opts.cron === '' ? null : opts.cron;
142
+ if (opts.alert === false)
143
+ body.alert = null;
144
+ else if (typeof opts.alert === 'string')
145
+ body.alert = opts.alert === '' ? null : opts.alert;
146
+ if (opts.params !== undefined || opts.paramsFile !== undefined) {
147
+ let raw;
148
+ if (opts.paramsFile) {
149
+ const { readFileSync } = await import('fs');
150
+ raw = readFileSync(opts.paramsFile, 'utf8');
151
+ }
152
+ else {
153
+ raw = opts.params;
154
+ }
155
+ let parsed;
156
+ try {
157
+ parsed = JSON.parse(raw);
158
+ }
159
+ catch (e) {
160
+ console.log(chalk.red(`✗ Invalid JSON for --params: ${e.message}`));
161
+ process.exitCode = 1;
162
+ return;
163
+ }
164
+ if (!Array.isArray(parsed)) {
165
+ console.log(chalk.red('✗ --params must be a JSON array of objects'));
166
+ process.exitCode = 1;
167
+ return;
168
+ }
169
+ body.params = parsed;
170
+ }
171
+ if (Object.keys(body).length === 0) {
172
+ console.log(chalk.yellow('Nothing to update. Provide at least one option.'));
173
+ return;
174
+ }
175
+ const spinner = ora('Updating scrap…').start();
176
+ const data = await api.put(`/api/scraps/${id}`, body);
177
+ spinner.succeed(`Scrap updated: ${chalk.bold(data._id)}`);
178
+ for (const key of Object.keys(body)) {
179
+ console.log(chalk.dim(` ${key}: `) + String(data[key] ?? '—'));
180
+ }
181
+ });
182
+ // run
183
+ scraps
184
+ .command('run <id>')
185
+ .description('Run a scrap')
186
+ .option('-w, --watch', 'Stream activities after launching')
187
+ .action(async (id, opts) => {
188
+ validateObjectId(id);
189
+ const spinner = ora('Launching scrap…').start();
190
+ await api.get(`/api/scraps/load/${id}`);
191
+ spinner.succeed('Scrap launched');
192
+ if (opts.watch) {
193
+ await watchActivities(id);
194
+ }
195
+ });
196
+ // data
197
+ scraps
198
+ .command('data <id>')
199
+ .description('Get scrap data (latest history)')
200
+ .option('--json', 'Output as JSON')
201
+ .action(async (id, opts) => {
202
+ validateObjectId(id);
203
+ const scrap = await api.get(`/api/scraps/${id}`);
204
+ const lastHistory = scrap.history?.[0];
205
+ if (!lastHistory) {
206
+ console.log(chalk.dim('No data yet. Run the scrap first.'));
207
+ return;
208
+ }
209
+ if (opts.json)
210
+ return json(lastHistory);
211
+ const items = lastHistory.items;
212
+ console.log(chalk.bold('Last run data:'));
213
+ console.log(chalk.dim(` Items: ${Array.isArray(items) ? items.length : '—'}`));
214
+ console.log(chalk.dim(' Use --json for full output.'));
215
+ });
216
+ // delete
217
+ scraps
218
+ .command('delete <id>')
219
+ .alias('rm')
220
+ .description('Delete a scrap')
221
+ .option('-f, --force', 'Skip confirmation prompt')
222
+ .action(async (id, opts) => {
223
+ validateObjectId(id);
224
+ if (!opts.force) {
225
+ const { createInterface } = await import('readline');
226
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
227
+ let answer;
228
+ try {
229
+ answer = await new Promise((resolve) => {
230
+ rl.question(`Delete scrap ${chalk.bold(id)}? ${chalk.dim('[y/N]')} `, (a) => resolve(a.trim().toLowerCase()));
231
+ });
232
+ }
233
+ finally {
234
+ rl.close();
235
+ }
236
+ if (answer !== 'y' && answer !== 'yes') {
237
+ console.log(chalk.dim('Aborted.'));
238
+ return;
239
+ }
240
+ }
241
+ const spinner = ora('Deleting…').start();
242
+ await api.delete(`/api/scraps/${id}`);
243
+ spinner.succeed('Scrap deleted');
244
+ });
245
+ // banner
246
+ scraps
247
+ .command('banner <id>')
248
+ .description('Upload a banner image for a scrap')
249
+ .requiredOption('-f, --file <path>', 'Path to image file (jpg, png, webp)')
250
+ .action(async (id, opts) => {
251
+ validateObjectId(id);
252
+ const { readFileSync, existsSync } = await import('fs');
253
+ const { basename } = await import('path');
254
+ if (!existsSync(opts.file)) {
255
+ console.log(chalk.red(`✗ File not found: ${opts.file}`));
256
+ process.exitCode = 1;
257
+ return;
258
+ }
259
+ const fileBuffer = readFileSync(opts.file);
260
+ const filename = basename(opts.file);
261
+ const ext = filename.split('.').pop()?.toLowerCase() ?? '';
262
+ const mimeMap = {
263
+ png: 'image/png',
264
+ jpg: 'image/jpeg',
265
+ jpeg: 'image/jpeg',
266
+ webp: 'image/webp',
267
+ };
268
+ const mimeType = mimeMap[ext] ?? 'image/png';
269
+ const blob = new Blob([fileBuffer], { type: mimeType });
270
+ const formData = new FormData();
271
+ formData.append('banner', blob, filename);
272
+ const spinner = ora('Uploading banner…').start();
273
+ await api.upload(`/api/scraps/${id}/banner`, formData);
274
+ spinner.succeed(`Banner uploaded for scrap ${chalk.bold(id)}`);
275
+ });
276
+ // watch (stream activities)
277
+ scraps
278
+ .command('watch <id>')
279
+ .description('Stream scrap activities in real-time')
280
+ .action(async (id) => {
281
+ validateObjectId(id);
282
+ await watchActivities(id);
283
+ });
284
+ // trigger
285
+ scraps
286
+ .command('trigger <id>')
287
+ .description('Launch a scrap as a background worker')
288
+ .option('-w, --watch', 'Stream activities after triggering')
289
+ .action(async (id, opts) => {
290
+ validateObjectId(id);
291
+ const spinner = ora('Triggering worker…').start();
292
+ await api.post(`/api/scraps/worker/${id}`);
293
+ spinner.succeed('Worker triggered');
294
+ if (opts.watch)
295
+ await watchActivities(id);
296
+ });
297
+ // account subcommand group
298
+ const account = scraps
299
+ .command('account')
300
+ .description('Manage scrap account credentials and session');
301
+ // helper: prompt for a value with readline (visible input)
302
+ async function promptLine(prompt) {
303
+ const { createInterface } = await import('readline');
304
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
305
+ try {
306
+ return await new Promise((resolve) => {
307
+ rl.question(prompt, (answer) => resolve(answer.trim()));
308
+ });
309
+ }
310
+ finally {
311
+ rl.close();
312
+ }
313
+ }
314
+ // account set
315
+ account
316
+ .command('set <id>')
317
+ .description('Set credentials for a scrap account')
318
+ .option('-u, --username <username>', 'Account username')
319
+ .option('-p, --password <password>', 'Account password (insecure: prefer interactive prompt)')
320
+ .action(async (id, opts) => {
321
+ validateObjectId(id);
322
+ let username = opts.username || '';
323
+ let password = opts.password || '';
324
+ if (opts.password) {
325
+ console.log(chalk.yellow('⚠ Passing --password on the command line is insecure and may be stored in shell history.'));
326
+ }
327
+ if (!username) {
328
+ username = await promptLine('Username: ');
329
+ if (!username) {
330
+ console.log(chalk.red('✗ Username is required.'));
331
+ process.exitCode = 1;
332
+ return;
333
+ }
334
+ }
335
+ if (!password) {
336
+ password = await promptPassword('Password: ');
337
+ if (!password) {
338
+ console.log(chalk.red('✗ Password is required.'));
339
+ process.exitCode = 1;
340
+ return;
341
+ }
342
+ }
343
+ const spinner = ora('Saving credentials…').start();
344
+ const data = await api.put(`/api/scraps/${id}/account`, { username, password });
345
+ spinner.succeed('Credentials saved');
346
+ const acc = data.account;
347
+ console.log(chalk.dim(' Credentials: ') + (acc.hasCredentials ? chalk.green('✓ configured') : chalk.dim('not set')));
348
+ console.log(chalk.dim(' Session: ') + (acc.hasSession ? chalk.green('active') : chalk.dim('none')));
349
+ });
350
+ // account delete
351
+ account
352
+ .command('delete <id>')
353
+ .description('Delete account credentials for a scrap')
354
+ .option('-f, --force', 'Skip confirmation prompt')
355
+ .action(async (id, opts) => {
356
+ validateObjectId(id);
357
+ if (!opts.force) {
358
+ const { createInterface } = await import('readline');
359
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
360
+ let answer;
361
+ try {
362
+ answer = await new Promise((resolve) => {
363
+ rl.question(`Delete account credentials for scrap ${chalk.bold(id)}? ${chalk.dim('[y/N]')} `, (a) => resolve(a.trim().toLowerCase()));
364
+ });
365
+ }
366
+ finally {
367
+ rl.close();
368
+ }
369
+ if (answer !== 'y' && answer !== 'yes') {
370
+ console.log(chalk.dim('Aborted.'));
371
+ return;
372
+ }
373
+ }
374
+ const spinner = ora('Deleting credentials…').start();
375
+ await api.delete(`/api/scraps/${id}/account`);
376
+ spinner.succeed('Account credentials deleted');
377
+ });
378
+ // account clear-session
379
+ account
380
+ .command('clear-session <id>')
381
+ .description('Clear the saved session for a scrap account')
382
+ .action(async (id) => {
383
+ validateObjectId(id);
384
+ const spinner = ora('Clearing session…').start();
385
+ await api.delete(`/api/scraps/${id}/account/session`);
386
+ spinner.succeed('Session cleared');
387
+ });
388
+ // account status
389
+ account
390
+ .command('status <id>')
391
+ .description('Show account credentials and session status for a scrap')
392
+ .option('--json', 'Output as JSON')
393
+ .action(async (id, opts) => {
394
+ validateObjectId(id);
395
+ const spinner = ora('Fetching scrap…').start();
396
+ const data = await api.get(`/api/scraps/${id}`);
397
+ spinner.stop();
398
+ const acc = data.account;
399
+ if (opts.json) {
400
+ const { json: jsonFn } = await import('../lib/format.js');
401
+ return jsonFn(acc ?? null);
402
+ }
403
+ if (!acc) {
404
+ console.log(chalk.dim('No account data available.'));
405
+ return;
406
+ }
407
+ const credLine = acc.hasCredentials
408
+ ? chalk.green('✓ Credentials configured')
409
+ : chalk.dim('✗ No credentials');
410
+ let sessionLine;
411
+ if (acc.hasSession) {
412
+ if (acc.sessionSavedAt) {
413
+ const savedAt = new Date(acc.sessionSavedAt);
414
+ const diffMs = Date.now() - savedAt.getTime();
415
+ const diffH = Math.floor(diffMs / 1000 / 60 / 60);
416
+ const diffM = Math.floor((diffMs / 1000 / 60) % 60);
417
+ const ago = diffH > 0 ? `${diffH}h ago` : `${diffM}m ago`;
418
+ sessionLine = chalk.green(`✓ Session active (saved ${ago})`);
419
+ }
420
+ else {
421
+ sessionLine = chalk.green('✓ Session active');
422
+ }
423
+ }
424
+ else {
425
+ sessionLine = chalk.dim('✗ No active session');
426
+ }
427
+ console.log(`${credLine} | ${sessionLine}`);
428
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import chalk from 'chalk';
4
+ import { login, logout } from './commands/login.js';
5
+ import { scraps } from './commands/scraps.js';
6
+ const program = new Command()
7
+ .name('trawl')
8
+ .description('Trawl CLI — manage scraps from the terminal')
9
+ .version('0.1.0')
10
+ .option('--debug', 'Show full error stack traces');
11
+ program.addCommand(login);
12
+ program.addCommand(logout);
13
+ program.addCommand(scraps);
14
+ program.parseAsync().catch((err) => {
15
+ const { debug } = program.opts();
16
+ if (debug || process.env['DEBUG']) {
17
+ console.error(err);
18
+ }
19
+ else {
20
+ console.error(chalk.red('✗ ' + err.message));
21
+ }
22
+ process.exitCode = 1;
23
+ });
@@ -0,0 +1,12 @@
1
+ export declare const api: {
2
+ get: <T>(path: string) => Promise<T>;
3
+ post: <T>(path: string, body?: unknown) => Promise<T>;
4
+ put: <T>(path: string, body?: unknown) => Promise<T>;
5
+ delete: <T>(path: string) => Promise<T>;
6
+ upload: <T>(path: string, formData: FormData) => Promise<T>;
7
+ publicPost: <T>(path: string, body?: unknown) => Promise<{
8
+ data: T;
9
+ headers: Headers;
10
+ }>;
11
+ stream: (path: string) => AsyncGenerator<string>;
12
+ };
@@ -0,0 +1,176 @@
1
+ import config from './config.js';
2
+ class ApiError extends Error {
3
+ status;
4
+ constructor(status, message) {
5
+ super(message);
6
+ this.status = status;
7
+ this.name = 'ApiError';
8
+ }
9
+ }
10
+ function extractErrorMessage(raw) {
11
+ if (!raw)
12
+ return '';
13
+ try {
14
+ const parsed = JSON.parse(raw);
15
+ if (parsed && typeof parsed === 'object') {
16
+ const env = parsed;
17
+ const nested = typeof env.error === 'string' ? env.error : null;
18
+ if (nested) {
19
+ try {
20
+ const inner = JSON.parse(nested);
21
+ const details = inner.details;
22
+ if (details && typeof details.message === 'string')
23
+ return details.message;
24
+ if (typeof inner.message === 'string')
25
+ return inner.message;
26
+ }
27
+ catch {
28
+ return nested;
29
+ }
30
+ }
31
+ if (typeof env.message === 'string')
32
+ return env.message;
33
+ }
34
+ }
35
+ catch {
36
+ // not JSON — fall through
37
+ }
38
+ return raw;
39
+ }
40
+ async function throwIfError(res, isPublic = false) {
41
+ if (res.status === 401 && !isPublic) {
42
+ throw new ApiError(401, 'Session expired or invalid. Run: trawl login');
43
+ }
44
+ if (!res.ok) {
45
+ const raw = await res.text();
46
+ const message = extractErrorMessage(raw);
47
+ if (res.status === 401 && isPublic) {
48
+ throw new ApiError(401, `Invalid credentials${message ? `: ${message}` : ''}`);
49
+ }
50
+ throw new ApiError(res.status, `${res.status} ${res.statusText}: ${message}`);
51
+ }
52
+ }
53
+ async function request(path, options = {}) {
54
+ const token = config.get('token');
55
+ if (!token)
56
+ throw new Error('Not logged in. Run: trawl login');
57
+ const url = `${config.get('apiUrl')}${path}`;
58
+ const res = await fetch(url, {
59
+ ...options,
60
+ headers: {
61
+ 'Content-Type': 'application/json',
62
+ ...options.headers,
63
+ Cookie: `TOKEN=${token}`,
64
+ },
65
+ });
66
+ await throwIfError(res);
67
+ const text = await res.text();
68
+ try {
69
+ if (!text)
70
+ return {};
71
+ const parsed = JSON.parse(text);
72
+ // Unwrap API envelope { type, message, data: T }
73
+ if (parsed !== null && typeof parsed === 'object' && 'data' in parsed) {
74
+ return parsed.data;
75
+ }
76
+ return parsed;
77
+ }
78
+ catch {
79
+ throw new Error('Invalid JSON in server response');
80
+ }
81
+ }
82
+ async function upload(path, formData) {
83
+ const token = config.get('token');
84
+ if (!token)
85
+ throw new Error('Not logged in. Run: trawl login');
86
+ const url = `${config.get('apiUrl')}${path}`;
87
+ // Do NOT set Content-Type — fetch sets it automatically with the correct multipart boundary
88
+ const res = await fetch(url, {
89
+ method: 'POST',
90
+ body: formData,
91
+ headers: {
92
+ Cookie: `TOKEN=${token}`,
93
+ },
94
+ });
95
+ await throwIfError(res);
96
+ const text = await res.text();
97
+ try {
98
+ if (!text)
99
+ return {};
100
+ const parsed = JSON.parse(text);
101
+ // Unwrap API envelope { type, message, data: T }
102
+ if (parsed !== null && typeof parsed === 'object' && 'data' in parsed) {
103
+ return parsed.data;
104
+ }
105
+ return parsed;
106
+ }
107
+ catch {
108
+ throw new Error('Invalid JSON in server response');
109
+ }
110
+ }
111
+ async function publicPost(path, body) {
112
+ const url = `${config.get('apiUrl')}${path}`;
113
+ const res = await fetch(url, {
114
+ method: 'POST',
115
+ headers: { 'Content-Type': 'application/json' },
116
+ body: body ? JSON.stringify(body) : undefined,
117
+ });
118
+ await throwIfError(res, true);
119
+ const text = await res.text();
120
+ try {
121
+ const data = text ? JSON.parse(text) : {};
122
+ return { data, headers: res.headers };
123
+ }
124
+ catch {
125
+ throw new Error('Invalid JSON in server response');
126
+ }
127
+ }
128
+ export const api = {
129
+ get: (path) => request(path),
130
+ post: (path, body) => request(path, {
131
+ method: 'POST',
132
+ body: body ? JSON.stringify(body) : undefined,
133
+ }),
134
+ put: (path, body) => request(path, {
135
+ method: 'PUT',
136
+ body: body ? JSON.stringify(body) : undefined,
137
+ }),
138
+ delete: (path) => request(path, { method: 'DELETE' }),
139
+ upload: (path, formData) => upload(path, formData),
140
+ publicPost: (path, body) => publicPost(path, body),
141
+ stream: async function* (path) {
142
+ const token = config.get('token');
143
+ if (!token)
144
+ throw new Error('Not logged in. Run: trawl login');
145
+ const url = `${config.get('apiUrl')}${path}`;
146
+ const res = await fetch(url, {
147
+ headers: {
148
+ Accept: 'text/event-stream',
149
+ Cookie: `TOKEN=${token}`,
150
+ },
151
+ });
152
+ if (!res.ok || !res.body) {
153
+ throw new ApiError(res.status, `SSE failed: ${res.status}`);
154
+ }
155
+ const reader = res.body.getReader();
156
+ const decoder = new TextDecoder();
157
+ let buffer = '';
158
+ while (true) {
159
+ const { done, value } = await reader.read();
160
+ if (done)
161
+ break;
162
+ buffer += decoder.decode(value, { stream: true });
163
+ const lines = buffer.split('\n');
164
+ buffer = lines.pop() ?? '';
165
+ for (const line of lines) {
166
+ if (line.startsWith('data: ')) {
167
+ yield line.slice(6);
168
+ }
169
+ }
170
+ }
171
+ // Flush any remaining data in buffer (stream ended without trailing newline)
172
+ if (buffer.startsWith('data: ')) {
173
+ yield buffer.slice(6);
174
+ }
175
+ },
176
+ };
@@ -0,0 +1,7 @@
1
+ import Conf from 'conf';
2
+ interface TrawlConfig {
3
+ apiUrl: string;
4
+ token: string;
5
+ }
6
+ declare const config: Conf<TrawlConfig>;
7
+ export default config;
@@ -0,0 +1,9 @@
1
+ import Conf from 'conf';
2
+ const config = new Conf({
3
+ projectName: 'trawl-cli',
4
+ defaults: {
5
+ apiUrl: 'https://api.trawl.me',
6
+ token: '',
7
+ },
8
+ });
9
+ export default config;
@@ -0,0 +1,2 @@
1
+ export declare function table(rows: Record<string, unknown>[], columns: string[]): void;
2
+ export declare function json(data: unknown): void;
@@ -0,0 +1,25 @@
1
+ import chalk from 'chalk';
2
+ import stripAnsi from 'strip-ansi';
3
+ export function table(rows, columns) {
4
+ if (!rows.length) {
5
+ console.log(chalk.dim('No results.'));
6
+ return;
7
+ }
8
+ const widths = columns.map((col) => Math.max(col.length, ...rows.map((r) => stripAnsi(String(r[col] ?? '')).length)));
9
+ const header = columns.map((col, i) => chalk.bold(col.padEnd(widths[i]))).join(' ');
10
+ console.log(header);
11
+ console.log(chalk.dim('─'.repeat(header.replace(/\x1b\[[0-9;]*m/g, '').length)));
12
+ for (const row of rows) {
13
+ const line = columns
14
+ .map((col, i) => {
15
+ const raw = String(row[col] ?? '');
16
+ const visLen = stripAnsi(raw).length;
17
+ return raw + ' '.repeat(Math.max(0, widths[i] - visLen));
18
+ })
19
+ .join(' ');
20
+ console.log(line);
21
+ }
22
+ }
23
+ export function json(data) {
24
+ console.log(JSON.stringify(data, null, 2));
25
+ }
@@ -0,0 +1 @@
1
+ export declare function promptPassword(prompt: string): Promise<string>;
@@ -0,0 +1,44 @@
1
+ export async function promptPassword(prompt) {
2
+ process.stderr.write(prompt);
3
+ return new Promise((resolve) => {
4
+ let password = '';
5
+ if (process.stdin.isTTY) {
6
+ process.stdin.setRawMode(true);
7
+ process.stdin.resume();
8
+ process.stdin.setEncoding('utf8');
9
+ const onData = (char) => {
10
+ if (char === '\r' || char === '\n') {
11
+ process.stdin.setRawMode(false);
12
+ process.stdin.pause();
13
+ process.stdin.removeListener('data', onData);
14
+ process.stderr.write('\n');
15
+ resolve(password);
16
+ }
17
+ else if (char === '\u0003') {
18
+ // Ctrl+C
19
+ process.stdin.setRawMode(false);
20
+ process.stdin.pause();
21
+ process.exit(1);
22
+ }
23
+ else if (char === '\u007F' || char === '\b') {
24
+ // Backspace
25
+ password = password.slice(0, -1);
26
+ }
27
+ else {
28
+ password += char;
29
+ }
30
+ };
31
+ process.stdin.on('data', onData);
32
+ }
33
+ else {
34
+ // Non-TTY fallback (e.g. piped input in CI)
35
+ import('readline').then(({ createInterface }) => {
36
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
37
+ rl.question('', (answer) => {
38
+ rl.close();
39
+ resolve(answer.trim());
40
+ });
41
+ });
42
+ }
43
+ });
44
+ }
@@ -0,0 +1,4 @@
1
+ export declare function validateObjectId(id: string): void;
2
+ export declare function requireString(value: unknown, name: string): string;
3
+ export declare function requireUrl(value: unknown, name: string): string;
4
+ export declare function requireJwt(value: unknown, name: string): string;
@@ -0,0 +1,33 @@
1
+ export function validateObjectId(id) {
2
+ if (!/^[0-9a-fA-F]{24}$/.test(id)) {
3
+ throw new Error(`Invalid scrap ID: "${id}" — expected a 24-char hex ObjectId`);
4
+ }
5
+ }
6
+ export function requireString(value, name) {
7
+ if (typeof value !== 'string' || value.trim() === '') {
8
+ throw new Error(`${name} is required and must be a non-empty string`);
9
+ }
10
+ return value.trim();
11
+ }
12
+ export function requireUrl(value, name) {
13
+ const str = requireString(value, name);
14
+ try {
15
+ const url = new URL(str);
16
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
17
+ throw new Error(`${name} must use http or https protocol`);
18
+ }
19
+ }
20
+ catch (err) {
21
+ if (err instanceof Error && err.message.startsWith(name))
22
+ throw err;
23
+ throw new Error(`${name} must be a valid URL`);
24
+ }
25
+ return str;
26
+ }
27
+ export function requireJwt(value, name) {
28
+ const str = requireString(value, name);
29
+ if (str.split('.').length !== 3) {
30
+ throw new Error(`${name} must be a valid JWT token`);
31
+ }
32
+ return str;
33
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@trawlme/cli",
3
+ "version": "0.1.0",
4
+ "description": "Trawl CLI — manage scraps from the terminal",
5
+ "type": "module",
6
+ "bin": {
7
+ "trawl": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "typecheck": "tsc --noEmit",
19
+ "dev": "tsc --watch",
20
+ "start": "node dist/index.js",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest",
23
+ "test:coverage": "vitest run --coverage",
24
+ "snapshot:spec": "node scripts/snapshot-spec.mjs"
25
+ },
26
+ "keywords": [
27
+ "trawl",
28
+ "scraping",
29
+ "cli"
30
+ ],
31
+ "author": "Pierre Brisorgueil",
32
+ "license": "ISC",
33
+ "dependencies": {
34
+ "chalk": "^5.6.2",
35
+ "commander": "^14.0.3",
36
+ "conf": "^15.1.0",
37
+ "ora": "^9.3.0",
38
+ "strip-ansi": "^7.2.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^25.5.2",
42
+ "@vitest/coverage-v8": "^4.1.2",
43
+ "typescript": "^6.0.2",
44
+ "vitest": "^4.1.2"
45
+ }
46
+ }