@prave/cli 1.4.1 → 1.4.2
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/run.js +145 -9
- package/package.json +3 -3
package/dist/commands/run.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
2
|
import { resolve, relative, basename, sep } from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
4
|
import { Buffer } from 'node:buffer';
|
|
4
5
|
import chalk from 'chalk';
|
|
5
6
|
import open from 'open';
|
|
@@ -62,11 +63,40 @@ export async function runDeployCommand(pathArg) {
|
|
|
62
63
|
const creds0 = await requireAuth('prave run');
|
|
63
64
|
if (!creds0)
|
|
64
65
|
return;
|
|
65
|
-
//
|
|
66
|
+
// 1a. Look for .env / .env.production / .env.local etc. The scanner
|
|
67
|
+
// would otherwise hard-reject them on the upload. Offer to lift the
|
|
68
|
+
// values out of the bundle and pass them to the wizard so they end
|
|
69
|
+
// up encrypted on the run row instead of in the tarball.
|
|
70
|
+
const envFiles = await findEnvFiles(root);
|
|
71
|
+
let envVars = {};
|
|
72
|
+
const stripPaths = new Set();
|
|
73
|
+
if (envFiles.length > 0) {
|
|
74
|
+
const totalKeys = envFiles.reduce((n, f) => n + Object.keys(f.values).length, 0);
|
|
75
|
+
console.log();
|
|
76
|
+
console.log(chalk.bold(`Found ${envFiles.length} env file${envFiles.length === 1 ? '' : 's'} with ${totalKeys} variable${totalKeys === 1 ? '' : 's'}:`));
|
|
77
|
+
for (const f of envFiles) {
|
|
78
|
+
console.log(` ${chalk.cyan('•')} ${f.path} ${chalk.dim(`(${Object.keys(f.values).length} keys)`)}`);
|
|
79
|
+
}
|
|
80
|
+
console.log(chalk.dim('\nIf you continue, Prave strips these from the upload and passes the\n' +
|
|
81
|
+
'values to the browser wizard. They get AES-256-GCM-encrypted onto\n' +
|
|
82
|
+
'the run, decrypted only inside the sandbox at run-time.\n' +
|
|
83
|
+
'Decline to abort so you can clean up first.'));
|
|
84
|
+
const yes = await confirmYesNo('Strip & pass env vars to the wizard?');
|
|
85
|
+
if (!yes) {
|
|
86
|
+
log.error('Aborted. Remove the .env file(s) (or rename to .env.example) and re-run.');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
for (const f of envFiles) {
|
|
90
|
+
stripPaths.add(f.path);
|
|
91
|
+
Object.assign(envVars, f.values);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// 1b. Local secret-scan BEFORE we ship anything. Cheap defence in
|
|
66
95
|
// depth — even though the API scans again, surfacing the finding
|
|
67
96
|
// pre-upload saves the user a round-trip and avoids briefly storing
|
|
68
|
-
// a secret-bearing tarball in our bucket.
|
|
69
|
-
|
|
97
|
+
// a secret-bearing tarball in our bucket. The env files we just
|
|
98
|
+
// accepted to lift out are excluded from the scan.
|
|
99
|
+
const localFindings = await preflightScan(root, stripPaths);
|
|
70
100
|
if (localFindings.length > 0) {
|
|
71
101
|
log.error('Bundle contains files that look like secrets:');
|
|
72
102
|
for (const f of localFindings.slice(0, 10)) {
|
|
@@ -89,13 +119,28 @@ export async function runDeployCommand(pathArg) {
|
|
|
89
119
|
initSpinner.fail(`Could not open deploy session: ${err.message}`);
|
|
90
120
|
process.exit(1);
|
|
91
121
|
}
|
|
122
|
+
// 2b. Ship env vars to the wizard before the upload so they're
|
|
123
|
+
// waiting when the browser opens. The wizard reads them once via
|
|
124
|
+
// /deploy/status and pre-fills its env-vars step.
|
|
125
|
+
if (Object.keys(envVars).length > 0) {
|
|
126
|
+
const envSpinner = ora('Sending env vars to the wizard…').start();
|
|
127
|
+
try {
|
|
128
|
+
await api.post(`/api/v1/deploy/env?session=${encodeURIComponent(session.session_id)}`, { env_vars: envVars }, true);
|
|
129
|
+
envSpinner.succeed(`Passed ${Object.keys(envVars).length} env var(s)`);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
envSpinner.fail(`Could not ship env vars: ${err.message}`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
92
136
|
// 3. Pack the directory into a gzipped tar in memory. tar.create's
|
|
93
137
|
// `cwd` option is critical — paths inside the archive must be
|
|
94
|
-
// RELATIVE to the project root, not absolute.
|
|
138
|
+
// RELATIVE to the project root, not absolute. Stripped paths are
|
|
139
|
+
// env files we already shipped via /deploy/env above.
|
|
95
140
|
const packSpinner = ora('Bundling project…').start();
|
|
96
141
|
let tarball;
|
|
97
142
|
try {
|
|
98
|
-
tarball = await packDirectory(root);
|
|
143
|
+
tarball = await packDirectory(root, stripPaths);
|
|
99
144
|
packSpinner.succeed(`Bundled ${formatBytes(tarball.length)}`);
|
|
100
145
|
}
|
|
101
146
|
catch (err) {
|
|
@@ -221,7 +266,7 @@ async function findSkillMd(root) {
|
|
|
221
266
|
return null;
|
|
222
267
|
}
|
|
223
268
|
}
|
|
224
|
-
async function preflightScan(root) {
|
|
269
|
+
async function preflightScan(root, stripPaths = new Set()) {
|
|
225
270
|
const inputs = [];
|
|
226
271
|
let files = 0;
|
|
227
272
|
let bytes = 0;
|
|
@@ -243,8 +288,13 @@ async function preflightScan(root) {
|
|
|
243
288
|
}
|
|
244
289
|
if (!entry.isFile())
|
|
245
290
|
continue;
|
|
246
|
-
files++;
|
|
247
291
|
const rel = relative(root, abs).split(sep).join('/');
|
|
292
|
+
// The user already accepted to lift this env file out of the
|
|
293
|
+
// bundle in the previous step. Don't scan it (we know it has
|
|
294
|
+
// secrets — that's why we're lifting it).
|
|
295
|
+
if (stripPaths.has(rel))
|
|
296
|
+
continue;
|
|
297
|
+
files++;
|
|
248
298
|
if (!isLikelyTextPath(rel)) {
|
|
249
299
|
inputs.push({ path: rel });
|
|
250
300
|
continue;
|
|
@@ -262,12 +312,17 @@ async function preflightScan(root) {
|
|
|
262
312
|
await visit(root);
|
|
263
313
|
return scanForSecrets(inputs).findings;
|
|
264
314
|
}
|
|
265
|
-
async function packDirectory(root) {
|
|
315
|
+
async function packDirectory(root, stripPaths = new Set()) {
|
|
266
316
|
// Top-level entries only — tar.create resolves them against `cwd`.
|
|
267
317
|
const entries = (await readdir(root)).filter((n) => !TAR_IGNORE.has(n));
|
|
268
318
|
if (entries.length === 0) {
|
|
269
319
|
throw new Error('Project directory is empty.');
|
|
270
320
|
}
|
|
321
|
+
// Normalise the strip-set against tar.create's relative-path format
|
|
322
|
+
// (POSIX forward slashes, no leading "./"). Paths sit one level
|
|
323
|
+
// beneath the archive's basename prefix, so we compare on the
|
|
324
|
+
// input-side relative path tar.create hands us.
|
|
325
|
+
const stripNormalised = new Set([...stripPaths].map((p) => p.replace(/\\/g, '/').replace(/^\.\//, '')));
|
|
271
326
|
const stream = tar.create({
|
|
272
327
|
gzip: true,
|
|
273
328
|
cwd: root,
|
|
@@ -276,7 +331,10 @@ async function packDirectory(root) {
|
|
|
276
331
|
// gets a `<project>/SKILL.md` shape, not a flat dump at the
|
|
277
332
|
// archive root.
|
|
278
333
|
prefix: basename(root),
|
|
279
|
-
filter: (
|
|
334
|
+
filter: (path) => {
|
|
335
|
+
const norm = path.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
336
|
+
return !stripNormalised.has(norm);
|
|
337
|
+
},
|
|
280
338
|
}, entries);
|
|
281
339
|
const chunks = [];
|
|
282
340
|
for await (const chunk of stream) {
|
|
@@ -284,6 +342,84 @@ async function packDirectory(root) {
|
|
|
284
342
|
}
|
|
285
343
|
return Buffer.concat(chunks);
|
|
286
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Look for common `.env*` files at the project root. We don't scan
|
|
347
|
+
* deeper than top-level — a nested `.env` is almost certainly a
|
|
348
|
+
* shipping example, not the real secrets file. Returns an empty list
|
|
349
|
+
* when nothing's found.
|
|
350
|
+
*/
|
|
351
|
+
async function findEnvFiles(root) {
|
|
352
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
353
|
+
const findings = [];
|
|
354
|
+
for (const entry of entries) {
|
|
355
|
+
if (!entry.isFile())
|
|
356
|
+
continue;
|
|
357
|
+
const name = entry.name;
|
|
358
|
+
if (!isRealEnvFile(name))
|
|
359
|
+
continue;
|
|
360
|
+
try {
|
|
361
|
+
const raw = await readFile(`${root}${sep}${name}`, 'utf8');
|
|
362
|
+
const values = parseDotenv(raw);
|
|
363
|
+
if (Object.keys(values).length === 0)
|
|
364
|
+
continue;
|
|
365
|
+
findings.push({ path: name, values });
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
/* unreadable — skip */
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return findings;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* `.env`, `.env.local`, `.env.production`, `.env.development`, `.envrc`
|
|
375
|
+
* count as "real env files". Templates (`.env.example`, `.env.sample`,
|
|
376
|
+
* `.env.template`) are explicitly NOT lifted — those are meant to be
|
|
377
|
+
* shipped.
|
|
378
|
+
*/
|
|
379
|
+
function isRealEnvFile(name) {
|
|
380
|
+
if (name === '.env' || name === '.envrc')
|
|
381
|
+
return true;
|
|
382
|
+
if (!name.startsWith('.env.'))
|
|
383
|
+
return false;
|
|
384
|
+
const suffix = name.slice('.env.'.length);
|
|
385
|
+
if (/^(example|sample|template)$/i.test(suffix))
|
|
386
|
+
return false;
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
function parseDotenv(text) {
|
|
390
|
+
const out = {};
|
|
391
|
+
for (const raw of text.split('\n')) {
|
|
392
|
+
const line = raw.trim();
|
|
393
|
+
if (!line || line.startsWith('#'))
|
|
394
|
+
continue;
|
|
395
|
+
const eq = line.indexOf('=');
|
|
396
|
+
if (eq <= 0)
|
|
397
|
+
continue;
|
|
398
|
+
const key = line.slice(0, eq).trim();
|
|
399
|
+
let value = line.slice(eq + 1).trim();
|
|
400
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
401
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
402
|
+
value = value.slice(1, -1);
|
|
403
|
+
}
|
|
404
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
405
|
+
out[key] = value;
|
|
406
|
+
}
|
|
407
|
+
return out;
|
|
408
|
+
}
|
|
409
|
+
async function confirmYesNo(question) {
|
|
410
|
+
// Non-interactive shells (pipes, CI) can't prompt — default to "no"
|
|
411
|
+
// so we never silently ship secrets in a script.
|
|
412
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY)
|
|
413
|
+
return false;
|
|
414
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
415
|
+
try {
|
|
416
|
+
const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
|
|
417
|
+
return answer === '' || answer === 'y' || answer === 'yes';
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
rl.close();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
287
423
|
function formatBytes(n) {
|
|
288
424
|
if (n < 1024)
|
|
289
425
|
return `${n}B`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prave/cli",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
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.2"
|
|
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"
|
|
74
|
+
"postinstall": "node scripts/postinstall.mjs || true"
|
|
75
75
|
}
|
|
76
76
|
}
|