@timekast/factory 1.13.0 → 1.15.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.
@@ -246,6 +246,7 @@ const STATUS_GLYPH = {
246
246
  ok: '✅',
247
247
  fail: '❌',
248
248
  skipped: '—',
249
+ warn: '🟡',
249
250
  };
250
251
  /**
251
252
  * Render the launcher preflight report. Each check on its own line with a glyph,
@@ -268,13 +269,17 @@ export function renderLauncherDoctor(checks) {
268
269
  }
269
270
  }
270
271
  }
271
- const allOk = checks.every((c) => c.status === 'ok');
272
+ const hasFail = checks.some((c) => c.status === 'fail');
273
+ const hasWarn = checks.some((c) => c.status === 'warn');
272
274
  console.log('');
273
- if (allOk) {
274
- console.log('Entorno listo para usar el launcher.');
275
+ if (hasFail) {
276
+ console.log('Entorno incompleto: corrige los checks marcados ❌ y vuelve a intentar.');
277
+ }
278
+ else if (hasWarn) {
279
+ console.log('Entorno listo. Revisa los avisos 🟡 (no bloquean el launcher).');
275
280
  }
276
281
  else {
277
- console.log('Entorno incompleto: corrige los checks marcados ❌ y vuelve a intentar.');
282
+ console.log('Entorno listo para usar el launcher.');
278
283
  }
279
284
  }
280
285
  /**
@@ -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
+ }
@@ -29,6 +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, 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` +
@@ -203,7 +204,7 @@ export function parseProvisionFlags(argv) {
203
204
  const SERVICE_LABEL = {
204
205
  neon: 'Neon (proyecto + branch develop)',
205
206
  vercel: 'Vercel (env sync por target)',
206
- github: 'GitHub + Vercel (sustrato git + proyecto + migrate.yml)',
207
+ github: 'GitHub + Vercel (sustrato git + proyecto + env + E2E)',
207
208
  dns: 'DNS + Resend (dominio del entorno)',
208
209
  };
209
210
  /** Build the ordered plan: each requested service is `skip` if complete, else `create`. */
@@ -244,14 +245,17 @@ function deriveProjectName(flags, rootDir) {
244
245
  // it wins over the dir name. Validate it as a subdomain label when given,
245
246
  // since it flows into `{slug}.timekast.mx` downstream (PROV-005).
246
247
  if (flags.slug) {
247
- assertValidSubdomainSlug(flags.slug);
248
- return flags.slug;
248
+ const slug = flags.slug.toLowerCase(); // every resource is lowercase (repo/dir/domains)
249
+ assertValidSubdomainSlug(slug);
250
+ return slug;
249
251
  }
250
252
  // No `--slug`: the project name is the directory provision runs in (the scaffold
251
253
  // dir), NOT the org/team scope. A team id like `team_ldqH...` is neither a real
252
254
  // project name nor a valid subdomain, and falling back to it produced a confusing
253
255
  // `repo TimeKast/team_ldqH... no existe` error. Validate it like an explicit slug.
254
- const fromDir = basename(rootDir);
256
+ // Lowercased so a dir created with mixed case (e.g. `Edmond/`) yields a valid slug
257
+ // instead of being rejected for the uppercase — matches the `new` normalization.
258
+ const fromDir = basename(rootDir).toLowerCase();
255
259
  assertValidSubdomainSlug(fromDir);
256
260
  return fromDir;
257
261
  }
@@ -677,7 +681,7 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
677
681
  console.log(` ↻ ${v.key} [${v.target.join(', ')}]`);
678
682
  }
679
683
  // Generate the gitignored `.env.local` so the dev can run the app, migrations and
680
- // seeds locally (`pnpm dev`/`db:migrate`/`db:seed:admin` read `.env.local`). Uses
684
+ // seeds locally (`pnpm dev`/`db:migrate`/`invite:admin` read `.env.local`). Uses
681
685
  // the DIRECT strings (single-process tooling/migrations, never the pooler).
682
686
  // DATABASE_URL → develop (safe default), DATABASE_URL_MAIN → main (db:query:main).
683
687
  const envLocal = writeEnvLocal(rootDir, neonSecrets, { force: deps.force });
@@ -727,47 +731,11 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
727
731
  'El proyecto + las env vars ya están listos; pushea o redeploya a mano.');
