@trawlme/cli 3.0.0 → 3.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.
@@ -2,42 +2,16 @@ import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
- import { readFileSync } from 'node:fs';
6
- import { fileURLToPath } from 'node:url';
7
- import { dirname, join } from 'node:path';
8
5
  import { json } from '../lib/format.js';
6
+ import { PKG_NAME, currentVersion, fetchLatestVersion } from '../lib/version.js';
9
7
  const execFileP = promisify(execFile);
10
- const PKG_NAME = '@trawlme/cli';
11
- /** The version currently RUNNING — read from this package's own package.json.
12
- * (At publish time semantic-release stamps the real version into the tarball's
13
- * package.json, so at runtime this is the installed version; the repo's
14
- * checked-in value is vestigial.) */
15
- function currentVersion() {
16
- const here = dirname(fileURLToPath(import.meta.url)); // dist/commands
17
- const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8'));
18
- return pkg.version;
19
- }
20
- /** Latest published version from the npm registry. Throws with a friendly
21
- * message when npm is missing or the registry is unreachable. */
22
- async function latestVersion() {
23
- try {
24
- const { stdout } = await execFileP('npm', ['view', PKG_NAME, 'version'], { timeout: 30_000 });
25
- return stdout.trim();
26
- }
27
- catch (err) {
28
- const e = err;
29
- if (e.code === 'ENOENT')
30
- throw new Error('npm was not found on PATH — install Node.js/npm, then run: npm install -g @trawlme/cli@latest');
31
- throw new Error(`could not reach the npm registry to check for updates (${e.message})`);
32
- }
33
- }
34
8
  export const upgrade = new Command('upgrade')
35
9
  .description('Update the trawl CLI itself to the latest published version')
36
10
  .option('--check', 'Only report whether an update is available — do not install')
37
11
  .option('--json', 'Output as JSON')
