@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.
@@ -1,4 +1,5 @@
1
1
  import { PostHog } from 'posthog-node';
2
+ import chalk from 'chalk';
2
3
  import os from 'node:os';
3
4
  import crypto from 'node:crypto';
4
5
  import { readFileSync } from 'node:fs';
@@ -8,26 +9,66 @@ import config from './config.js';
8
9
  // public-by-design — cf memory feedback_public_keys_in_config.md
9
10
  const POSTHOG_KEY = 'phc_pGgSLivbq8rDZzXC9trWWJG7sBjNghXeTJjETzBcnEDv';
10
11
  const POSTHOG_HOST = 'https://eu.i.posthog.com';
12
+ // Fail fast: a firewalled/offline endpoint must never add tens of seconds of
13
+ // trailing latency to a CLI command. Library defaults are requestTimeout 10s x
14
+ // 3 retries at a 3s delay (~40s+ worst case per call), and a 30s shutdown
15
+ // ceiling on top of that — cap all three low.
16
+ const REQUEST_TIMEOUT_MS = 3000;
17
+ const FETCH_RETRY_COUNT = 1;
18
+ const FETCH_RETRY_DELAY_MS = 250;
19
+ // A touch above one request + one retry, so the shutdown-time flush has room
20
+ // to complete normally without inheriting the library's 30s default ceiling.
21
+ const SHUTDOWN_TIMEOUT_MS = 8000;
11
22
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
23
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8'));
13
24
  let client = null;
14
25
  let distinctId = null;
