@timekast/factory 1.16.0 → 1.17.1

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.
@@ -10,12 +10,13 @@
10
10
  * Exposed to derivatives as `pnpm env:push` (an npx alias). Run it after editing
11
11
  * `.env.local` to add a new product variable post-provision.
12
12
  */
13
- import { existsSync, readFileSync } from 'node:fs';
13
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { CLIError } from '../lib/cli-error.js';
16
- import { PROVISION_OWNED } from '../lib/env-file.js';
17
- import { applyWizardAnswers, buildQuestionPlan, parseEnvExample, } from '../lib/env-wizard.js';
16
+ import { PROVISION_OWNED, setEnvLine } from '../lib/env-file.js';
17
+ import { applyDefaultOverrides, applyWizardAnswers, buildEnvReview, buildQuestionPlan, checkEnvCompleteness, generateVapidKeys, isQuestionActive, parseEnvBody, parseEnvExample, provisionEmailDefaults, } from '../lib/env-wizard.js';
18
18
  import { defaultEnvPath, readVercelToken } from '../lib/provision-preflight.js';
19
+ import { readProvisionState } from '../lib/provision-state.js';
19
20
  import { pushEnvPlan, vercelProjectPath, VercelApiError, } from '../lib/vercel-env.js';
20
21
  // Re-exported so existing importers (and `cli/tests/unit/env-push.test.ts`) keep
21
22
  // resolving PROVISION_OWNED from this module after it moved to `lib/env-file.js`.
@@ -127,6 +128,8 @@ export function parseEnvWizardFlags(argv) {
127
128
  apply: argv.includes('--apply'),
128
129
  json: argv.includes('--json'),
129
130
  push: argv.includes('--push'),
131
+ review: argv.includes('--review'),
132
+ genVapid: argv.includes('--gen-vapid'),
130
133
  };
131
134
  }
132
135
  function readEnvExamplePlan(rootDir) {
@@ -136,9 +139,25 @@ function readEnvExamplePlan(rootDir) {
136
139
  }
137
140
  return buildQuestionPlan(parseEnvExample(readFileSync(examplePath, 'utf8')));
138
141
  }
142
+ /**
143
+ * Read the question plan and override defaults with what provision already
144
+ * configured (e.g. EMAIL_PROVIDER → `resend` once the DNS/Resend step ran), so the
145
+ * wizard never re-asks a decision provision already made. A missing/corrupt state
146
+ * file is non-fatal — the plan just falls back to the `.env.example` defaults.
147
+ */
148
+ function planWithProvisionDefaults(rootDir) {
149
+ let state = null;
150
+ try {
151
+ state = readProvisionState(rootDir);
152
+ }
153
+ catch {
154
+ state = null;
155
+ }
156
+ return applyDefaultOverrides(readEnvExamplePlan(rootDir), provisionEmailDefaults(state));
157
+ }
139
158
  /** `factory env wizard --plan [--json]` — emit the question plan. */
140
159
  export function runEnvWizardPlan(flags, rootDir = process.cwd()) {
141
- const questions = readEnvExamplePlan(rootDir);
160
+ const questions = planWithProvisionDefaults(rootDir);
142
161
  if (flags.json) {
143
162
  process.stdout.write(`${JSON.stringify({ questions }, null, 2)}\n`);
144
163
  return;
@@ -176,6 +195,11 @@ export async function runEnvWizardApply(flags, rootDir = process.cwd(), deps = {
176
195
  const answers = parsed.answers ?? {};
177
196
  const written = applyWizardAnswers(rootDir, answers);
178
197
  console.log(`✔ Escribí ${written.length} variable(s) en \`.env.local\`.`);
198
+ // Completeness guards (OAuth creds, VAPID) — warn, never block the write. Read
199
+ // the resolved file so an already-present cred (from provision) counts as set.
200
+ const finalEnv = parseEnvBody(readFileSync(path.join(rootDir, '.env.local'), 'utf8'));
201
+ for (const w of checkEnvCompleteness(finalEnv))
202
+ console.log(`⚠ ${w}`);
179
203
  const shouldPush = flags.push || parsed.push === true;
180
204
  if (!shouldPush) {
181
205
  console.log('ℹ️ Para subirlas a Vercel cuando quieras: `pnpm env:push`.');
@@ -184,3 +208,71 @@ export async function runEnvWizardApply(flags, rootDir = process.cwd(), deps = {
184
208
  const push = deps.push ?? ((dir) => runEnvPush({ dryRun: false }, dir));
185
209
  await push(rootDir);
186
210
  }
211
+ /**
212
+ * `factory env wizard --review [--json]` — a secret-safe summary of the resolved
213
+ * `.env.local`. Non-secret values are shown; secret values are masked; empty ones
214
+ * are flagged. This is what the guided flow shows the dev at the end (the agent
215
+ * NEVER echoes raw secrets) — the dev opens `.env.local` to see the full values.
216
+ */
217
+ export function runEnvWizardReview(flags, rootDir = process.cwd()) {
218
+ const localPath = path.join(rootDir, '.env.local');
219
+ if (!existsSync(localPath)) {
220
+ throw new CLIError('No encontré `.env.local`. Corre el wizard (`factory env wizard`) primero.');
221
+ }
222
+ const body = readFileSync(localPath, 'utf8');
223
+ const answers = parseEnvBody(body);
224
+ const questions = planWithProvisionDefaults(rootDir).filter((q) => isQuestionActive(q, answers));
225
+ const rows = buildEnvReview(questions, body);
226
+ const warnings = checkEnvCompleteness(answers);
227
+ if (flags.json) {
228
+ process.stdout.write(`${JSON.stringify({ rows, warnings }, null, 2)}\n`);
229
+ return;
230
+ }
231
+ console.log('Configuración de `.env.local` (los secretos van enmascarados):\n');
232
+ let section = '';
233
+ for (const row of rows) {
234
+ if (row.section !== section) {
235
+ section = row.section;
236
+ console.log(`## ${section}`);
237
+ }
238
+ const flag = row.status === 'empty' ? ' ⚠' : '';
239
+ console.log(` ${row.key} = ${row.display}${flag}`);
240
+ }
241
+ if (warnings.length) {
242
+ console.log('\nAvisos:');
243
+ for (const w of warnings)
244
+ console.log(` ⚠ ${w}`);
245
+ }
246
+ console.log('\nAbre `.env.local` para ver los valores completos (los secretos no se imprimen).');
247
+ }
248
+ /**
249
+ * `factory env wizard --gen-vapid` — generate a VAPID keypair and write it to
250
+ * `.env.local`. The private key is a SECRET: it is written to the file (0600) and
251
+ * NEVER printed. The public key is public by design, so it is safe to echo.
252
+ */
253
+ export function runEnvWizardGenVapid(rootDir = process.cwd()) {
254
+ const localPath = path.join(rootDir, '.env.local');
255
+ const examplePath = path.join(rootDir, '.env.example');
256
+ const basePath = existsSync(localPath) ? localPath : examplePath;
257
+ if (!existsSync(basePath)) {
258
+ throw new CLIError('No encontré `.env.local` ni `.env.example` para escribir las llaves VAPID.');
259
+ }
260
+ const { publicKey, privateKey } = generateVapidKeys();
261
+ let body = readFileSync(basePath, 'utf8');
262
+ body = setEnvLine(body, 'NEXT_PUBLIC_VAPID_PUBLIC_KEY', publicKey);
263
+ body = setEnvLine(body, 'VAPID_PRIVATE_KEY', privateKey);
264
+ // Seed a sensible VAPID_SUBJECT (push contact) from EMAIL_FROM if it's unset.
265
+ const env = parseEnvBody(body);
266
+ const subjectMissing = !env['VAPID_SUBJECT'];
267
+ if (subjectMissing) {
268
+ const from = env['EMAIL_FROM'];
269
+ body = setEnvLine(body, 'VAPID_SUBJECT', from ? `mailto:${from}` : 'mailto:admin@example.com');
270
+ }
271
+ writeFileSync(localPath, body, 'utf8');
272
+ chmodSync(localPath, 0o600); // holds VAPID_PRIVATE_KEY → owner-only.
273
+ console.log('✔ Llaves VAPID generadas y escritas a `.env.local` (la privada queda oculta).');
274
+ console.log(` NEXT_PUBLIC_VAPID_PUBLIC_KEY=${publicKey}`);
275
+ if (subjectMissing && !env['EMAIL_FROM']) {
276
+ console.log(' ℹ️ Revisa VAPID_SUBJECT — puse un placeholder (correo de contacto para push).');
277
+ }
278
+ }
@@ -29,7 +29,7 @@ import { apexZoneOf, CloudflareApiError, CLOUDFLARE_API_BASE, ensureCnameRecord,
29
29
  import { createDevelopBranch, createNeonProject, createProjectApiKey, deleteNeonApiKeysByName, deleteNeonProject, getConnectionUri, listNeonBranches, listNeonProjects, NeonApiError, NEON_API_BASE, NEON_DEVELOP_BRANCH, } from '../lib/neon-api.js';
30
30
  import { defaultEnvPath, readVercelToken, runProvisionPreflight, } from '../lib/provision-preflight.js';
31
31
  import { addVercelDomain, buildEnvPlan, createVercelProject, generateAuthSecret, generateMfaEncryptionKey, getLinkedRepoId, getVercelDomainStatus, GitHubAppNotInstalledError, linkGitRepo, listVercelProjects, patchProjectSettings, pushEnvPlan, removeVercelDomain, triggerDeployment, upsertEnvVar, listEnvVars, VercelApiError, writeVercelProjectJson, } from '../lib/vercel-env.js';
32
- import { writeEnvLocal } from '../lib/env-file.js';
32
+ import { writeE2eLocalCreds, writeEnvLocal } from '../lib/env-file.js';
33
33
  import { adoptState, ADOPT_UNKNOWN, clearProvisionState, clearState, hasProvisionState, isServiceComplete, PROVISION_DIR, PROVISION_STATE_FILE, readDestroyTarget, readProvisionState, writeProvisionState, } from '../lib/provision-state.js';
34
34
  /**
35
35
  * The THREE GitHub Secrets the E2E runner (`pnpm test:e2e` → `e2e-runner.ts` +
@@ -255,10 +255,53 @@ function deriveProjectName(flags, rootDir) {
255
255
  // `repo TimeKast/team_ldqH... no existe` error. Validate it like an explicit slug.
256
256
  // Lowercased so a dir created with mixed case (e.g. `Edmond/`) yields a valid slug
257
257
  // instead of being rejected for the uppercase — matches the `new` normalization.
258
+ // No `--slug`: prefer the slug documented in project-config.md over the directory
259
+ // name, so the provisioned domains/project match the project's identity doc rather
260
+ // than an incidental dir/repo name (e.g. `factory-test` vs a documented
261
+ // `factory-testlab`). Best-effort: fall back to the dir on any miss.
258
262
  const fromDir = basename(rootDir).toLowerCase();
263
+ const fromConfig = readConfigSlug(rootDir);
264
+ if (fromConfig && fromConfig !== fromDir) {
265
+ console.log(`ℹ️ Slug tomado de project-config.md: \`${fromConfig}\` ` +
266
+ `(el directorio es \`${fromDir}\`). Pasa \`--slug\` para forzar otro.`);
267
+ return fromConfig; // readConfigSlug already validated it as a subdomain label
268
+ }
259
269
  assertValidSubdomainSlug(fromDir);
260
270
  return fromDir;
261
271
  }
272
+ /**
273
+ * Best-effort read of the documented slug from `project/planning/project-config.md`.
274
+ * Matches both the table form (`| **Slug** | my-app |`) and prose (`**Slug:** my-app`).
275
+ * Returns `null` when the file is absent/unparsable or the value isn't a valid
276
+ * subdomain label — the caller then falls back to the directory name.
277
+ */
278
+ export function readConfigSlug(rootDir) {
279
+ const configPath = join(rootDir, 'project', 'planning', 'project-config.md');
280
+ if (!existsSync(configPath))
281
+ return null;
282
+ let text;
283
+ try {
284
+ text = readFileSync(configPath, 'utf8');
285
+ }
286
+ catch {
287
+ return null;
288
+ }
289
+ const line = text.split('\n').find((l) => /(^|\|)\s*\**\s*Slug\b/i.test(l));
290
+ if (!line)
291
+ return null;
292
+ // Drop everything up to and including "Slug", strip the markdown separators, then
293
+ // take the WHOLE first token that remains and validate it as a unit (so a value
294
+ // like `not_a_slug` is rejected outright, not truncated to a valid-looking prefix).
295
+ const token = line
296
+ .slice(line.search(/Slug\b/i) + 4)
297
+ .replace(/[*|:]/g, ' ')
298
+ .trim()
299
+ .split(/\s+/)[0];
300
+ if (!token)
301
+ return null;
302
+ const slug = token.toLowerCase();
303
+ return isValidSubdomainSlug(slug) ? slug : null;
304
+ }
262
305
  /**
263
306
  * Read the VALUE of `NEON_API_KEY` (the shared org key) from the rail
264
307
  * (`~/.claude/.env`). Mirrors `readVercelToken`: split on the FIRST `=`, strip a
@@ -701,6 +744,14 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
701
744
  else if (envLocal === 'skipped') {
702
745
  console.log(' ⏭ .env.local ya existía (no se sobrescribe — usa --force para regenerarlo)');
703
746
  }
747
+ // Local E2E creds: write NEON_API_KEY (the org key from the rail — already on the
748
+ // dev's host) + NEON_PROJECT_ID so `pnpm test:e2e` runs locally with zero manual
749
+ // `setup:e2e`. Both are PROVISION_OWNED → never pushed to Vercel. Safe on a skipped
750
+ // `.env.local` (setEnvLine touches only these two keys). The key value is never printed.
751
+ if (state.neon?.projectId &&
752
+ writeE2eLocalCreds(rootDir, state.neon.projectId, readNeonApiKey(envPath))) {
753
+ console.log(' + .env.local (NEON_API_KEY · NEON_PROJECT_ID — para `pnpm test:e2e` local)');
754
+ }
704
755
  // `pnpm install` the derived dir (issue §4). A missing package.json (non-Node
705
756
  // derivative) is reported, NOT fatal (issue §8).
706
757
  if (existsSync(join(rootDir, 'package.json'))) {
@@ -2424,6 +2475,18 @@ export async function runProvision(flags) {
2424
2475
  // this write keeps the baseline durable for them too.
2425
2476
  writeProvisionState(rootDir, runState);
2426
2477
  }
2478
+ // 5a. Retry the E2E GitHub Secrets if Neon recorded them pending. Neon runs BEFORE
2479
+ // the git substrate, so on a fresh bootstrap the repo may not exist yet when it
2480
+ // first tries `gh secret set` → it records `apiKeySecretPending` (non-fatal)
2481
+ // instead of aborting. Now that the substrate confirmed the repo
2482
+ // (`runState.github.repo`), re-run Neon: it is idempotent (skips the project +
2483
+ // branch create) and re-mints the unpersisted key to store the three secrets.
2484
+ // Without this, CI E2E stays credential-less until a MANUAL `--resume`.
2485
+ if (!flags.dryRun && runState.neon?.apiKeySecretPending && runState.github?.repo) {
2486
+ console.log('\n↻ Reintentando los secrets de E2E ahora que el repo existe...');
2487
+ await provisionNeon(rootDir, runState);
2488
+ writeProvisionState(rootDir, runState);
2489
+ }
2427
2490
  // 5b. Client domain (PROV-006) — runs AFTER the services so the Vercel project
2428
2491
  // (and the interim domains) already exist. Standalone patch or post-services.
2429
2492
  if (hasDomain) {
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import { dirname, join } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { CLIError } from './lib/cli-error.js';
13
13
  import { parseAddFlags, runAdd } from './commands/add.js';
14
- import { parseEnvPushFlags, parseEnvWizardFlags, runEnvPush, runEnvWizardApply, runEnvWizardPlan, } from './commands/env.js';
14
+ import { parseEnvPushFlags, parseEnvWizardFlags, runEnvPush, runEnvWizardApply, runEnvWizardGenVapid, runEnvWizardPlan, runEnvWizardReview, } from './commands/env.js';
15
15
  import { parseInviteAdminFlags, runInviteAdmin } from './commands/invite-admin.js';
16
16
  import { envWizardFlow } from './tui/env-wizard-flow.js';
17
17
  import { parseDoctorFlags, runDoctor, runLauncherDoctor } from './commands/doctor.js';
@@ -250,6 +250,14 @@ async function main(argv) {
250
250
  runEnvWizardPlan(wflags);
251
251
  return;
252
252
  }
253
+ if (wflags.review) {
254
+ runEnvWizardReview(wflags);
255
+ return;
256
+ }
257
+ if (wflags.genVapid) {
258
+ runEnvWizardGenVapid();
259
+ return;
260
+ }
253
261
  if (wflags.apply) {
254
262
  await runEnvWizardApply(wflags);
255
263
  return;
@@ -26,21 +26,21 @@ import { CLIError } from './cli-error.js';
26
26
  import { CLAUDE_MD_FILE, TIMEKAST_DIR } from './constants.js';
27
27
  import { parseLockfile } from './lockfile.js';
28
28
  /** Roots a manifest path may live under (mirrors `profiles.json#track`). */
29
- const ALLOWED_ROOTS = ['.claude/', 'scripts/'];
29
+ const ALLOWED_ROOTS = ['.claude/', 'scripts/', '.husky/'];
30
30
  /**
31
31
  * Reject a manifest path that is unsafe to write: absolute, escaping via `..`, or
32
- * outside the tracked scope (`.claude/**`, `scripts/**`, `CLAUDE.md`). A correct
32
+ * outside the tracked scope (`.claude/**`, `scripts/**`, `CLAUDE.md`, `.husky/**`). A correct
33
33
  * release never produces these — this guards a mispublished/tampered tarball from
34
34
  * writing `src/` or escaping the repo root. Separator-agnostic (Windows-safe).
35
35
  */
36
- function assertSafeTrackedPath(rel) {
36
+ export function assertSafeTrackedPath(rel) {
37
37
  const segments = rel.split(/[\\/]/);
38
38
  if (rel.length === 0 || path.isAbsolute(rel) || segments.includes('..')) {
39
39
  throw new CLIError(`El manifest declara un path inseguro \`${rel}\` (absoluto o con \`..\`); no se tocó nada.`);
40
40
  }
41
41
  const tracked = rel === CLAUDE_MD_FILE || ALLOWED_ROOTS.some((root) => rel.startsWith(root));
42
42
  if (!tracked) {
43
- throw new CLIError(`El manifest declara \`${rel}\`, fuera del scope rastreado (\`.claude/\`, \`scripts/\`, \`CLAUDE.md\`). ` +
43
+ throw new CLIError(`El manifest declara \`${rel}\`, fuera del scope rastreado (\`.claude/\`, \`scripts/\`, \`CLAUDE.md\`, \`.husky/\`). ` +
44
44
  'El release está mal construido; no se tocó nada.');
45
45
  }
46
46
  }
@@ -30,6 +30,11 @@ export const PROVISION_OWNED = new Set([
30
30
  // (rpID must match the env's host) or the at-rest TOTP crypto.
31
31
  'MFA_ENCRYPTION_KEY',
32
32
  'WEBAUTHN_RP_ID',
33
+ // LOCAL-ONLY E2E credentials (like DATABASE_URL_MAIN): the E2E runner reads them
34
+ // from `.env.local` to spin an isolated Neon branch per run. CI gets them as GitHub
35
+ // Secrets; they must NEVER reach Vercel (a day-2 push would leak the Neon key).
36
+ 'NEON_API_KEY',
37
+ 'NEON_PROJECT_ID',
33
38
  ]);
34
39
  /**
35
40
  * Replace the value of `KEY=...` in an env-file body, preserving every other line.
@@ -76,3 +81,26 @@ export function writeEnvLocal(rootDir, neon, opts = {}) {
76
81
  chmodSync(localPath, 0o600);
77
82
  return 'written';
78
83
  }
84
+ /**
85
+ * Write the LOCAL E2E Neon credentials into `.env.local` so `pnpm test:e2e` runs on
86
+ * the dev's machine without a manual `setup:e2e`. The E2E runner reads NEON_API_KEY +
87
+ * NEON_PROJECT_ID from `.env.local` to spin an isolated branch per run. CI gets these
88
+ * as GitHub Secrets (a project-scoped key); locally we reuse the dev's ORG key from
89
+ * the rail (already on their host). Both stay LOCAL-ONLY (PROVISION_OWNED → never
90
+ * pushed to Vercel). Returns false (no-op) when the key/project id is missing or
91
+ * `.env.local` does not exist yet (`writeEnvLocal` creates it first). Safe on an
92
+ * existing file: `setEnvLine` only touches these two keys, preserving the rest.
93
+ */
94
+ export function writeE2eLocalCreds(rootDir, projectId, apiKey) {
95
+ if (!apiKey || !projectId)
96
+ return false;
97
+ const localPath = join(rootDir, '.env.local');
98
+ if (!existsSync(localPath))
99
+ return false;
100
+ let body = readFileSync(localPath, 'utf8');
101
+ body = setEnvLine(body, 'NEON_PROJECT_ID', projectId);
102
+ body = setEnvLine(body, 'NEON_API_KEY', apiKey);
103
+ writeFileSync(localPath, body, 'utf8');
104
+ chmodSync(localPath, 0o600); // holds NEON_API_KEY → owner-only.
105
+ return true;
106
+ }
@@ -10,6 +10,7 @@
10
10
  * (EMAIL_PROVIDER → its creds, the OAuth flags → their creds) are encoded as a
11
11
  * small `GATES` map — the one piece of domain knowledge the file can't express.
12
12
  */
13
+ import { createECDH } from 'node:crypto';
13
14
  import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
14
15
  import { join } from 'node:path';
15
16
  import { CLIError } from './cli-error.js';
@@ -182,3 +183,125 @@ export function applyWizardAnswers(rootDir, answers) {
182
183
  chmodSync(localPath, 0o600);
183
184
  return written;
184
185
  }
186
+ // ─────────────────────────────────────────────────────────────────────────────
187
+ // Guided-wizard helpers — the pieces the `tk-provision` skill (or the TUI) uses
188
+ // to (a) pre-fill defaults from what provision already configured, (b) never leak
189
+ // secret values, (c) auto-generate what a dev can't type, and (d) catch broken
190
+ // setups (a provider flag on with empty creds). All pure over their inputs.
191
+ // ─────────────────────────────────────────────────────────────────────────────
192
+ /** True when `key`'s value is a secret (never printed in review/summary output). */
193
+ export function isSecretKey(key) {
194
+ return SECRET_KEY.test(key);
195
+ }
196
+ /** Parse an env-file body into `{ KEY: value }` (unquoted), skipping comments/blanks. */
197
+ export function parseEnvBody(body) {
198
+ const out = {};
199
+ for (const line of body.split('\n')) {
200
+ const trimmed = line.trimStart();
201
+ if (trimmed === '' || trimmed.startsWith('#'))
202
+ continue;
203
+ const eq = line.indexOf('=');
204
+ if (eq === -1)
205
+ continue;
206
+ const key = line.slice(0, eq).trim();
207
+ if (key)
208
+ out[key] = unquote(line.slice(eq + 1));
209
+ }
210
+ return out;
211
+ }
212
+ /** Mask a value for display: secrets → `••••••••`, everything else verbatim. */
213
+ export function maskValue(key, value) {
214
+ if (!value)
215
+ return value;
216
+ return isSecretKey(key) ? '••••••••' : value;
217
+ }
218
+ /** Override the `defaultValue` of any question whose key appears in `overrides`. */
219
+ export function applyDefaultOverrides(questions, overrides) {
220
+ return questions.map((q) => (q.key in overrides ? { ...q, defaultValue: overrides[q.key] } : q));
221
+ }
222
+ /**
223
+ * Defaults derived from what provision already did. Provision wires Resend as part
224
+ * of its `dns` step (RESEND_API_KEY + EMAIL_FROM into Vercel); when the `dns` block
225
+ * exists in the state, the derivative's email IS Resend — so default the wizard to
226
+ * `resend`, closing the `.env.local` (none) ↔ Vercel (resend) drift. EMAIL_FROM
227
+ * stays the dev's to confirm. Structural typing keeps this module decoupled from
228
+ * the full `ProvisionState` shape.
229
+ */
230
+ export function provisionEmailDefaults(state) {
231
+ return state?.dns ? { EMAIL_PROVIDER: 'resend' } : {};
232
+ }
233
+ /** OAuth providers wired in `.env.example`: a flag that reveals an id + secret. */
234
+ const OAUTH_PROVIDERS = [
235
+ {
236
+ flag: 'NEXT_PUBLIC_AUTH_GOOGLE',
237
+ id: 'AUTH_GOOGLE_ID',
238
+ secret: 'AUTH_GOOGLE_SECRET',
239
+ label: 'Google',
240
+ },
241
+ {
242
+ flag: 'NEXT_PUBLIC_AUTH_GITHUB',
243
+ id: 'AUTH_GITHUB_ID',
244
+ secret: 'AUTH_GITHUB_SECRET',
245
+ label: 'GitHub',
246
+ },
247
+ ];
248
+ /**
249
+ * Warn (do NOT block) about half-configured setups that would ship a broken app:
250
+ * an OAuth provider enabled with empty creds → broken login button; notifications
251
+ * enabled with no VAPID keys → dead push. Pure over the resolved env map.
252
+ */
253
+ export function checkEnvCompleteness(env) {
254
+ const warnings = [];
255
+ for (const p of OAUTH_PROVIDERS) {
256
+ if (env[p.flag] !== 'true')
257
+ continue;
258
+ const missing = [p.id, p.secret].filter((k) => !env[k]);
259
+ if (missing.length) {
260
+ warnings.push(`Login con ${p.label} activado pero falta ${missing.join(' + ')} → el botón de login ` +
261
+ `quedará roto. Complétalas o pon ${p.flag}="false".`);
262
+ }
263
+ }
264
+ if (env['NEXT_PUBLIC_NOTIFICATIONS_ENABLED'] === 'true' &&
265
+ (!env['NEXT_PUBLIC_VAPID_PUBLIC_KEY'] || !env['VAPID_PRIVATE_KEY'])) {
266
+ warnings.push('Notificaciones activadas pero faltan las llaves VAPID → el push web no funcionará. ' +
267
+ 'Corre `factory env wizard --gen-vapid` para generarlas.');
268
+ }
269
+ return warnings;
270
+ }
271
+ /**
272
+ * Generate a VAPID keypair (P-256, the Web Push standard) with `node:crypto` — no
273
+ * dependency on `web-push`. Public key = 65-byte uncompressed EC point (public by
274
+ * design, ships to the browser); private key = the 32-byte scalar (a SECRET —
275
+ * callers write it to `.env.local` and NEVER print it). Both base64url.
276
+ */
277
+ export function generateVapidKeys() {
278
+ const ecdh = createECDH('prime256v1');
279
+ ecdh.generateKeys();
280
+ const priv = ecdh.getPrivateKey();
281
+ // Left-pad to a fixed 32 bytes (the scalar can be shorter when it has leading
282
+ // zero bytes) so the key is always the canonical VAPID length.
283
+ const priv32 = priv.length === 32 ? priv : Buffer.concat([Buffer.alloc(32 - priv.length), priv]);
284
+ return {
285
+ publicKey: ecdh.getPublicKey().toString('base64url'),
286
+ privateKey: priv32.toString('base64url'),
287
+ };
288
+ }
289
+ /**
290
+ * Build a secret-safe review of `.env.local` for the final "here's how your app is
291
+ * configured" summary. Secret values are masked; empty values are flagged. Callers
292
+ * pass the questions they want shown (typically the ACTIVE ones for the resolved env).
293
+ */
294
+ export function buildEnvReview(questions, body) {
295
+ const env = parseEnvBody(body);
296
+ return questions.map((q) => {
297
+ const value = env[q.key] ?? '';
298
+ const status = value ? 'set' : 'empty';
299
+ return {
300
+ key: q.key,
301
+ section: q.section,
302
+ kind: q.kind,
303
+ status,
304
+ display: status === 'empty' ? '(sin configurar)' : maskValue(q.key, value),
305
+ };
306
+ });
307
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "1.16.0",
3
+ "version": "1.17.1",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",