@prave/cli 1.4.10 → 1.4.12

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/index.js CHANGED
@@ -25,6 +25,7 @@ import { whatdoesCommand } from './commands/whatdoes.js';
25
25
  import { whoamiCommand } from './commands/whoami.js';
26
26
  import { initAnalytics } from './lib/analytics.js';
27
27
  import { captureAuthSnapshot, nudgeFirstRun } from './lib/nudge.js';
28
+ import { maybeCheckForUpdate } from './lib/update-check.js';
28
29
  const __dirname = dirname(fileURLToPath(import.meta.url));
29
30
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
30
31
  initAnalytics(pkg.version);
@@ -217,6 +218,19 @@ program.hook('preAction', async () => {
217
218
  /* never block the command on nudge bookkeeping */
218
219
  }
219
220
  });
221
+ // Once-a-day npm update check. Most invocations short-circuit on the
222
+ // 24-hour throttle stored in ~/.prave/config.json; when the throttle
223
+ // has elapsed, a short fetch to the npm registry compares versions
224
+ // and prints a one-line upgrade hint at the end of the command. Hard
225
+ // 2-second timeout so a slow registry can never block the user.
226
+ program.hook('postAction', async () => {
227
+ try {
228
+ await maybeCheckForUpdate(pkg.version);
229
+ }
230
+ catch {
231
+ /* nudges + update checks are decorative — never block on them */
232
+ }
233
+ });
220
234
  // Global first-run banner. Fires once on the user's very first command
221
235
  // regardless of which one it was — catches commands that don't have a
222
236
  // per-action nudge wired in (e.g. `prave docs`, `prave conflicts`).