728
732
  }
729
733
  }
730
- /**
731
- * Generate the gitignored `.env.local` from the derivative's `.env.example`, filling
732
- * the two Neon connection strings + a fresh AUTH_SECRET. Without this the dev cannot
733
- * run `pnpm dev`/`db:migrate`/`db:seed:admin` locally — those read `.env.local`, which
734
- * provision otherwise never creates (it only syncs env to Vercel).
735
- *
736
- * Uses the DIRECT (non-pooler) strings: `.env.local` drives single-process tooling
737
- * (`drizzle-kit migrate`/`generate`, `db:seed`, `db:query`) + local `pnpm dev` — none
738
- * of which want the PgBouncer pooler (the pooled host is only for the serverless
739
- * runtime on Vercel). Do NOT swap these back to the pooled variants.
740
- *
741
- * DATABASE_URL → the develop branch (safe default for local work); DATABASE_URL_MAIN
742
- * → the main branch (read by `pnpm db:query:main`). NEVER clobbers an existing
743
- * `.env.local` (the dev may have edited it) unless `--force`. The `.env.example` stays
744
- * the SSOT of which keys exist; the keys are appended if absent (older scaffolds).
745
- */
746
- export function writeEnvLocal(rootDir, neon, opts = {}) {
747
- const examplePath = join(rootDir, '.env.example');
748
- if (!existsSync(examplePath))
749
- return 'no-example';
750
- const localPath = join(rootDir, '.env.local');
751
- if (existsSync(localPath) && !opts.force)
752
- return 'skipped';
753
- let body = readFileSync(examplePath, 'utf8');
754
- body = setEnvLine(body, 'DATABASE_URL', neon.developDirect);
755
- body = setEnvLine(body, 'DATABASE_URL_MAIN', neon.mainDirect);
756
- body = setEnvLine(body, 'AUTH_SECRET', generateAuthSecret());
757
- writeFileSync(localPath, body, 'utf8');
758
- return 'written';
759
- }
760
- /**
761
- * Replace the value of `KEY=...` in an env-file body, preserving every other line.
762
- * Appends `KEY="value"` when the key is absent (so an older `.env.example` without
763
- * `DATABASE_URL_MAIN` still gets it). The value is double-quoted — Neon URLs (pooled
764
- * or direct) carry `?`/`&` query params that an unquoted dotenv value would truncate.
765
- */
766
- function setEnvLine(body, key, value) {
767
- const re = new RegExp(`^${key}=.*$`, 'm');
768
- const line = `${key}="${value}"`;
769
- return re.test(body) ? body.replace(re, line) : `${body.trimEnd()}\n${line}\n`;
770
- }
734
+ // `writeEnvLocal` + `setEnvLine` moved to `../lib/env-file.js` (shared with the
735
+ // day-2 env wizard); `writeEnvLocal` is imported above for internal use and
736
+ // re-exported here so existing importers (and `cli/tests/unit/provision-env.test.ts`)
737
+ // keep resolving it from this module.
738
+ export { writeEnvLocal };
771
739
  /**
772
740
  * The HTTPS git URL of the repo `gh repo create` made (`new.ts` creates
773
741
  * `${FACTORY_ORG}/${name}` — the canonical remote is its `.git` HTTPS URL). Never
@@ -833,7 +801,13 @@ async function buildGitSubstrate(rootDir, repoSlug, runGit, force) {
833
801
  // 2. Add (or reset, under --force) the `origin` remote → the HTTPS repo URL.
834
802
  const existingOrigin = await readOriginUrl(runGit, rootDir);
835
803
  if (existingOrigin && existingOrigin !== url) {
836
- if (!force) {
804
+ // GitHub repo URLs are case-insensitive: an `origin` differing ONLY by case
805
+ // (e.g. `TimeKast/Edmond.git` vs the canonical lowercase `TimeKast/edmond.git`)
806
+ // points at the SAME repo. Re-case it to the canonical URL silently — no `--force`
807
+ // needed. A difference BEYOND case is a genuinely different repo → throw unless
808
+ // `--force` resets it.
809
+ const sameRepoDifferentCase = existingOrigin.toLowerCase() === url.toLowerCase();
810
+ if (!sameRepoDifferentCase && !force) {
837
811
  throw new CLIError(`El repo ya tiene un remote \`origin\` apuntando a otro URL (\`${existingOrigin}\` ≠ \`${url}\`).\n` +
838
812
  'Usa `--force` para sobrescribirlo, o limpia el remote si reutilizas este directorio.');
839
813
  }
@@ -927,8 +901,9 @@ export function writeE2eWorkflow(rootDir) {
927
901
  /**
928
902
  * PROV-004 — the `github` step: build the git substrate, create the Vercel PROJECT,
929
903
  * connect the GitHub repo (GitHub App gate), confirm `productionBranch: main`,
930
- * write `.vercel/project.json` + the `migrate.yml` template, and push the migration
931
- * GitHub Secrets. HIGH-risk: every failure is recoverable via `--resume`.
904
+ * write `.vercel/project.json`, and activate the E2E workflow. Migrations run in the
905
+ * Vercel build (`vercel-build`: `pnpm db:migrate && pnpm build`), NOT a GitHub Actions
906
+ * workflow. HIGH-risk: every failure is recoverable via `--resume`.
932
907
  *
933
908
  * Order (asserted by the unit test): git substrate (rename → remote add → push
934
909
  * main) runs ENTIRELY before `POST /v9/projects`. The project create is skipped on
@@ -1115,7 +1090,7 @@ function cloudflareErrorToCliError(error, context) {
1115
1090
  * CNAME for BOTH domains (create, or PATCH a manually-proxied record).
1116
1091
  * 4. Resend: upsert `RESEND_API_KEY` (production+preview) + the org
1117
1092
  * `EMAIL_FROM=noreply@updates.timekast.mx` (mail subdomain) — no-echo.
1118
- * 5. Record `state.dns = { domain }` (the prod domain) and persist.
1093
+ * 5. Record `state.dns = { domain, devDomain }` (prod + the dev domain) and persist.
1119
1094
  *
1120
1095
  * Requires `state.vercel.projectId` (PROV-004). No-echo: the Cloudflare token and
1121
1096
  * the Resend key never reach stdout — only domain NAMES + key NAMES are printed.
@@ -1191,8 +1166,10 @@ export async function provisionDns(rootDir, state, teamId, deps = {}) {
1191
1166
  }
1192
1167
  // 4. Resend — wire RESEND_API_KEY (rail) + the FIXED EMAIL_FROM into Vercel env.
1193
1168
  await wireResendEnv(projectId, vercelToken, teamId, envPath, vercelDeps);
1194
- // 5. Record the DNS state (the prod domain is the canonical one) and persist.
1195
- state.dns = { domain: prod };
1169
+ // 5. Record BOTH interim domains in state — prod is canonical, dev tracks `develop`.
1170
+ // The dev domain is derivable from the slug, but persisting it keeps the state
1171
+ // file transparent about every domain provision created (not just prod).
1172
+ state.dns = { domain: prod, devDomain: dev };
1196
1173
  writeProvisionState(rootDir, state);
1197
1174
  }
1198
1175
  /**
@@ -1451,7 +1428,10 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
1451
1428
  if (!verified) {
1452
1429
  // Timeout: persist the pending state (resumable) and stop with a clear error (§5).
1453
1430
  state.dns = {
1454
- ...(state.dns ?? { domain: provisionDomains(state.projectName).prod }),
1431
+ ...(state.dns ?? {
1432
+ domain: provisionDomains(state.projectName).prod,
1433
+ devDomain: provisionDomains(state.projectName).dev,
1434
+ }),
1455
1435
  clientDomain: domain,
1456
1436
  clientDomainStatus: 'pending-verification',
1457
1437
  };
@@ -1470,7 +1450,10 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
1470
1450
  await registerResendDomain(mailDomainFor(domain), envPath, fetchImpl);
1471
1451
  // 7. Record the client domain as LIVE and persist.
1472
1452
  state.dns = {
1473
- ...(state.dns ?? { domain: provisionDomains(state.projectName).prod }),
1453
+ ...(state.dns ?? {
1454
+ domain: provisionDomains(state.projectName).prod,
1455
+ devDomain: provisionDomains(state.projectName).dev,
1456
+ }),
1474
1457
  clientDomain: domain,
1475
1458
  clientDomainStatus: 'live',
1476
1459
  };
@@ -1577,13 +1560,13 @@ async function runServiceStep(service, rootDir, state, flags) {
1577
1560
  case 'github':
1578
1561
  // PROV-004: git substrate (rename → remote add → push main) + create the
1579
1562
  // Vercel project + connect git (GitHub App) + PATCH production-branch +
1580
- // .vercel/project.json + migrate.yml + migration secrets.
1563
+ // .vercel/project.json + activate E2E. Migrations run in vercel-build, not a workflow.
1581
1564
  await provisionGithubVercel(rootDir, state, flags.team, { force: flags.force });
1582
1565
  break;
1583
1566
  case 'dns':
1584
1567
  // PROV-005: attach the interim domains to Vercel (dev → develop, prod →
1585
1568
  // production) + create their Cloudflare CNAMEs (proxied:false) + wire Resend
1586
- // (RESEND_API_KEY + the fixed EMAIL_FROM), then `state.dns = { domain }`.
1569
+ // (RESEND_API_KEY + the fixed EMAIL_FROM), then `state.dns = { domain, devDomain }`.
1587
1570
  await provisionDns(rootDir, state, flags.team);
1588
1571
  break;
1589
1572
  default:
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,73 @@
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
+ ]);
29
+ /**
30
+ * Replace the value of `KEY=...` in an env-file body, preserving every other line.
31
+ * Appends `KEY="value"` when the key is absent (so an older `.env.example` without
32
+ * `DATABASE_URL_MAIN` still gets it). The value is double-quoted — Neon URLs (pooled
33
+ * or direct) carry `?`/`&` query params that an unquoted dotenv value would truncate.
34
+ */
35
+ export function setEnvLine(body, key, value) {
36
+ const re = new RegExp(`^${key}=.*$`, 'm');
37
+ const line = `${key}="${value}"`;
38
+ return re.test(body) ? body.replace(re, line) : `${body.trimEnd()}\n${line}\n`;
39
+ }
40
+ /**
41
+ * Generate the gitignored `.env.local` from the derivative's `.env.example`, filling
42
+ * the two Neon connection strings + a fresh AUTH_SECRET. Without this the dev cannot
43
+ * run `pnpm dev`/`db:migrate`/`invite:admin` locally — those read `.env.local`, which
44
+ * provision otherwise never creates (it only syncs env to Vercel).
45
+ *
46
+ * Uses the DIRECT (non-pooler) strings: `.env.local` drives single-process tooling
47
+ * (`drizzle-kit migrate`/`generate`, `db:seed`, `db:query`) + local `pnpm dev` — none
48
+ * of which want the PgBouncer pooler (the pooled host is only for the serverless
49
+ * runtime on Vercel). Do NOT swap these back to the pooled variants.
50
+ *
51
+ * DATABASE_URL → the develop branch (safe default for local work); DATABASE_URL_MAIN
52
+ * → the main branch (read by `pnpm db:query:main`). NEVER clobbers an existing
53
+ * `.env.local` (the dev may have edited it) unless `--force`. The `.env.example` stays
54
+ * the SSOT of which keys exist; the keys are appended if absent (older scaffolds).
55
+ */
56
+ export function writeEnvLocal(rootDir, neon, opts = {}) {
57
+ const examplePath = join(rootDir, '.env.example');
58
+ if (!existsSync(examplePath))
59
+ return 'no-example';
60
+ const localPath = join(rootDir, '.env.local');
61
+ if (existsSync(localPath) && !opts.force)
62
+ return 'skipped';
63
+ let body = readFileSync(examplePath, 'utf8');
64
+ body = setEnvLine(body, 'DATABASE_URL', neon.developDirect);
65
+ body = setEnvLine(body, 'DATABASE_URL_MAIN', neon.mainDirect);
66
+ body = setEnvLine(body, 'AUTH_SECRET', generateAuthSecret());
67
+ writeFileSync(localPath, body, 'utf8');
68
+ // `.env.local` holds DB strings + AUTH_SECRET → owner-only (0600). Same
69
+ // discipline the secrets rail uses; matters on the headless multi-tenant
70
+ // Agent Server where the default umask would leave it world-readable.
71
+ chmodSync(localPath, 0o600);
72
+ return 'written';
73
+ }
@@ -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;
@@ -15,6 +15,9 @@
15
15
  * --launcher` can render every step with ✅ / ❌ across all platforms (the
16
16
  * cross-platform equivalent of the macOS launcher's `checkPrereqs`).
17
17
  */
18
+ import { readFileSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import path from 'node:path';
18
21
  import { execa } from 'execa';
19
22
  import { PreflightError } from './cli-error.js';
20
23
  import { FACTORY_ORG } from './constants.js';
@@ -132,12 +135,28 @@ export async function runPreflight() {
132
135
  await assertGhAuthenticated();
133
136
  await assertOrgMembership();
134
137
  }
138
+ /**
139
+ * The methodology-rail keys the launcher expects in `~/.claude/.env` (imported
140
+ * via `factory secrets <file>`). Provisioning tokens + their team/org ids + the
141
+ * proposal tooling keys. Single source so the check + tests agree.
142
+ */
143
+ export const REQUIRED_SECRET_KEYS = [
144
+ 'NEON_API_KEY',
145
+ 'VERCEL_TOKEN',
146
+ 'RESEND_API_KEY',
147
+ 'CLOUDFLARE_API_TOKEN',
148
+ 'SHORTIO_API_KEY',
149
+ 'GAMMA_API_KEY',
150
+ 'VERCEL_TEAM_ID',
151
+ 'NEON_ORGANIZATION_ID',
152
+ ];
135
153
  /** The ordered launcher labels (single source so render + tests agree). */
136
154
  const LAUNCHER_LABELS = {
137
155
  node: `Node ≥ ${MIN_NODE_MAJOR}`,
138
156
  gh: 'GitHub CLI (gh) instalado',
139
157
  auth: 'Sesión de GitHub activa',
140
158
  org: `Membresía del org ${FACTORY_ORG}`,
159
+ secrets: 'Secrets del org en el rail',
141
160
  };
142
161
  /** A confirmation message for a passing check (the `ok` branch). */
143
162
  const OK_MESSAGES = {
@@ -145,7 +164,45 @@ const OK_MESSAGES = {
145
164
  gh: 'GitHub CLI (gh) está instalado.',
146
165
  auth: 'Tu sesión de GitHub CLI está activa.',
147
166
  org: `Eres miembro del org ${FACTORY_ORG}.`,
167
+ secrets: `Las ${REQUIRED_SECRET_KEYS.length} keys del rail están presentes.`,
148
168
  };
169
+ /**
170
+ * Read the secret KEY NAMES present in the rail (`~/.claude/.env`), never values.
171
+ * A missing/unreadable rail yields an empty set (→ the check warns with all keys
172
+ * missing). Tests inject `railContent` to avoid touching the real home dir.
173
+ */
174
+ function readRailKeyNames(railContent) {
175
+ let content = railContent;
176
+ if (content === undefined) {
177
+ try {
178
+ content = readFileSync(path.join(homedir(), '.claude', '.env'), 'utf8');
179
+ }
180
+ catch {
181
+ content = '';
182
+ }
183
+ }
184
+ const names = new Set();
185
+ for (const line of content.split(/\r?\n/)) {
186
+ const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/);
187
+ if (match)
188
+ names.add(match[1]);
189
+ }
190
+ return names;
191
+ }
192
+ /** Build the secrets check (`ok` if all required keys present, else `warn`). */
193
+ function secretsCheck(railContent) {
194
+ const present = readRailKeyNames(railContent);
195
+ const missing = REQUIRED_SECRET_KEYS.filter((key) => !present.has(key));
196
+ return {
197
+ id: 'secrets',
198
+ label: LAUNCHER_LABELS.secrets,
199
+ status: missing.length === 0 ? 'ok' : 'warn',
200
+ message: missing.length === 0
201
+ ? OK_MESSAGES.secrets
202
+ : `Faltan ${missing.length} de ${REQUIRED_SECRET_KEYS.length} secrets en el rail: ` +
203
+ `${missing.join(', ')}. Impórtalos desde el card Secrets.`,
204
+ };
205
+ }
149
206
  /**
150
207
  * Run the launcher preflight (LTUI-002): the cross-platform equivalent of the
151
208
  * macOS launcher's `checkPrereqs`. Verifies, IN ORDER:
@@ -211,5 +268,8 @@ export async function runLauncherPreflight(deps = {}) {
211
268
  });
212
269
  }
213
270
  }
271
+ // Secrets check — independent of the gh/auth/org chain (runs even if those
272
+ // failed) and non-blocking: resolves to `ok` or `warn`, never `fail`/`skipped`.
273
+ results.push(secretsCheck(deps.railContent));
214
274
  return results;
215
275
  }
@@ -111,7 +111,7 @@ async function assertGhAuthenticated(spawn) {
111
111
  }
112
112
  /**
113
113
  * Step 1b' — the `gh` token carries the `workflow` scope. The derived scaffold ships
114
- * `.github/workflows/*.yml` (ci, e2e, …) and provision generates more (migrate.yml);
114
+ * `.github/workflows/*.yml` (ci, e2e, …) and provision activates more (e2e.yml);
115
115
  * GitHub REJECTS an HTTPS push that creates/updates a workflow file unless the OAuth
116
116
  * token has the `workflow` scope ("refusing to allow an OAuth App to create or update
117
117
  * workflow … without `workflow` scope"). Parses `gh auth status` for the scope; the
@@ -358,14 +358,15 @@ export function readDestroyTarget(rootDir) {
358
358
  'Bórralo o reconstrúyelo con `--adopt`.');
359
359
  }
360
360
  const lifecycle = step.lifecycle === 'ephemeral' ? 'ephemeral' : 'live';
361
- // The committed `dns` block records only the prod domain (`domain`) + the optional
362
- // client domain — NOT the interim DEV domain (`{slug}-dev.timekast.mx`), which
363
- // provision ALWAYS creates (Vercel binding + Cloudflare CNAME). Re-derive it from
364
- // the slug (same as the `--adopt` shape + the `--domain` dry-run already do) so
365
- // `--destroy` tears down the dev CNAME instead of orphaning it. Gated on `dns`
366
- // being present (no DNS step → no dev domain to remove); passed through the same
361
+ // The `dns` block now records the interim DEV domain (`{slug}-dev.timekast.mx`)
362
+ // alongside prod, but it stays derivable from the slug. Prefer the persisted value
363
+ // and fall back to deriving it (state files written before `devDomain` existed) so
364
+ // `--destroy` tears down the dev CNAME instead of orphaning it. Gated on `dns` being
365
+ // present (no DNS step → no dev domain to remove); passed through the same
367
366
  // `realDomain` shape guard as every other low-trust domain field.
368
- const devDomain = step.dns ? realDomain(provisionDomains(step.projectName).dev) : null;
367
+ const devDomain = step.dns
368
+ ? realDomain(step.dns.devDomain ?? provisionDomains(step.projectName).dev)
369
+ : null;
369
370
  const domains = [
370
371
  realDomain(step.dns?.domain),
371
372
  devDomain,
@@ -13,7 +13,12 @@ const VALID_NAME = /^[A-Za-z0-9._-]+$/;
13
13
  const FORMAT_HINT = 'El nombre solo puede contener letras, números, guiones (-), guiones bajos (_) y puntos (.), ' +
14
14
  'sin espacios, y no puede empezar con `-` ni `.`.';
15
15
  /**
16
- * Validate a project name. Returns the trimmed name on success.
16
+ * Validate a project name. Returns the trimmed, LOWERCASED name on success — every
17
+ * derived resource (local dir, GitHub repo, `package.json.name`, the `{slug}` /
18
+ * `{slug}-dev` provision subdomains) must be lowercase, so this is the single
19
+ * chokepoint that normalizes it. GitHub repo names and DNS labels are
20
+ * case-insensitive, and `provision` rejects an uppercase slug outright — creating
21
+ * the repo lowercase here keeps `new` and `provision` from disagreeing on casing.
17
22
  * @throws {CLIError} with the allowed-format hint on any invalid input.
18
23
  */
19
24
  export function validateProjectName(raw) {
@@ -30,5 +35,5 @@ export function validateProjectName(raw) {
30
35
  if (!VALID_NAME.test(name)) {
31
36
  throw new CLIError(`Nombre inválido: \`${name}\` contiene caracteres no permitidos.\n${FORMAT_HINT}`);
32
37
  }
33
- return name;
38
+ return name.toLowerCase();
34
39
  }
@@ -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.13.0",
3
+ "version": "1.15.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",