@timekast/factory 1.13.0 → 1.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  /**
@@ -203,7 +203,7 @@ export function parseProvisionFlags(argv) {
203
203
  const SERVICE_LABEL = {
204
204
  neon: 'Neon (proyecto + branch develop)',
205
205
  vercel: 'Vercel (env sync por target)',
206
- github: 'GitHub + Vercel (sustrato git + proyecto + migrate.yml)',
206
+ github: 'GitHub + Vercel (sustrato git + proyecto + env + E2E)',
207
207
  dns: 'DNS + Resend (dominio del entorno)',
208
208
  };
209
209
  /** Build the ordered plan: each requested service is `skip` if complete, else `create`. */
@@ -244,14 +244,17 @@ function deriveProjectName(flags, rootDir) {
244
244
  // it wins over the dir name. Validate it as a subdomain label when given,
245
245
  // since it flows into `{slug}.timekast.mx` downstream (PROV-005).
246
246
  if (flags.slug) {
247
- assertValidSubdomainSlug(flags.slug);
248
- return flags.slug;
247
+ const slug = flags.slug.toLowerCase(); // every resource is lowercase (repo/dir/domains)
248
+ assertValidSubdomainSlug(slug);
249
+ return slug;
249
250
  }
250
251
  // No `--slug`: the project name is the directory provision runs in (the scaffold
251
252
  // dir), NOT the org/team scope. A team id like `team_ldqH...` is neither a real
252
253
  // project name nor a valid subdomain, and falling back to it produced a confusing
253
254
  // `repo TimeKast/team_ldqH... no existe` error. Validate it like an explicit slug.
254
- const fromDir = basename(rootDir);
255
+ // Lowercased so a dir created with mixed case (e.g. `Edmond/`) yields a valid slug
256
+ // instead of being rejected for the uppercase — matches the `new` normalization.
257
+ const fromDir = basename(rootDir).toLowerCase();
255
258
  assertValidSubdomainSlug(fromDir);
256
259
  return fromDir;
257
260
  }
