@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 +57 -0
- package/dist/commands/login.d.ts +3 -0
- package/dist/commands/login.js +74 -0
- package/dist/commands/scraps.d.ts +2 -0
- package/dist/commands/scraps.js +428 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +23 -0
- package/dist/lib/api.d.ts +12 -0
- package/dist/lib/api.js +176 -0
- package/dist/lib/config.d.ts +7 -0
- package/dist/lib/config.js +9 -0
- package/dist/lib/format.d.ts +2 -0
- package/dist/lib/format.js +25 -0
- package/dist/lib/prompt.d.ts +1 -0
- package/dist/lib/prompt.js +44 -0
- package/dist/lib/validate.d.ts +4 -0
- package/dist/lib/validate.js +33 -0
- package/package.json +46 -0
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,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,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
|
+
});
|
package/dist/index.d.ts
ADDED
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
|
+
};
|
package/dist/lib/api.js
ADDED
|
@@ -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,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
|
+
}
|