@@ -1,14 +1,39 @@
1
- import { chmod, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
1
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
3
  import { CONFIG } from './config.js';
4
+ /**
5
+ * Persist credentials atomically.
6
+ *
7
+ * The PostToolUse hook fires once per tool call, so two concurrent CLI
8
+ * processes refreshing the access token at the same second is the
9
+ * common case, not the edge. A plain `writeFile` then leaves the file
10
+ * in a partial / interleaved state for the brief window the bytes are
11
+ * being flushed — a concurrent `loadCredentials` reads that partial
12
+ * blob, `JSON.parse` throws, the catch swallows it and returns null,
13
+ * and the user is silently treated as logged out for every subsequent
14
+ * hook fire until they manually run `prave login` again.
15
+ *
16
+ * Writing to a sibling temp file and renaming over the target is
17
+ * atomic on POSIX (rename(2)) so readers either see the previous
18
+ * complete file or the new complete file — never a half-written one.
19
+ */
4
20
  export async function saveCredentials(creds) {
5
- await mkdir(dirname(CONFIG.credentialsPath), { recursive: true });
6
- await writeFile(CONFIG.credentialsPath, JSON.stringify(creds, null, 2), 'utf8');
7
- await chmod(CONFIG.credentialsPath, 0o600);
21
+ const target = CONFIG.credentialsPath;
22
+ await mkdir(dirname(target), { recursive: true });
23
+ // Per-process tmp suffix so two parallel writers don't stomp each
24
+ // other's tmp file — the OS still serialises the final rename.
25
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
26
+ await writeFile(tmp, JSON.stringify(creds, null, 2), 'utf8');
27
+ await chmod(tmp, 0o600);
28
+ await rename(tmp, target);
8
29
  }
9
30
  export async function loadCredentials() {
10
31
  try {
11
32
  const raw = await readFile(CONFIG.credentialsPath, 'utf8');
33
+ // Belt + suspenders: even with atomic writes, a credentials file
34
+ // can land truncated if a previous CLI version was killed mid-write
35
+ // or the FS had a power loss. Treat unparseable content as logged
36
+ // out instead of crashing — the next `prave login` repairs it.
12
37
  return JSON.parse(raw);
13
38
  }
14
39
  catch {
@@ -0,0 +1,129 @@
1
+ import chalk from 'chalk';
2
+ import { readLocalConfig, writeLocalConfigPatch } from './local-config.js';
3
+ /**
4
+ * Once-a-day npm update check.
5
+ *
6
+ * A real "first command in a fresh terminal session" hook would need
7
+ * shell integration we don't ship. The user-visible behaviour is
8
+ * identical with a 24-hour timestamp throttle: the first `prave <cmd>`
9
+ * the user runs on any given day reaches out to npm, compares versions,
10
+ * and prints a one-line dim hint if a newer release exists. Subsequent
11
+ * commands inside the same window skip the network round-trip entirely.
12
+ *
13
+ * Rules:
14
+ * • Hard cap on round-trip time (2 s). A slow npm registry must NEVER
15
+ * block the user's command. The promise is fire-and-forget from the
16
+ * caller's perspective — we always return cleanly.
17
+ * • Skipped when stdout isn't a TTY (pipes, CI), or when
18
+ * PRAVE_QUIET=1 / PRAVE_TELEMETRY=0 is set.
19
+ * • The check itself runs through `node fetch` so we don't take a
20
+ * dependency on a specific HTTP client (the CLI's `api.ts` is
21
+ * auth-aware and overkill for a public, unauthenticated GET).
22
+ * • Persists `last_update_check` (unix ms) in ~/.prave/config.json
23
+ * regardless of outcome — a network blip is treated like a clean
24
+ * "you're up to date" so we don't re-spam npm every command after
25
+ * a failure.
26
+ */
27
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
28
+ const REQUEST_TIMEOUT_MS = 2_000;
29
+ const NPM_PACKAGE = '@prave/cli';
30
+ /**
31
+ * Returns true if the throttle has elapsed and we should hit npm now.
32
+ * Reads-and-writes the config so concurrent invocations from the same
33
+ * shell don't double-fire (last-write-wins is fine here).
34
+ */
35
+ async function dueForCheck() {
36
+ const cfg = await readLocalConfig();
37
+ const last = typeof cfg.last_update_check === 'number' ? cfg.last_update_check : 0;
38
+ return Date.now() - last >= CHECK_INTERVAL_MS;
39
+ }
40
+ /**
41
+ * SemVer-ish comparator. Returns:
42
+ * < 0 if a < b
43
+ * 0 if equal
44
+ * > 0 if a > b
45
+ *
46
+ * Tolerates pre-release tails (`1.4.9-rc.1`) by lexicographically
47
+ * comparing them when the numeric tuples tie. Sufficient for the
48
+ * "newer is available" hint; we don't ship a strict semver library.
49
+ */
50
+ function compareVersions(a, b) {
51
+ const [aNum, aPre = ''] = a.split('-', 2);
52
+ const [bNum, bPre = ''] = b.split('-', 2);
53
+ const aParts = aNum.split('.').map((n) => parseInt(n, 10));
54
+ const bParts = bNum.split('.').map((n) => parseInt(n, 10));
55
+ const length = Math.max(aParts.length, bParts.length);
56
+ for (let i = 0; i < length; i++) {
57
+ const x = aParts[i] ?? 0;
58
+ const y = bParts[i] ?? 0;
59
+ if (Number.isNaN(x) || Number.isNaN(y))
60
+ return 0; // unparseable → treat as equal
61
+ if (x !== y)
62
+ return x - y;
63
+ }
64
+ // Numeric tuples equal — a release version > any pre-release of the
65
+ // same number ("1.4.9" > "1.4.9-rc.1"), and pre-releases sort by
66
+ // lexicographic order ("1.4.9-rc.1" < "1.4.9-rc.2").
67
+ if (aPre === bPre)
68
+ return 0;
69
+ if (!aPre)
70
+ return 1;
71
+ if (!bPre)
72
+ return -1;
73
+ return aPre < bPre ? -1 : 1;
74
+ }
75
+ async function fetchLatestVersion() {
76
+ // npm's registry returns the latest version in dist-tags without
77
+ // requiring an unauthenticated header; this is the same endpoint
78
+ // `npm view @prave/cli version` hits.
79
+ const url = `https://registry.npmjs.org/-/package/${encodeURIComponent(NPM_PACKAGE)}/dist-tags`;
80
+ const ctrl = new AbortController();
81
+ const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
82
+ try {
83
+ const res = await fetch(url, { signal: ctrl.signal });
84
+ if (!res.ok)
85
+ return null;
86
+ const json = (await res.json());
87
+ return typeof json.latest === 'string' ? json.latest : null;
88
+ }
89
+ catch {
90
+ // Aborted, offline, registry down, JSON broken — treat as "no update".
91
+ return null;
92
+ }
93
+ finally {
94
+ clearTimeout(timer);
95
+ }
96
+ }
97
+ function printUpgradeNotice(current, latest) {
98
+ const rule = chalk.dim('─'.repeat(60));
99
+ console.log();
100
+ console.log(rule);
101
+ console.log(`${chalk.yellow('↑')} ${chalk.bold('Prave CLI update available')} ` +
102
+ `${chalk.dim(current)} ${chalk.dim('→')} ${chalk.green(latest)}`);
103
+ console.log(chalk.dim(` Run npm install -g ${NPM_PACKAGE}@latest`));
104
+ console.log(rule);
105
+ }
106
+ /**
107
+ * Public entry. Cheap to call: most invocations short-circuit on the
108
+ * throttle and don't touch the network. Caller is expected to `await`
109
+ * but the promise resolves quickly in the steady state.
110
+ */
111
+ export async function maybeCheckForUpdate(currentVersion) {
112
+ if (!process.stdout.isTTY)
113
+ return;
114
+ if (process.env.PRAVE_QUIET === '1')
115
+ return;
116
+ if (process.env.PRAVE_TELEMETRY === '0')
117
+ return;
118
+ if (!(await dueForCheck()))
119
+ return;
120
+ // Record the attempt up-front so a network hang doesn't cause us to
121
+ // re-fire on every subsequent command before this one resolves.
122
+ await writeLocalConfigPatch({ last_update_check: Date.now() });
123
+ const latest = await fetchLatestVersion();
124
+ if (!latest)
125
+ return;
126
+ if (compareVersions(latest, currentVersion) <= 0)
127
+ return;
128
+ printUpgradeNotice(currentVersion, latest);
129
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prave/cli",
3
- "version": "1.4.10",
3
+ "version": "1.4.12",
4
4
  "description": "Prave CLI — discover, install, version, test, and ship Claude Skills. The developer platform for the complete Skill lifecycle.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -54,7 +54,7 @@
54
54
  "ora": "^8.0.1",
55
55
  "tar": "^7.4.3",
56
56
  "undici": "^6.18.0",
57
- "@prave/shared": "1.4.10"
57
+ "@prave/shared": "1.4.12"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^20.12.7",
@@ -71,6 +71,6 @@
71
71
  "build": "tsc -p tsconfig.json && node scripts/inject-config.mjs",
72
72
  "typecheck": "tsc -p tsconfig.json --noEmit",
73
73
  "lint": "tsc -p tsconfig.json --noEmit",
74
- "postinstall": "node scripts/postinstall.mjs || true"
74
+ "postinstall": "test -f scripts/postinstall.mjs && node scripts/postinstall.mjs || true"
75
75
  }
76
76
  }