@@ -833,7 +836,13 @@ async function buildGitSubstrate(rootDir, repoSlug, runGit, force) {
833
836
  // 2. Add (or reset, under --force) the `origin` remote → the HTTPS repo URL.
834
837
  const existingOrigin = await readOriginUrl(runGit, rootDir);
835
838
  if (existingOrigin && existingOrigin !== url) {
836
- if (!force) {
839
+ // GitHub repo URLs are case-insensitive: an `origin` differing ONLY by case
840
+ // (e.g. `TimeKast/Edmond.git` vs the canonical lowercase `TimeKast/edmond.git`)
841
+ // points at the SAME repo. Re-case it to the canonical URL silently — no `--force`
842
+ // needed. A difference BEYOND case is a genuinely different repo → throw unless
843
+ // `--force` resets it.
844
+ const sameRepoDifferentCase = existingOrigin.toLowerCase() === url.toLowerCase();
845
+ if (!sameRepoDifferentCase && !force) {
837
846
  throw new CLIError(`El repo ya tiene un remote \`origin\` apuntando a otro URL (\`${existingOrigin}\` ≠ \`${url}\`).\n` +
838
847
  'Usa `--force` para sobrescribirlo, o limpia el remote si reutilizas este directorio.');
839
848
  }
@@ -927,8 +936,9 @@ export function writeE2eWorkflow(rootDir) {
927
936
  /**
928
937
  * PROV-004 — the `github` step: build the git substrate, create the Vercel PROJECT,
929
938
  * 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`.
939
+ * write `.vercel/project.json`, and activate the E2E workflow. Migrations run in the
940
+ * Vercel build (`vercel-build`: `pnpm db:migrate && pnpm build`), NOT a GitHub Actions
941
+ * workflow. HIGH-risk: every failure is recoverable via `--resume`.
932
942
  *
933
943
  * Order (asserted by the unit test): git substrate (rename → remote add → push
934
944
  * main) runs ENTIRELY before `POST /v9/projects`. The project create is skipped on
@@ -1115,7 +1125,7 @@ function cloudflareErrorToCliError(error, context) {
1115
1125
  * CNAME for BOTH domains (create, or PATCH a manually-proxied record).
1116
1126
  * 4. Resend: upsert `RESEND_API_KEY` (production+preview) + the org
1117
1127
  * `EMAIL_FROM=noreply@updates.timekast.mx` (mail subdomain) — no-echo.
1118
- * 5. Record `state.dns = { domain }` (the prod domain) and persist.
1128
+ * 5. Record `state.dns = { domain, devDomain }` (prod + the dev domain) and persist.
1119
1129
  *
1120
1130
  * Requires `state.vercel.projectId` (PROV-004). No-echo: the Cloudflare token and
1121
1131
  * the Resend key never reach stdout — only domain NAMES + key NAMES are printed.
@@ -1191,8 +1201,10 @@ export async function provisionDns(rootDir, state, teamId, deps = {}) {
1191
1201
  }
1192
1202
  // 4. Resend — wire RESEND_API_KEY (rail) + the FIXED EMAIL_FROM into Vercel env.
1193
1203
  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 };
1204
+ // 5. Record BOTH interim domains in state — prod is canonical, dev tracks `develop`.
1205
+ // The dev domain is derivable from the slug, but persisting it keeps the state
1206
+ // file transparent about every domain provision created (not just prod).
1207
+ state.dns = { domain: prod, devDomain: dev };
1196
1208
  writeProvisionState(rootDir, state);
1197
1209
  }
1198
1210
  /**
@@ -1451,7 +1463,10 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
1451
1463
  if (!verified) {
1452
1464
  // Timeout: persist the pending state (resumable) and stop with a clear error (§5).
1453
1465
  state.dns = {
1454
- ...(state.dns ?? { domain: provisionDomains(state.projectName).prod }),
1466
+ ...(state.dns ?? {
1467
+ domain: provisionDomains(state.projectName).prod,
1468
+ devDomain: provisionDomains(state.projectName).dev,
1469
+ }),
1455
1470
  clientDomain: domain,
1456
1471
  clientDomainStatus: 'pending-verification',
1457
1472
  };
@@ -1470,7 +1485,10 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
1470
1485
  await registerResendDomain(mailDomainFor(domain), envPath, fetchImpl);
1471
1486
  // 7. Record the client domain as LIVE and persist.
1472
1487
  state.dns = {
1473
- ...(state.dns ?? { domain: provisionDomains(state.projectName).prod }),
1488
+ ...(state.dns ?? {
1489
+ domain: provisionDomains(state.projectName).prod,
1490
+ devDomain: provisionDomains(state.projectName).dev,
1491
+ }),
1474
1492
  clientDomain: domain,
1475
1493
  clientDomainStatus: 'live',
1476
1494
  };
@@ -1577,13 +1595,13 @@ async function runServiceStep(service, rootDir, state, flags) {
1577
1595
  case 'github':
1578
1596
  // PROV-004: git substrate (rename → remote add → push main) + create the
1579
1597
  // Vercel project + connect git (GitHub App) + PATCH production-branch +
1580
- // .vercel/project.json + migrate.yml + migration secrets.
1598
+ // .vercel/project.json + activate E2E. Migrations run in vercel-build, not a workflow.
1581
1599
  await provisionGithubVercel(rootDir, state, flags.team, { force: flags.force });
1582
1600
  break;
1583
1601
  case 'dns':
1584
1602
  // PROV-005: attach the interim domains to Vercel (dev → develop, prod →
1585
1603
  // production) + create their Cloudflare CNAMEs (proxied:false) + wire Resend
1586
- // (RESEND_API_KEY + the fixed EMAIL_FROM), then `state.dns = { domain }`.
1604
+ // (RESEND_API_KEY + the fixed EMAIL_FROM), then `state.dns = { domain, devDomain }`.
1587
1605
  await provisionDns(rootDir, state, flags.team);
1588
1606
  break;
1589
1607
  default:
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "1.13.0",
3
+ "version": "1.14.2",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",