@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.
- package/dist/commands/doctor.js +88 -0
- package/dist/commands/new.js +28 -1
- package/dist/commands/provision.js +2487 -0
- package/dist/commands/secrets.js +4 -0
- package/dist/commands/status.js +19 -3
- package/dist/commands/update.js +47 -0
- package/dist/index.js +79 -23
- package/dist/lib/cloudflare-api.js +243 -0
- package/dist/lib/constants.js +11 -0
- package/dist/lib/env-merge.js +40 -3
- package/dist/lib/neon-api.js +342 -0
- package/dist/lib/preflight.js +199 -4
- package/dist/lib/provision-pointer.js +32 -0
- package/dist/lib/provision-preflight.js +342 -0
- package/dist/lib/provision-state.js +385 -0
- package/dist/lib/unpack.js +10 -9
- package/dist/lib/validate-name.js +7 -2
- package/dist/lib/vercel-env.js +490 -0
- package/dist/tui/index.js +8 -0
- package/dist/tui/menu.js +135 -0
- package/dist/tui/provision-flow.js +166 -0
- package/package.json +2 -2
- package/dist/commands/bootstrap-version.js +0 -43
- package/dist/commands/launcher-path.js +0 -31
- package/dist/launcher.applescript +0 -239
|
@@ -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
|
+
}
|
package/dist/lib/unpack.js
CHANGED
|
@@ -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
|
|
27
|
-
// release line (`
|
|
28
|
-
// can point at a launcher release. Filtering to `v*`
|
|
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 (`
|
|
108
|
-
* not GitHub Releases — the CLI line (`cli-v*`);
|
|
109
|
-
* so this predicate isolates the kit from every
|
|
110
|
-
* Without it, `gh`'s repo-wide "latest" / a
|
|
111
|
-
* mistaken for the latest kit version and break
|
|
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
|
-
* `
|
|
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
|
}
|