@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 +29 -0
- package/dist/lib/account-bridge.js +66 -0
- package/dist/lib/api.js +39 -1
- package/dist/lib/credentials.js +29 -4
- package/package.json +3 -3
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 = {
|
package/dist/lib/credentials.js
CHANGED
|
@@ -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
|
-
|
|
6
|
-
await
|
|
7
|
-
|
|
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.
|
|
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.
|
|
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
|
}
|