@fiodos/cli 0.1.14 → 0.1.16

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/src/writeEnv.js CHANGED
@@ -1,16 +1,32 @@
1
1
  /**
2
- * writeEnv — consent-based API key / URL alignment for the app's environment.
2
+ * writeEnv — consent-based handling of the orb's environment variables.
3
3
  *
4
- * The orb must read the SAME key/URL the CLI published with. We always ASK
5
- * before writing (independent of --yes, which only skips orb/handler prompts).
6
- * If the developer declines, a copy-paste reminder is shown at the very END of
7
- * the run — after analysis, publish, orb mount and handler wiring are done.
4
+ * The orb must read the SAME key/URL the CLI published with. We always ASK before
5
+ * writing (independent of --yes, which only skips orb/handler prompts). Sensitive
6
+ * values follow ONE consistent flow:
7
+ *
8
+ * 1. Local env file — "add it automatically, or add it yourself?" (yes/no).
9
+ * · yes → written to the framework's env file, which we ALSO ensure is in
10
+ * .gitignore so the secret never reaches GitHub.
11
+ * · no → a copy-paste reminder is shown at the very END of the run.
12
+ * 2. Deploy provider — the SAME yes/no flow for the build-time public key
13
+ * (VITE_/NEXT_PUBLIC_), because that value is inlined at BUILD time: if it is
14
+ * only in the (gitignored, never-pushed) local env and NOT in the provider,
15
+ * the production build ships WITHOUT it and the orb never appears. We detect
16
+ * a linked Vercel project and offer to push it automatically; otherwise we
17
+ * warn clearly and print exactly what to add where.
18
+ *
19
+ * SECURITY: no secret is ever hardcoded into committed source. The orb key
20
+ * (`fyd_…`) is a PUBLISHABLE client key (safe inside the frontend bundle), but it
21
+ * still must exist in the build environment — which is why it goes through env
22
+ * vars and the deploy-provider step, never into the repo.
8
23
  */
9
24
  'use strict';
10
25
 
11
26
  const fs = require('fs');
12
27
  const path = require('path');
13
28
  const readline = require('readline');
29
+ const { execFileSync } = require('child_process');
14
30
 
