@trawlme/cli 3.0.0 → 3.2.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.
@@ -6,6 +6,7 @@ import { json } from '../lib/format.js';
6
6
  import { requireUrl, requireString } from '../lib/validate.js';
7
7
  import { UsageError } from '../lib/errors.js';
8
8
  import { renderPinch, pinchEnabled } from '../lib/pinch.js';
9
+ import { startPinchAnimation } from '../lib/pinchAnimation.js';
9
10
  /** Best-effort, honest first-run summary — never claims a background retry
10
11
  * happened when auto-fix was disabled for this call, and never claims a
11
12
  * scrap was persisted when the response carries none (#114-F3 — a hard
@@ -130,24 +131,25 @@ export const create = new Command('create')
130
131
  data = await call();
131
132
  }
132
133
  else {
133
- // #122 — Pinch shows up front, working, while the server-side wizard
134
- // runs (legitimately 30-250s+, see LONG_RUN_TIMEOUT_MS above). Belt-
135
- // and-suspenders `!opts.json` alongside pinchEnabled(): we're already
136
- // inside the non-`--json` branch, but the check is kept explicit here
137
- // too so stdout purity under `--json` (#106-F2/#121) never depends on
138
- // this code staying inside that branch.
139
- if (!opts.json && pinchEnabled())
140
- console.log(renderPinch('thinking'));
134
+ // #122/#131 — while the server-side wizard runs (legitimately 30-250s+,
135
+ // see LONG_RUN_TIMEOUT_MS above) Pinch ANIMATES in place (claw-wiggle)
136
+ // on a color-capable TTY; otherwise the ora spinner (which itself
137
+ // no-ops under a non-TTY, so piped human output stays clean). Both
138
+ // write to stderr only — stdout purity under `--json` is guaranteed by
139
+ // the `opts.json` branch above, never by this code.
140
+ const anim = pinchEnabled() ? startPinchAnimation(`Creating a scrap from ${url}…`) : null;
141
141
  try {
142
- data = await spin(call, {
143
- text: `Creating a scrap from ${url}…`,
144
- // No verdict symbol here (#106-F3) — ora's success only means "the
145
- // HTTP call didn't throw", not "the first run succeeded". The real
146
- // outcome renders below via the icon + the First run: line.
147
- successText: 'Request complete',
148
- });
142
+ data = anim
143
+ ? await call()
144
+ : await spin(call, {
145
+ // No verdict symbol (#106-F3) ora's success only means "the
146
+ // HTTP call didn't throw", not "the first run succeeded".
147
+ text: `Creating a scrap from ${url}…`,
148
+ successText: 'Request complete',
149
+ });
149
150
  }
150
151
  catch (err) {
152
+ anim?.stop();
151
153
  // #114-F1 — a client-side timeout (NetworkError, "timed out after
152
154
  // …ms" per api.ts's safeFetch) does NOT mean the wizard failed
153
155
  // server-side: the scrap creation + first run keep going on the
@@ -165,6 +167,9 @@ export const create = new Command('create')
165
167
  // stays intact (this stays a NetworkError -> exit 5, same as before).
166
168
  throw err;
167
169
  }
170
+ // Success — stop + erase the animation so the result prints on a clean
171
+ // line (the celebrating/confused frame below is the one-shot outcome).
172
+ anim?.stop();
168
173
  }
169
174
  if (opts.json) {
170
175
  json(data);
@@ -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
  /**
@@ -29,6 +29,23 @@ export type PinchState = 'wave' | 'thinking' | 'celebrating' | 'confused';
29
29
  * state's grid is static (see `gridForState`).
30
30
  */
31
31
  export declare function renderPinch(state: PinchState, frame?: number): string;
