@timekast/factory 1.12.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.
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Atomic state model for `factory provision` (PROV-001).
3
+ *
4
+ * `provision` creates external resources one at a time (Neon project, Vercel
5
+ * project, GitHub repo, DNS). After EACH successful create it persists the new
6
+ * provider id into `.timekast/provision.json` so the run is resumable: if a later
7
+ * step fails (or the process is killed), `--resume` re-reads this file and skips
8
+ * the steps already recorded. The state is COMMITTED to the repo (it holds
9
+ * provider IDs + scope + lifecycle — NEVER secret values, NEVER a connection
10
+ * string with embedded `user:password`), so a teammate or the `--destroy` flow
11
+ * (PROV-008) can act on the exact resources that were created. The DB connection
12
+ * strings are NEVER persisted anywhere — the consumers (env sync, migrate secrets,
13
+ * `.env.local`) re-derive them fresh from Neon on demand (`resolveNeonSecrets` in
14
+ * `provision.ts`), using the branch ids below + the rail `NEON_API_KEY`. Neon is the
15
+ * single source of truth for credentials.
16
+ *
17
+ * Atomicity contract (issue §8 — "Interrupción durante escritura atómica"):
18
+ * every write goes to a sibling temp file first, then `renameSync` swaps it into
19
+ * place. `rename(2)` is atomic on the same filesystem, so a process killed
20
+ * mid-write leaves the PREVIOUS `provision.json` fully intact — never a truncated
21
+ * half-written JSON. This mirrors the backup-and-swap discipline of
22
+ * `atomic-swap.ts` (which the `update` engine uses), scoped down to a single
23
+ * single-file document.
24
+ *
25
+ * Robustness (issue §8 — "`.timekast/provision.json` corrupto"): the reader
26
+ * tolerates a missing OR corrupt file. A missing file → `null` (nothing to
27
+ * resume). A corrupt file → a typed `CLIError` that points the dev at `--force`
28
+ * (which discards the bad state and starts over), never a raw `JSON.parse` stack
29
+ * trace.
30
+ *
31
+ * Pure + FS-light: `rootDir` is always explicit so the unit tests drive every
32
+ * branch against a temp dir — no real `.timekast/` and no cwd coupling.
33
+ */
34
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
35
+ import path from 'node:path';
36
+ import { CLIError } from './cli-error.js';
37
+ import { provisionDomains } from './cloudflare-api.js';
38
+ import { TIMEKAST_DIR } from './constants.js';
39
+ /**
40
+ * Directory (repo-root relative) the provision state lives in. This is the SAME
41
+ * `.timekast/` dir the update lockfile uses, so ALL Factory CLI metadata sits in
42
+ * ONE place instead of scattered across folders. COMMITTED — see `.gitignore`
43
+ * (provision.json committed; the `*.local.json` rule is kept as a defensive guard so
44
+ * any credentials file dropped here can never be tracked — provision writes none).
45
+ */
46
+ export const PROVISION_DIR = TIMEKAST_DIR;
47
+ /** Filename of the provision state document inside {@link PROVISION_DIR}. */
48
+ export const PROVISION_STATE_FILE = 'provision.json';
49
+ /** Absolute path to the provision state file under a repo root. */
50
+ export function provisionStatePath(rootDir) {
51
+ return path.join(rootDir, PROVISION_DIR, PROVISION_STATE_FILE);
52
+ }
53
+ /** True when a provision state file already exists (a run is in progress / done). */
54
+ export function hasProvisionState(rootDir) {
55
+ return existsSync(provisionStatePath(rootDir));
56
+ }
57
+ /**
58
+ * Validate the structural shape of a parsed state object. Returns the typed
59
+ * state on success, or `null` when the shape is wrong (the caller turns that into
60
+ * a corrupt-state error). Keeps the JSON-parse and the shape-check separate so
61
+ * the reader can distinguish "not JSON" from "JSON but wrong shape" — both are
62
+ * "corrupt" to the dev, but the distinction keeps the validator pure/testable.
63
+ */
64
+ function asProvisionState(parsed) {
65
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
66
+ return null;
67
+ const obj = parsed;
68
+ if (typeof obj.projectName !== 'string' || obj.projectName === '')
69
+ return null;
70
+ if (obj.lifecycle !== 'ephemeral' && obj.lifecycle !== 'live')
71
+ return null;
72
+ if (typeof obj.scope !== 'string')
73
+ return null;
74
+ if (typeof obj.createdAt !== 'string')
75
+ return null;
76
+ return obj;
77
+ }
78
+ /**
79
+ * Read the provision state, tolerating absence and corruption (issue §8).
80
+ *
81
+ * - file absent → `null` (nothing to resume — a fresh run)
82
+ * - file present + valid → the typed {@link ProvisionState}
83
+ * - file present + broken → `CLIError` pointing at `--force` to start over
84
+ *
85
+ * "Broken" covers an unreadable file (perms/race), non-JSON content, and
86
+ * JSON-of-the-wrong-shape. Never throws a raw `JSON.parse` error.
87
+ *
88
+ * @throws {CLIError} when the file exists but cannot be parsed into a valid state.
89
+ */
90
+ export function readProvisionState(rootDir) {
91
+ const file = provisionStatePath(rootDir);
92
+ if (!existsSync(file))
93
+ return null;
94
+ let text;
95
+ try {
96
+ text = readFileSync(file, 'utf8');
97
+ }
98
+ catch {
99
+ throw new CLIError(`No se pudo leer el estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`). ` +
100
+ 'Si está dañado, vuelve a correr con `--force` para descartarlo y empezar de cero.');
101
+ }
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(text);
105
+ }
106
+ catch {
107
+ throw new CLIError(`El estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`) está corrupto (JSON inválido). ` +
108
+ 'Vuelve a correr con `--force` para descartarlo y empezar de cero.');
109
+ }
110
+ const state = asProvisionState(parsed);
111
+ if (!state) {
112
+ throw new CLIError(`El estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`) tiene una estructura inesperada. ` +
113
+ 'Vuelve a correr con `--force` para descartarlo y empezar de cero.');
114
+ }
115
+ return state;
116
+ }
117
+ /**
118
+ * Persist the provision state ATOMICALLY (issue §6 + §8).
119
+ *
120
+ * Ensures `.timekast/` exists (`mkdir -p` before the first write — issue §8 edge
121
+ * case), writes the JSON to a sibling temp file, then `renameSync`s it over the
122
+ * target. The rename is atomic on the same filesystem, so an interruption mid-
123
+ * write leaves the previous `provision.json` intact (or no file at all on the
124
+ * very first write) — never a truncated document.
125
+ *
126
+ * Call this AFTER each individual provider create succeeds, so the freshly-
127
+ * created resource's id is durable before the next (riskier) step runs.
128
+ */
129
+ export function writeProvisionState(rootDir, state) {
130
+ const dir = path.join(rootDir, PROVISION_DIR);
131
+ mkdirSync(dir, { recursive: true });
132
+ const target = provisionStatePath(rootDir);
133
+ const tmp = path.join(dir, `.${PROVISION_STATE_FILE}.tmp`);
134
+ const body = `${JSON.stringify(state, null, 2)}\n`;
135
+ try {
136
+ writeFileSync(tmp, body, 'utf8');
137
+ renameSync(tmp, target);
138
+ }
139
+ catch (error) {
140
+ // Best-effort cleanup of the temp file so a failed swap leaves no litter.
141
+ rmSync(tmp, { force: true });
142
+ throw new CLIError(`No se pudo escribir el estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`): ` +
143
+ `${error?.message ?? 'error desconocido'}.`);
144
+ }
145
+ }
146
+ /**
147
+ * Discard the provision state (the `--force` path). Best-effort: a missing file
148
+ * is a no-op, so `--force` is safe to pass on a fresh run.
149
+ */
150
+ export function clearProvisionState(rootDir) {
151
+ rmSync(provisionStatePath(rootDir), { force: true });
152
+ }
153
+ /**
154
+ * Delete `.timekast/provision.json` AFTER a successful `--destroy` teardown
155
+ * (PROV-008 §4). Distinct in INTENT from {@link clearProvisionState} (the
156
+ * `--force` "start over" path) though it removes the same file: `--destroy` calls
157
+ * this ONLY once every provider teardown completed without leaving a recorded
158
+ * resource behind, so the state file is never removed while a half-destroyed stack
159
+ * still has live resources to act on. Best-effort delete (a missing file is a
160
+ * no-op), so a re-run after a manual delete is safe.
161
+ */
162
+ export function clearState(rootDir) {
163
+ rmSync(provisionStatePath(rootDir), { force: true });
164
+ }
165
+ /**
166
+ * The "unknown" sentinel a `--adopt` field carries when a provider could not be
167
+ * read (no token / no access / no response — issue §4). It is a STRING (not
168
+ * `undefined`/absent) so the persisted state explicitly records "we looked and
169
+ * could not resolve this", distinct from "this provider was not queried". A later
170
+ * `factory provision --adopt --force` (once the token is available) overwrites it.
171
+ */
172
+ export const ADOPT_UNKNOWN = 'unknown';
173
+ /**
174
+ * Persist a `--adopt`-reconstructed state ATOMICALLY (PROV-007 §6). Merges the
175
+ * partial provider fields the adopt resolved over a fully-`unknown` baseline (so
176
+ * a provider that was skipped/failed is recorded as {@link ADOPT_UNKNOWN}, never
177
+ * left absent), stamps `lifecycle: 'live'` + `adoptedAt`, and writes it through
178
+ * the SAME temp-file + `renameSync` swap as {@link writeProvisionState}.
179
+ *
180
+ * Overwrite guard (issue §4): if a state file already exists this throws a clear
181
+ * `CLIError` UNLESS `options.force` is set — the adopt must not silently clobber a
182
+ * normally-provisioned project's state. (`runAdopt` checks existence up-front for
183
+ * a friendlier message; this is the durable last-line guard.)
184
+ *
185
+ * Pure-ish + FS-light: `rootDir` and the clock (`now`) are explicit so the unit
186
+ * test drives every branch against a temp dir with a fixed timestamp.
187
+ *
188
+ * @throws {CLIError} when a state file already exists and `force` is not set.
189
+ */
190
+ export function adoptState(rootDir, partial, options = {}) {
191
+ if (hasProvisionState(rootDir) && !options.force) {
192
+ throw new CLIError(`Ya existe un estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`). ` +
193
+ 'Usa `--force` para sobrescribirlo con el resultado del adopt, o bórralo si reutilizas este directorio.');
194
+ }
195
+ const slug = partial.slug ?? ADOPT_UNKNOWN;
196
+ const now = options.now ?? (() => new Date());
197
+ const state = {
198
+ lifecycle: 'live',
199
+ slug,
200
+ vercelProjectId: partial.vercelProjectId ?? ADOPT_UNKNOWN,
201
+ neonProjectId: partial.neonProjectId ?? ADOPT_UNKNOWN,
202
+ neonBranchDevId: partial.neonBranchDevId ?? ADOPT_UNKNOWN,
203
+ githubRepo: partial.githubRepo ?? ADOPT_UNKNOWN,
204
+ domains: partial.domains ?? { dev: ADOPT_UNKNOWN, prod: ADOPT_UNKNOWN },
205
+ adoptedAt: now().toISOString(),
206
+ };
207
+ const dir = path.join(rootDir, PROVISION_DIR);
208
+ mkdirSync(dir, { recursive: true });
209
+ const target = provisionStatePath(rootDir);
210
+ const tmp = path.join(dir, `.${PROVISION_STATE_FILE}.tmp`);
211
+ const body = `${JSON.stringify(state, null, 2)}\n`;
212
+ try {
213
+ writeFileSync(tmp, body, 'utf8');
214
+ renameSync(tmp, target);
215
+ }
216
+ catch (error) {
217
+ rmSync(tmp, { force: true });
218
+ throw new CLIError(`No se pudo escribir el estado de adopt (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`): ` +
219
+ `${error?.message ?? 'error desconocido'}.`);
220
+ }
221
+ return state;
222
+ }
223
+ /**
224
+ * True when `service`'s create step is already recorded in `state` — the check
225
+ * `--resume` uses to skip a completed step. A `null` state (no prior run) means
226
+ * nothing is complete.
227
+ */
228
+ export function isServiceComplete(state, service) {
229
+ if (!state)
230
+ return false;
231
+ switch (service) {
232
+ case 'neon':
233
+ // Neon is complete only when the FINAL step landed (key minted + stored as
234
+ // the GitHub Secret). developBranchId alone is NOT done — the steps after it
235
+ // (connection strings, API key, secret) must still run, and re-minting the
236
+ // key would orphan a duplicate. A partial run resumes from where it stopped.
237
+ return state.neon?.apiKeySecretDone === true;
238
+ case 'vercel':
239
+ // Complete only when the env sync landed — NOT on the mere presence of
240
+ // state.vercel (the github step writes `projectId` before the vercel step
241
+ // runs, so that alone would wrongly skip the env push).
242
+ return state.vercel?.envSynced === true;
243
+ case 'github':
244
+ // Complete only when the whole step finished — NOT on the mere presence of
245
+ // state.github (its `repo` is recorded at the START of the step, so a step
246
+ // that failed mid-way would be wrongly skipped on resume).
247
+ return state.github?.done === true;
248
+ case 'dns':
249
+ return state.dns !== undefined;
250
+ default:
251
+ return false;
252
+ }
253
+ }
254
+ /** Coerce a persisted field to a real id, mapping the `unknown` sentinel + empties to `null`. */
255
+ function realId(value) {
256
+ if (typeof value !== 'string')
257
+ return null;
258
+ const trimmed = value.trim();
259
+ if (trimmed === '' || trimmed === ADOPT_UNKNOWN)
260
+ return null;
261
+ return trimmed;
262
+ }
263
+ /**
264
+ * `owner/repo` shape gate for a `githubRepo` field read from the LOW-TRUST state
265
+ * file (the file is committed, but a teammate/attacker could edit it). A
266
+ * malformed value (wrong shape, or a leading `-` that could smuggle a flag into
267
+ * `gh repo delete`) → `null` (skip), never passed to a command. Mirrors the
268
+ * runtime re-validation in `runDestroy` so the corruption is dropped at the
269
+ * read boundary too (FIX 3 — validate the shape, don't act on garbage).
270
+ */
271
+ const GITHUB_REPO_RE = /^[A-Za-z0-9_.][A-Za-z0-9_.-]*\/[A-Za-z0-9_.][A-Za-z0-9_.-]*$/;
272
+ /** Coerce a persisted `githubRepo` to a valid `owner/repo`, else `null` (skip). */
273
+ function realRepo(value) {
274
+ const id = realId(value);
275
+ if (id === null)
276
+ return null;
277
+ // Reject any segment starting with `-` (anti flag-smuggling) + enforce owner/repo.
278
+ if (id.startsWith('-') || !GITHUB_REPO_RE.test(id))
279
+ return null;
280
+ return id;
281
+ }
282
+ /**
283
+ * Coerce a persisted domain to a safe DNS host, else `null` (skip). A domain
284
+ * read from the low-trust state file that starts with `-` (flag smuggling) or
285
+ * carries characters outside the DNS host alphabet is dropped, never acted on.
286
+ */
287
+ const DOMAIN_RE = /^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$/;
288
+ function realDomain(value) {
289
+ const id = realId(value);
290
+ if (id === null)
291
+ return null;
292
+ if (id.startsWith('-') || !DOMAIN_RE.test(id))
293
+ return null;
294
+ return id;
295
+ }
296
+ /** True when a parsed object looks like the flat {@link AdoptedState} (adopt backfill). */
297
+ function isAdoptedShape(obj) {
298
+ // The adopt shape uses `slug` + `vercelProjectId` (flat); the step shape uses
299
+ // `projectName` + nested `vercel.projectId`. `slug` present = adopt.
300
+ return typeof obj.slug === 'string';
301
+ }
302
+ /**
303
+ * Read the provision state file as a {@link DestroyTarget}, tolerating BOTH the
304
+ * step-by-step {@link ProvisionState} and the flat {@link AdoptedState} shapes
305
+ * (PROV-008 §4). Returns `null` when no state file exists (the caller turns that
306
+ * into the "run `--adopt` first" error). A present-but-unparsable file throws a
307
+ * typed {@link CLIError} (never a raw `JSON.parse` trace).
308
+ *
309
+ * This is the ONE authority `--destroy` reads: the `lifecycle` it carries is the
310
+ * single guard between an auto-nuke (`ephemeral`) and a refusal (`live`), with no
311
+ * `version` sub-check.
312
+ *
313
+ * @throws {CLIError} when the file exists but cannot be parsed into a known shape.
314
+ */
315
+ export function readDestroyTarget(rootDir) {
316
+ const file = provisionStatePath(rootDir);
317
+ if (!existsSync(file))
318
+ return null;
319
+ let text;
320
+ try {
321
+ text = readFileSync(file, 'utf8');
322
+ }
323
+ catch {
324
+ throw new CLIError(`No se pudo leer el estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`). ` +
325
+ 'Si está dañado, bórralo o reconstrúyelo con `--adopt`.');
326
+ }
327
+ let parsed;
328
+ try {
329
+ parsed = JSON.parse(text);
330
+ }
331
+ catch {
332
+ throw new CLIError(`El estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`) está corrupto (JSON inválido). ` +
333
+ 'Bórralo o reconstrúyelo con `--adopt`.');
334
+ }
335
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
336
+ throw new CLIError(`El estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`) tiene una estructura inesperada. ` +
337
+ 'Bórralo o reconstrúyelo con `--adopt`.');
338
+ }
339
+ const obj = parsed;
340
+ if (isAdoptedShape(obj)) {
341
+ const adopted = obj;
342
+ const domains = [realDomain(adopted.domains?.dev), realDomain(adopted.domains?.prod)].filter((d) => d !== null);
343
+ return {
344
+ slug: typeof adopted.slug === 'string' ? adopted.slug : '',
345
+ lifecycle: adopted.lifecycle === 'live' ? 'live' : 'live', // adopt is always live
346
+ scope: null, // the adopt shape does not persist the team scope
347
+ neonProjectId: realId(adopted.neonProjectId),
348
+ neonBranchDevId: realId(adopted.neonBranchDevId),
349
+ vercelProjectId: realId(adopted.vercelProjectId),
350
+ githubRepo: realRepo(adopted.githubRepo),
351
+ domains,
352
+ };
353
+ }
354
+ // Step-by-step ProvisionState shape.
355
+ const step = obj;
356
+ if (typeof step.projectName !== 'string' || step.projectName === '') {
357
+ throw new CLIError(`El estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`) no tiene un \`projectName\`/\`slug\` válido. ` +
358
+ 'Bórralo o reconstrúyelo con `--adopt`.');
359
+ }
360
+ const lifecycle = step.lifecycle === 'ephemeral' ? 'ephemeral' : 'live';
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
366
+ // `realDomain` shape guard as every other low-trust domain field.
367
+ const devDomain = step.dns
368
+ ? realDomain(step.dns.devDomain ?? provisionDomains(step.projectName).dev)
369
+ : null;
370
+ const domains = [
371
+ realDomain(step.dns?.domain),
372
+ devDomain,
373
+ realDomain(step.dns?.clientDomain),
374
+ ].filter((d) => d !== null);
375
+ return {
376
+ slug: step.projectName,
377
+ lifecycle,
378
+ scope: realId(step.scope),
379
+ neonProjectId: realId(step.neon?.projectId),
380
+ neonBranchDevId: realId(step.neon?.developBranchId),
381
+ vercelProjectId: realId(step.vercel?.projectId),
382
+ githubRepo: realRepo(step.github?.repo),
383
+ domains,
384
+ };
385
+ }
@@ -23,9 +23,9 @@ import { compare } from './semver.js';
23
23
  /** Download the profile tarball into `tmpDir`; returns the tarball path. */
