@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.
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
|
272
|
+
const hasFail = checks.some((c) => c.status === 'fail');
|
|
273
|
+
const hasWarn = checks.some((c) => c.status === 'warn');
|
|
272
274
|
console.log('');
|
|
273
|
-
if (
|
|
274
|
-
console.log('Entorno
|
|
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
|
|
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 +
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
931
|
-
*
|
|
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
|
|
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
|
|
1195
|
-
|
|
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 ?? {
|
|
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 ?? {
|
|
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 +
|
|
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:
|
package/dist/lib/preflight.js
CHANGED
|
@@ -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
|
|
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
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
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
|
|
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
|
}
|