@prave/cli 1.4.6 → 1.4.8
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/import.js +5 -5
- package/dist/commands/install.js +9 -18
- package/dist/commands/list.js +2 -2
- package/dist/commands/login.js +1 -0
- package/dist/commands/overview.js +5 -0
- package/dist/commands/search.js +2 -2
- package/dist/commands/sync.js +7 -28
- package/dist/commands/update.js +1 -1
- package/dist/commands/whatdoes.js +2 -2
- package/dist/commands/whoami.js +1 -1
- package/dist/index.js +15 -34
- package/dist/lib/nudge-constants.js +88 -0
- package/dist/lib/nudge.js +143 -34
- package/package.json +2 -2
- package/dist/commands/deploy.js +0 -189
- package/dist/commands/run.js +0 -481
package/dist/commands/import.js
CHANGED
|
@@ -123,17 +123,17 @@ export async function importCommand(opts) {
|
|
|
123
123
|
const me = await fetchMyPlan();
|
|
124
124
|
if (!me.limits.can_authoring_public && !me.limits.can_authoring_private) {
|
|
125
125
|
log.warn('Uploading Skills requires the Pro plan or higher.');
|
|
126
|
-
log.dim(formatUpgradeHint('
|
|
126
|
+
log.dim(formatUpgradeHint('pro'));
|
|
127
127
|
return;
|
|
128
128
|
}
|
|
129
129
|
if (visibility === 'private' && !me.limits.can_authoring_private) {
|
|
130
130
|
log.warn('Private uploads require the Pro plan.');
|
|
131
|
-
log.dim(formatUpgradeHint('
|
|
131
|
+
log.dim(formatUpgradeHint('pro'));
|
|
132
132
|
return;
|
|
133
133
|
}
|
|
134
134
|
if (visibility === 'public' && !me.limits.can_authoring_public) {
|
|
135
135
|
log.warn('Public uploads require the Pro plan.');
|
|
136
|
-
log.dim(formatUpgradeHint('
|
|
136
|
+
log.dim(formatUpgradeHint('pro'));
|
|
137
137
|
return;
|
|
138
138
|
}
|
|
139
139
|
// Clamp the queue to the caller's authoring ceiling. `null` = unlimited
|
|
@@ -142,7 +142,7 @@ export async function importCommand(opts) {
|
|
|
142
142
|
if (me.limits.authoring_max_skills !== null &&
|
|
143
143
|
queue.length > me.limits.authoring_max_skills) {
|
|
144
144
|
log.warn(`Your ${me.limits.label} plan caps authored Skills at ${me.limits.authoring_max_skills}. Trimming queue from ${queue.length} → ${me.limits.authoring_max_skills}.`);
|
|
145
|
-
log.dim(formatUpgradeHint('
|
|
145
|
+
log.dim(formatUpgradeHint('pro'));
|
|
146
146
|
queue = queue.slice(0, me.limits.authoring_max_skills);
|
|
147
147
|
}
|
|
148
148
|
const uploadSpinner = ora(`Uploading ${queue.length} skills as ${visibility}…`).start();
|
|
@@ -191,7 +191,7 @@ export async function importCommand(opts) {
|
|
|
191
191
|
.join(' · ');
|
|
192
192
|
uploadSpinner.succeed(tail);
|
|
193
193
|
if (gated > 0)
|
|
194
|
-
log.dim(formatUpgradeHint('
|
|
194
|
+
log.dim(formatUpgradeHint('pro'));
|
|
195
195
|
// Always analyze after upload — best effort, never blocks the user.
|
|
196
196
|
const analyzeSpinner = ora(`Analyzing ${queue.length} skills…`).start();
|
|
197
197
|
for (const s of queue) {
|
package/dist/commands/install.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { createInterface } from 'node:readline/promises';
|
|
4
3
|
import chalk from 'chalk';
|
|
5
4
|
import ora from 'ora';
|
|
6
5
|
import { track } from '../lib/analytics.js';
|
|
7
6
|
import { api, ApiError } from '../lib/api.js';
|
|
8
7
|
import { CONFIG } from '../lib/config.js';
|
|
9
8
|
import { loadCredentials, requireAuth } from '../lib/credentials.js';
|
|
9
|
+
import { nudgeAfter } from '../lib/nudge.js';
|
|
10
10
|
import { fetchMyPlan, formatUpgradeHint } from '../lib/plan.js';
|
|
11
11
|
import { assertSlug, InvalidSlugError } from '../lib/slug.js';
|
|
12
12
|
import { log } from '../utils/logger.js';
|
|
@@ -51,7 +51,7 @@ export async function installCommand(slug, opts = {}) {
|
|
|
51
51
|
const remaining = data.installs.remaining;
|
|
52
52
|
if (remaining !== null && remaining <= 0) {
|
|
53
53
|
log.warn(`Your ${me.limits.label} plan caps installs at ${me.limits.install_monthly_limit}/month. You've used all of them.`);
|
|
54
|
-
log.dim(formatUpgradeHint('
|
|
54
|
+
log.dim(formatUpgradeHint('pro'));
|
|
55
55
|
process.exitCode = 1;
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
@@ -102,22 +102,13 @@ export async function installCommand(slug, opts = {}) {
|
|
|
102
102
|
/* file unreadable — skip */
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const ans = (await rl.question('\nDeploy to all configured agents? [y/N] ')).trim().toLowerCase();
|
|
113
|
-
if (ans === 'y' || ans === 'yes') {
|
|
114
|
-
const { deployCommand } = await import('./deploy.js');
|
|
115
|
-
await deployCommand(slug, {});
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
finally {
|
|
119
|
-
rl.close();
|
|
120
|
-
}
|
|
105
|
+
// `prave deploy` was retired — install fans out to the user's
|
|
106
|
+
// configured agent targets directly (see multi_agent_targets in
|
|
107
|
+
// PLAN_LIMITS), so no follow-up prompt is needed.
|
|
108
|
+
// Defensive: requireAuth above already blocks unauth callers, so this
|
|
109
|
+
// is a no-op today. Keeps the nudge consistent if install ever opens
|
|
110
|
+
// to anonymous use (it's safe — `nudgeAfter` self-gates on auth).
|
|
111
|
+
await nudgeAfter('install');
|
|
121
112
|
}
|
|
122
113
|
async function pullOne(slug, ctx) {
|
|
123
114
|
const { data: skill } = await api.get(`/api/v1/skills/${encodeURIComponent(slug)}`, ctx.hasSession);
|
package/dist/commands/list.js
CHANGED
|
@@ -5,7 +5,7 @@ import { tokenTier } from '@prave/shared';
|
|
|
5
5
|
import { track } from '../lib/analytics.js';
|
|
6
6
|
import { api } from '../lib/api.js';
|
|
7
7
|
import { CONFIG } from '../lib/config.js';
|
|
8
|
-
import {
|
|
8
|
+
import { nudgeAfter } from '../lib/nudge.js';
|
|
9
9
|
import { log } from '../utils/logger.js';
|
|
10
10
|
const TIER_EMOJI = {
|
|
11
11
|
lean: '🟢',
|
|
@@ -69,7 +69,7 @@ export async function listCommand(opts = {}) {
|
|
|
69
69
|
console.log(` ${chalk.cyan('•')} ${name}`);
|
|
70
70
|
}
|
|
71
71
|
log.dim(`\n${localSlugs.length} local skill${localSlugs.length === 1 ? '' : 's'} in ${CONFIG.skillsDir}`);
|
|
72
|
-
await
|
|
72
|
+
await nudgeAfter('list');
|
|
73
73
|
return;
|
|
74
74
|
}
|
|
75
75
|
// Enriched path — pull intelligence and merge by slug (best-effort).
|
package/dist/commands/login.js
CHANGED
|
@@ -39,6 +39,7 @@ export async function loginCommand() {
|
|
|
39
39
|
expires_at: data.expires_at ?? undefined,
|
|
40
40
|
});
|
|
41
41
|
spinner.succeed('Logged in.');
|
|
42
|
+
log.dim("Nudges disabled — you're all set.");
|
|
42
43
|
// Replay any Skill-invocation events the hook buffered while the
|
|
43
44
|
// user was offline / signed out. Silent when nothing's queued;
|
|
44
45
|
// prints "Syncing N events…" + a confirmation when the file has
|
|
@@ -3,6 +3,7 @@ import ora from 'ora';
|
|
|
3
3
|
import { track } from '../lib/analytics.js';
|
|
4
4
|
import { api, ApiError } from '../lib/api.js';
|
|
5
5
|
import { requireAuth } from '../lib/credentials.js';
|
|
6
|
+
import { nudgeAfter } from '../lib/nudge.js';
|
|
6
7
|
import { log } from '../utils/logger.js';
|
|
7
8
|
function formatThousands(n) {
|
|
8
9
|
if (n < 1000)
|
|
@@ -71,5 +72,9 @@ export async function overviewCommand(opts = {}) {
|
|
|
71
72
|
spinner.stop();
|
|
72
73
|
log.error(err instanceof ApiError ? err.message : err.message);
|
|
73
74
|
process.exitCode = 1;
|
|
75
|
+
return;
|
|
74
76
|
}
|
|
77
|
+
// No-op for signed-in users (overview requires auth), but harmless and
|
|
78
|
+
// future-proof if we ever expose a public overview-equivalent.
|
|
79
|
+
await nudgeAfter('overview');
|
|
75
80
|
}
|
package/dist/commands/search.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { track } from '../lib/analytics.js';
|
|
3
3
|
import { api } from '../lib/api.js';
|
|
4
|
-
import {
|
|
4
|
+
import { nudgeAfter } from '../lib/nudge.js';
|
|
5
5
|
import { log } from '../utils/logger.js';
|
|
6
6
|
const SLUG_COL = 32;
|
|
7
7
|
function formatInstalls(n) {
|
|
@@ -42,5 +42,5 @@ export async function searchCommand(query) {
|
|
|
42
42
|
if (skills.length > 0) {
|
|
43
43
|
console.log(chalk.dim(' → prave install <slug>'));
|
|
44
44
|
}
|
|
45
|
-
await
|
|
45
|
+
await nudgeAfter('search');
|
|
46
46
|
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -72,11 +72,11 @@ export async function syncCommand() {
|
|
|
72
72
|
const session = await requireAuth('prave sync');
|
|
73
73
|
if (!session)
|
|
74
74
|
return;
|
|
75
|
-
// Plan gate: sync requires
|
|
75
|
+
// Plan gate: sync requires Pro or higher.
|
|
76
76
|
const me = await fetchMyPlan();
|
|
77
77
|
if (!me.limits.can_cli_sync) {
|
|
78
|
-
log.warn('Sync requires the
|
|
79
|
-
log.dim(formatUpgradeHint('
|
|
78
|
+
log.warn('Sync requires the Pro plan or higher.');
|
|
79
|
+
log.dim(formatUpgradeHint('pro'));
|
|
80
80
|
return;
|
|
81
81
|
}
|
|
82
82
|
// Last-sync nudge. We read state up-front because if the user bails on
|
|
@@ -134,19 +134,10 @@ export async function syncCommand() {
|
|
|
134
134
|
// Pre-flight time estimate — sets expectations before the user hits Y.
|
|
135
135
|
const estSeconds = estimateSeconds(slugs.length);
|
|
136
136
|
console.log(chalk.dim(`Syncing ${slugs.length} Skill${slugs.length === 1 ? '' : 's'} — this takes about ${estSeconds} seconds.`));
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const ans = (await rl.question(`\nAfter syncing, deploy all ${slugs.length} Skills to your configured agents? [y/N] `))
|
|
142
|
-
.trim()
|
|
143
|
-
.toLowerCase();
|
|
144
|
-
deployAfter = ans === 'y' || ans === 'yes';
|
|
145
|
-
}
|
|
146
|
-
finally {
|
|
147
|
-
rl.close();
|
|
148
|
-
}
|
|
149
|
-
// Parallel install with live progress counter.
|
|
137
|
+
// Parallel install with live progress counter. The legacy post-sync
|
|
138
|
+
// "deploy to all agents?" prompt is gone — `prave deploy` has been
|
|
139
|
+
// retired and `installCommand` already fans out to the user's
|
|
140
|
+
// configured agent targets directly.
|
|
150
141
|
const progress = ora(`Installed 0 / ${slugs.length}`).start();
|
|
151
142
|
let done = 0;
|
|
152
143
|
let updated = 0;
|
|
@@ -174,18 +165,6 @@ export async function syncCommand() {
|
|
|
174
165
|
// *did* attempt a sync, and we want the cooldown to apply to retries
|
|
175
166
|
// just as much as to the happy path.
|
|
176
167
|
await writeState({ last_sync_at: new Date().toISOString() }).catch(() => { });
|
|
177
|
-
if (deployAfter && updated > 0) {
|
|
178
|
-
const { deployCommand } = await import('./deploy.js');
|
|
179
|
-
log.info(`\nDeploying ${updated} Skills to configured agents…`);
|
|
180
|
-
for (const slug of slugs) {
|
|
181
|
-
try {
|
|
182
|
-
await deployCommand(slug, {});
|
|
183
|
-
}
|
|
184
|
-
catch (err) {
|
|
185
|
-
log.warn(` ✗ deploy ${slug} — ${err.message}`);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
168
|
// Tail end of sync: fire a quiet usage scan so the optimiser stays warm
|
|
190
169
|
// without the user having to remember an extra command. Quiet mode means
|
|
191
170
|
// we only log a one-liner ("Usage: 12 new, 88 known.") instead of taking
|
package/dist/commands/update.js
CHANGED
|
@@ -30,7 +30,7 @@ export async function updateCommand(slug, opts = {}) {
|
|
|
30
30
|
const me = await fetchMyPlan();
|
|
31
31
|
if (!me.limits.can_cli_update) {
|
|
32
32
|
log.warn(`\`prave update\` requires the Pro plan or higher.`);
|
|
33
|
-
log.dim(formatUpgradeHint('
|
|
33
|
+
log.dim(formatUpgradeHint('pro'));
|
|
34
34
|
process.exitCode = 1;
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
@@ -3,7 +3,7 @@ import ora from 'ora';
|
|
|
3
3
|
import { tokenTier } from '@prave/shared';
|
|
4
4
|
import { track } from '../lib/analytics.js';
|
|
5
5
|
import { api, ApiError } from '../lib/api.js';
|
|
6
|
-
import {
|
|
6
|
+
import { nudgeAfter } from '../lib/nudge.js';
|
|
7
7
|
import { log } from '../utils/logger.js';
|
|
8
8
|
const TIER_BADGE = {
|
|
9
9
|
lean: chalk.green('🟢 Lean'),
|
|
@@ -72,7 +72,7 @@ export async function whatdoesCommand(skillName) {
|
|
|
72
72
|
console.log(`🔗 Requires: ${requires}`);
|
|
73
73
|
console.log(`${data.conflicts.length > 0 ? chalk.yellow('⚠️ ') : '⚠️ '}Conflicts: ${conflicts}`);
|
|
74
74
|
console.log(chalk.dim(RULE));
|
|
75
|
-
await
|
|
75
|
+
await nudgeAfter('whatdoes');
|
|
76
76
|
}
|
|
77
77
|
catch (err) {
|
|
78
78
|
spinner.stop();
|
package/dist/commands/whoami.js
CHANGED
|
@@ -11,7 +11,7 @@ import { log } from '../utils/logger.js';
|
|
|
11
11
|
* is the only thing that ever surfaces in UI / CLI copy.
|
|
12
12
|
*/
|
|
13
13
|
function planDisplay(plan) {
|
|
14
|
-
if (plan === 'free' || plan === '
|
|
14
|
+
if (plan === 'free' || plan === 'pro' || plan === 'max') {
|
|
15
15
|
return planLabel(plan);
|
|
16
16
|
}
|
|
17
17
|
return plan;
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,6 @@ import { mcpInstallCommand } from './commands/mcp-install.js';
|
|
|
15
15
|
import { mcpServerCommand } from './commands/mcp-server.js';
|
|
16
16
|
import { optimizeCommand } from './commands/optimize.js';
|
|
17
17
|
import { overviewCommand } from './commands/overview.js';
|
|
18
|
-
import { runDeployCommand, runListCommand, runLogsCommand, runTriggerCommand, } from './commands/run.js';
|
|
19
18
|
import { searchCommand } from './commands/search.js';
|
|
20
19
|
import { settingsCommand } from './commands/settings.js';
|
|
21
20
|
import { syncCommand } from './commands/sync.js';
|
|
@@ -25,6 +24,7 @@ import { usageHookInstallCommand, usageHookUninstallCommand, usageReportCommand,
|
|
|
25
24
|
import { whatdoesCommand } from './commands/whatdoes.js';
|
|
26
25
|
import { whoamiCommand } from './commands/whoami.js';
|
|
27
26
|
import { initAnalytics } from './lib/analytics.js';
|
|
27
|
+
import { nudgeFirstRun } from './lib/nudge.js';
|
|
28
28
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
29
|
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
|
|
30
30
|
initAnalytics(pkg.version);
|
|
@@ -40,7 +40,6 @@ const program = new Command()
|
|
|
40
40
|
' prave login # device-code auth in your browser',
|
|
41
41
|
' prave search <q> # discover community skills',
|
|
42
42
|
' prave install <slug> # pull into ~/.claude/skills/ (multi-agent prompt included)',
|
|
43
|
-
' prave run deploy # bundle cwd, schedule on Prave\'s sandbox',
|
|
44
43
|
' prave usage hook install # real-time invocation tracking',
|
|
45
44
|
'',
|
|
46
45
|
' Docs: https://prave.app/docs',
|
|
@@ -151,30 +150,6 @@ program
|
|
|
151
150
|
.command('docs [slug]')
|
|
152
151
|
.description('Open the docs in your browser. Bare `prave docs` lands on the home page; `prave docs cli/run` or `prave docs web/runs` jumps straight to a section.')
|
|
153
152
|
.action((slug) => docsCommand(slug));
|
|
154
|
-
// ─── prave run — scheduled server-side executions (Runs) ──────────
|
|
155
|
-
const run = program
|
|
156
|
-
.command('run')
|
|
157
|
-
.description('Schedule a skill to fire on a cron, executed by your chosen AI agent on Prave\'s sandbox. Bring the whole project — SKILL.md, scripts, .env.');
|
|
158
|
-
run
|
|
159
|
-
.command('deploy [path]')
|
|
160
|
-
.description('Bundle the current directory (or `path`), upload it to Prave, and open the browser wizard to pick the schedule + agent.')
|
|
161
|
-
.action((path) => runDeployCommand(path));
|
|
162
|
-
run
|
|
163
|
-
.command('update <slug> [path]')
|
|
164
|
-
.description("Re-upload the bundle for an existing run. Same flow as `deploy`, but the run's schedule, agent and env vars stay intact — only the project files swap.")
|
|
165
|
-
.action((slug, path) => runDeployCommand(path, { updateRunSlug: slug }));
|
|
166
|
-
run
|
|
167
|
-
.command('list')
|
|
168
|
-
.description('List your scheduled runs with next-fire time + last status.')
|
|
169
|
-
.action(runListCommand);
|
|
170
|
-
run
|
|
171
|
-
.command('logs <slug>')
|
|
172
|
-
.description('Print the latest execution log for a scheduled run.')
|
|
173
|
-
.action(runLogsCommand);
|
|
174
|
-
run
|
|
175
|
-
.command('trigger <slug>')
|
|
176
|
-
.description('Fire a one-shot execution outside the cron schedule. Refused with 409 if another execution is already in flight. Tail with `prave run logs <slug>` afterwards.')
|
|
177
|
-
.action(runTriggerCommand);
|
|
178
153
|
program
|
|
179
154
|
.command('mcp install')
|
|
180
155
|
.alias('mcp-install')
|
|
@@ -219,16 +194,9 @@ program
|
|
|
219
194
|
'Settings',
|
|
220
195
|
' prave settings # configure agents + paths',
|
|
221
196
|
'',
|
|
222
|
-
'Runs (scheduled cron on Prave)',
|
|
223
|
-
' prave run deploy # bundle cwd, upload, open wizard',
|
|
224
|
-
' prave run update <slug> # re-upload bundle for an existing run',
|
|
225
|
-
' prave run trigger <slug> # fire one execution outside the cron',
|
|
226
|
-
' prave run list # your scheduled runs',
|
|
227
|
-
' prave run logs <slug> # tail latest execution log',
|
|
228
|
-
'',
|
|
229
197
|
'Docs',
|
|
230
198
|
' prave docs # open the docs in your browser',
|
|
231
|
-
' prave docs <slug> # jump to a section
|
|
199
|
+
' prave docs <slug> # jump to a section',
|
|
232
200
|
'',
|
|
233
201
|
'Telemetry',
|
|
234
202
|
' PRAVE_TELEMETRY=0 # opt out of CLI usage analytics',
|
|
@@ -236,6 +204,19 @@ program
|
|
|
236
204
|
'Docs: https://prave.app/docs',
|
|
237
205
|
].join('\n'));
|
|
238
206
|
});
|
|
207
|
+
// Global first-run banner. Fires once on the user's very first command
|
|
208
|
+
// regardless of which one it was — catches commands that don't have a
|
|
209
|
+
// per-action nudge wired in (e.g. `prave docs`, `prave conflicts`).
|
|
210
|
+
// Per-command `nudgeAfter` calls also invoke `nudgeFirstRun` first, so
|
|
211
|
+
// the once-per-process guard inside the nudge module prevents doubling.
|
|
212
|
+
program.hook('postAction', async () => {
|
|
213
|
+
try {
|
|
214
|
+
await nudgeFirstRun();
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
/* nudges are decorative — never block on them */
|
|
218
|
+
}
|
|
219
|
+
});
|
|
239
220
|
program.parseAsync().catch((err) => {
|
|
240
221
|
console.error(err.message);
|
|
241
222
|
process.exit(1);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized copy for every CLI conversion nudge.
|
|
3
|
+
*
|
|
4
|
+
* Tuning the funnel = editing this file. The renderer in nudge.ts is dumb
|
|
5
|
+
* and the wiring in command files is dumb; all the messaging lives here so
|
|
6
|
+
* a product person can reword without grokking node/chalk.
|
|
7
|
+
*
|
|
8
|
+
* Soft nudges = single short pitch under the command output. Strong nudges
|
|
9
|
+
* = double-line banner with a bulleted value-prop list. Use strong for
|
|
10
|
+
* commands where the gap between "what they just got locally" and "what
|
|
11
|
+
* they'd get signed-in" is biggest (overview/whatdoes/first-run).
|
|
12
|
+
*/
|
|
13
|
+
/* --------------------------------------------------------------------- */
|
|
14
|
+
/* Soft nudges (Nudge 1–4) — throttled to ~1 in every 3 commands. */
|
|
15
|
+
/* --------------------------------------------------------------------- */
|
|
16
|
+
export const NUDGE_GENERIC = {
|
|
17
|
+
kind: 'soft',
|
|
18
|
+
icon: '💡',
|
|
19
|
+
title: 'Sign in to unlock the full Prave experience',
|
|
20
|
+
cta: 'prave.app/signup · takes 10 seconds',
|
|
21
|
+
};
|
|
22
|
+
export const NUDGE_LIST = {
|
|
23
|
+
kind: 'soft',
|
|
24
|
+
icon: '🔍',
|
|
25
|
+
title: 'Want AI-generated descriptions for each Skill?',
|
|
26
|
+
cta: 'Sign in free at prave.app — no credit card needed.',
|
|
27
|
+
};
|
|
28
|
+
export const NUDGE_SEARCH = {
|
|
29
|
+
kind: 'soft',
|
|
30
|
+
icon: '🚀',
|
|
31
|
+
title: 'Unlock semantic search and find Skills by intent.',
|
|
32
|
+
cta: 'Upgrade to Pro at prave.app — $12/mo',
|
|
33
|
+
};
|
|
34
|
+
export const NUDGE_INSTALL = {
|
|
35
|
+
kind: 'soft',
|
|
36
|
+
icon: '📊',
|
|
37
|
+
title: 'Track token costs and usage for this Skill at prave.app',
|
|
38
|
+
cta: 'Free account · takes 10 seconds · no credit card',
|
|
39
|
+
};
|
|
40
|
+
/* --------------------------------------------------------------------- */
|
|
41
|
+
/* Strong nudges (Nudge 5–6) — always shown to unauthenticated users. */
|
|
42
|
+
/* --------------------------------------------------------------------- */
|
|
43
|
+
export const NUDGE_DEEP = {
|
|
44
|
+
kind: 'strong',
|
|
45
|
+
icon: '✨',
|
|
46
|
+
title: "You're one step away from the full picture.",
|
|
47
|
+
bullets: [
|
|
48
|
+
'AI-generated descriptions for all your Skills',
|
|
49
|
+
'Conflict detection across your library',
|
|
50
|
+
'30-day usage history from PostToolUse hook',
|
|
51
|
+
'Cross-machine sync',
|
|
52
|
+
],
|
|
53
|
+
cta: 'prave.app/signup · free forever · 10 seconds',
|
|
54
|
+
};
|
|
55
|
+
export const NUDGE_FIRST_RUN = {
|
|
56
|
+
kind: 'strong',
|
|
57
|
+
icon: '👋',
|
|
58
|
+
title: "Welcome to Prave! You're running the CLI — nice.",
|
|
59
|
+
bullets: [
|
|
60
|
+
'4,300+ developers are already using it',
|
|
61
|
+
'Discover 1,200+ Skills with semantic search',
|
|
62
|
+
'Audit your token footprint',
|
|
63
|
+
'Sync across all your machines',
|
|
64
|
+
],
|
|
65
|
+
cta: 'prave login ← connect CLI to your account',
|
|
66
|
+
ctaSecondary: 'prave.app/signup ← create free account first',
|
|
67
|
+
};
|
|
68
|
+
/** Picks the right variant for a given context. */
|
|
69
|
+
export function nudgeFor(context) {
|
|
70
|
+
switch (context) {
|
|
71
|
+
case 'list':
|
|
72
|
+
return NUDGE_LIST;
|
|
73
|
+
case 'search':
|
|
74
|
+
return NUDGE_SEARCH;
|
|
75
|
+
case 'install':
|
|
76
|
+
return NUDGE_INSTALL;
|
|
77
|
+
case 'whatdoes':
|
|
78
|
+
case 'overview':
|
|
79
|
+
return NUDGE_DEEP;
|
|
80
|
+
case 'generic':
|
|
81
|
+
default:
|
|
82
|
+
return NUDGE_GENERIC;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** True for nudges that bypass the 1-in-3 throttle. */
|
|
86
|
+
export function isAlwaysShow(context) {
|
|
87
|
+
return context === 'whatdoes' || context === 'overview';
|
|
88
|
+
}
|
package/dist/lib/nudge.js
CHANGED
|
@@ -1,48 +1,157 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
1
2
|
import chalk from 'chalk';
|
|
3
|
+
import { CONFIG } from './config.js';
|
|
2
4
|
import { loadCredentials } from './credentials.js';
|
|
5
|
+
import { isAlwaysShow, NUDGE_FIRST_RUN, nudgeFor, } from './nudge-constants.js';
|
|
3
6
|
let alreadyNudged = false;
|
|
4
|
-
|
|
7
|
+
/* --------------------------------------------------------------------- */
|
|
8
|
+
/* Auth + state helpers */
|
|
9
|
+
/* --------------------------------------------------------------------- */
|
|
10
|
+
export async function isAuthenticated() {
|
|
11
|
+
const creds = await loadCredentials();
|
|
12
|
+
if (!creds?.access_token)
|
|
13
|
+
return false;
|
|
14
|
+
// expires_at is unix-seconds. If absent (pre-refresh-token creds) we
|
|
15
|
+
// assume valid — the API call itself will 401 and trigger re-login.
|
|
16
|
+
if (typeof creds.expires_at === 'number' && creds.expires_at < Math.floor(Date.now() / 1000)) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
async function readConfig() {
|
|
22
|
+
try {
|
|
23
|
+
const raw = await readFile(CONFIG.configPath, 'utf8');
|
|
24
|
+
const parsed = JSON.parse(raw);
|
|
25
|
+
if (parsed && typeof parsed === 'object')
|
|
26
|
+
return parsed;
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function writeConfigPatch(patch) {
|
|
34
|
+
const existing = await readConfig();
|
|
35
|
+
const next = { ...existing, ...patch };
|
|
36
|
+
await mkdir(CONFIG.praveDir, { recursive: true, mode: 0o700 });
|
|
37
|
+
await writeFile(CONFIG.configPath, JSON.stringify(next, null, 2), 'utf8');
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Counter-based throttle for soft nudges. Reads the current count,
|
|
41
|
+
* increments, persists, and returns true when (count % 3 === 0) — so
|
|
42
|
+
* roughly every third invocation. The first invocation returns false:
|
|
43
|
+
* we'd rather skip than be loud on the user's first real command.
|
|
44
|
+
*/
|
|
45
|
+
export async function shouldShowNudge() {
|
|
46
|
+
const cfg = await readConfig();
|
|
47
|
+
const current = typeof cfg.nudge_count === 'number' ? cfg.nudge_count : 0;
|
|
48
|
+
const next = current + 1;
|
|
49
|
+
await writeConfigPatch({ nudge_count: next });
|
|
50
|
+
return next % 3 === 0;
|
|
51
|
+
}
|
|
52
|
+
/* --------------------------------------------------------------------- */
|
|
53
|
+
/* Renderer */
|
|
54
|
+
/* --------------------------------------------------------------------- */
|
|
55
|
+
function makeRule(char, width = 60) {
|
|
56
|
+
return char.repeat(width);
|
|
57
|
+
}
|
|
58
|
+
function renderSoft(n) {
|
|
59
|
+
const rule = chalk.dim(makeRule('─'));
|
|
60
|
+
const title = chalk.dim(`${n.icon} ${n.title}`);
|
|
61
|
+
const cta = chalk.dim(` ${n.cta}`);
|
|
62
|
+
return [rule, title, cta, rule].join('\n');
|
|
63
|
+
}
|
|
64
|
+
function renderStrong(n) {
|
|
65
|
+
const rule = chalk.cyan(makeRule('═'));
|
|
66
|
+
const title = chalk.bold(`${n.icon} ${n.title}`);
|
|
67
|
+
const bullets = n.bullets.map((b) => chalk.dim(` → ${b}`)).join('\n');
|
|
68
|
+
const cta = chalk.cyan(` ${n.cta}`);
|
|
69
|
+
const cta2 = n.ctaSecondary ? '\n' + chalk.cyan(` ${n.ctaSecondary}`) : '';
|
|
70
|
+
return [rule, title, '', bullets, '', cta + cta2, rule].join('\n');
|
|
71
|
+
}
|
|
72
|
+
export function showNudge(nudge) {
|
|
73
|
+
console.log();
|
|
74
|
+
console.log(nudge.kind === 'strong' ? renderStrong(nudge) : renderSoft(nudge));
|
|
75
|
+
}
|
|
76
|
+
/* --------------------------------------------------------------------- */
|
|
77
|
+
/* Public entry points */
|
|
78
|
+
/* --------------------------------------------------------------------- */
|
|
79
|
+
/**
|
|
80
|
+
* Gate that all nudge entry points share. Skips when:
|
|
81
|
+
* - user is signed in
|
|
82
|
+
* - we've already nudged in this process
|
|
83
|
+
* - stdout isn't a TTY (pipes, CI)
|
|
84
|
+
* - PRAVE_QUIET=1 or PRAVE_TELEMETRY=0 is set
|
|
85
|
+
*/
|
|
86
|
+
async function canNudge() {
|
|
5
87
|
if (alreadyNudged)
|
|
6
|
-
return;
|
|
88
|
+
return false;
|
|
89
|
+
if (!process.stdout.isTTY)
|
|
90
|
+
return false;
|
|
7
91
|
if (process.env.PRAVE_QUIET === '1')
|
|
8
|
-
return;
|
|
92
|
+
return false;
|
|
9
93
|
if (process.env.PRAVE_TELEMETRY === '0')
|
|
10
|
-
return;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
94
|
+
return false;
|
|
95
|
+
if (await isAuthenticated())
|
|
96
|
+
return false;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Show the first-run welcome banner if it's the user's very first command
|
|
101
|
+
* (no nudge_count yet AND first_run not yet set to false). Always strong,
|
|
102
|
+
* always bypasses the throttle.
|
|
103
|
+
*
|
|
104
|
+
* Returns true when shown — call sites use this to skip the regular nudge
|
|
105
|
+
* on the same turn so we don't double-print.
|
|
106
|
+
*/
|
|
107
|
+
export async function nudgeFirstRun() {
|
|
108
|
+
if (!(await canNudge()))
|
|
109
|
+
return false;
|
|
110
|
+
const cfg = await readConfig();
|
|
111
|
+
// first_run defaults to "yes, show it" unless we've already flipped
|
|
112
|
+
// the flag. A missing config file → first run.
|
|
113
|
+
const alreadyShown = cfg.first_run === false;
|
|
114
|
+
if (alreadyShown)
|
|
115
|
+
return false;
|
|
116
|
+
alreadyNudged = true;
|
|
117
|
+
showNudge(NUDGE_FIRST_RUN);
|
|
118
|
+
await writeConfigPatch({ first_run: false });
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Main per-command nudge entry. Pick the most specific context — the
|
|
123
|
+
* renderer maps it to the matching constant. Strong contexts
|
|
124
|
+
* (overview/whatdoes) always show; soft ones go through the 1-in-3
|
|
125
|
+
* throttle.
|
|
126
|
+
*/
|
|
127
|
+
export async function nudgeAfter(context = 'generic') {
|
|
128
|
+
// First-run handling lives here so every command path picks it up
|
|
129
|
+
// without needing its own boilerplate. If we showed the welcome banner,
|
|
130
|
+
// skip the regular nudge for this turn — too noisy otherwise.
|
|
131
|
+
const showedFirstRun = await nudgeFirstRun();
|
|
132
|
+
if (showedFirstRun)
|
|
133
|
+
return;
|
|
134
|
+
if (!(await canNudge()))
|
|
135
|
+
return;
|
|
136
|
+
if (isAlwaysShow(context)) {
|
|
137
|
+
alreadyNudged = true;
|
|
138
|
+
showNudge(nudgeFor(context));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!(await shouldShowNudge()))
|
|
14
142
|
return;
|
|
15
|
-
const creds = await loadCredentials();
|
|
16
|
-
if (creds)
|
|
17
|
-
return; // logged-in → no nudge
|
|
18
143
|
alreadyNudged = true;
|
|
19
|
-
|
|
20
|
-
const url = chalk.dim('— takes 10 seconds, free forever');
|
|
21
|
-
const message = (() => {
|
|
22
|
-
switch (context) {
|
|
23
|
-
case 'search':
|
|
24
|
-
return `${chalk.dim('Save these to your library, get token costs + conflicts.')}\n${chalk.dim('→')} ${cta} ${url}`;
|
|
25
|
-
case 'whatdoes':
|
|
26
|
-
return `${chalk.dim('Want the full audit — triggers, conflicts, token cost?')}\n${chalk.dim('→')} ${cta} ${url}`;
|
|
27
|
-
case 'list':
|
|
28
|
-
return `${chalk.dim('Sign in to see AI descriptions, conflicts and token cost.')}\n${chalk.dim('→')} ${cta} ${url}`;
|
|
29
|
-
case 'overview':
|
|
30
|
-
return `${chalk.dim('Track this over time on prave.app.')}\n${chalk.dim('→')} ${cta} ${url}`;
|
|
31
|
-
case 'generic':
|
|
32
|
-
default:
|
|
33
|
-
return `${chalk.dim('Get the full Skill Intelligence on prave.app.')}\n${chalk.dim('→')} ${cta} ${url}`;
|
|
34
|
-
}
|
|
35
|
-
})();
|
|
36
|
-
// One blank line so the nudge isn't glued to the command's main
|
|
37
|
-
// output. Two `console.log` calls so the chalk styles survive the
|
|
38
|
-
// pipeline cleanly.
|
|
39
|
-
console.log();
|
|
40
|
-
console.log(message);
|
|
144
|
+
showNudge(nudgeFor(context));
|
|
41
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Back-compat alias. Older call sites used this name; new code should
|
|
148
|
+
* call `nudgeAfter` directly.
|
|
149
|
+
*/
|
|
150
|
+
export const nudgeIfAnonymous = nudgeAfter;
|
|
42
151
|
/**
|
|
43
152
|
* Post-install banner used by package.json's `postinstall` script.
|
|
44
|
-
* Runs once after `npm i -g @prave/cli`.
|
|
45
|
-
*
|
|
153
|
+
* Runs once after `npm i -g @prave/cli`. Kept short — npm's install log
|
|
154
|
+
* is already crowded.
|
|
46
155
|
*/
|
|
47
156
|
export function printPostInstallBanner() {
|
|
48
157
|
if (process.env.CI)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prave/cli",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.8",
|
|
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.8"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^20.12.7",
|
package/dist/commands/deploy.js
DELETED
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Multi-agent fan-out helper.
|
|
3
|
-
*
|
|
4
|
-
* This module used to back a public `prave deploy <skill>` command, but
|
|
5
|
-
* shipping that as a top-level CLI surface only duplicated what
|
|
6
|
-
* `prave install` already prompts for ("Deploy to all configured agents?
|
|
7
|
-
* [y/N]"). The standalone command was removed; `deployCommand` lives on
|
|
8
|
-
* as an internal helper that `install.ts` and `sync.ts` import to do
|
|
9
|
-
* the actual fan-out + Cursor `.mdc` rewrite.
|
|
10
|
-
*/
|
|
11
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
12
|
-
import { homedir } from 'node:os';
|
|
13
|
-
import { join } from 'node:path';
|
|
14
|
-
import chalk from 'chalk';
|
|
15
|
-
import ora from 'ora';
|
|
16
|
-
import { AGENT_REGISTRY, compileSkill } from '@prave/shared';
|
|
17
|
-
import { track } from '../lib/analytics.js';
|
|
18
|
-
import { api, ApiError } from '../lib/api.js';
|
|
19
|
-
import { requireAuth } from '../lib/credentials.js';
|
|
20
|
-
import { fetchMyPlan, formatUpgradeHint } from '../lib/plan.js';
|
|
21
|
-
import { CONFIG } from '../lib/config.js';
|
|
22
|
-
import { assertSlug, InvalidSlugError } from '../lib/slug.js';
|
|
23
|
-
import { log } from '../utils/logger.js';
|
|
24
|
-
function detectOsKey(detected) {
|
|
25
|
-
if (detected === 'windows')
|
|
26
|
-
return 'windows';
|
|
27
|
-
// mac + linux both use POSIX paths under "mac" in the registry.
|
|
28
|
-
return 'mac';
|
|
29
|
-
}
|
|
30
|
-
function expandHome(p, os) {
|
|
31
|
-
if (os === 'windows')
|
|
32
|
-
return p;
|
|
33
|
-
if (p.startsWith('~')) {
|
|
34
|
-
return join(homedir(), p.slice(1).replace(/^[\\/]/, ''));
|
|
35
|
-
}
|
|
36
|
-
return p;
|
|
37
|
-
}
|
|
38
|
-
async function readLocalSkill(slug) {
|
|
39
|
-
const path = join(CONFIG.skillsDir, slug, 'SKILL.md');
|
|
40
|
-
try {
|
|
41
|
-
return await readFile(path, 'utf8');
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
async function fetchRemoteSkill(slug) {
|
|
48
|
-
const { data } = await api.get(`/api/v1/skills/${encodeURIComponent(slug)}`, true);
|
|
49
|
-
if (!data.content) {
|
|
50
|
-
throw new ApiError(`Remote skill "${slug}" has no content.`, 404);
|
|
51
|
-
}
|
|
52
|
-
return data.content;
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Build the on-disk write path for the given agent + slug. The
|
|
56
|
-
* `compileSkill()` import from `@prave/shared` decides the *content*
|
|
57
|
-
* transform (e.g. Cursor `.mdc` frontmatter rewrite); this function
|
|
58
|
-
* only resolves the absolute filesystem location by combining the
|
|
59
|
-
* agent's `basePath` from user settings with the relative path the
|
|
60
|
-
* shared compiler returned.
|
|
61
|
-
*/
|
|
62
|
-
function buildDestPath(agent, basePath, os, slug, relPath) {
|
|
63
|
-
const expanded = expandHome(basePath, os);
|
|
64
|
-
// The shared compiler returns POSIX-style relPaths
|
|
65
|
-
// (e.g. `<slug>/SKILL.md` or `.cursor/rules/<slug>.mdc`). For
|
|
66
|
-
// Cursor specifically the user's `basePath` already points at
|
|
67
|
-
// `.cursor/rules/`, so we collapse the leading `.cursor/rules/`
|
|
68
|
-
// to avoid the path duplicating to `.cursor/rules/.cursor/rules/`.
|
|
69
|
-
const collapsed = agent === 'cursor' && relPath.startsWith('.cursor/rules/')
|
|
70
|
-
? relPath.slice('.cursor/rules/'.length)
|
|
71
|
-
: relPath;
|
|
72
|
-
const file = join(expanded, ...collapsed.split('/'));
|
|
73
|
-
const dir = file.slice(0, file.length - collapsed.split('/').slice(-1)[0].length - 1);
|
|
74
|
-
return {
|
|
75
|
-
dir,
|
|
76
|
-
file,
|
|
77
|
-
display: `${basePath.replace(/[\\/]+$/, '')}/${collapsed}`,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
export async function deployCommand(skillName, opts = {}) {
|
|
81
|
-
track('cli_deploy', { slug: skillName, agent: opts.agent ?? 'all', dry_run: !!opts.dryRun });
|
|
82
|
-
try {
|
|
83
|
-
assertSlug(skillName);
|
|
84
|
-
}
|
|
85
|
-
catch (err) {
|
|
86
|
-
if (err instanceof InvalidSlugError) {
|
|
87
|
-
log.error(err.message);
|
|
88
|
-
process.exitCode = 1;
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
throw err;
|
|
92
|
-
}
|
|
93
|
-
const session = await requireAuth('prave deploy');
|
|
94
|
-
if (!session)
|
|
95
|
-
return;
|
|
96
|
-
const start = Date.now();
|
|
97
|
-
// Plan-gate the multi-agent target list. Free can only deploy to
|
|
98
|
-
// Claude Code; Pro+ unlocks all 6. We compute the allowed set from
|
|
99
|
-
// PLAN_LIMITS so the Stripe-side and CLI-side stay in lockstep.
|
|
100
|
-
const me = await fetchMyPlan();
|
|
101
|
-
const allowedAgents = new Set(me.limits.multi_agent_targets);
|
|
102
|
-
if (allowedAgents.size === 0) {
|
|
103
|
-
log.warn(`Your ${me.limits.label} plan can't deploy to any agent.`);
|
|
104
|
-
log.dim(formatUpgradeHint('explorer'));
|
|
105
|
-
process.exitCode = 1;
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
let settings;
|
|
109
|
-
try {
|
|
110
|
-
const { data } = await api.get('/api/v1/settings/agents', true);
|
|
111
|
-
settings = data;
|
|
112
|
-
}
|
|
113
|
-
catch (err) {
|
|
114
|
-
log.error(err instanceof ApiError ? err.message : err.message);
|
|
115
|
-
process.exitCode = 1;
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
const targetFilter = opts.agent && opts.agent.toLowerCase() !== 'all'
|
|
119
|
-
? opts.agent.toLowerCase()
|
|
120
|
-
: null;
|
|
121
|
-
const targets = settings.enabled_agents
|
|
122
|
-
.filter((a) => allowedAgents.has(a))
|
|
123
|
-
.filter((a) => targetFilter === null || a === targetFilter);
|
|
124
|
-
// If the user picked a target their plan can't reach, tell them why.
|
|
125
|
-
if (targetFilter && !allowedAgents.has(targetFilter)) {
|
|
126
|
-
log.warn(`Deploying to ${targetFilter} requires the Pro plan. Free can only deploy to Claude Code.`);
|
|
127
|
-
log.dim(formatUpgradeHint('explorer'));
|
|
128
|
-
process.exitCode = 1;
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
if (targets.length === 0) {
|
|
132
|
-
log.warn('No matching agents enabled. Run `prave settings` to configure.');
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
// Load source content (local first, fall back to remote).
|
|
136
|
-
let source = await readLocalSkill(skillName);
|
|
137
|
-
if (!source) {
|
|
138
|
-
try {
|
|
139
|
-
source = await fetchRemoteSkill(skillName);
|
|
140
|
-
}
|
|
141
|
-
catch (err) {
|
|
142
|
-
log.error(err instanceof ApiError ? err.message : err.message);
|
|
143
|
-
process.exitCode = 1;
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
const os = detectOsKey(settings.detected_os);
|
|
148
|
-
console.log(`Deploying "${chalk.bold(skillName)}" to ${targets.length} agent${targets.length === 1 ? '' : 's'}${opts.dryRun ? chalk.dim(' (dry-run)') : ''}…`);
|
|
149
|
-
const spinner = ora('Writing files…').start();
|
|
150
|
-
let okCount = 0;
|
|
151
|
-
for (const agent of targets) {
|
|
152
|
-
const meta = AGENT_REGISTRY[agent];
|
|
153
|
-
const paths = settings.skill_paths[agent] ?? meta.defaultPath;
|
|
154
|
-
const basePath = os === 'windows' ? paths.windows : paths.mac;
|
|
155
|
-
// Shared compileSkill() is the single source of truth — same
|
|
156
|
-
// function the SaaS /dashboard/compile page calls, so the CLI
|
|
157
|
-
// and the web zip produce byte-identical output for the same
|
|
158
|
-
// SKILL.md.
|
|
159
|
-
const artifact = compileSkill(source, skillName, agent);
|
|
160
|
-
const dest = buildDestPath(agent, basePath, os, skillName, artifact.path);
|
|
161
|
-
spinner.text = `→ ${meta.label}`;
|
|
162
|
-
if (opts.dryRun) {
|
|
163
|
-
okCount += 1;
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
try {
|
|
167
|
-
await mkdir(dest.dir, { recursive: true });
|
|
168
|
-
await writeFile(dest.file, artifact.content, 'utf8');
|
|
169
|
-
okCount += 1;
|
|
170
|
-
}
|
|
171
|
-
catch (err) {
|
|
172
|
-
spinner.warn(`Failed: ${meta.label} — ${err.message}`);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
spinner.stop();
|
|
176
|
-
for (const agent of targets) {
|
|
177
|
-
const meta = AGENT_REGISTRY[agent];
|
|
178
|
-
const paths = settings.skill_paths[agent] ?? meta.defaultPath;
|
|
179
|
-
const basePath = os === 'windows' ? paths.windows : paths.mac;
|
|
180
|
-
const artifact = compileSkill(source, skillName, agent);
|
|
181
|
-
const dest = buildDestPath(agent, basePath, os, skillName, artifact.path);
|
|
182
|
-
const tag = artifact.converted ? chalk.dim(' (converted)') : '';
|
|
183
|
-
console.log(`${chalk.green('✓')} ${meta.label.padEnd(14)} → ${dest.display}${tag}`);
|
|
184
|
-
}
|
|
185
|
-
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
186
|
-
console.log(opts.dryRun
|
|
187
|
-
? chalk.dim(`Dry-run complete in ${elapsed}s — ${okCount} agents would receive the skill.`)
|
|
188
|
-
: chalk.dim(`Deployed in ${elapsed}s`));
|
|
189
|
-
}
|
package/dist/commands/run.js
DELETED
|
@@ -1,481 +0,0 @@
|
|
|
1
|
-
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
-
import { resolve, relative, basename, sep } from 'node:path';
|
|
3
|
-
import { createInterface } from 'node:readline/promises';
|
|
4
|
-
import { Buffer } from 'node:buffer';
|
|
5
|
-
import chalk from 'chalk';
|
|
6
|
-
import open from 'open';
|
|
7
|
-
import ora from 'ora';
|
|
8
|
-
import * as tar from 'tar';
|
|
9
|
-
import { request } from 'undici';
|
|
10
|
-
import { isLikelyTextPath, scanForSecrets, } from '@prave/shared';
|
|
11
|
-
import { api, ApiError } from '../lib/api.js';
|
|
12
|
-
import { CONFIG } from '../lib/config.js';
|
|
13
|
-
import { loadCredentials, requireAuth } from '../lib/credentials.js';
|
|
14
|
-
import { log } from '../utils/logger.js';
|
|
15
|
-
/**
|
|
16
|
-
* `prave run` — scheduled server-side skill executions on Prave's
|
|
17
|
-
* infrastructure.
|
|
18
|
-
*
|
|
19
|
-
* prave run deploy [path] # bundle dir → upload → open wizard
|
|
20
|
-
* prave run list # list my deployed runs
|
|
21
|
-
* prave run logs <slug> # tail the most recent execution logs
|
|
22
|
-
*
|
|
23
|
-
* `deploy` is the headline action — the rest just expose the same data
|
|
24
|
-
* the dashboard already shows. The wizard handles schedule + agent
|
|
25
|
-
* selection in the browser because picking from an agent dropdown
|
|
26
|
-
* filtered to "which API keys do I have" is way clearer in a UI than
|
|
27
|
-
* an interactive prompt.
|
|
28
|
-
*/
|
|
29
|
-
// Sane-default ignore list for `tar.create` — none of these belong in
|
|
30
|
-
// a deployable bundle and dragging them up adds noise to the scan +
|
|
31
|
-
// bloats storage.
|
|
32
|
-
const TAR_IGNORE = new Set([
|
|
33
|
-
'.git',
|
|
34
|
-
'.github',
|
|
35
|
-
'.next',
|
|
36
|
-
'.turbo',
|
|
37
|
-
'.svelte-kit',
|
|
38
|
-
'.cache',
|
|
39
|
-
'node_modules',
|
|
40
|
-
'dist',
|
|
41
|
-
'build',
|
|
42
|
-
'coverage',
|
|
43
|
-
'.venv',
|
|
44
|
-
'venv',
|
|
45
|
-
'__pycache__',
|
|
46
|
-
'.DS_Store',
|
|
47
|
-
]);
|
|
48
|
-
const MAX_BUNDLE_BYTES = 20 * 1024 * 1024; // matches API + Supabase Storage cap
|
|
49
|
-
const MAX_FILES = 200;
|
|
50
|
-
export async function runDeployCommand(pathArg, options = {}) {
|
|
51
|
-
const isUpdate = Boolean(options.updateRunSlug);
|
|
52
|
-
const root = resolve(pathArg ?? process.cwd());
|
|
53
|
-
const rootStat = await stat(root).catch(() => null);
|
|
54
|
-
if (!rootStat?.isDirectory()) {
|
|
55
|
-
log.error(`Not a directory: ${root}`);
|
|
56
|
-
process.exit(1);
|
|
57
|
-
}
|
|
58
|
-
// Sanity check — the dir should *look* like a skill project. We don't
|
|
59
|
-
// hard-reject; we just warn if there's no SKILL.md anywhere.
|
|
60
|
-
const skillMd = await findSkillMd(root);
|
|
61
|
-
if (!skillMd) {
|
|
62
|
-
log.warn('No SKILL.md found in this directory. Deploys still work without it, but the runner will not have skill-shaped context for the agent.');
|
|
63
|
-
}
|
|
64
|
-
const creds0 = await requireAuth('prave run');
|
|
65
|
-
if (!creds0)
|
|
66
|
-
return;
|
|
67
|
-
// 1a. Look for .env / .env.production / .env.local etc. The scanner
|
|
68
|
-
// would otherwise hard-reject them on the upload. Offer to lift the
|
|
69
|
-
// values out of the bundle and pass them to the wizard so they end
|
|
70
|
-
// up encrypted on the run row instead of in the tarball.
|
|
71
|
-
const envFiles = await findEnvFiles(root);
|
|
72
|
-
let envVars = {};
|
|
73
|
-
const stripPaths = new Set();
|
|
74
|
-
if (envFiles.length > 0) {
|
|
75
|
-
const totalKeys = envFiles.reduce((n, f) => n + Object.keys(f.values).length, 0);
|
|
76
|
-
console.log();
|
|
77
|
-
console.log(chalk.bold(`Found ${envFiles.length} env file${envFiles.length === 1 ? '' : 's'} with ${totalKeys} variable${totalKeys === 1 ? '' : 's'}:`));
|
|
78
|
-
for (const f of envFiles) {
|
|
79
|
-
console.log(` ${chalk.cyan('•')} ${f.path} ${chalk.dim(`(${Object.keys(f.values).length} keys)`)}`);
|
|
80
|
-
}
|
|
81
|
-
console.log(chalk.dim('\nIf you continue, Prave strips these from the upload and passes the\n' +
|
|
82
|
-
'values to the browser wizard. They get AES-256-GCM-encrypted onto\n' +
|
|
83
|
-
'the run, decrypted only inside the sandbox at run-time.\n' +
|
|
84
|
-
'Decline to abort so you can clean up first.'));
|
|
85
|
-
const yes = await confirmYesNo('Strip & pass env vars to the wizard?');
|
|
86
|
-
if (!yes) {
|
|
87
|
-
log.error('Aborted. Remove the .env file(s) (or rename to .env.example) and re-run.');
|
|
88
|
-
process.exit(1);
|
|
89
|
-
}
|
|
90
|
-
for (const f of envFiles) {
|
|
91
|
-
stripPaths.add(f.path);
|
|
92
|
-
Object.assign(envVars, f.values);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
// 1b. Local secret-scan BEFORE we ship anything. Cheap defence in
|
|
96
|
-
// depth — even though the API scans again, surfacing the finding
|
|
97
|
-
// pre-upload saves the user a round-trip and avoids briefly storing
|
|
98
|
-
// a secret-bearing tarball in our bucket. The env files we just
|
|
99
|
-
// accepted to lift out are excluded from the scan.
|
|
100
|
-
const localFindings = await preflightScan(root, stripPaths);
|
|
101
|
-
if (localFindings.length > 0) {
|
|
102
|
-
log.error('Bundle contains files that look like secrets:');
|
|
103
|
-
for (const f of localFindings.slice(0, 10)) {
|
|
104
|
-
console.error(` ${chalk.red('•')} ${f.rule} ${chalk.dim(f.path)}${f.line ? `:${f.line}` : ''}`);
|
|
105
|
-
}
|
|
106
|
-
console.error(chalk.dim('\nRemove these files (or scrub the values) and re-run.\n' +
|
|
107
|
-
'For env vars, ship a .env.example template instead — Prave\n' +
|
|
108
|
-
'will prompt you for the real values during the wizard.'));
|
|
109
|
-
process.exit(1);
|
|
110
|
-
}
|
|
111
|
-
// 2. Mint the deploy session. Update flows tag the session with
|
|
112
|
-
// the existing run's slug so the upload handler swaps that run's
|
|
113
|
-
// bundle pointer instead of creating a fresh row.
|
|
114
|
-
const initSpinner = ora(isUpdate ? `Opening update session for ${options.updateRunSlug}…` : 'Opening deploy session…').start();
|
|
115
|
-
let session;
|
|
116
|
-
try {
|
|
117
|
-
const initPath = isUpdate
|
|
118
|
-
? `/api/v1/deploy/init?run_slug=${encodeURIComponent(options.updateRunSlug)}`
|
|
119
|
-
: '/api/v1/deploy/init';
|
|
120
|
-
const { data } = await api.post(initPath, {}, true);
|
|
121
|
-
session = data.session;
|
|
122
|
-
initSpinner.succeed('Session opened');
|
|
123
|
-
}
|
|
124
|
-
catch (err) {
|
|
125
|
-
initSpinner.fail(`Could not open deploy session: ${err.message}`);
|
|
126
|
-
process.exit(1);
|
|
127
|
-
}
|
|
128
|
-
// 2b. Ship env vars to the wizard before the upload so they're
|
|
129
|
-
// waiting when the browser opens. The wizard reads them once via
|
|
130
|
-
// /deploy/status and pre-fills its env-vars step.
|
|
131
|
-
if (Object.keys(envVars).length > 0) {
|
|
132
|
-
const envSpinner = ora('Sending env vars to the wizard…').start();
|
|
133
|
-
try {
|
|
134
|
-
await api.post(`/api/v1/deploy/env?session=${encodeURIComponent(session.session_id)}`, { env_vars: envVars }, true);
|
|
135
|
-
envSpinner.succeed(`Passed ${Object.keys(envVars).length} env var(s)`);
|
|
136
|
-
}
|
|
137
|
-
catch (err) {
|
|
138
|
-
envSpinner.fail(`Could not ship env vars: ${err.message}`);
|
|
139
|
-
process.exit(1);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
// 3. Pack the directory into a gzipped tar in memory. tar.create's
|
|
143
|
-
// `cwd` option is critical — paths inside the archive must be
|
|
144
|
-
// RELATIVE to the project root, not absolute. Stripped paths are
|
|
145
|
-
// env files we already shipped via /deploy/env above.
|
|
146
|
-
const packSpinner = ora('Bundling project…').start();
|
|
147
|
-
let tarball;
|
|
148
|
-
try {
|
|
149
|
-
tarball = await packDirectory(root, stripPaths);
|
|
150
|
-
packSpinner.succeed(`Bundled ${formatBytes(tarball.length)}`);
|
|
151
|
-
}
|
|
152
|
-
catch (err) {
|
|
153
|
-
packSpinner.fail(`Bundle failed: ${err.message}`);
|
|
154
|
-
process.exit(1);
|
|
155
|
-
}
|
|
156
|
-
if (tarball.length > MAX_BUNDLE_BYTES) {
|
|
157
|
-
log.error(`Bundle is ${formatBytes(tarball.length)}, cap is ${formatBytes(MAX_BUNDLE_BYTES)}. ` +
|
|
158
|
-
'Add large files to .gitignore-style noise (or place them in node_modules / .git which we already skip).');
|
|
159
|
-
process.exit(1);
|
|
160
|
-
}
|
|
161
|
-
// 4. Stream the tarball to /deploy/upload. We use undici directly
|
|
162
|
-
// because api.ts only does JSON.
|
|
163
|
-
const uploadSpinner = ora('Uploading to Prave…').start();
|
|
164
|
-
const creds = await loadCredentials();
|
|
165
|
-
if (!creds) {
|
|
166
|
-
uploadSpinner.fail('Credentials expired mid-flight — please re-login.');
|
|
167
|
-
process.exit(1);
|
|
168
|
-
}
|
|
169
|
-
const uploadUrl = `${CONFIG.apiUrl}/api/v1/deploy/upload?session=${encodeURIComponent(session.session_id)}`;
|
|
170
|
-
try {
|
|
171
|
-
const { statusCode, body } = await request(uploadUrl, {
|
|
172
|
-
method: 'POST',
|
|
173
|
-
headers: {
|
|
174
|
-
'Content-Type': 'application/gzip',
|
|
175
|
-
Authorization: `Bearer ${creds.access_token}`,
|
|
176
|
-
},
|
|
177
|
-
body: tarball,
|
|
178
|
-
});
|
|
179
|
-
const text = await body.text();
|
|
180
|
-
if (statusCode >= 400) {
|
|
181
|
-
const parsed = safeJson(text);
|
|
182
|
-
const msg = parsed?.error ?? `HTTP ${statusCode}`;
|
|
183
|
-
uploadSpinner.fail(`Upload rejected: ${msg}`);
|
|
184
|
-
process.exit(1);
|
|
185
|
-
}
|
|
186
|
-
uploadSpinner.succeed('Upload complete');
|
|
187
|
-
// Update flow: the server already swapped the run's bundle
|
|
188
|
-
// pointer. No wizard, no browser open — just confirm + exit.
|
|
189
|
-
const parsed = safeJson(text);
|
|
190
|
-
if (isUpdate || parsed?.updated_run_slug) {
|
|
191
|
-
console.log();
|
|
192
|
-
console.log(chalk.bold(`Updated ${parsed?.updated_run_slug ?? options.updateRunSlug}.`));
|
|
193
|
-
console.log(chalk.dim(' Next scheduled fire will use the freshly-uploaded bundle.\n' +
|
|
194
|
-
' Open the dashboard:'));
|
|
195
|
-
console.log(chalk.cyan(' ' + session.wizard_url));
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
catch (err) {
|
|
200
|
-
uploadSpinner.fail(`Upload failed: ${err.message}`);
|
|
201
|
-
process.exit(1);
|
|
202
|
-
}
|
|
203
|
-
// 5. New-run flow: open the browser at the wizard.
|
|
204
|
-
console.log();
|
|
205
|
-
console.log(chalk.bold('Finish in the browser:'));
|
|
206
|
-
console.log(chalk.cyan(' ' + session.wizard_url));
|
|
207
|
-
try {
|
|
208
|
-
await open(session.wizard_url);
|
|
209
|
-
}
|
|
210
|
-
catch {
|
|
211
|
-
/* user can copy the URL manually */
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
export async function runListCommand() {
|
|
215
|
-
if (!(await requireAuth('prave run list')))
|
|
216
|
-
return;
|
|
217
|
-
const spinner = ora('Fetching runs…').start();
|
|
218
|
-
try {
|
|
219
|
-
const { data } = await api.get('/api/v1/runs', true);
|
|
220
|
-
spinner.stop();
|
|
221
|
-
if (!data.runs.length) {
|
|
222
|
-
console.log(chalk.dim('No runs yet. `prave run deploy` to schedule your first one.'));
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
for (const r of data.runs) {
|
|
226
|
-
const nextRun = r.next_run_at
|
|
227
|
-
? new Date(r.next_run_at).toLocaleString()
|
|
228
|
-
: chalk.dim('paused');
|
|
229
|
-
const status = r.status === 'active'
|
|
230
|
-
? chalk.green('active')
|
|
231
|
-
: r.status === 'paused'
|
|
232
|
-
? chalk.yellow('paused')
|
|
233
|
-
: chalk.red(r.status);
|
|
234
|
-
console.log(` ${chalk.bold(r.name)} ${chalk.dim(r.slug)}\n` +
|
|
235
|
-
` ${chalk.dim(`agent=${r.agent} schedule=${r.schedule_kind} status=${status} next=${nextRun}`)}`);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
catch (err) {
|
|
239
|
-
spinner.fail(err.message);
|
|
240
|
-
process.exit(err instanceof ApiError ? 1 : 1);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
export async function runLogsCommand(slug) {
|
|
244
|
-
if (!(await requireAuth('prave run logs')))
|
|
245
|
-
return;
|
|
246
|
-
const spinner = ora(`Fetching logs for ${slug}…`).start();
|
|
247
|
-
try {
|
|
248
|
-
const { data } = await api.get(`/api/v1/runs/${encodeURIComponent(slug)}/executions?limit=10`, true);
|
|
249
|
-
spinner.stop();
|
|
250
|
-
if (!data.executions.length) {
|
|
251
|
-
console.log(chalk.dim('No executions yet. The first one will appear after the next scheduled fire.'));
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
const latest = data.executions[0];
|
|
255
|
-
const status = latest.status === 'success'
|
|
256
|
-
? chalk.green(latest.status)
|
|
257
|
-
: latest.status === 'running'
|
|
258
|
-
? chalk.cyan(latest.status)
|
|
259
|
-
: chalk.red(latest.status);
|
|
260
|
-
console.log(`${chalk.bold(slug)} ${chalk.dim(latest.started_at)} ${status}` +
|
|
261
|
-
(latest.duration_ms !== null ? chalk.dim(` (${latest.duration_ms}ms)`) : ''));
|
|
262
|
-
console.log();
|
|
263
|
-
console.log(latest.log_text ?? chalk.dim('(no log captured)'));
|
|
264
|
-
if (latest.error_message) {
|
|
265
|
-
console.log();
|
|
266
|
-
console.log(chalk.red('Error: ') + latest.error_message);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
catch (err) {
|
|
270
|
-
spinner.fail(err.message);
|
|
271
|
-
process.exit(1);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
// ── helpers ──────────────────────────────────────────────────────────
|
|
275
|
-
async function findSkillMd(root) {
|
|
276
|
-
// Top-level only — most skill projects ship SKILL.md at the root.
|
|
277
|
-
// Deeper search would be wasted work for the warning we'd print.
|
|
278
|
-
try {
|
|
279
|
-
const entries = await readdir(root);
|
|
280
|
-
return entries.find((n) => n.toLowerCase() === 'skill.md') ?? null;
|
|
281
|
-
}
|
|
282
|
-
catch {
|
|
283
|
-
return null;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
async function preflightScan(root, stripPaths = new Set()) {
|
|
287
|
-
const inputs = [];
|
|
288
|
-
let files = 0;
|
|
289
|
-
let bytes = 0;
|
|
290
|
-
const visit = async (dir) => {
|
|
291
|
-
if (files >= MAX_FILES)
|
|
292
|
-
return;
|
|
293
|
-
if (bytes >= MAX_BUNDLE_BYTES)
|
|
294
|
-
return;
|
|
295
|
-
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
296
|
-
for (const entry of entries) {
|
|
297
|
-
if (TAR_IGNORE.has(entry.name))
|
|
298
|
-
continue;
|
|
299
|
-
if (entry.name.startsWith('._'))
|
|
300
|
-
continue;
|
|
301
|
-
const abs = `${dir}${sep}${entry.name}`;
|
|
302
|
-
if (entry.isDirectory()) {
|
|
303
|
-
await visit(abs);
|
|
304
|
-
continue;
|
|
305
|
-
}
|
|
306
|
-
if (!entry.isFile())
|
|
307
|
-
continue;
|
|
308
|
-
const rel = relative(root, abs).split(sep).join('/');
|
|
309
|
-
// The user already accepted to lift this env file out of the
|
|
310
|
-
// bundle in the previous step. Don't scan it (we know it has
|
|
311
|
-
// secrets — that's why we're lifting it).
|
|
312
|
-
if (stripPaths.has(rel))
|
|
313
|
-
continue;
|
|
314
|
-
files++;
|
|
315
|
-
if (!isLikelyTextPath(rel)) {
|
|
316
|
-
inputs.push({ path: rel });
|
|
317
|
-
continue;
|
|
318
|
-
}
|
|
319
|
-
try {
|
|
320
|
-
const content = await readFile(abs, 'utf8');
|
|
321
|
-
bytes += content.length;
|
|
322
|
-
inputs.push({ path: rel, content });
|
|
323
|
-
}
|
|
324
|
-
catch {
|
|
325
|
-
inputs.push({ path: rel });
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
};
|
|
329
|
-
await visit(root);
|
|
330
|
-
return scanForSecrets(inputs).findings;
|
|
331
|
-
}
|
|
332
|
-
async function packDirectory(root, stripPaths = new Set()) {
|
|
333
|
-
// Top-level entries only — tar.create resolves them against `cwd`.
|
|
334
|
-
const entries = (await readdir(root)).filter((n) => !TAR_IGNORE.has(n));
|
|
335
|
-
if (entries.length === 0) {
|
|
336
|
-
throw new Error('Project directory is empty.');
|
|
337
|
-
}
|
|
338
|
-
// Normalise the strip-set against tar.create's relative-path format
|
|
339
|
-
// (POSIX forward slashes, no leading "./"). Paths sit one level
|
|
340
|
-
// beneath the archive's basename prefix, so we compare on the
|
|
341
|
-
// input-side relative path tar.create hands us.
|
|
342
|
-
const stripNormalised = new Set([...stripPaths].map((p) => p.replace(/\\/g, '/').replace(/^\.\//, '')));
|
|
343
|
-
const stream = tar.create({
|
|
344
|
-
gzip: true,
|
|
345
|
-
cwd: root,
|
|
346
|
-
portable: true,
|
|
347
|
-
// Prefix all paths with the project's basename so the runner
|
|
348
|
-
// gets a `<project>/SKILL.md` shape, not a flat dump at the
|
|
349
|
-
// archive root.
|
|
350
|
-
prefix: basename(root),
|
|
351
|
-
filter: (path) => {
|
|
352
|
-
const norm = path.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
353
|
-
return !stripNormalised.has(norm);
|
|
354
|
-
},
|
|
355
|
-
}, entries);
|
|
356
|
-
const chunks = [];
|
|
357
|
-
for await (const chunk of stream) {
|
|
358
|
-
chunks.push(Buffer.from(chunk));
|
|
359
|
-
}
|
|
360
|
-
return Buffer.concat(chunks);
|
|
361
|
-
}
|
|
362
|
-
/**
|
|
363
|
-
* Look for common `.env*` files at the project root. We don't scan
|
|
364
|
-
* deeper than top-level — a nested `.env` is almost certainly a
|
|
365
|
-
* shipping example, not the real secrets file. Returns an empty list
|
|
366
|
-
* when nothing's found.
|
|
367
|
-
*/
|
|
368
|
-
async function findEnvFiles(root) {
|
|
369
|
-
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
370
|
-
const findings = [];
|
|
371
|
-
for (const entry of entries) {
|
|
372
|
-
if (!entry.isFile())
|
|
373
|
-
continue;
|
|
374
|
-
const name = entry.name;
|
|
375
|
-
if (!isRealEnvFile(name))
|
|
376
|
-
continue;
|
|
377
|
-
try {
|
|
378
|
-
const raw = await readFile(`${root}${sep}${name}`, 'utf8');
|
|
379
|
-
const values = parseDotenv(raw);
|
|
380
|
-
if (Object.keys(values).length === 0)
|
|
381
|
-
continue;
|
|
382
|
-
findings.push({ path: name, values });
|
|
383
|
-
}
|
|
384
|
-
catch {
|
|
385
|
-
/* unreadable — skip */
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
return findings;
|
|
389
|
-
}
|
|
390
|
-
/**
|
|
391
|
-
* `.env`, `.env.local`, `.env.production`, `.env.development`, `.envrc`
|
|
392
|
-
* count as "real env files". Templates (`.env.example`, `.env.sample`,
|
|
393
|
-
* `.env.template`) are explicitly NOT lifted — those are meant to be
|
|
394
|
-
* shipped.
|
|
395
|
-
*/
|
|
396
|
-
function isRealEnvFile(name) {
|
|
397
|
-
if (name === '.env' || name === '.envrc')
|
|
398
|
-
return true;
|
|
399
|
-
if (!name.startsWith('.env.'))
|
|
400
|
-
return false;
|
|
401
|
-
const suffix = name.slice('.env.'.length);
|
|
402
|
-
if (/^(example|sample|template)$/i.test(suffix))
|
|
403
|
-
return false;
|
|
404
|
-
return true;
|
|
405
|
-
}
|
|
406
|
-
function parseDotenv(text) {
|
|
407
|
-
const out = {};
|
|
408
|
-
for (const raw of text.split('\n')) {
|
|
409
|
-
const line = raw.trim();
|
|
410
|
-
if (!line || line.startsWith('#'))
|
|
411
|
-
continue;
|
|
412
|
-
const eq = line.indexOf('=');
|
|
413
|
-
if (eq <= 0)
|
|
414
|
-
continue;
|
|
415
|
-
const key = line.slice(0, eq).trim();
|
|
416
|
-
let value = line.slice(eq + 1).trim();
|
|
417
|
-
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
418
|
-
(value.startsWith("'") && value.endsWith("'"))) {
|
|
419
|
-
value = value.slice(1, -1);
|
|
420
|
-
}
|
|
421
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
422
|
-
out[key] = value;
|
|
423
|
-
}
|
|
424
|
-
return out;
|
|
425
|
-
}
|
|
426
|
-
async function confirmYesNo(question) {
|
|
427
|
-
// Non-interactive shells (pipes, CI) can't prompt — default to "no"
|
|
428
|
-
// so we never silently ship secrets in a script.
|
|
429
|
-
if (!process.stdout.isTTY || !process.stdin.isTTY)
|
|
430
|
-
return false;
|
|
431
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
432
|
-
try {
|
|
433
|
-
const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
|
|
434
|
-
return answer === '' || answer === 'y' || answer === 'yes';
|
|
435
|
-
}
|
|
436
|
-
finally {
|
|
437
|
-
rl.close();
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
function formatBytes(n) {
|
|
441
|
-
if (n < 1024)
|
|
442
|
-
return `${n}B`;
|
|
443
|
-
if (n < 1024 * 1024)
|
|
444
|
-
return `${(n / 1024).toFixed(1)}KB`;
|
|
445
|
-
return `${(n / 1024 / 1024).toFixed(2)}MB`;
|
|
446
|
-
}
|
|
447
|
-
function safeJson(text) {
|
|
448
|
-
try {
|
|
449
|
-
return JSON.parse(text);
|
|
450
|
-
}
|
|
451
|
-
catch {
|
|
452
|
-
return null;
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
/**
|
|
456
|
-
* `prave run trigger <slug>` — fire a single ad-hoc execution outside
|
|
457
|
-
* the cron schedule. Refused with 409 when another execution is in
|
|
458
|
-
* flight (same single-flight guard the dashboard's "Run now" button
|
|
459
|
-
* uses). Returns immediately after enqueueing — tail with
|
|
460
|
-
* `prave run logs <slug>` after a few seconds.
|
|
461
|
-
*/
|
|
462
|
-
export async function runTriggerCommand(slug) {
|
|
463
|
-
if (!(await requireAuth('prave run trigger')))
|
|
464
|
-
return;
|
|
465
|
-
const spinner = ora(`Triggering ${slug}…`).start();
|
|
466
|
-
try {
|
|
467
|
-
await api.post(`/api/v1/runs/${encodeURIComponent(slug)}/trigger`, undefined, true);
|
|
468
|
-
spinner.succeed(`Triggered ${chalk.bold(slug)} — log will appear in a few seconds.`);
|
|
469
|
-
console.log(chalk.dim(` Tail it with `) +
|
|
470
|
-
chalk.cyan(`prave run logs ${slug}`) +
|
|
471
|
-
chalk.dim(` (or watch the dashboard).`));
|
|
472
|
-
}
|
|
473
|
-
catch (err) {
|
|
474
|
-
if (err instanceof ApiError && err.status === 409) {
|
|
475
|
-
spinner.warn(`An execution is already in flight for ${slug} — wait for it to finish, then re-run.`);
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
spinner.fail(err.message);
|
|
479
|
-
process.exit(1);
|
|
480
|
-
}
|
|
481
|
-
}
|