24
24
  export async function downloadProfileTarball(profile, tmpDir) {
25
25
  // List releases and resolve the latest KIT release by tag, then download BY TAG.
26
- // We do NOT let `gh` resolve repo-wide "latest": the repo also hosts the launcher
27
- // release line (`launcher-v*`), whose assets are not kit tarballs, so "latest"
28
- // can point at a launcher release. Filtering to `v*` keeps the kit line isolated.
26
+ // We do NOT let `gh` resolve repo-wide "latest": the repo also hosts the GUI
27
+ // launcher release line (`gui-v*`), whose assets are installers, not kit tarballs,
28
+ // so "latest" can point at a launcher release. Filtering to `v*` isolates the kit.
29
29
  const { stdout } = await execa('gh', [
30
30
  'release',
31
31
  'list',
@@ -104,18 +104,19 @@ export function resolveLatestBeta(releases) {
104
104
  }
105
105
  /**
106
106
  * True when a release tag belongs to the KIT's release line (`v<semver>`, e.g.
107
- * `v10.9.0`). The same repo hosts the launcher line (`launcher-v*`) and — via npm,
108
- * not GitHub Releases — the CLI line (`cli-v*`); neither starts with `v<digit>`,
109
- * so this predicate isolates the kit from every other release line in the repo.
110
- * Without it, `gh`'s repo-wide "latest" / a non-prerelease `launcher-v*` could be
111
- * mistaken for the latest kit version and break `status` / `update`.
107
+ * `v10.9.0`). The same repo hosts the GUI launcher line (`gui-v*`, Electron app
108
+ * GitHub Releases) and — via npm, not GitHub Releases — the CLI line (`cli-v*`);
109
+ * neither starts with `v<digit>`, so this predicate isolates the kit from every
110
+ * other release line in the repo. Without it, `gh`'s repo-wide "latest" / a
111
+ * non-prerelease `gui-v*` could be mistaken for the latest kit version and break
112
+ * `status` / `update`.
112
113
  */
113
114
  export function isKitReleaseTag(tagName) {
114
115
  return /^v\d/.test(tagName);
115
116
  }
116
117
  /**
117
118
  * Pick the latest STABLE kit release from a list: filter to kit tags (excluding
118
- * `launcher-v*` / any non-kit line) AND non-prerelease, then the highest by semver.
119
+ * `gui-v*` / any non-kit line) AND non-prerelease, then the highest by semver.
119
120
  * Returns null when no stable kit release exists.
120
121
  */
121
122
  export function resolveLatestKitRelease(releases) {
@@ -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
  }