@prave/cli 1.4.11 → 1.4.13

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
@@ -23,6 +23,7 @@ import { updateCommand } from './commands/update.js';
23
23
  import { usageHookInstallCommand, usageHookUninstallCommand, usageReportCommand, } from './commands/usage.js';
24
24
  import { whatdoesCommand } from './commands/whatdoes.js';
25
25
  import { whoamiCommand } from './commands/whoami.js';
26
+ import { maybeShowAccountBridge } from './lib/account-bridge.js';
26
27
  import { initAnalytics } from './lib/analytics.js';
27
28
  import { captureAuthSnapshot, nudgeFirstRun } from './lib/nudge.js';
28
29
  import { maybeCheckForUpdate } from './lib/update-check.js';
@@ -244,6 +245,34 @@ program.hook('postAction', async () => {
244
245
  /* nudges are decorative — never block on them */
245
246
  }
246
247
  });
248
+ // Account bridge — pull SIGNED-IN users back to the web dashboard with
249
+ // a rotating tip after authenticated commands. Counterpart to the
250
+ // nudgeFirstRun banner above which targets ANON users.
251
+ //
252
+ // Skip-list: commands that already own a meaningful conversion surface,
253
+ // run silently as hooks (`usage report`), or are themselves about
254
+ // leaving the CLI for the browser (`docs`). `login` is on the list
255
+ // because the first-run banner above is the right surface for a brand
256
+ // new signed-in user — wait until their NEXT command to bridge.
257
+ const BRIDGE_SKIP_COMMANDS = new Set([
258
+ 'login',
259
+ 'logout',
260
+ 'docs',
261
+ 'whoami',
262
+ 'mcp-server',
263
+ 'mcp-install',
264
+ 'report', // `usage report` runs as a hook on every PostToolUse fire
265
+ ]);
266
+ program.hook('postAction', async (_thisCommand, actionCommand) => {
267
+ try {
268
+ if (BRIDGE_SKIP_COMMANDS.has(actionCommand.name()))
269
+ return;
270
+ await maybeShowAccountBridge();
271
+ }
272
+ catch {
273
+ /* bridges are decorative — never block on them */
274
+ }
275
+ });
247
276
  program.parseAsync().catch((err) => {
248
277
  console.error(err.message);
249
278
  process.exit(1);
@@ -0,0 +1,66 @@
1
+ import chalk from 'chalk';
2
+ import { isAuthenticated } from './nudge.js';
3
+ import { readState, writeState } from './state.js';
4
+ /**
5
+ * Account Bridge — pull signed-in CLI users back into the web app.
6
+ *
7
+ * Counterpart to nudge.ts, which targets anonymous users with a sign-up
8
+ * pitch. This module targets *authenticated* users with a "you have an
9
+ * account, here's what you're missing in the dashboard" tip — a soft
10
+ * conversion from CLI-only usage to engaged web user.
11
+ *
12
+ * Rules:
13
+ * • Silent for anonymous users. Always.
14
+ * • Throttled to ~2× per day per machine via `last_bridge_shown_at`
15
+ * in ~/.prave/state.json (12h cool-down between shows).
16
+ * • Skipped on non-TTY (CI, piped output), PRAVE_QUIET=1,
17
+ * PRAVE_TELEMETRY=0, and PRAVE_NO_BRIDGE=1.
18
+ * • Skipped on commands that already have their own conversion
19
+ * surface (login/logout/docs/whoami/usage hook/mcp-server) — the
20
+ * skip-list is consulted by the postAction hook caller, not here.
21
+ * • Prints to stderr so --json output stays clean for callers piping
22
+ * a command into jq.
23
+ */
24
+ const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
25
+ const TIPS = [
26
+ '💡 Audit your stack: https://prave.app/dashboard/intelligence',
27
+ '📊 See real trigger fires per day: https://prave.app/dashboard/intelligence',
28
+ '🔌 Wire Prave into Claude Desktop: prave mcp install',
29
+ '🤝 Share a Skill via magic-link: https://prave.app/dashboard/my-skills',
30
+ '📈 Your stack at a glance: https://prave.app/dashboard',
31
+ ];
32
+ let alreadyShownThisProcess = false;
33
+ /**
34
+ * Try to print the bridge. Safe to call from a postAction hook on
35
+ * every command — internal gates make it cheap (no I/O when the env
36
+ * disables it, single state read on the hot path).
37
+ */
38
+ export async function maybeShowAccountBridge() {
39
+ if (alreadyShownThisProcess)
40
+ return;
41
+ if (!process.stdout.isTTY)
42
+ return;
43
+ if (process.env.PRAVE_QUIET === '1')
44
+ return;
45
+ if (process.env.PRAVE_TELEMETRY === '0')
46
+ return;
47
+ if (process.env.PRAVE_NO_BRIDGE === '1')
48
+ return;
49
+ // Anonymous users get the existing nudge from nudge.ts — they don't
50
+ // need an account bridge until they have an account.
51
+ if (!(await isAuthenticated()))
52
+ return;
53
+ const state = await readState();
54
+ const last = state.last_bridge_shown_at ?? 0;
55
+ if (Date.now() - last < TWELVE_HOURS_MS)
56
+ return;
57
+ const tip = TIPS[Math.floor(Math.random() * TIPS.length)];
58
+ if (!tip)
59
+ return;
60
+ // stderr so JSON consumers (`prave list --json | jq ...`) get clean
61
+ // stdout. Padding above + below gives the tip breathing room without
62
+ // a heavy box rule — this is a hint, not a wall.
63
+ process.stderr.write(`\n ${chalk.dim(tip)}\n\n`);
64
+ alreadyShownThisProcess = true;
65
+ await writeState({ last_bridge_shown_at: Date.now() });
66
+ }
package/dist/lib/api.js CHANGED
@@ -103,6 +103,37 @@ async function ensureFreshAccessToken(creds) {
103
103
  const refreshed = await refreshTokens();
104
104
  return refreshed ?? creds;
105
105
  }
106
+ /**
107
+ * Process-once gate for buffered-telemetry replay. We kick off the
108
+ * flush the first time any authenticated call succeeds — that drains
109
+ * `~/.prave/telemetry-queue.jsonl` opportunistically without forcing
110
+ * the user through a fresh `prave login` after every offline window.
111
+ *
112
+ * Flag is set BEFORE the import to ensure that the POSTs made by the
113
+ * flush itself don't recursively re-trigger the kickoff (each goes
114
+ * back through `call()` which sees the flag already set).
115
+ */
116
+ let telemetryFlushKickedOff = false;
117
+ function kickoffTelemetryFlush() {
118
+ if (telemetryFlushKickedOff)
119
+ return;
120
+ telemetryFlushKickedOff = true;
121
+ // Fire-and-forget. Node keeps the event loop alive until the
122
+ // dynamic-import + HTTP POSTs resolve, so the process won't exit
123
+ // mid-flush — but the synchronous command output already printed
124
+ // before we got here. Telemetry-buffer atomic-removes only after
125
+ // each successful POST, so an aborted flush leaves unsynced events
126
+ // in place for next run.
127
+ void (async () => {
128
+ try {
129
+ const { flushBufferedTelemetry } = await import('./flush-telemetry.js');
130
+ await flushBufferedTelemetry();
131
+ }
132
+ catch {
133
+ /* never let a telemetry hiccup surface to the user */
134
+ }
135
+ })();
136
+ }
106
137
  async function call(method, path, body, withAuth = false, attempt = 0) {
107
138
  const headers = { 'Content-Type': 'application/json' };
108
139
  if (withAuth) {
@@ -118,8 +149,11 @@ async function call(method, path, body, withAuth = false, attempt = 0) {
118
149
  body: body ? JSON.stringify(body) : undefined,
119
150
  });
120
151
  const text = await resBody.text();
121
- if (statusCode === 204)
152
+ if (statusCode === 204) {
153
+ if (withAuth)
154
+ kickoffTelemetryFlush();
122
155
  return { data: undefined, status: statusCode };
156
+ }
123
157
  const payload = text ? JSON.parse(text) : { success: true, data: null, error: null };
124
158
  // 401 with a refresh token on file → swap the access token and retry once.
125
159
  // Any other status, or a second 401, falls through to the normal error path.
@@ -131,6 +165,10 @@ async function call(method, path, body, withAuth = false, attempt = 0) {
131
165
  if (statusCode >= 400 || payload.success === false) {
132
166
  throw new ApiError(payload.error ?? `HTTP ${statusCode}`, statusCode);
133
167
  }
168
+ // Successful authenticated call — opportunistic moment to drain the
169
+ // offline telemetry buffer. No-op when there's nothing pending.
170
+ if (withAuth)
171
+ kickoffTelemetryFlush();
134
172
  return { data: payload.data, status: statusCode };
135
173
  }
136
174
  export const api = {
@@ -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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prave/cli",
3
- "version": "1.4.11",
3
+ "version": "1.4.13",
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.11"
57
+ "@prave/shared": "1.4.13"
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
  }