26
+ let allowedCommands = new Set();
27
+ /**
28
+ * Cross-vendor opt-out (https://consoledonottrack.com) on top of the existing
29
+ * config/env gates. Any one of these disables telemetry entirely.
30
+ */
31
+ function isTelemetryDisabled() {
32
+ return (config.get('telemetry') === false ||
33
+ process.env['TRAWL_TELEMETRY'] === '0' ||
34
+ process.env['DO_NOT_TRACK'] === '1');
35
+ }
15
36
  export const initPostHog = () => {
16
- if (config.get('telemetry') === false || process.env['TRAWL_TELEMETRY'] === '0')
37
+ if (isTelemetryDisabled())
17
38
  return;
18
- if (!config.get('telemetryUserId')) {
39
+ const isFirstRun = !config.get('telemetryUserId');
40
+ if (isFirstRun) {
19
41
  config.set('telemetryUserId', `cli_${crypto.randomUUID()}`);
42
+ // One-time disclosure — telemetry is on by default and otherwise silent.
43
+ console.error(chalk.dim('ℹ Trawl CLI collects anonymous usage telemetry (no credentials or command arguments). ' +
44
+ 'Opt out: trawl telemetry off, TRAWL_TELEMETRY=0, or DO_NOT_TRACK=1.'));
20
45
  }
21
46
  distinctId = config.get('telemetryUserId');
22
47
  client = new PostHog(POSTHOG_KEY, {
23
48
  host: POSTHOG_HOST,
24
49
  flushAt: 1,
25
50
  flushInterval: 0,
51
+ requestTimeout: REQUEST_TIMEOUT_MS,
52
+ fetchRetryCount: FETCH_RETRY_COUNT,
53
+ fetchRetryDelay: FETCH_RETRY_DELAY_MS,
26
54
  });
27
55
  };
56
+ /**
57
+ * Register the complete set of valid "<parent> <name>" telemetry tokens,
58
+ * derived from the actual commander command tree (see resolveCommandName /
59
+ * collectCommandNames in src/index.ts). captureCommand refuses any command
60
+ * string outside this set — defense in depth so free-form user input (argv
61
+ * operands, flag values) can never reach PostHog as the event name, even if a
62
+ * future change accidentally reintroduces argv-derived naming.
63
+ */
64
+ export const registerAllowedCommands = (names) => {
65
+ allowedCommands = new Set(names);
66
+ };
28
67
  export const captureCommand = (command, props = {}) => {
29
68
  if (!client || !distinctId)
30
69
  return;
70
+ if (!allowedCommands.has(command))
71
+ return;
31
72
  client.capture({
32
73
  distinctId,
33
74
  event: 'cli.command.run',
@@ -42,11 +83,14 @@ export const captureCommand = (command, props = {}) => {
42
83
  });
43
84
  };
44
85
  export const shutdown = async () => {
86
+ // Explicit ceiling — client.shutdown() otherwise defaults to a 30s internal
87
+ // race, which would undercut the whole point of capping request/retry time.
45
88
  if (client)
46
- await client.shutdown();
89
+ await client.shutdown(SHUTDOWN_TIMEOUT_MS);
47
90
  };
48
91
  /** Reset singleton state — for testing only */
49
92
  export const _resetForTesting = () => {
50
93
  client = null;
51
94
  distinctId = null;
95
+ allowedCommands = new Set();
52
96
  };
@@ -1,6 +1,6 @@
1
1
  export async function promptPassword(prompt) {
2
2
  process.stderr.write(prompt);
3
- return new Promise((resolve) => {
3
+ return new Promise((resolve, reject) => {
4
4
  let password = '';
5
5
  if (process.stdin.isTTY) {
6
6
  process.stdin.setRawMode(true);
@@ -31,13 +31,24 @@ export async function promptPassword(prompt) {
31
31
  process.stdin.on('data', onData);
32
32
  }
33
33
  else {
34
- // Non-TTY fallback (e.g. piped input in CI)
34
+ // Non-TTY fallback (e.g. piped input in CI). If stdin closes (EOF)
35
+ // before an answer arrives — e.g. `trawl login < /dev/null` — the
36
+ // `question` callback never fires; without a `close` handler the
37
+ // promise hangs forever and the process exits 0 without logging in.
38
+ // Reject loudly instead. (#68)
35
39
  import('readline').then(({ createInterface }) => {
36
40
  const rl = createInterface({ input: process.stdin, output: process.stdout });
41
+ let answered = false;
37
42
  rl.question('', (answer) => {
43
+ answered = true;
38
44
  rl.close();
39
45
  resolve(answer.trim());
40
46
  });
47
+ rl.on('close', () => {
48
+ if (!answered) {
49
+ reject(new Error('No input received — pass -e/-p or set TRAWL_TOKEN for non-interactive use'));
50
+ }
51
+ });
41
52
  });
42
53
  }
43
54
  });
@@ -2,3 +2,12 @@ export declare function validateObjectId(id: string): void;
2
2
  export declare function requireString(value: unknown, name: string): string;
3
3
  export declare function requireUrl(value: unknown, name: string): string;
4
4
  export declare function requireJwt(value: unknown, name: string): string;
5
+ /**
6
+ * Like requireJwt, but also rejects a JWT whose `exp` claim has already
7
+ * passed — `trawl login --token <expired>` / `TRAWL_TOKEN=<expired>` must
8
+ * not print "✓ Logged in" for a token that can't actually authenticate.
9
+ * A JWT with an undecodable/opaque payload is let through (we can't prove
10
+ * it's expired), mirroring `trawl token`'s advisory-only handling of the
11
+ * same case. (#68)
12
+ */
13
+ export declare function requireFreshJwt(value: unknown, name: string): string;
@@ -1,11 +1,13 @@
1
+ import { decodeExp } from './jwt.js';
2
+ import { UsageError } from './errors.js';
1
3
  export function validateObjectId(id) {
2
4
  if (!/^[0-9a-fA-F]{24}$/.test(id)) {
3
- throw new Error(`Invalid scrap ID: "${id}" — expected a 24-char hex ObjectId`);
5
+ throw new UsageError(`Invalid scrap ID: "${id}" — expected a 24-char hex ObjectId`);
4
6
  }
5
7
  }
6
8
  export function requireString(value, name) {
7
9
  if (typeof value !== 'string' || value.trim() === '') {
8
- throw new Error(`${name} is required and must be a non-empty string`);
10
+ throw new UsageError(`${name} is required and must be a non-empty string`);
9
11
  }
10
12
  return value.trim();
11
13
  }
@@ -14,20 +16,37 @@ export function requireUrl(value, name) {
14
16
  try {
15
17
  const url = new URL(str);
16
18
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
17
- throw new Error(`${name} must use http or https protocol`);
19
+ throw new UsageError(`${name} must use http or https protocol`);
18
20
  }
19
21
  }
20
22
  catch (err) {
21
23
  if (err instanceof Error && err.message.startsWith(name))
22
24
  throw err;
23
- throw new Error(`${name} must be a valid URL`);
25
+ throw new UsageError(`${name} must be a valid URL`);
24
26
  }
25
27
  return str;
26
28
  }
27
29
  export function requireJwt(value, name) {
28
30
  const str = requireString(value, name);
29
31
  if (str.split('.').length !== 3) {
30
- throw new Error(`${name} must be a valid JWT token`);
32
+ throw new UsageError(`${name} must be a valid JWT token`);
31
33
  }
32
34
  return str;
33
35
  }
36
+ /**
37
+ * Like requireJwt, but also rejects a JWT whose `exp` claim has already
38
+ * passed — `trawl login --token <expired>` / `TRAWL_TOKEN=<expired>` must
39
+ * not print "✓ Logged in" for a token that can't actually authenticate.
40
+ * A JWT with an undecodable/opaque payload is let through (we can't prove
41
+ * it's expired), mirroring `trawl token`'s advisory-only handling of the
42
+ * same case. (#68)
43
+ */
44
+ export function requireFreshJwt(value, name) {
45
+ const jwt = requireJwt(value, name);
46
+ const exp = decodeExp(jwt);
47
+ const nowSeconds = Math.floor(Date.now() / 1000);
48
+ if (exp !== null && exp < nowSeconds) {
49
+ throw new UsageError(`${name} is an expired JWT (exp has already passed) — obtain a fresh token and retry`);
50
+ }
51
+ return jwt;
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "1.16.0",
3
+ "version": "1.18.0",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  "LICENSE"
13
13
  ],
14
14
  "engines": {
15
- "node": ">=18"
15
+ "node": ">=20"
16
16
  },
17
17
  "scripts": {
18
18
  "build": "tsc",