32
+ /**
33
+ * #131 — the ART lines only (no caption), optionally COMPACT: trailing and
34
+ * leading fully-transparent grid rows are dropped so Pinch takes fewer
35
+ * terminal lines (the r6f grids carry an all-'.' bottom row + blank-ish
36
+ * antenna padding). Used by the animation loop, where a caption + full
37
+ * height would be too heavy for a frame redrawn every ~380ms.
38
+ */
39
+ export declare function renderPinchArt(state: PinchState, opts?: {
40
+ compact?: boolean;
41
+ }): string;
42
+ /**
43
+ * #131 — frame sequence for the "working" animation shown during long waits
44
+ * (the `create` wizard, `--watch`). Reuses the existing r6f state grids: the
45
+ * claws pump up (wave → celebrating) and back down, reading as Pinch busily
46
+ * waving while it works. No new art — just an order over the grids we have.
47
+ */
48
+ export declare const WORKING_FRAMES: readonly PinchState[];
32
49
  /**
33
50
  * True when it's safe to print Pinch art: a real color-capable interactive
34
51
  * terminal. False under NO_COLOR (https://no-color.org — presence, not
package/dist/lib/pinch.js CHANGED
@@ -154,6 +154,37 @@ export function renderPinch(state, frame = 0) {
154
154
  }
155
155
  return [...lines, CAPTIONS[state]].join('\n');
156
156
  }
157
+ /**
158
+ * #131 — the ART lines only (no caption), optionally COMPACT: trailing and
159
+ * leading fully-transparent grid rows are dropped so Pinch takes fewer
160
+ * terminal lines (the r6f grids carry an all-'.' bottom row + blank-ish
161
+ * antenna padding). Used by the animation loop, where a caption + full
162
+ * height would be too heavy for a frame redrawn every ~380ms.
163
+ */
164
+ export function renderPinchArt(state, opts = {}) {
165
+ let grid = gridForState(state, 0);
166
+ if (opts.compact) {
167
+ const blank = (row) => [...row].every((c) => c === TRANSPARENT);
168
+ let start = 0;
169
+ let end = grid.length;
170
+ while (end > start && blank(grid[end - 1]))
171
+ end--;
172
+ while (start < end && blank(grid[start]))
173
+ start++;
174
+ grid = grid.slice(start, end);
175
+ }
176
+ const lines = renderGrid(grid);
177
+ if (state === 'confused')
178
+ lines[2] = `${lines[2]} \x1b[1m?${RESET}`;
179
+ return lines.join('\n');
180
+ }
181
+ /**
182
+ * #131 — frame sequence for the "working" animation shown during long waits
183
+ * (the `create` wizard, `--watch`). Reuses the existing r6f state grids: the
184
+ * claws pump up (wave → celebrating) and back down, reading as Pinch busily
185
+ * waving while it works. No new art — just an order over the grids we have.
186
+ */
187
+ export const WORKING_FRAMES = ['thinking', 'wave', 'celebrating', 'wave'];
157
188
  /**
158
189
  * True when it's safe to print Pinch art: a real color-capable interactive
159
190
  * terminal. False under NO_COLOR (https://no-color.org — presence, not
@@ -0,0 +1,17 @@
1
+ export interface PinchAnimation {
2
+ /** Stop the loop and erase the whole animation block, leaving the cursor at
3
+ * its top-left so the caller prints its result on a clean line. Idempotent. */
4
+ stop(): void;
5
+ }
6
+ /**
7
+ * #131 — animate Pinch (a claw-wiggle over the r6f grids) IN PLACE on STDERR
8
+ * while a long operation runs (the `create` wizard, `--watch`). The frame is
9
+ * redrawn every {@link FRAME_MS} via ANSI cursor moves; `stop()` clears it.
10
+ *
11
+ * Contract: the CALLER must have already checked `pinchEnabled()` (TTY /
12
+ * !NO_COLOR) and that the command is NOT `--json` — this writes only to
13
+ * STDERR, so stdout stays pure regardless, but the animation is human-only.
14
+ * The interval is `unref()`'d so it can never keep the process alive on its
15
+ * own.
16
+ */
17
+ export declare function startPinchAnimation(statusText: string): PinchAnimation;
@@ -0,0 +1,56 @@
1
+ import chalk from 'chalk';
2
+ import { renderPinchArt, WORKING_FRAMES } from './pinch.js';
3
+ /** Frame cadence — slow enough to read as a wave, fast enough to feel alive. */
4
+ const FRAME_MS = 380;
5
+ /**
6
+ * #131 — animate Pinch (a claw-wiggle over the r6f grids) IN PLACE on STDERR
7
+ * while a long operation runs (the `create` wizard, `--watch`). The frame is
8
+ * redrawn every {@link FRAME_MS} via ANSI cursor moves; `stop()` clears it.
9
+ *
10
+ * Contract: the CALLER must have already checked `pinchEnabled()` (TTY /
11
+ * !NO_COLOR) and that the command is NOT `--json` — this writes only to
12
+ * STDERR, so stdout stays pure regardless, but the animation is human-only.
13
+ * The interval is `unref()`'d so it can never keep the process alive on its
14
+ * own.
15
+ */
16
+ export function startPinchAnimation(statusText) {
17
+ const frames = WORKING_FRAMES.map((s) => renderPinchArt(s, { compact: true }));
18
+ const artHeight = frames[0].split('\n').length;
19
+ const totalHeight = artHeight + 1; // + the status line below the art
20
+ const out = process.stderr;
21
+ let i = 0;
22
+ const block = (frame) => `${frame}\n${chalk.dim(statusText)}\n`;
23
+ const cursorUp = (n) => {
24
+ if (n > 0)
25
+ out.write(`\x1b[${n}A`);
26
+ };
27
+ // Redraw a frame over the previous one — clear each line to EOL first so a
28
+ // narrower frame can't leave trailing pixels from a wider one.
29
+ const redraw = (frame) => {
30
+ const lines = `${frame}\n${chalk.dim(statusText)}`.split('\n');
31
+ out.write(lines.map((l) => `\x1b[2K${l}`).join('\n') + '\n');
32
+ };
33
+ out.write('\x1b[?25l'); // hide cursor for a flicker-free redraw
34
+ out.write(block(frames[0]));
35
+ const timer = setInterval(() => {
36
+ i += 1;
37
+ cursorUp(totalHeight);
38
+ redraw(frames[i % frames.length]);
39
+ }, FRAME_MS);
40
+ if (typeof timer.unref === 'function')
41
+ timer.unref();
42
+ let stopped = false;
43
+ return {
44
+ stop() {
45
+ if (stopped)
46
+ return;
47
+ stopped = true;
48
+ clearInterval(timer);
49
+ cursorUp(totalHeight); // back to the top of the block
50
+ for (let l = 0; l < totalHeight; l++)
51
+ out.write('\x1b[2K\n'); // erase each line
52
+ cursorUp(totalHeight); // back to top so the caller's next print lands here
53
+ out.write('\x1b[?25h'); // restore the cursor
54
+ },
55
+ };
56
+ }
@@ -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.2.0",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {