@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.
- package/dist/commands/create.js +20 -15
- package/dist/commands/upgrade.js +2 -28
- package/dist/index.js +18 -0
- package/dist/lib/config.d.ts +5 -0
- package/dist/lib/pinch.d.ts +17 -0
- package/dist/lib/pinch.js +31 -0
- package/dist/lib/pinchAnimation.d.ts +17 -0
- package/dist/lib/pinchAnimation.js +56 -0
- package/dist/lib/updateCheckWorker.d.ts +1 -0
- package/dist/lib/updateCheckWorker.js +30 -0
- package/dist/lib/updateNotifier.d.ts +20 -0
- package/dist/lib/updateNotifier.js +150 -0
- package/dist/lib/version.d.ts +15 -0
- package/dist/lib/version.js +36 -0
- package/package.json +1 -1
package/dist/commands/create.js
CHANGED
|
@@ -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 —
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
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 =
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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);
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -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
|
|
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)) {
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/lib/pinch.d.ts
CHANGED
|
@@ -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
|
+
}
|