@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.
@@ -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
- // 1. Local secret-scan BEFORE we ship anything. Cheap defence in
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
- const localFindings = await preflightScan(root);
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: (_path) => true,
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.1",
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.1"
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
  }