38
12
  .action(async (opts) => {
39
13
  const current = currentVersion();
40
- const latest = await latestVersion();
14
+ const latest = await fetchLatestVersion();
41
15
  const upToDate = current === latest;
42
16
  if (upToDate) {
43
17
  if (opts.json)
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ import { autoUpdateInstalledSkills } from './lib/skills.js';
16
16
  import { initPostHog, captureCommand, shutdown, registerAllowedCommands } from './lib/posthog.js';
17
17
  import { classifyError, reportError } from './lib/errors.js';
18
18
  import { renderPinch, pinchEnabled } from './lib/pinch.js';
19
+ import { maybeNotifyUpdate, scheduleUpdateCheck } from './lib/updateNotifier.js';
19
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
21
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
21
22
  /**
@@ -368,6 +369,23 @@ export async function runCli(argv = process.argv) {
368
369
  // Flush + close telemetry before the process exits. A `process.on('exit')`
369
370
  // handler cannot reliably run async work, so this must happen here.
370
371
  await shutdown();
372
+ // Passive "update available" notifier (#129) — never on a help/version
373
+ // query, and each half individually guarded so a notifier bug can never
374
+ // turn a successful command into a failure or change its exit code.
375
+ if (!isHelpOrVersion(argv)) {
376
+ try {
377
+ maybeNotifyUpdate();
378
+ }
379
+ catch {
380
+ // swallow — see updateNotifier.ts, this is already self-guarded too.
381
+ }
382
+ try {
383
+ scheduleUpdateCheck();
384
+ }
385
+ catch {
386
+ // swallow — background refresh must never affect this invocation.
387
+ }
388
+ }
371
389
  }
372
390
  }
373
391
  if (isEntryPoint(process.argv[1], import.meta.url)) {
@@ -4,6 +4,11 @@ interface TrawlConfig {
4
4
  token: string;
5
5
  telemetry: boolean;
6
6
  telemetryUserId: string;
7
+ /** Opt-out switch for the passive "update available" notifier
8
+ * (src/lib/updateNotifier.ts). Optional — absent/undefined means enabled;
9
+ * only an explicit `false` disables it. No default entry needed since it's
10
+ * optional. (#129) */
11
+ updateNotifier?: boolean;
7
12
  }
8
13
  declare const config: Conf<TrawlConfig>;
9
14
  /**
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Standalone worker script spawned DETACHED by scheduleUpdateCheck()
3
+ * (updateNotifier.ts) to refresh the update-check cache in the background —
4
+ * this never runs on the CLI's hot path. Invoked as
5
+ * `node dist/lib/updateCheckWorker.js <cachePath>`, it queries the npm
6
+ * registry for the latest published version, writes
7
+ * `{ latest, checkedAt }` to `<cachePath>`, and exits. The NEXT `trawl`
8
+ * invocation picks up the refreshed cache — this run never waits on it.
9
+ *
10
+ * Any failure (npm missing, registry unreachable, cache dir unwritable) exits
11
+ * silently (code 0) — a failed background refresh must never surface to the
12
+ * user; the cache simply stays stale until the next stale-check fires.
13
+ */
14
+ import { execFile } from 'node:child_process';
15
+ import { writeFileSync } from 'node:fs';
16
+ import { PKG_NAME } from './version.js';
17
+ const cachePath = process.argv[2];
18
+ if (cachePath) {
19
+ execFile('npm', ['view', PKG_NAME, 'version'], { timeout: 30_000 }, (err, stdout) => {
20
+ if (!err) {
21
+ try {
22
+ writeFileSync(cachePath, JSON.stringify({ latest: String(stdout).trim(), checkedAt: Date.now() }));
23
+ }
24
+ catch {
25
+ // Best-effort — a write failure just means the next invocation retries.
26
+ }
27
+ }
28
+ process.exit(0);
29
+ });
30
+ }
@@ -0,0 +1,20 @@
1
+ /** The cache file lives beside the CLI's own `conf` config file — same
2
+ * directory, no extra config-dir resolution logic to duplicate. */
3
+ export declare function updateCachePath(): string;
4
+ /**
5
+ * Synchronous, instant — safe to call on every invocation's hot path. Prints
6
+ * ONE line to stderr when a cached "latest" version is a valid semver
7
+ * strictly greater than the running version, and every gate holds:
8
+ * an interactive stderr TTY, no `--json` flag, not a help/version query, and
9
+ * not opted out (env or config). Any error (bad cache, etc.) is swallowed —
10
+ * this must never throw.
11
+ */
12
+ export declare function maybeNotifyUpdate(argv?: string[]): void;
13
+ /**
14
+ * Non-blocking background refresh. When the cache is missing or older than
15
+ * CHECK_INTERVAL_MS, spawns a fully detached child (updateCheckWorker.ts)
16
+ * that queries npm and writes the refreshed cache, then exits — this
17
+ * invocation never waits on it (`.unref()`), so the refreshed cache is only
18
+ * ever used by the NEXT invocation. Never throws.
19
+ */
20
+ export declare function scheduleUpdateCheck(): void;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Passive "update available" notifier (#129) — follows the `update-notifier`
3
+ * model: notify from a CACHE, refresh the cache in the BACKGROUND, never
4
+ * block or slow down the command it hitches a ride on.
5
+ *
6
+ * - `maybeNotifyUpdate()` is synchronous and instant — it only ever reads a
7
+ * cache file that already exists on disk and prints at most one line to
8
+ * stderr. It never talks to the network and never throws.
9
+ * - `scheduleUpdateCheck()` refreshes that cache for the NEXT invocation by
10
+ * spawning a fully detached, unref'd child process
11
+ * (src/lib/updateCheckWorker.ts) — this invocation never awaits it.
12
+ *
13
+ * Both are called from src/index.ts's `runCli()` finally block, after
14
+ * `shutdown()`, wrapped so a notifier failure can never turn a successful
15
+ * command into a failed one.
16
+ */
17
+ import { readFileSync } from 'node:fs';
18
+ import { dirname, join } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { spawn } from 'node:child_process';
21
+ import chalk from 'chalk';
22
+ import config from './config.js';
23
+ import { currentVersion } from './version.js';
24
+ /** Refresh cadence for the background worker — a cache younger than this is
25
+ * left alone; older (or missing) triggers a detached refresh. */
26
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
27
+ /** Sibling compiled worker script (dist/lib/updateCheckWorker.js next to this
28
+ * module's own dist/lib/updateNotifier.js) — resolved relative to THIS
29
+ * module so it works regardless of cwd or how the CLI was installed. */
30
+ const WORKER_PATH = fileURLToPath(new URL('./updateCheckWorker.js', import.meta.url));
31
+ /** The cache file lives beside the CLI's own `conf` config file — same
32
+ * directory, no extra config-dir resolution logic to duplicate. */
33
+ export function updateCachePath() {
34
+ return join(dirname(config.path), 'update-check.json');
35
+ }
36
+ function readCache() {
37
+ try {
38
+ const raw = readFileSync(updateCachePath(), 'utf8');
39
+ const parsed = JSON.parse(raw);
40
+ if (typeof parsed.latest !== 'string' || typeof parsed.checkedAt !== 'number')
41
+ return undefined;
42
+ return { latest: parsed.latest, checkedAt: parsed.checkedAt };
43
+ }
44
+ catch {
45
+ // Missing file, corrupt JSON, unreadable — treat all the same: no cache.
46
+ return undefined;
47
+ }
48
+ }
49
+ /** Opted out via env (presence-based, mirrors NO_COLOR) or an explicit
50
+ * `false` config value. Absent/undefined config key means enabled. */
51
+ function isOptedOut() {
52
+ if (process.env['TRAWL_NO_UPDATE_NOTIFIER'] !== undefined)
53
+ return true;
54
+ return config.get('updateNotifier') === false;
55
+ }
56
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
57
+ function isValidSemver(v) {
58
+ return SEMVER_RE.test(v);
59
+ }
60
+ /** Plain MAJOR.MINOR.PATCH comparison — no prerelease/build-metadata support,
61
+ * consistent with this package's own version scheme (see version.ts). */
62
+ function isGreaterSemver(a, b) {
63
+ if (!isValidSemver(a) || !isValidSemver(b))
64
+ return false;
65
+ const pa = a.split('.').map(Number);
66
+ const pb = b.split('.').map(Number);
67
+ for (let i = 0; i < 3; i++) {
68
+ if (pa[i] > pb[i])
69
+ return true;
70
+ if (pa[i] < pb[i])
71
+ return false;
72
+ }
73
+ return false;
74
+ }
75
+ /** Best-effort `--json` argv scan — same "argv scan, never trust flag
76
+ * values" caveat as index.ts's hasJsonFlag: positional values never equal
77
+ * the literal `--json` string. Kept local (not imported from index.ts) so
78
+ * this module has no reverse dependency on the entrypoint. */
79
+ function hasJsonFlag(argv) {
80
+ return argv.includes('--json');
81
+ }
82
+ /** Same shape as index.ts's isHelpOrVersion (bare invocation, `help`,
83
+ * -h/--help/-V/--version) — kept local for the same reason as hasJsonFlag
84
+ * above. Belt-and-suspenders: src/index.ts already skips calling this
85
+ * function entirely on a help/version invocation. */
86
+ function isHelpOrVersionInvocation(argv) {
87
+ if (argv.some((a) => a === '-h' || a === '--help' || a === '-V' || a === '--version'))
88
+ return true;
89
+ const args = argv.slice(2);
90
+ if (args.length === 0)
91
+ return true;
92
+ if (args[0] === 'help')
93
+ return true;
94
+ return false;
95
+ }
96
+ /**
97
+ * Synchronous, instant — safe to call on every invocation's hot path. Prints
98
+ * ONE line to stderr when a cached "latest" version is a valid semver
99
+ * strictly greater than the running version, and every gate holds:
100
+ * an interactive stderr TTY, no `--json` flag, not a help/version query, and
101
+ * not opted out (env or config). Any error (bad cache, etc.) is swallowed —
102
+ * this must never throw.
103
+ */
104
+ export function maybeNotifyUpdate(argv = process.argv) {
105
+ try {
106
+ if (!process.stderr.isTTY)
107
+ return;
108
+ if (hasJsonFlag(argv))
109
+ return;
110
+ if (isHelpOrVersionInvocation(argv))
111
+ return;
112
+ if (isOptedOut())
113
+ return;
114
+ const cache = readCache();
115
+ if (!cache)
116
+ return;
117
+ const current = currentVersion();
118
+ if (!isGreaterSemver(cache.latest, current))
119
+ return;
120
+ console.error(chalk.dim(`↑ Update available: ${current} → ${cache.latest}. Run \`trawl upgrade\`.`));
121
+ }
122
+ catch {
123
+ // Never let the notifier turn a successful command into a failure.
124
+ }
125
+ }
126
+ /**
127
+ * Non-blocking background refresh. When the cache is missing or older than
128
+ * CHECK_INTERVAL_MS, spawns a fully detached child (updateCheckWorker.ts)
129
+ * that queries npm and writes the refreshed cache, then exits — this
130
+ * invocation never waits on it (`.unref()`), so the refreshed cache is only
131
+ * ever used by the NEXT invocation. Never throws.
132
+ */
133
+ export function scheduleUpdateCheck() {
134
+ try {
135
+ if (isOptedOut())
136
+ return;
137
+ const cache = readCache();
138
+ const isStale = !cache || Date.now() - cache.checkedAt > CHECK_INTERVAL_MS;
139
+ if (!isStale)
140
+ return;
141
+ const child = spawn(process.execPath, [WORKER_PATH, updateCachePath()], {
142
+ detached: true,
143
+ stdio: 'ignore',
144
+ });
145
+ child.unref();
146
+ }
147
+ catch {
148
+ // A failed background-refresh spawn must never affect the caller.
149
+ }
150
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Shared version plumbing — the package name + "what version am I" + "what's
3
+ * published" primitives, used by BOTH `trawl upgrade` (src/commands/upgrade.ts)
4
+ * and the passive update notifier (src/lib/updateNotifier.ts). Extracted so
5
+ * neither duplicates the npm-registry lookup or the package.json read (#129).
6
+ */
7
+ export declare const PKG_NAME = "@trawlme/cli";
8
+ /** The version currently RUNNING — read from this package's own package.json.
9
+ * (At publish time semantic-release stamps the real version into the tarball's
10
+ * package.json, so at runtime this is the installed version; the repo's
11
+ * checked-in value is vestigial.) */
12
+ export declare function currentVersion(): string;
13
+ /** Latest published version from the npm registry. Throws with a friendly
14
+ * message when npm is missing or the registry is unreachable. */
15
+ export declare function fetchLatestVersion(): Promise<string>;
@@ -0,0 +1,36 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+ const execFileP = promisify(execFile);
7
+ /**
8
+ * Shared version plumbing — the package name + "what version am I" + "what's
9
+ * published" primitives, used by BOTH `trawl upgrade` (src/commands/upgrade.ts)
10
+ * and the passive update notifier (src/lib/updateNotifier.ts). Extracted so
11
+ * neither duplicates the npm-registry lookup or the package.json read (#129).
12
+ */
13
+ export const PKG_NAME = '@trawlme/cli';
14
+ /** The version currently RUNNING — read from this package's own package.json.
15
+ * (At publish time semantic-release stamps the real version into the tarball's
16
+ * package.json, so at runtime this is the installed version; the repo's
17
+ * checked-in value is vestigial.) */
18
+ export function currentVersion() {
19
+ const here = dirname(fileURLToPath(import.meta.url)); // dist/lib
20
+ const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8'));
21
+ return pkg.version;
22
+ }
23
+ /** Latest published version from the npm registry. Throws with a friendly
24
+ * message when npm is missing or the registry is unreachable. */
25
+ export async function fetchLatestVersion() {
26
+ try {
27
+ const { stdout } = await execFileP('npm', ['view', PKG_NAME, 'version'], { timeout: 30_000 });
28
+ return stdout.trim();
29
+ }
30
+ catch (err) {
31
+ const e = err;
32
+ if (e.code === 'ENOENT')
33
+ throw new Error('npm was not found on PATH — install Node.js/npm, then run: npm install -g @trawlme/cli@latest');
34
+ throw new Error(`could not reach the npm registry to check for updates (${e.message})`);
35
+ }
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {