@timekast/factory 1.14.2 → 1.16.0

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.
@@ -0,0 +1,186 @@
1
+ /**
2
+ * `factory env push` — day-2 environment variable sync to Vercel.
3
+ *
4
+ * Reads `.env.local` and pushes ONLY the product vars (everything EXCEPT the
5
+ * provision-owned, per-environment vars) to `production` + `preview` via the
6
+ * Vercel REST API. Reuses `pushEnvPlan` + `readVercelToken` + the existing
7
+ * `.vercel/project.json` link — no `vercel link`, no re-implemented REST push,
8
+ * no risk of clobbering the per-env DATABASE_URL/AUTH_SECRET that provision set.
9
+ *
10
+ * Exposed to derivatives as `pnpm env:push` (an npx alias). Run it after editing
11
+ * `.env.local` to add a new product variable post-provision.
12
+ */
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import path from 'node:path';
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';
18
+ import { defaultEnvPath, readVercelToken } from '../lib/provision-preflight.js';
19
+ import { pushEnvPlan, vercelProjectPath, VercelApiError, } from '../lib/vercel-env.js';
20
+ // Re-exported so existing importers (and `cli/tests/unit/env-push.test.ts`) keep
21
+ // resolving PROVISION_OWNED from this module after it moved to `lib/env-file.js`.
22
+ export { PROVISION_OWNED };
23
+ export function parseEnvPushFlags(argv) {
24
+ return { dryRun: argv.includes('--dry-run') };
25
+ }
26
+ /**
27
+ * Parse `.env.local` into the product vars to push: skip comments/blanks, the
28
+ * provision-owned keys, and empty values. First-`=` split; surrounding quotes
29
+ * stripped (mirrors the rail reader in provision.ts).
30
+ */
31
+ export function readProductVars(filePath) {
32
+ const out = {};
33
+ if (!existsSync(filePath))
34
+ return out;
35
+ let text;
36
+ try {
37
+ text = readFileSync(filePath, 'utf8');
38
+ }
39
+ catch {
40
+ return out;
41
+ }
42
+ for (const line of text.split('\n')) {
43
+ const trimmed = line.trimStart();
44
+ if (trimmed === '' || trimmed.startsWith('#'))
45
+ continue;
46
+ const eq = line.indexOf('=');
47
+ if (eq === -1)
48
+ continue;
49
+ const key = line.slice(0, eq).trim();
50
+ if (key === '' || PROVISION_OWNED.has(key))
51
+ continue;
52
+ let value = line.slice(eq + 1).replace(/\s+$/, '');
53
+ if (value.length >= 2 &&
54
+ ((value.startsWith('"') && value.endsWith('"')) ||
55
+ (value.startsWith("'") && value.endsWith("'")))) {
56
+ value = value.slice(1, -1);
57
+ }
58
+ if (value === '')
59
+ continue;
60
+ out[key] = value;
61
+ }
62
+ return out;
63
+ }
64
+ /** Read `{ projectId, teamId }` from `.vercel/project.json` (written by provision). */
65
+ function readProjectLink(rootDir) {
66
+ const file = vercelProjectPath(rootDir);
67
+ if (!existsSync(file)) {
68
+ throw new CLIError('No encontré `.vercel/project.json`. Corre `/provision` (o `factory provision`) ' +
69
+ 'para vincular el proyecto Vercel antes de subir variables.');
70
+ }
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
74
+ }
75
+ catch {
76
+ throw new CLIError('No pude parsear `.vercel/project.json`.');
77
+ }
78
+ const projectId = parsed.projectId;
79
+ const teamId = parsed.teamId ?? parsed.orgId;
80
+ if (!projectId || !teamId) {
81
+ throw new CLIError('`.vercel/project.json` no tiene `projectId`/`teamId`.');
82
+ }
83
+ return { projectId, teamId };
84
+ }
85
+ export async function runEnvPush(flags, rootDir = process.cwd()) {
86
+ const productVars = readProductVars(path.join(rootDir, '.env.local'));
87
+ const keys = Object.keys(productVars).sort();
88
+ if (keys.length === 0) {
89
+ console.log('No hay variables de producto que subir en `.env.local` ' +
90
+ '(todo es provision-owned o está vacío).');
91
+ return;
92
+ }
93
+ const targets = ['production', 'preview'];
94
+ const plan = keys.map((key) => ({
95
+ key,
96
+ value: productVars[key],
97
+ target: targets,
98
+ }));
99
+ if (flags.dryRun) {
100
+ console.log(`Plan (${plan.length} vars → production, preview):`);
101
+ for (const key of keys)
102
+ console.log(` ${key}`);
103
+ console.log('\n(--dry-run: nada se envió a Vercel.)');
104
+ return;
105
+ }
106
+ const { projectId, teamId } = readProjectLink(rootDir);
107
+ const token = readVercelToken(defaultEnvPath());
108
+ if (!token) {
109
+ throw new CLIError('No se pudo leer `VERCEL_TOKEN` de `~/.claude/.env`. ' +
110
+ 'Corre `factory secrets <archivo>` para bajar el token y reintenta.');
111
+ }
112
+ try {
113
+ const result = await pushEnvPlan(projectId, token, teamId, plan);
114
+ console.log(`✔ env push: ${result.created.length} creadas, ${result.updated.length} actualizadas ` +
115
+ '(production, preview).');
116
+ }
117
+ catch (error) {
118
+ if (error instanceof VercelApiError) {
119
+ throw new CLIError(`Vercel rechazó el push de env: ${error.message}`);
120
+ }
121
+ throw error;
122
+ }
123
+ }
124
+ export function parseEnvWizardFlags(argv) {
125
+ return {
126
+ plan: argv.includes('--plan'),
127
+ apply: argv.includes('--apply'),
128
+ json: argv.includes('--json'),
129
+ push: argv.includes('--push'),
130
+ };
131
+ }
132
+ function readEnvExamplePlan(rootDir) {
133
+ const examplePath = path.join(rootDir, '.env.example');
134
+ if (!existsSync(examplePath)) {
135
+ throw new CLIError('No encontré `.env.example` en este proyecto. El wizard deriva las preguntas de ahí.');
136
+ }
137
+ return buildQuestionPlan(parseEnvExample(readFileSync(examplePath, 'utf8')));
138
+ }
139
+ /** `factory env wizard --plan [--json]` — emit the question plan. */
140
+ export function runEnvWizardPlan(flags, rootDir = process.cwd()) {
141
+ const questions = readEnvExamplePlan(rootDir);
142
+ if (flags.json) {
143
+ process.stdout.write(`${JSON.stringify({ questions }, null, 2)}\n`);
144
+ return;
145
+ }
146
+ console.log(`Plan del wizard de env — ${questions.length} variables:\n`);
147
+ let section = '';
148
+ for (const q of questions) {
149
+ if (q.section !== section) {
150
+ section = q.section;
151
+ console.log(`## ${section}`);
152
+ }
153
+ const gate = q.showWhen ? ` (si ${q.showWhen.key} ∈ {${q.showWhen.in.join(', ')}})` : '';
154
+ console.log(` - ${q.key} [${q.kind}]${gate}`);
155
+ }
156
+ }
157
+ async function readStdinJson() {
158
+ const chunks = [];
159
+ for await (const chunk of process.stdin)
160
+ chunks.push(chunk);
161
+ return Buffer.concat(chunks).toString('utf8');
162
+ }
163
+ /**
164
+ * `factory env wizard --apply [--push]` — read `{ answers, push? }` JSON from
165
+ * stdin, write the answers to `.env.local`, and optionally push product vars.
166
+ */
167
+ export async function runEnvWizardApply(flags, rootDir = process.cwd(), deps = {}) {
168
+ const raw = await (deps.readStdin ?? readStdinJson)();
169
+ let parsed;
170
+ try {
171
+ parsed = JSON.parse(raw);
172
+ }
173
+ catch {
174
+ throw new CLIError('Esperaba JSON `{ "answers": { KEY: "value" }, "push"?: bool }` por stdin.');
175
+ }
176
+ const answers = parsed.answers ?? {};
177
+ const written = applyWizardAnswers(rootDir, answers);
178
+ console.log(`✔ Escribí ${written.length} variable(s) en \`.env.local\`.`);
179
+ const shouldPush = flags.push || parsed.push === true;
180
+ if (!shouldPush) {
181
+ console.log('ℹ️ Para subirlas a Vercel cuando quieras: `pnpm env:push`.');
182
+ return;
183
+ }
184
+ const push = deps.push ?? ((dir) => runEnvPush({ dryRun: false }, dir));
185
+ await push(rootDir);
186
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `factory invite-admin` — mint a single-use super_admin invite for a derivative.
3
+ *
4
+ * Replaces the old `SUPER_ADMIN_*` seed. The actual minting lives in the kit
5
+ * script `scripts/tools/invite-admin.ts` (it needs the kit's `@/lib/invites` +
6
+ * `@/lib/email`, which this CLI must not import). This command's only job is to
7
+ * resolve the TARGET branch's connection string from `.env.local` and spawn that
8
+ * script with it:
9
+ *
10
+ * - `--target develop` (default) → `DATABASE_URL` (the develop branch)
11
+ * - `--target main` → `DATABASE_URL_MAIN` (production)
12
+ *
13
+ * `--target main` is consumed by SETUP-002 after the first deploy+migrate; the
14
+ * accept link must point at production, so pass `--app-url=https://...` there.
15
+ *
16
+ * Usage:
17
+ * factory invite-admin --email=you@example.com [--target develop|main] [--app-url=<url>]
18
+ */
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import path from 'node:path';
21
+ import { execa } from 'execa';
22
+ import { CLIError } from '../lib/cli-error.js';
23
+ /** Map a target branch to the `.env.local` var holding its connection string. */
24
+ export const TARGET_DB_VAR = {
25
+ develop: 'DATABASE_URL',
26
+ main: 'DATABASE_URL_MAIN',
27
+ };
28
+ /** Repo-relative path to the kit script this command spawns. */
29
+ export const INVITE_ADMIN_SCRIPT = 'scripts/tools/invite-admin.ts';
30
+ function parseTarget(value) {
31
+ if (value === 'develop' || value === 'main')
32
+ return value;
33
+ throw new CLIError(`\`--target\` inválido: \`${value ?? '(vacío)'}\`. Usa \`develop\` o \`main\`.`);
34
+ }
35
+ export function parseInviteAdminFlags(argv) {
36
+ let email = null;
37
+ let target = 'develop';
38
+ let appUrl = null;
39
+ for (let i = 0; i < argv.length; i++) {
40
+ const arg = argv[i];
41
+ if (arg.startsWith('--email='))
42
+ email = arg.slice('--email='.length);
43
+ else if (arg === '--email')
44
+ email = argv[++i] ?? null;
45
+ else if (arg.startsWith('--target='))
46
+ target = parseTarget(arg.slice('--target='.length));
47
+ else if (arg === '--target')
48
+ target = parseTarget(argv[++i]);
49
+ else if (arg.startsWith('--app-url='))
50
+ appUrl = arg.slice('--app-url='.length);
51
+ else if (arg === '--app-url')
52
+ appUrl = argv[++i] ?? null;
53
+ else if (!arg.startsWith('-') && email === null)
54
+ email = arg; // bare positional email
55
+ }
56
+ return { email, target, appUrl };
57
+ }
58
+ /**
59
+ * Read a single KEY from a derivative's `.env.local`. Split on the first `=`,
60
+ * strip surrounding quotes (mirrors `readProductVars` in `env.ts`). Returns null
61
+ * when the file or the key is absent/empty.
62
+ */
63
+ export function readEnvLocalVar(rootDir, key) {
64
+ const file = path.join(rootDir, '.env.local');
65
+ if (!existsSync(file))
66
+ return null;
67
+ let text;
68
+ try {
69
+ text = readFileSync(file, 'utf8');
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ for (const line of text.split('\n')) {
75
+ const trimmed = line.trimStart();
76
+ if (trimmed === '' || trimmed.startsWith('#'))
77
+ continue;
78
+ const eq = line.indexOf('=');
79
+ if (eq === -1 || line.slice(0, eq).trim() !== key)
80
+ continue;
81
+ let value = line.slice(eq + 1).trim();
82
+ if (value.length >= 2 &&
83
+ ((value.startsWith('"') && value.endsWith('"')) ||
84
+ (value.startsWith("'") && value.endsWith("'")))) {
85
+ value = value.slice(1, -1);
86
+ }
87
+ return value || null;
88
+ }
89
+ return null;
90
+ }
91
+ export async function runInviteAdmin(flags, rootDir = process.cwd(), deps = {}) {
92
+ if (!flags.email) {
93
+ throw new CLIError('Falta el email. Uso: `factory invite-admin --email=you@example.com [--target develop|main]`.');
94
+ }
95
+ if (!existsSync(path.join(rootDir, INVITE_ADMIN_SCRIPT))) {
96
+ throw new CLIError(`No encontré \`${INVITE_ADMIN_SCRIPT}\`. Corre \`factory update\` para traer el ` +
97
+ 'script del kit antes de invitar al admin.');
98
+ }
99
+ // Resolve the target branch's connection string. develop is the local default
100
+ // (the script's own dotenv loads DATABASE_URL from .env.local), but we resolve
101
+ // it anyway to fail early with a clear message; for main we MUST override.
102
+ const dbVar = TARGET_DB_VAR[flags.target];
103
+ const dbUrl = readEnvLocalVar(rootDir, dbVar);
104
+ if (!dbUrl) {
105
+ throw new CLIError(`No encontré \`${dbVar}\` en \`.env.local\` (target: ${flags.target}). ` +
106
+ 'Corre `/provision` (o `factory provision`) para escribir las connection strings.');
107
+ }
108
+ const env = { ...process.env, DATABASE_URL: dbUrl };
109
+ // For main the accept link must point at production, not localhost — the
110
+ // caller (SETUP-002) passes the deployed domain via --app-url.
111
+ if (flags.appUrl)
112
+ env.NEXT_PUBLIC_APP_URL = flags.appUrl;
113
+ console.log(`→ Creando invitación de super_admin (${flags.target}) para ${flags.email}…`);
114
+ // `pnpm exec tsx` resolves the kit's local tsx (a devDependency) — same
115
+ // package-manager assumption provision already makes (`pnpm install`). The
116
+ // script reads .env.local for the rest of its config (email provider, app URL)
117
+ // and uses the DATABASE_URL we pass for the chosen branch.
118
+ const run = deps.run ?? ((file, args, options) => execa(file, args, { ...options, stdio: 'inherit' }));
119
+ try {
120
+ await run('pnpm', ['exec', 'tsx', INVITE_ADMIN_SCRIPT, `--email=${flags.email}`], {
121
+ cwd: rootDir,
122
+ env,
123
+ });
124
+ }
125
+ catch {
126
+ throw new CLIError('El script `invite-admin` falló. Revisa la salida de arriba.');
127
+ }
128
+ }
@@ -28,7 +28,8 @@ import { CLIError } from '../lib/cli-error.js';
28
28
  import { apexZoneOf, CloudflareApiError, CLOUDFLARE_API_BASE, ensureCnameRecord, findCnameRecord, resolveZoneId, resolveZoneIdForName, CNAME_VERCEL_TARGET, TIMEKAST_ZONE_NAME, provisionDomains, } from '../lib/cloudflare-api.js';
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
- import { addVercelDomain, buildEnvPlan, createVercelProject, generateAuthSecret, getLinkedRepoId, getVercelDomainStatus, GitHubAppNotInstalledError, linkGitRepo, listVercelProjects, patchProjectSettings, pushEnvPlan, removeVercelDomain, triggerDeployment, upsertEnvVar, listEnvVars, VercelApiError, writeVercelProjectJson, } from '../lib/vercel-env.js';
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
33
  import { adoptState, ADOPT_UNKNOWN, clearProvisionState, clearState, hasProvisionState, isServiceComplete, PROVISION_DIR, PROVISION_STATE_FILE, readDestroyTarget, readProvisionState, writeProvisionState, } from '../lib/provision-state.js';
33
34
  /**
34
35
  * The THREE GitHub Secrets the E2E runner (`pnpm test:e2e` → `e2e-runner.ts` +
@@ -651,11 +652,21 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
651
652
  writeVercelProjectJson(rootDir, { projectId, orgId: teamId, teamId }, { force: deps.force });
652
653
  // Mint an INDEPENDENT AUTH_SECRET per environment (issue §4 — never shared).
653
654
  // POOLED strings → the Vercel runtime DATABASE_URL (serverless/concurrent).
655
+ // WebAuthn rpID = the EXACT host per env (provision's interim domains). A passkey
656
+ // dies if the rpID changes, so a later custom-domain swap (--domain / PROV-006)
657
+ // must also re-point WEBAUTHN_RP_ID — tracked as a follow-up. MFA_ENCRYPTION_KEY
658
+ // is minted independently per env (base64url) so a derivative with MFA enabled
659
+ // never boots broken (P-1).
660
+ const rpDomains = provisionDomains(state.projectName);
654
661
  const plan = buildEnvPlan({
655
662
  mainDatabaseUrl: neonSecrets.mainPooled,
656
663
  developDatabaseUrl: neonSecrets.developPooled,
657
664
  authSecretProd: generateAuthSecret(),
658
665
  authSecretPreview: generateAuthSecret(),
666
+ mfaEncryptionKeyProd: generateMfaEncryptionKey(),
667
+ mfaEncryptionKeyPreview: generateMfaEncryptionKey(),
668
+ webauthnRpIdProd: rpDomains.prod,
669
+ webauthnRpIdPreview: rpDomains.dev,
659
670
  shared: readSharedVercelEnv(envPath),
660
671
  });
661
672
  let result;
@@ -680,7 +691,7 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
680
691
  console.log(` ↻ ${v.key} [${v.target.join(', ')}]`);
681
692
  }
682
693
  // Generate the gitignored `.env.local` so the dev can run the app, migrations and
683
- // seeds locally (`pnpm dev`/`db:migrate`/`db:seed:admin` read `.env.local`). Uses
694
+ // seeds locally (`pnpm dev`/`db:migrate`/`invite:admin` read `.env.local`). Uses
684
695
  // the DIRECT strings (single-process tooling/migrations, never the pooler).
685
696
  // DATABASE_URL → develop (safe default), DATABASE_URL_MAIN → main (db:query:main).
686
697
  const envLocal = writeEnvLocal(rootDir, neonSecrets, { force: deps.force });
@@ -730,47 +741,11 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
730
741
  'El proyecto + las env vars ya están listos; pushea o redeploya a mano.');
731
742
  }
732
743
  }
733
- /**
734
- * Generate the gitignored `.env.local` from the derivative's `.env.example`, filling
735
- * the two Neon connection strings + a fresh AUTH_SECRET. Without this the dev cannot
736
- * run `pnpm dev`/`db:migrate`/`db:seed:admin` locally — those read `.env.local`, which
737
- * provision otherwise never creates (it only syncs env to Vercel).
738
- *
739
- * Uses the DIRECT (non-pooler) strings: `.env.local` drives single-process tooling
740
- * (`drizzle-kit migrate`/`generate`, `db:seed`, `db:query`) + local `pnpm dev` — none
741
- * of which want the PgBouncer pooler (the pooled host is only for the serverless
742
- * runtime on Vercel). Do NOT swap these back to the pooled variants.
743
- *
744
- * DATABASE_URL → the develop branch (safe default for local work); DATABASE_URL_MAIN
745
- * → the main branch (read by `pnpm db:query:main`). NEVER clobbers an existing
746
- * `.env.local` (the dev may have edited it) unless `--force`. The `.env.example` stays
747
- * the SSOT of which keys exist; the keys are appended if absent (older scaffolds).
748
- */
749
- export function writeEnvLocal(rootDir, neon, opts = {}) {
750
- const examplePath = join(rootDir, '.env.example');
751
- if (!existsSync(examplePath))
752
- return 'no-example';
753
- const localPath = join(rootDir, '.env.local');
754
- if (existsSync(localPath) && !opts.force)
755
- return 'skipped';
756
- let body = readFileSync(examplePath, 'utf8');
757
- body = setEnvLine(body, 'DATABASE_URL', neon.developDirect);
758
- body = setEnvLine(body, 'DATABASE_URL_MAIN', neon.mainDirect);
759
- body = setEnvLine(body, 'AUTH_SECRET', generateAuthSecret());
760
- writeFileSync(localPath, body, 'utf8');
761
- return 'written';
762
- }
763
- /**
764
- * Replace the value of `KEY=...` in an env-file body, preserving every other line.
765
- * Appends `KEY="value"` when the key is absent (so an older `.env.example` without
766
- * `DATABASE_URL_MAIN` still gets it). The value is double-quoted — Neon URLs (pooled
767
- * or direct) carry `?`/`&` query params that an unquoted dotenv value would truncate.
768
- */
769
- function setEnvLine(body, key, value) {
770
- const re = new RegExp(`^${key}=.*$`, 'm');
771
- const line = `${key}="${value}"`;
772
- return re.test(body) ? body.replace(re, line) : `${body.trimEnd()}\n${line}\n`;
773
- }
744
+ // `writeEnvLocal` + `setEnvLine` moved to `../lib/env-file.js` (shared with the
745
+ // day-2 env wizard); `writeEnvLocal` is imported above for internal use and
746
+ // re-exported here so existing importers (and `cli/tests/unit/provision-env.test.ts`)
747
+ // keep resolving it from this module.
748
+ export { writeEnvLocal };
774
749
  /**
775
750
  * The HTTPS git URL of the repo `gh repo create` made (`new.ts` creates
776
751
  * `${FACTORY_ORG}/${name}` — the canonical remote is its `.git` HTTPS URL). Never
package/dist/index.js CHANGED
@@ -11,6 +11,9 @@ 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';
15
+ import { parseInviteAdminFlags, runInviteAdmin } from './commands/invite-admin.js';
16
+ import { envWizardFlow } from './tui/env-wizard-flow.js';
14
17
  import { parseDoctorFlags, runDoctor, runLauncherDoctor } from './commands/doctor.js';
15
18
  import { parseNewFlags, runNew } from './commands/new.js';
16
19
  import { parseProvisionFlags, runProvision } from './commands/provision.js';
@@ -39,6 +42,8 @@ Comandos:
39
42
  unpublish [qué] Baja del hub un entregable publicado (proposal|mockup|both; default both)
40
43
  provision Aprovisiona la infra de un derivado (Neon/Vercel/GitHub/DNS) — flag-driven, --json
41
44
  secrets <file> Mergea un archivo KEY=value de secretos de metodología a ~/.claude/.env (chmod 600)
45
+ env <sub> push: sube product vars a Vercel (day-2) · wizard: configura .env.local
46
+ invite-admin Crea una invitación de super_admin para un derivado (sin password en texto plano)
42
47
 
43
48
  Sobre \`new\`:
44
49
  Pregunta el perfil de forma interactiva. Los flags \`--full\` / \`--core\` lo
@@ -105,6 +110,23 @@ Sobre \`status\` y \`doctor\`:
105
110
  gh auth, membresía del org) — termina con código 1 si falla algún check, para
106
111
  que un lanzador GUI / CI / el TUI puedan ramificar sobre el resultado.
107
112
 
113
+ Sobre \`env\`:
114
+ \`env push\` sube las product vars de \`.env.local\` a Vercel (production+preview),
115
+ sin tocar las provision-owned. \`env wizard\` configura \`.env.local\`:
116
+ (sin flags) Wizard interactivo en la terminal (deriva las preguntas de \`.env.example\`)
117
+ --plan [--json] Emite el plan de preguntas (lo consume el agente / la skill tk-provision)
118
+ --apply [--push] Lee \`{ answers, push? }\` JSON por stdin y escribe \`.env.local\`
119
+
120
+ Sobre \`invite-admin\`:
121
+ Materializa el primer super_admin de un derivado por invitación (reemplaza el
122
+ viejo seed con password en texto plano). Resuelve la connection string del
123
+ target desde \`.env.local\` y spawnea el script del kit con ella:
124
+ --email=<addr> Email que recibe la invitación y será super_admin (requerido)
125
+ --target=<t> develop (default, usa DATABASE_URL) | main (usa DATABASE_URL_MAIN)
126
+ --app-url=<url> Override del NEXT_PUBLIC_APP_URL del link de aceptación
127
+ (úsalo con --target main para que apunte a producción)
128
+ Alias en el derivado: \`pnpm invite:admin --email=...\`.
129
+
108
130
  Opciones:
109
131
  -h, --help Muestra esta ayuda
110
132
  -v, --version Muestra la versión del CLI
@@ -211,6 +233,40 @@ async function main(argv) {
211
233
  await secretsCommand(file);
212
234
  return;
213
235
  }
236
+ case 'env': {
237
+ const sub = rest[0];
238
+ // `factory env push` — day-2 product-var sync to Vercel (reuses the
239
+ // provision push path; never touches the per-env provision-owned vars).
240
+ if (sub === 'push') {
241
+ await runEnvPush(parseEnvPushFlags(rest.slice(1)));
242
+ return;
243
+ }
244
+ // `factory env wizard` — onboarding env config. `--plan [--json]` and
245
+ // `--apply` are the non-interactive primitives (the agent/skill drives the
246
+ // questions); no flags → the interactive TUI flow (TTY-only, hints in headless).
247
+ if (sub === 'wizard') {
248
+ const wflags = parseEnvWizardFlags(rest.slice(1));
249
+ if (wflags.plan) {
250
+ runEnvWizardPlan(wflags);
251
+ return;
252
+ }
253
+ if (wflags.apply) {
254
+ await runEnvWizardApply(wflags);
255
+ return;
256
+ }
257
+ await envWizardFlow();
258
+ return;
259
+ }
260
+ throw new CLIError(`Subcomando de env desconocido: \`${sub ?? '(ninguno)'}\`. ` +
261
+ 'Uso: `factory env push` o `factory env wizard`.');
262
+ }
263
+ case 'invite-admin': {
264
+ // Mint a single-use super_admin invite against the target branch's DB.
265
+ // Spawns the kit script `scripts/tools/invite-admin.ts` with the resolved
266
+ // DATABASE_URL — no plaintext admin password anywhere.
267
+ await runInviteAdmin(parseInviteAdminFlags(rest));
268
+ return;
269
+ }
214
270
  default:
215
271
  throw new CLIError(`Comando desconocido: \`${command}\`.\nEjecuta \`factory --help\` para ver los comandos disponibles.`);
216
272
  }
@@ -81,6 +81,11 @@ export const PUBLISH_SCRIPT_NAME = 'factory:publish';
81
81
  export const PUBLISH_SCRIPT_CMD = 'npx @timekast/factory publish';
82
82
  export const UNPUBLISH_SCRIPT_NAME = 'factory:unpublish';
83
83
  export const UNPUBLISH_SCRIPT_CMD = 'npx @timekast/factory unpublish';
84
+ // Day-2 env var sync. Named `env:push` (parallel to the kit's `env:check`),
85
+ // not `factory:*`, because devs reach for it like a normal env tool. Still goes
86
+ // through `npx` so it resolves without a global install.
87
+ export const ENV_PUSH_SCRIPT_NAME = 'env:push';
88
+ export const ENV_PUSH_SCRIPT_CMD = 'npx @timekast/factory env push';
84
89
  /**
85
90
  * All convenience scripts the installer ensures in a Node derivative's
86
91
  * package.json (each: add if missing, never overwrite a divergent value). Keyed
@@ -93,6 +98,7 @@ export const FACTORY_SCRIPTS = {
93
98
  [STATUS_SCRIPT_NAME]: STATUS_SCRIPT_CMD,
94
99
  [PUBLISH_SCRIPT_NAME]: PUBLISH_SCRIPT_CMD,
95
100
  [UNPUBLISH_SCRIPT_NAME]: UNPUBLISH_SCRIPT_CMD,
101
+ [ENV_PUSH_SCRIPT_NAME]: ENV_PUSH_SCRIPT_CMD,
96
102
  };
97
103
  /**
98
104
  * Kit-local convenience script: the readiness sweep (`pnpm preflight`, consumed
@@ -105,6 +111,16 @@ export const FACTORY_SCRIPTS = {
105
111
  export const PREFLIGHT_SCRIPT_NAME = 'preflight';
106
112
  export const PREFLIGHT_SCRIPT_CMD = 'tsx scripts/tools/preflight.ts';
107
113
  export const PREFLIGHT_SCRIPT_FILE = 'scripts/tools/preflight.ts';
114
+ /**
115
+ * Kit-local super_admin invite alias. npx-based (resolves the CLI so it gets the
116
+ * develop/main target switching) but FILE-GATED on the script the CLI spawns —
117
+ * a core/non-Factory derivative without `scripts/tools/invite-admin.ts` never
118
+ * gets a broken `invite:admin`. Insert-if-missing; self-heals on the next
119
+ * update once the script lands (same contract as `preflight`).
120
+ */
121
+ export const INVITE_ADMIN_SCRIPT_NAME = 'invite:admin';
122
+ export const INVITE_ADMIN_SCRIPT_CMD = 'npx @timekast/factory invite-admin';
123
+ export const INVITE_ADMIN_SCRIPT_FILE = 'scripts/tools/invite-admin.ts';
108
124
  /**
109
125
  * Kit-local reference generators — autogen of `project/reference/*` (INVENTORY,
110
126
  * CODEBASE, HOOKS, SCHEMA, API). Each shells a `scripts/tools/generate-*.mjs`
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `.env.local` authoring primitives — shared between `provision` (the first-run
3
+ * write of the Neon strings + AUTH_SECRET) and the day-2 env wizard (`env
4
+ * wizard`, which fills the rest of `.env.example` interactively).
5
+ *
6
+ * Both are pure file ops (no network, no prompts): the interactive layer (the
7
+ * `tk-provision` skill or the TUI) collects answers and calls `setEnvLine`; the
8
+ * CLI never prompts here, so it stays headless-safe.
9
+ */
10
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { generateAuthSecret } from './vercel-env.js';
13
+ /**
14
+ * Vars that provision owns and sets PER-ENVIRONMENT (or keeps local-only). The
15
+ * day-2 paths (`env push`, the env wizard) NEVER touch these: re-pushing
16
+ * `DATABASE_URL` / `AUTH_SECRET` uniformly would overwrite the distinct
17
+ * prod↔preview values provision wrote, cross-wiring production to develop.
18
+ * `DATABASE_URL_MAIN` is local-only (`db:query:main`) and must never reach Vercel.
19
+ *
20
+ * MUST be a superset of `buildEnvPlan`'s per-env keys (vercel-env.ts) — enforced
21
+ * by the anti-drift test in `cli/tests/unit/env-push.test.ts`.
22
+ */
23
+ export const PROVISION_OWNED = new Set([
24
+ 'DATABASE_URL',
25
+ 'DATABASE_URL_MAIN',
26
+ 'AUTH_SECRET',
27
+ 'NEXT_PUBLIC_APP_ENV',
28
+ // Provision mints these per-env too (P-1): the MFA key (base64url) + the
29
+ // WebAuthn rpID (the exact host). A shared day-2 push would break passkeys
30
+ // (rpID must match the env's host) or the at-rest TOTP crypto.
31
+ 'MFA_ENCRYPTION_KEY',
32
+ 'WEBAUTHN_RP_ID',
33
+ ]);
34
+ /**
35
+ * Replace the value of `KEY=...` in an env-file body, preserving every other line.
36
+ * Appends `KEY="value"` when the key is absent (so an older `.env.example` without
37
+ * `DATABASE_URL_MAIN` still gets it). The value is double-quoted — Neon URLs (pooled
38
+ * or direct) carry `?`/`&` query params that an unquoted dotenv value would truncate.
39
+ */
40
+ export function setEnvLine(body, key, value) {
41
+ const re = new RegExp(`^${key}=.*$`, 'm');
42
+ const line = `${key}="${value}"`;
43
+ return re.test(body) ? body.replace(re, line) : `${body.trimEnd()}\n${line}\n`;
44
+ }
45
+ /**
46
+ * Generate the gitignored `.env.local` from the derivative's `.env.example`, filling
47
+ * the two Neon connection strings + a fresh AUTH_SECRET. Without this the dev cannot
48
+ * run `pnpm dev`/`db:migrate`/`invite:admin` locally — those read `.env.local`, which
49
+ * provision otherwise never creates (it only syncs env to Vercel).
50
+ *
51
+ * Uses the DIRECT (non-pooler) strings: `.env.local` drives single-process tooling
52
+ * (`drizzle-kit migrate`/`generate`, `db:seed`, `db:query`) + local `pnpm dev` — none
53
+ * of which want the PgBouncer pooler (the pooled host is only for the serverless
54
+ * runtime on Vercel). Do NOT swap these back to the pooled variants.
55
+ *
56
+ * DATABASE_URL → the develop branch (safe default for local work); DATABASE_URL_MAIN
57
+ * → the main branch (read by `pnpm db:query:main`). NEVER clobbers an existing
58
+ * `.env.local` (the dev may have edited it) unless `--force`. The `.env.example` stays
59
+ * the SSOT of which keys exist; the keys are appended if absent (older scaffolds).
60
+ */
61
+ export function writeEnvLocal(rootDir, neon, opts = {}) {
62
+ const examplePath = join(rootDir, '.env.example');
63
+ if (!existsSync(examplePath))
64
+ return 'no-example';
65
+ const localPath = join(rootDir, '.env.local');
66
+ if (existsSync(localPath) && !opts.force)
67
+ return 'skipped';
68
+ let body = readFileSync(examplePath, 'utf8');
69
+ body = setEnvLine(body, 'DATABASE_URL', neon.developDirect);
70
+ body = setEnvLine(body, 'DATABASE_URL_MAIN', neon.mainDirect);
71
+ body = setEnvLine(body, 'AUTH_SECRET', generateAuthSecret());
72
+ writeFileSync(localPath, body, 'utf8');
73
+ // `.env.local` holds DB strings + AUTH_SECRET → owner-only (0600). Same
74
+ // discipline the secrets rail uses; matters on the headless multi-tenant
75
+ // Agent Server where the default umask would leave it world-readable.
76
+ chmodSync(localPath, 0o600);
77
+ return 'written';
78
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Env wizard — parse a derivative's `.env.example` into a question plan the
3
+ * INTERACTIVE layer renders (the `tk-provision` skill via AskUserQuestion, or the
4
+ * TUI via `prompts`). Everything here is pure + non-interactive: the CLI never
5
+ * prompts, so it stays headless-safe — the front-end collects the answers and
6
+ * hands them back to `applyWizardAnswers`.
7
+ *
8
+ * `.env.example` is the SSOT of WHICH keys exist; this module derives the
9
+ * questions (+ their help text) from it. Only the few real conditionals
10
+ * (EMAIL_PROVIDER → its creds, the OAuth flags → their creds) are encoded as a
11
+ * small `GATES` map — the one piece of domain knowledge the file can't express.
12
+ */
13
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { CLIError } from './cli-error.js';
16
+ import { PROVISION_OWNED, setEnvLine } from './env-file.js';
17
+ /**
18
+ * The real conditionals `.env.example` can't express. Keyed by the DEPENDENT var
19
+ * → the driver answer that reveals it. Kept deliberately small — adding a new
20
+ * env var does NOT require touching this unless it is genuinely conditional.
21
+ */
22
+ const GATES = {
23
+ EMAIL_FROM: { key: 'EMAIL_PROVIDER', in: ['resend', 'smtp'] },
24
+ RESEND_API_KEY: { key: 'EMAIL_PROVIDER', in: ['resend'] },
25
+ EMAIL_SERVER_HOST: { key: 'EMAIL_PROVIDER', in: ['smtp'] },
26
+ EMAIL_SERVER_PORT: { key: 'EMAIL_PROVIDER', in: ['smtp'] },
27
+ EMAIL_SERVER_USER: { key: 'EMAIL_PROVIDER', in: ['smtp'] },
28
+ EMAIL_SERVER_PASSWORD: { key: 'EMAIL_PROVIDER', in: ['smtp'] },
29
+ EMAIL_SERVER_SECURE: { key: 'EMAIL_PROVIDER', in: ['smtp'] },
30
+ AUTH_GOOGLE_ID: { key: 'NEXT_PUBLIC_AUTH_GOOGLE', in: ['true'] },
31
+ AUTH_GOOGLE_SECRET: { key: 'NEXT_PUBLIC_AUTH_GOOGLE', in: ['true'] },
32
+ AUTH_GITHUB_ID: { key: 'NEXT_PUBLIC_AUTH_GITHUB', in: ['true'] },
33
+ AUTH_GITHUB_SECRET: { key: 'NEXT_PUBLIC_AUTH_GITHUB', in: ['true'] },
34
+ };
35
+ /** Vars that get a `select` with a fixed choice set instead of free text. */
36
+ const SELECTS = {
37
+ EMAIL_PROVIDER: ['none', 'resend', 'smtp'],
38
+ };
39
+ const DIVIDER = /^#\s*-{3,}\s*$/;
40
+ const ACTIVE = /^([A-Z][A-Z0-9_]*)=(.*)$/;
41
+ const COMMENTED = /^#\s*([A-Z][A-Z0-9_]*)=(.*)$/;
42
+ const SECRET_KEY = /(_SECRET|_KEY|_TOKEN|_PASSWORD)$/;
43
+ function unquote(raw) {
44
+ const v = raw.trim();
45
+ if (v.length >= 2 &&
46
+ ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
47
+ return v.slice(1, -1);
48
+ }
49
+ return v;
50
+ }
51
+ /**
52
+ * Parse `.env.example` into entries, preserving file order. A section title is a
53
+ * comment line sandwiched between two `# ----` dividers; other comment lines
54
+ * become the help text of the next var.
55
+ */
56
+ export function parseEnvExample(text) {
57
+ const lines = text.split('\n');
58
+ const entries = [];
59
+ let section = '';
60
+ let comment = [];
61
+ for (let i = 0; i < lines.length; i++) {
62
+ const line = lines[i].replace(/\s+$/, '');
63
+ const prev = (lines[i - 1] ?? '').trim();
64
+ const next = (lines[i + 1] ?? '').trim();
65
+ if (DIVIDER.test(line))
66
+ continue; // structural — skip
67
+ // Section header: a comment sandwiched between two dividers.
68
+ if (line.startsWith('#') && DIVIDER.test(prev) && DIVIDER.test(next)) {
69
+ section = line.replace(/^#\s*/, '').trim();
70
+ comment = [];
71
+ continue;
72
+ }
73
+ const active = line.match(ACTIVE);
74
+ if (active) {
75
+ entries.push({
76
+ key: active[1],
77
+ section,
78
+ defaultValue: unquote(active[2]),
79
+ commented: false,
80
+ help: comment.join(' ').trim(),
81
+ });
82
+ comment = [];
83
+ continue;
84
+ }
85
+ const commented = line.match(COMMENTED);
86
+ if (commented) {
87
+ entries.push({
88
+ key: commented[1],
89
+ section,
90
+ defaultValue: unquote(commented[2]),
91
+ commented: true,
92
+ help: comment.join(' ').trim(),
93
+ });
94
+ comment = [];
95
+ continue;
96
+ }
97
+ if (line.startsWith('#')) {
98
+ comment.push(line.replace(/^#\s*/, '').trim());
99
+ continue;
100
+ }
101
+ // Blank / other line → break the comment run.
102
+ comment = [];
103
+ }
104
+ return entries;
105
+ }
106
+ function questionKind(key, defaultValue) {
107
+ if (SELECTS[key])
108
+ return 'select';
109
+ if (SECRET_KEY.test(key))
110
+ return 'secret';
111
+ if (defaultValue === 'true' || defaultValue === 'false')
112
+ return 'boolean';
113
+ return 'text';
114
+ }
115
+ /**
116
+ * Turn parsed entries into the ordered question plan. Includes:
117
+ * - every ACTIVE non-provision-owned var (the product config the dev reviews), and
118
+ * - the GATED dependents (email creds, OAuth creds), active or commented.
119
+ * Excludes provision-owned vars and advanced commented vars (rate-limit, VAPID,
120
+ * Upstash, Sentry, NextAuth aliases) that aren't gated — those stay manual +
121
+ * `pnpm env:push`.
122
+ */
123
+ export function buildQuestionPlan(entries) {
124
+ const questions = [];
125
+ const seen = new Set();
126
+ for (const e of entries) {
127
+ if (seen.has(e.key))
128
+ continue;
129
+ if (PROVISION_OWNED.has(e.key))
130
+ continue;
131
+ const gate = GATES[e.key];
132
+ // A commented var is only included when it's a gated dependent; advanced
133
+ // commented vars (no gate) are skipped.
134
+ if (e.commented && !gate)
135
+ continue;
136
+ seen.add(e.key);
137
+ const q = {
138
+ key: e.key,
139
+ section: e.section,
140
+ help: e.help,
141
+ defaultValue: e.defaultValue,
142
+ kind: questionKind(e.key, e.defaultValue),
143
+ };
144
+ if (SELECTS[e.key])
145
+ q.choices = SELECTS[e.key];
146
+ if (gate)
147
+ q.showWhen = gate;
148
+ questions.push(q);
149
+ }
150
+ return questions;
151
+ }
152
+ /** True when `q`'s gate is satisfied by the answers collected so far. */
153
+ export function isQuestionActive(q, answers) {
154
+ if (!q.showWhen)
155
+ return true;
156
+ const driver = answers[q.showWhen.key];
157
+ return driver !== undefined && q.showWhen.in.includes(driver);
158
+ }
159
+ /**
160
+ * Write the collected answers into `.env.local` via `setEnvLine`, preserving
161
+ * every other line. Falls back to `.env.example` as the base when `.env.local`
162
+ * doesn't exist yet. NEVER writes provision-owned keys (provision sets those
163
+ * per-environment). Returns the keys actually written.
164
+ */
165
+ export function applyWizardAnswers(rootDir, answers) {
166
+ const localPath = join(rootDir, '.env.local');
167
+ const examplePath = join(rootDir, '.env.example');
168
+ const basePath = existsSync(localPath) ? localPath : examplePath;
169
+ if (!existsSync(basePath)) {
170
+ throw new CLIError('No encontré `.env.local` ni `.env.example` para escribir las variables.');
171
+ }
172
+ let body = readFileSync(basePath, 'utf8');
173
+ const written = [];
174
+ for (const [key, value] of Object.entries(answers)) {
175
+ if (PROVISION_OWNED.has(key))
176
+ continue; // provision owns these per-env
177
+ body = setEnvLine(body, key, value);
178
+ written.push(key);
179
+ }
180
+ writeFileSync(localPath, body, 'utf8');
181
+ // `.env.local` may now hold secrets (API keys, OAuth) → owner-only (0600).
182
+ chmodSync(localPath, 0o600);
183
+ return written;
184
+ }
@@ -8,7 +8,7 @@
8
8
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { CLIError } from './cli-error.js';
11
- import { FACTORY_SCRIPTS, GENERATE_REFERENCE_CMD, GENERATE_REFERENCE_GATE_FILE, GENERATE_REFERENCE_NAME, GENERATOR_SCRIPTS, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, UPDATE_SCRIPT_NAME, } from './constants.js';
11
+ import { FACTORY_SCRIPTS, GENERATE_REFERENCE_CMD, GENERATE_REFERENCE_GATE_FILE, GENERATE_REFERENCE_NAME, GENERATOR_SCRIPTS, INVITE_ADMIN_SCRIPT_CMD, INVITE_ADMIN_SCRIPT_FILE, INVITE_ADMIN_SCRIPT_NAME, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, UPDATE_SCRIPT_NAME, } from './constants.js';
12
12
  /**
13
13
  * Auto-detect the profile to install into an existing repo from its
14
14
  * `package.json`: a `factoryVersion` field means the repo was born from the
@@ -111,6 +111,8 @@ export function kitLocalScripts(rootDir) {
111
111
  const scripts = {};
112
112
  if (has(PREFLIGHT_SCRIPT_FILE))
113
113
  scripts[PREFLIGHT_SCRIPT_NAME] = PREFLIGHT_SCRIPT_CMD;
114
+ if (has(INVITE_ADMIN_SCRIPT_FILE))
115
+ scripts[INVITE_ADMIN_SCRIPT_NAME] = INVITE_ADMIN_SCRIPT_CMD;
114
116
  for (const gen of GENERATOR_SCRIPTS) {
115
117
  if (has(gen.file))
116
118
  scripts[gen.name] = gen.cmd;
@@ -369,6 +369,17 @@ export async function upsertEnvVar(projectId, token, teamId, plan, existing, dep
369
369
  export function generateAuthSecret() {
370
370
  return randomBytes(32).toString('hex');
371
371
  }
372
+ /**
373
+ * Generate a fresh `MFA_ENCRYPTION_KEY` — the AES-256 key the runtime uses to
374
+ * encrypt TOTP secrets at rest (`src/lib/auth/totp-crypto.ts`). It MUST be
375
+ * base64url of EXACTLY 32 bytes — NOT hex like `AUTH_SECRET` (the runtime rejects
376
+ * a wrong-length/format key). Minted independently per environment so prod and
377
+ * preview never share it. Without this var a derivative with MFA enabled boots
378
+ * broken (P-1).
379
+ */
380
+ export function generateMfaEncryptionKey() {
381
+ return randomBytes(32).toString('base64url');
382
+ }
372
383
  /** Absolute path to `.vercel/project.json` under a repo root. */
373
384
  export function vercelProjectPath(rootDir) {
374
385
  return path.join(rootDir, VERCEL_DIR, VERCEL_PROJECT_FILE);
@@ -447,6 +458,15 @@ export function buildEnvPlan(input) {
447
458
  // Auth secret — distinct value per environment (never shared).
448
459
  { key: 'AUTH_SECRET', value: input.authSecretProd, target: ['production'] },
449
460
  { key: 'AUTH_SECRET', value: input.authSecretPreview, target: ['preview'] },
461
+ // MFA encryption key — distinct per env (base64url 32 bytes). Without it a
462
+ // derivative with MFA enabled boots broken; minted independently so prod and
463
+ // preview never share it (P-1).
464
+ { key: 'MFA_ENCRYPTION_KEY', value: input.mfaEncryptionKeyProd, target: ['production'] },
465
+ { key: 'MFA_ENCRYPTION_KEY', value: input.mfaEncryptionKeyPreview, target: ['preview'] },
466
+ // WebAuthn relying-party id — the EXACT host per env (a passkey dies if the
467
+ // rpID changes), so passkeys work without manual Vercel setup (P-1 / PASSKEY-002 §8).
468
+ { key: 'WEBAUTHN_RP_ID', value: input.webauthnRpIdProd, target: ['production'] },
469
+ { key: 'WEBAUTHN_RP_ID', value: input.webauthnRpIdPreview, target: ['preview'] },
450
470
  // Deployment identity — drives the env-aware PWA manifest (see .env.example).
451
471
  { key: 'NEXT_PUBLIC_APP_ENV', value: 'production', target: ['production'] },
452
472
  { key: 'NEXT_PUBLIC_APP_ENV', value: 'preview', target: ['preview'] },
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Env wizard — interactive TUI flow (Capa B). Walks the question plan derived
3
+ * from `.env.example` with `prompts`, respecting the gates as answers come in,
4
+ * then applies the answers to `.env.local` and offers to push to Vercel.
5
+ *
6
+ * Shares the SAME pure primitives as the agent path (`lib/env-wizard.ts` +
7
+ * `commands/env.ts`): this file only adds the terminal interaction. No TTY →
8
+ * degrade with a hint to the headless `--plan`/`--apply` primitives.
9
+ */
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import path from 'node:path';
12
+ import prompts from 'prompts';
13
+ import { runEnvPush } from '../commands/env.js';
14
+ import { CLIError } from '../lib/cli-error.js';
15
+ import { applyWizardAnswers, buildQuestionPlan, isQuestionActive, parseEnvExample, } from '../lib/env-wizard.js';
16
+ /** Ask one question by kind. Returns `undefined` when the user cancels (Ctrl+C). */
17
+ async function askOne(q) {
18
+ const message = q.help ? `${q.key} — ${q.help}` : q.key;
19
+ const onCancel = { onCancel: () => true };
20
+ if (q.kind === 'select') {
21
+ const choices = (q.choices ?? []).map((c) => ({ title: c, value: c }));
22
+ const initial = Math.max(0, (q.choices ?? []).indexOf(q.defaultValue));
23
+ const { v } = await prompts({ type: 'select', name: 'v', message, choices, initial }, onCancel);
24
+ return v === undefined ? undefined : String(v);
25
+ }
26
+ if (q.kind === 'boolean') {
27
+ const { v } = await prompts({
28
+ type: 'toggle',
29
+ name: 'v',
30
+ message,
31
+ initial: q.defaultValue === 'true',
32
+ active: 'true',
33
+ inactive: 'false',
34
+ }, onCancel);
35
+ return v === undefined ? undefined : v ? 'true' : 'false';
36
+ }
37
+ if (q.kind === 'secret') {
38
+ const { v } = await prompts({ type: 'password', name: 'v', message }, onCancel);
39
+ return v === undefined ? undefined : String(v);
40
+ }
41
+ const { v } = await prompts({ type: 'text', name: 'v', message, initial: q.defaultValue }, onCancel);
42
+ return v === undefined ? undefined : String(v);
43
+ }
44
+ /**
45
+ * Run the interactive env wizard in `rootDir`. Reachable from `factory env
46
+ * wizard` (no flags) and from the TUI menu.
47
+ */
48
+ export async function envWizardFlow(rootDir = process.cwd(), deps = {}) {
49
+ const isTTY = deps.isTTY ?? Boolean(process.stdout.isTTY);
50
+ if (!isTTY) {
51
+ throw new CLIError('El wizard interactivo necesita una terminal. En headless usa ' +
52
+ '`factory env wizard --plan --json` y `factory env wizard --apply`.');
53
+ }
54
+ const examplePath = path.join(rootDir, '.env.example');
55
+ if (!existsSync(examplePath)) {
56
+ throw new CLIError('No encontré `.env.example` en este proyecto. El wizard deriva las preguntas de ahí.');
57
+ }
58
+ const plan = buildQuestionPlan(parseEnvExample(readFileSync(examplePath, 'utf8')));
59
+ console.log('\nConfiguremos las variables de entorno del proyecto.\n' +
60
+ '(Enter conserva el valor por defecto · Ctrl+C cancela sin escribir nada.)\n');
61
+ const answers = {};
62
+ let section = '';
63
+ for (const q of plan) {
64
+ if (!isQuestionActive(q, answers))
65
+ continue;
66
+ if (q.section !== section) {
67
+ section = q.section;
68
+ console.log(`\n— ${section} —`);
69
+ }
70
+ const value = await askOne(q);
71
+ if (value === undefined) {
72
+ console.log('\nCancelado. No se escribió nada en `.env.local`.');
73
+ return;
74
+ }
75
+ answers[q.key] = value;
76
+ }
77
+ const written = applyWizardAnswers(rootDir, answers);
78
+ console.log(`\n✔ Escribí ${written.length} variable(s) en \`.env.local\`.`);
79
+ const { doPush } = await prompts({
80
+ type: 'confirm',
81
+ name: 'doPush',
82
+ message: '¿Subir las variables de producto a Vercel ahora? (production + preview)',
83
+ initial: false,
84
+ }, { onCancel: () => true });
85
+ if (doPush) {
86
+ const push = deps.push ?? ((dir) => runEnvPush({ dryRun: false }, dir));
87
+ await push(rootDir);
88
+ }
89
+ else {
90
+ console.log('ℹ️ Cuando quieras subirlas: `pnpm env:push`.');
91
+ }
92
+ }
package/dist/tui/menu.js CHANGED
@@ -23,6 +23,7 @@ import prompts from 'prompts';
23
23
  import { parseNewFlags, runNew } from '../commands/new.js';
24
24
  import { secretsCommand } from '../commands/secrets.js';
25
25
  import { parseUpdateFlags, runUpdate } from '../commands/update.js';
26
+ import { envWizardFlow } from './env-wizard-flow.js';
26
27
  import { provisionFlow } from './provision-flow.js';
27
28
  /** Prompt for the top-level action. Returns `undefined` if the user cancels. */
28
29
  async function promptAction() {
@@ -34,6 +35,7 @@ async function promptAction() {
34
35
  { title: 'Nuevo proyecto', value: 'new' },
35
36
  { title: 'Actualizar proyecto', value: 'update' },
36
37
  { title: 'Aprovisionar (ephemeral)', value: 'provision' },
38
+ { title: 'Configurar variables de entorno', value: 'env' },
37
39
  { title: 'Secrets', value: 'secrets' },
38
40
  ],
39
41
  initial: 0,
@@ -107,6 +109,7 @@ export async function runTuiMenu(deps = {}) {
107
109
  const updateProject = deps.updateProject ?? runUpdate;
108
110
  const secrets = deps.secrets ?? secretsCommand;
109
111
  const provision = deps.provision ?? provisionFlow;
112
+ const envWizard = deps.envWizard ?? envWizardFlow;
110
113
  // No TTY (CI / pipe): can't read interactive input. Degrade to HELP and return
111
114
  // immediately — never hang waiting for input that can't arrive.
112
115
  if (!isTTY) {
@@ -128,6 +131,9 @@ export async function runTuiMenu(deps = {}) {
128
131
  case 'provision':
129
132
  await dispatchProvision({ provision });
130
133
  return;
134
+ case 'env':
135
+ await envWizard();
136
+ return;
131
137
  case 'secrets':
132
138
  await dispatchSecrets({ secrets });
133
139
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "1.14.2",
3
+ "version": "1.16.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",