15
31
  function readJsonSafe(file) {
16
32
  try {
@@ -63,6 +79,11 @@ function resolveEnvPlan(appRoot) {
63
79
  return { framework: null, file: null, candidates: [], keyVar: null, urlVar: null };
64
80
  }
65
81
 
82
+ /** Frameworks whose public env vars are INLINED at build time (need provider env). */
83
+ function isBuildTimePublicFramework(framework) {
84
+ return framework === 'vite' || framework === 'next';
85
+ }
86
+
66
87
  function findEffectiveVar(appRoot, candidates, name) {
67
88
  for (const rel of candidates) {
68
89
  const filePath = path.join(appRoot, rel);
@@ -117,20 +138,127 @@ function askYesNo(question) {
117
138
  });
118
139
  }
119
140
 
141
+ /** True if `.gitignore` already keeps `fileName` (or a glob covering it) out of git. */
120
142
  function isGitIgnored(appRoot, fileName) {
121
143
  const gi = path.join(appRoot, '.gitignore');
122
144
  if (!fs.existsSync(gi)) return false;
123
145
  try {
124
146
  const content = fs.readFileSync(gi, 'utf8');
125
- const patterns = content.split('\n').map((l) => l.trim());
126
- return patterns.some(
127
- (p) => p === fileName || p === `/${fileName}` || p === '.env*' || p === '*.local' || p === '.env.local',
128
- );
147
+ return gitignoreCovers(content, fileName);
129
148
  } catch {
130
149
  return false;
131
150
  }
132
151
  }
133
152
 
153
+ /** Pure: does this .gitignore content keep `fileName` out of git? */
154
+ function gitignoreCovers(content, fileName) {
155
+ const patterns = content.split('\n').map((l) => l.trim());
156
+ const glob = fileName.endsWith('.local') ? '*.local' : '.env*';
157
+ return patterns.some(
158
+ (p) =>
159
+ p === fileName ||
160
+ p === `/${fileName}` ||
161
+ p === '.env*' ||
162
+ p === '.env.*' ||
163
+ p === '*.local' ||
164
+ p === glob ||
165
+ p === fileName.replace(/^\.env/, '.env*'),
166
+ );
167
+ }
168
+
169
+ /**
170
+ * Guarantee the secret-bearing env file is git-ignored (never pushed to GitHub).
171
+ * Appends a covering pattern to `.gitignore` (creating it when absent) when the
172
+ * file is not already ignored. Idempotent. Returns what happened.
173
+ */
174
+ function ensureEnvGitIgnored(appRoot, fileName) {
175
+ const gi = path.join(appRoot, '.gitignore');
176
+ const exists = fs.existsSync(gi);
177
+ let content = '';
178
+ try {
179
+ content = exists ? fs.readFileSync(gi, 'utf8') : '';
180
+ } catch {
181
+ content = '';
182
+ }
183
+ if (exists && gitignoreCovers(content, fileName)) {
184
+ return { changed: false, created: false, alreadyIgnored: true, pattern: null };
185
+ }
186
+ const pattern = fileName.endsWith('.local') ? '*.local' : fileName;
187
+ const header = '# Fiodos: keep local secrets out of git';
188
+ const prefix = content.length === 0 ? '' : content.endsWith('\n') ? '' : '\n';
189
+ const block = `${prefix}${content.length ? '\n' : ''}${header}\n${pattern}\n`;
190
+ try {
191
+ fs.writeFileSync(gi, content + block);
192
+ return { changed: true, created: !exists, alreadyIgnored: false, pattern };
193
+ } catch {
194
+ return { changed: false, created: false, alreadyIgnored: false, pattern, error: true };
195
+ }
196
+ }
197
+
198
+ /** Detect a deploy provider (and whether a Vercel project is already linked). */
199
+ function detectDeployProvider(appRoot) {
200
+ const has = (rel) => fs.existsSync(path.join(appRoot, rel));
201
+ if (has('.vercel/project.json') || has('vercel.json')) {
202
+ return { provider: 'vercel', linked: has('.vercel/project.json') };
203
+ }
204
+ if (has('netlify.toml') || has('.netlify')) {
205
+ return { provider: 'netlify', linked: false };
206
+ }
207
+ if (has('wrangler.toml')) {
208
+ return { provider: 'cloudflare', linked: false };
209
+ }
210
+ return { provider: null, linked: false };
211
+ }
212
+
213
+ function commandExists(cmd) {
214
+ try {
215
+ execFileSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { stdio: 'ignore' });
216
+ return true;
217
+ } catch {
218
+ return false;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Best-effort push of a single var to a LINKED Vercel project, non-interactively,
224
+ * across the given environments. Never hangs: only runs when the project is
225
+ * already linked and the CLI resolves, each call bounded by a timeout. Returns a
226
+ * per-environment result; failures (incl. "already exists") fall back to the
227
+ * manual reminder.
228
+ */
229
+ function attemptVercelEnvAdd(appRoot, name, value, environments) {
230
+ if (!fs.existsSync(path.join(appRoot, '.vercel', 'project.json'))) {
231
+ return { attempted: false, reason: 'not-linked' };
232
+ }
233
+ let bin = null;
234
+ let pre = [];
235
+ if (commandExists('vercel')) {
236
+ bin = 'vercel';
237
+ } else if (commandExists('npx')) {
238
+ bin = 'npx';
239
+ pre = ['--no-install', 'vercel'];
240
+ } else {
241
+ return { attempted: false, reason: 'no-cli' };
242
+ }
243
+ const results = [];
244
+ for (const env of environments) {
245
+ try {
246
+ execFileSync(bin, [...pre, 'env', 'add', name, env], {
247
+ cwd: appRoot,
248
+ input: `${value}\n`,
249
+ stdio: ['pipe', 'ignore', 'pipe'],
250
+ timeout: 60_000,
251
+ });
252
+ results.push({ env, ok: true });
253
+ } catch (e) {
254
+ const msg = String((e && (e.stderr || e.message)) || '').split('\n')[0];
255
+ results.push({ env, ok: false, msg });
256
+ }
257
+ }
258
+ const okCount = results.filter((r) => r.ok).length;
259
+ return { attempted: true, ok: okCount > 0, okCount, results };
260
+ }
261
+
134
262
  /**
135
263
  * Compute where the key/URL would go and what would change — no writes, no prompts.
136
264
  * @returns {object|null} null when there is nothing to do (already aligned / no key).
@@ -170,7 +298,7 @@ function computeEnvWritePlan(appRoot, apiKey, apiUrl, defaultApiUrl) {
170
298
  const urlOk = !urlNeedsFix && (wantUrl ? currentUrl === wantUrl : true);
171
299
 
172
300
  if (keyMatches && urlOk) {
173
- return { kind: 'aligned', targetRel, plan };
301
+ return { kind: 'aligned', targetRel, plan, framework: plan.framework, keyVar: plan.keyVar, apiKey };
174
302
  }
175
303
 
176
304
  const envLines = [`${plan.keyVar}=${apiKey}`];
@@ -180,6 +308,9 @@ function computeEnvWritePlan(appRoot, apiKey, apiUrl, defaultApiUrl) {
180
308
  return {
181
309
  kind: 'writable',
182
310
  plan,
311
+ framework: plan.framework,
312
+ keyVar: plan.keyVar,
313
+ urlVar: plan.urlVar,
183
314
  targetRel,
184
315
  absPath: path.join(appRoot, targetRel),
185
316
  envLines,
@@ -194,50 +325,88 @@ function computeEnvWritePlan(appRoot, apiKey, apiUrl, defaultApiUrl) {
194
325
  };
195
326
  }
196
327
 
197
- /** Branded reminder shown at the END when the developer declined the write. */
198
- function printEnvManualReminder(writePlan, { logUser, logDev }) {
199
- if (!writePlan || writePlan.kind === 'aligned') return;
328
+ /** Lines explaining how to set the build-time public key in the deploy provider. */
329
+ function deployReminderLines(deploy) {
330
+ const { keyVar, apiKey, provider } = deploy;
331
+ const lines = [];
332
+ lines.push(
333
+ `${keyVar} is a PUBLISHABLE client key (safe in your frontend bundle), but it is`,
334
+ );
335
+ lines.push(
336
+ "read at BUILD time — it must also live in your deploy provider's env, or the",
337
+ );
338
+ lines.push('production build ships without it and the orb will NOT appear.');
339
+ lines.push('');
340
+ if (provider === 'vercel') {
341
+ lines.push('Vercel — add it to Production, Preview and Development:');
342
+ lines.push(` vercel env add ${keyVar} production`);
343
+ lines.push(` vercel env add ${keyVar} preview`);
344
+ lines.push(` vercel env add ${keyVar} development`);
345
+ lines.push(` (value: ${apiKey})`);
346
+ lines.push(' …or Project → Settings → Environment Variables in the dashboard,');
347
+ lines.push(' then redeploy.');
348
+ } else if (provider === 'netlify') {
349
+ lines.push('Netlify — Site settings → Environment variables, then redeploy:');
350
+ lines.push(` ${keyVar}=${apiKey}`);
351
+ } else if (provider === 'cloudflare') {
352
+ lines.push('Cloudflare Pages — Settings → Environment variables, then redeploy:');
353
+ lines.push(` ${keyVar}=${apiKey}`);
354
+ } else {
355
+ lines.push('Your deploy provider (Vercel / Netlify / Cloudflare / …) — add it in');
356
+ lines.push('its Environment Variables panel and redeploy:');
357
+ lines.push(` ${keyVar}=${apiKey}`);
358
+ }
359
+ return lines;
360
+ }
200
361
 
201
- logUser('Add your API key to your environment file (last step)');
362
+ /** Branded reminder shown at the END (declined local write and/or deploy step). */
363
+ function printEnvManualReminder(result, { logUser, logDev }) {
364
+ if (!result) return;
365
+ const writePlan = result.writePlan || (result.kind ? result : null);
366
+ const deploy = result.deploy || null;
367
+ const declinedLocal =
368
+ result.status === 'declined by user' || result.status === 'manual';
202
369
 
203
- if (writePlan.kind === 'manual-angular') {
204
- console.log(
205
- ` Angular: open ${writePlan.file} and set fyodosApiKey / fyodosApiUrl:`,
206
- );
207
- console.log(` fyodosApiKey: '${writePlan.apiKey}'`);
208
- console.log(` fyodosApiUrl: '${writePlan.apiUrl}'`);
209
- return;
210
- }
370
+ if (writePlan && writePlan.kind !== 'aligned' && declinedLocal) {
371
+ logUser('Add your API key to your environment file (last step)');
211
372
 
212
- if (writePlan.kind === 'manual-unknown') {
213
- console.log(' Add to your project .env (use NEXT_PUBLIC_ for Next, VITE_ for Vite):');
214
- console.log(` ${writePlan.keyVar}=${writePlan.apiKey}`);
215
- if (writePlan.apiUrl && writePlan.apiUrl !== 'https://api.fyodos.com') {
216
- console.log(` ${writePlan.urlVar}=${writePlan.apiUrl}`);
373
+ if (writePlan.kind === 'manual-angular') {
374
+ console.log(` Angular: open ${writePlan.file} and set fyodosApiKey / fyodosApiUrl:`);
375
+ console.log(` fyodosApiKey: '${writePlan.apiKey}'`);
376
+ console.log(` fyodosApiUrl: '${writePlan.apiUrl}'`);
377
+ } else if (writePlan.kind === 'manual-unknown') {
378
+ console.log(' Add to your project .env (use NEXT_PUBLIC_ for Next, VITE_ for Vite):');
379
+ console.log(` ${writePlan.keyVar}=${writePlan.apiKey}`);
380
+ if (writePlan.apiUrl && writePlan.apiUrl !== 'https://api.fyodos.com') {
381
+ console.log(` ${writePlan.urlVar}=${writePlan.apiUrl}`);
382
+ }
383
+ } else {
384
+ console.log(` File: ${writePlan.absPath}`);
385
+ console.log(' Add these lines:');
386
+ for (const line of writePlan.envLines) console.log(` ${line}`);
217
387
  }
218
- return;
388
+ if (logDev) logDev(`[write-env] manual reminder for ${writePlan.targetRel || writePlan.file || 'env'}`);
219
389
  }
220
390
 
221
- const rel = writePlan.targetRel;
222
- const displayPath = writePlan.absPath;
223
- console.log(` File: ${displayPath}`);
224
- console.log(' Add these lines:');
225
- for (const line of writePlan.envLines) {
226
- console.log(` ${line}`);
391
+ if (deploy && deploy.reminder) {
392
+ logUser('Set the orb key in your deploy provider (or the orb is invisible in production)');
393
+ for (const line of deployReminderLines(deploy)) console.log(line ? ` ${line}` : '');
394
+ if (logDev) logDev(`[write-env] deploy reminder for provider=${deploy.provider || 'unknown'}`);
227
395
  }
228
- logDev(`[write-env] manual reminder for ${rel}`);
229
396
  }
230
397
 
231
398
  /**
232
- * Ask consent and optionally write. Returns { status, writePlan }.
399
+ * Ask consent and optionally write. Returns { status, writePlan, deploy }.
233
400
  * `assumeYes` comes from --write-env-yes only (NOT global --yes).
234
401
  */
235
402
  async function offerWriteEnv({ appRoot, apiKey, apiUrl, defaultApiUrl, assumeYes, log, logDev: devLog }) {
236
403
  const writePlan = computeEnvWritePlan(appRoot, apiKey, apiUrl, defaultApiUrl);
237
- if (!writePlan) return { status: 'skipped (no API key)', writePlan: null };
404
+ if (!writePlan) return { status: 'skipped (no API key)', writePlan: null, deploy: null };
238
405
  if (writePlan.kind === 'aligned') {
239
406
  log(`Your environment already uses the same API key as this project. Nothing to change.`);
240
- return { status: 'already aligned', writePlan };
407
+ // Even when the local env is aligned, production may still be missing the key.
408
+ const deploy = await offerDeployEnv({ appRoot, writePlan, assumeYes, log, devLog });
409
+ return { status: 'already aligned', writePlan, deploy };
241
410
  }
242
411
 
243
412
  const targetLabel =
@@ -249,18 +418,18 @@ async function offerWriteEnv({ appRoot, apiKey, apiUrl, defaultApiUrl, assumeYes
249
418
 
250
419
  let proceed = assumeYes;
251
420
  if (!proceed) {
252
- proceed = await askYesNo(
253
- `◉ Fiodos · Add your API key to ${targetLabel}? [yes/no] `,
254
- );
421
+ proceed = await askYesNo(`◉ Fiodos · Add your API key to ${targetLabel}? [yes/no] `);
255
422
  }
256
423
 
257
424
  if (!proceed) {
258
- return { status: 'declined by user', writePlan };
425
+ const deploy = await offerDeployEnv({ appRoot, writePlan, assumeYes, log, devLog });
426
+ return { status: 'declined by user', writePlan, deploy };
259
427
  }
260
428
 
261
429
  if (writePlan.kind === 'manual-angular' || writePlan.kind === 'manual-unknown') {
262
430
  log('Could not write automatically for this framework — see the reminder at the end.');
263
- return { status: 'manual', writePlan };
431
+ const deploy = await offerDeployEnv({ appRoot, writePlan, assumeYes, log, devLog });
432
+ return { status: 'manual', writePlan, deploy };
264
433
  }
265
434
 
266
435
  const { plan, targetRel, keyMatches, wantUrl, urlNeedsFix, apiKey: key, apiUrl: url } = writePlan;
@@ -279,10 +448,68 @@ async function offerWriteEnv({ appRoot, apiKey, apiUrl, defaultApiUrl, assumeYes
279
448
  fs.writeFileSync(filePath, `${out.join('\n')}\n`);
280
449
  log(`API key saved to ${targetRel}.`);
281
450
 
282
- if (!isGitIgnored(appRoot, targetRel)) {
283
- devLog(`Reminder: add ${targetRel} to .gitignore so you don't commit your API key.`);
451
+ // SECURITY: guarantee the secret-bearing file is git-ignored (never pushed).
452
+ const gi = ensureEnvGitIgnored(appRoot, targetRel);
453
+ if (gi.changed) {
454
+ log(`Added ${gi.pattern} to .gitignore so your key is never pushed to GitHub.`);
455
+ } else if (gi.alreadyIgnored) {
456
+ if (devLog) devLog(`[write-env] ${targetRel} already covered by .gitignore`);
457
+ } else if (gi.error) {
458
+ log(`Heads up: could not update .gitignore — make sure ${targetRel} is git-ignored.`);
284
459
  }
285
- return { status: 'written', writePlan };
460
+
461
+ const deploy = await offerDeployEnv({ appRoot, writePlan, assumeYes, log, devLog });
462
+ return { status: 'written', writePlan, deploy };
463
+ }
464
+
465
+ /**
466
+ * Deploy-provider step for the build-time PUBLIC key (VITE_/NEXT_PUBLIC_). Same
467
+ * yes/no flow as the local write. When a linked Vercel project is detected and
468
+ * the user consents, the key is pushed automatically; otherwise (declined, no
469
+ * provider, non-TTY, or failure) a clear reminder is queued for the end.
470
+ */
471
+ async function offerDeployEnv({ appRoot, writePlan, assumeYes, log, devLog }) {
472
+ const framework = writePlan.framework;
473
+ const keyVar = writePlan.keyVar;
474
+ const apiKey = writePlan.apiKey;
475
+ if (!apiKey || !keyVar || !isBuildTimePublicFramework(framework)) {
476
+ return null; // Angular / unknown handle their own manual reminders.
477
+ }
478
+ const det = detectDeployProvider(appRoot);
479
+ const base = { provider: det.provider, linked: det.linked, keyVar, apiKey };
480
+
481
+ // Only Vercel can be automated here, and only when the project is already
482
+ // linked (so the CLI never blocks on an interactive link/login).
483
+ if (det.provider === 'vercel' && det.linked) {
484
+ let proceed = assumeYes;
485
+ if (!proceed) {
486
+ proceed = await askYesNo(
487
+ `◉ Fiodos · Add ${keyVar} to your linked Vercel project (production, preview, development) now? [yes/no] `,
488
+ );
489
+ }
490
+ if (proceed) {
491
+ const res = attemptVercelEnvAdd(appRoot, keyVar, apiKey, [
492
+ 'production',
493
+ 'preview',
494
+ 'development',
495
+ ]);
496
+ if (res.attempted && res.ok) {
497
+ log(`Pushed ${keyVar} to Vercel (${res.okCount}/3 environments). Redeploy to apply.`);
498
+ if (res.okCount < 3) {
499
+ if (devLog) devLog('[write-env] some Vercel envs failed (often already set) — reminder kept');
500
+ return { ...base, reminder: true, partial: true };
501
+ }
502
+ return { ...base, reminder: false, automated: true };
503
+ }
504
+ if (devLog) devLog(`[write-env] vercel automation failed: ${res.reason || 'cli-error'}`);
505
+ log('Could not push to Vercel automatically — see the reminder at the end.');
506
+ return { ...base, reminder: true };
507
+ }
508
+ return { ...base, reminder: true };
509
+ }
510
+
511
+ // Any other provider / not linked / non-TTY: warn clearly at the end.
512
+ return { ...base, reminder: true };
286
513
  }
287
514
 
288
515
  module.exports = {
@@ -290,4 +517,9 @@ module.exports = {
290
517
  resolveEnvPlan,
291
518
  computeEnvWritePlan,
292
519
  printEnvManualReminder,
520
+ ensureEnvGitIgnored,
521
+ gitignoreCovers,
522
+ detectDeployProvider,
523
+ isBuildTimePublicFramework,
524
+ deployReminderLines,
293
525
  };