@timekast/factory 1.12.0 → 1.13.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.
- package/dist/commands/doctor.js +83 -0
- package/dist/commands/new.js +28 -1
- package/dist/commands/provision.js +2469 -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 +139 -4
- package/dist/lib/provision-pointer.js +32 -0
- package/dist/lib/provision-preflight.js +342 -0
- package/dist/lib/provision-state.js +384 -0
- package/dist/lib/unpack.js +10 -9
- 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
package/dist/commands/secrets.js
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
*
|
|
16
16
|
* The org gate is `runPreflight()` from `lib/preflight.ts` — the public API.
|
|
17
17
|
* `preflight()` / `assertOrgMembership` are NOT public and are not called here.
|
|
18
|
+
*
|
|
19
|
+
* NOTE: the device-flow `secrets pull` + admin `secrets sync` (the org secrets
|
|
20
|
+
* gate, SECGATE-*) were REMOVED — that gate/droplet effort is parked. The interim
|
|
21
|
+
* model is this shared-file import; the future is Infisical (a fresh branch).
|
|
18
22
|
*/
|
|
19
23
|
import { homedir } from 'node:os';
|
|
20
24
|
import path from 'node:path';
|
package/dist/commands/status.js
CHANGED
|
@@ -26,6 +26,12 @@ import { isKitReleaseTag } from '../lib/unpack.js';
|
|
|
26
26
|
* the tests assert on the exact rendered text (no magic string in the spec).
|
|
27
27
|
*/
|
|
28
28
|
export const BETA_BANNER = '⚠️ cerebro BETA — corre `factory update --stable` para salir del canal';
|
|
29
|
+
/** Parse `status`'s argv slice into flags. Unknown flags are ignored. */
|
|
30
|
+
export function parseStatusFlags(argv) {
|
|
31
|
+
return {
|
|
32
|
+
json: argv.includes('--json'),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
29
35
|
/**
|
|
30
36
|
* Query the Factory repo for the latest published release, IGNORING prereleases
|
|
31
37
|
* (`-rc`, `-test`, etc.) so a release candidate is never reported as latest.
|
|
@@ -48,8 +54,8 @@ async function fetchLatestRelease() {
|
|
|
48
54
|
'30',
|
|
49
55
|
]);
|
|
50
56
|
const releases = JSON.parse(stdout);
|
|
51
|
-
// Only consider the KIT release line (`v*`): the repo also hosts `
|
|
52
|
-
// releases, which must never be mistaken for the latest kit version.
|
|
57
|
+
// Only consider the KIT release line (`v*`): the repo also hosts `gui-v*`
|
|
58
|
+
// launcher releases, which must never be mistaken for the latest kit version.
|
|
53
59
|
const stable = releases.find((r) => !r.isPrerelease && isKitReleaseTag(r.tagName));
|
|
54
60
|
if (!stable)
|
|
55
61
|
return null;
|
|
@@ -151,8 +157,18 @@ function renderStatus(result) {
|
|
|
151
157
|
* Run `factory status`. Always exits 0 — a missing lockfile and a network error
|
|
152
158
|
* are advisory states, not failures (DIST-006 §8). Only an unexpected
|
|
153
159
|
* programming bug would surface through the top-level handler.
|
|
160
|
+
*
|
|
161
|
+
* In `--json` mode (TGUI-003) the `StatusResult` is written as a single JSON
|
|
162
|
+
* object to stdout and NOTHING else: the human renderer (`console.log`) is
|
|
163
|
+
* suppressed so the GUI can `JSON.parse` stdout directly. Fail-open is unchanged
|
|
164
|
+
* — `computeStatus` already maps a missing lockfile / network error to flags
|
|
165
|
+
* instead of throwing, so the JSON path also exits 0 in those cases.
|
|
154
166
|
*/
|
|
155
|
-
export async function runStatus(deps = {}) {
|
|
167
|
+
export async function runStatus(deps = {}, flags = { json: false }) {
|
|
156
168
|
const result = await computeStatus(deps);
|
|
169
|
+
if (flags.json) {
|
|
170
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
157
173
|
renderStatus(result);
|
|
158
174
|
}
|
package/dist/commands/update.js
CHANGED
|
@@ -47,6 +47,8 @@ import { extractManagedBlock, syncManagedBlock } from '../lib/gitattributes.js';
|
|
|
47
47
|
import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLockfile, writeLockfile, } from '../lib/lockfile.js';
|
|
48
48
|
import { detectProfile, insertFactoryUpdateScript, setAgentKitVersion, } from '../lib/package-json.js';
|
|
49
49
|
import { runPreflight } from '../lib/preflight.js';
|
|
50
|
+
import { printProvisionPointer } from '../lib/provision-pointer.js';
|
|
51
|
+
import { hasProvisionState } from '../lib/provision-state.js';
|
|
50
52
|
import { detectRepo } from '../lib/repo-detection.js';
|
|
51
53
|
import { downloadBetaProfileTarball, downloadProfileTarball, stageProfileTarball, } from '../lib/unpack.js';
|
|
52
54
|
/** Parse `update`'s argv slice into flags. Unknown flags are ignored here. */
|
|
@@ -263,6 +265,10 @@ async function runResume(rootDir, flags) {
|
|
|
263
265
|
await maybeCommitBrain(rootDir, newLock.version, [...state.plan.writes, ...state.plan.deletes], flags);
|
|
264
266
|
rmSync(state.stagedDir, { recursive: true, force: true });
|
|
265
267
|
console.log('✔ `update` retomado y completado.');
|
|
268
|
+
// A resumed update that succeeds completed the setup → show the end-pointer
|
|
269
|
+
// (PVSKILL-002 §8 edge: `--resume` SÍ lo muestra). Suppressed only in headless
|
|
270
|
+
// `--yes` (CI/launcher noise).
|
|
271
|
+
printProvisionPointer({ assumeYes: flags.yes });
|
|
266
272
|
}
|
|
267
273
|
/**
|
|
268
274
|
* Stamp the birth seal onto the new manifest before it becomes the lockfile.
|
|
@@ -407,6 +413,41 @@ function reportSummary(diff, manifest) {
|
|
|
407
413
|
`${diff.overwriteSilent.length} actualizados${unchanged}, ` +
|
|
408
414
|
`${diff.deleteSilent.length} retirados${kept}, ${diff.conflicts.length} conflictos.`);
|
|
409
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* After a successful update, OFFER to run `factory provision --adopt` when the
|
|
418
|
+
* derived project has no `.timekast/provision.json` (PROV-007 AC). A pre-CLI
|
|
419
|
+
* derivative (provisioned before the adopt path existed) needs the backfill so
|
|
420
|
+
* `--destroy`/`--resume`/the TUI can operate on it.
|
|
421
|
+
*
|
|
422
|
+
* Interactive-only (issue §8 edge): it ONLY prompts on a TTY and NEVER when
|
|
423
|
+
* `--yes` was passed (the GUI/headless launcher). It does NOT run the adopt
|
|
424
|
+
* itself — it just points the dev at the command (the adopt needs `--team` + the
|
|
425
|
+
* gate tokens the update flow does not carry). Headless / `--yes` → no-op.
|
|
426
|
+
*
|
|
427
|
+
* `isTTY`/`hasState` are injectable so the unit test drives every branch without
|
|
428
|
+
* a real terminal or a real `.timekast/` dir.
|
|
429
|
+
*/
|
|
430
|
+
export async function maybeOfferAdopt(rootDir, flags, opts = {}) {
|
|
431
|
+
const isTTY = opts.isTTY ?? Boolean(process.stdout.isTTY);
|
|
432
|
+
const hasState = opts.hasState ?? hasProvisionState(rootDir);
|
|
433
|
+
// Only offer interactively (a TTY) and never under `--yes` (GUI/headless): the
|
|
434
|
+
// adopt is opt-in and needs flags the update flow does not have.
|
|
435
|
+
if (hasState || flags.yes || !isTTY)
|
|
436
|
+
return;
|
|
437
|
+
const ask = opts.prompt ?? prompts;
|
|
438
|
+
// A cancelled prompt (Esc) returns an empty object → null-safe read (no-op).
|
|
439
|
+
const answer = (await ask({
|
|
440
|
+
type: 'confirm',
|
|
441
|
+
name: 'adopt',
|
|
442
|
+
message: 'Este proyecto no tiene `.timekast/provision.json` (registro de infra). ' +
|
|
443
|
+
'¿Reconstruirlo ahora con `factory provision --adopt`? (read-only — no toca ningún recurso)',
|
|
444
|
+
initial: false,
|
|
445
|
+
}));
|
|
446
|
+
if (answer?.adopt === true) {
|
|
447
|
+
console.log('\nCorre: `factory provision --adopt --team=<teamId>`\n' +
|
|
448
|
+
' (asegúrate de tener los tokens en `~/.claude/.env`; impórtalos con `factory secrets <archivo>` primero).');
|
|
449
|
+
}
|
|
450
|
+
}
|
|
410
451
|
/**
|
|
411
452
|
* Run `factory update`. Orchestrates the full flow; `deps.acquire` is the only
|
|
412
453
|
* network seam (tests inject a local fixture).
|
|
@@ -489,6 +530,8 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
489
530
|
}
|
|
490
531
|
if (legacy) {
|
|
491
532
|
await applyLegacy(rootDir, stagedDir, manifest);
|
|
533
|
+
// Legacy auto-register succeeded → end-pointer (PVSKILL-002).
|
|
534
|
+
printProvisionPointer({ assumeYes: flags.yes });
|
|
492
535
|
return;
|
|
493
536
|
}
|
|
494
537
|
// Hash only the union of tracked paths (old lock ∪ new manifest). Files
|
|
@@ -531,6 +574,10 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
531
574
|
maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
|
|
532
575
|
await maybeCommitBrain(rootDir, manifest.version, [...plan.writes, ...plan.deletes], flags);
|
|
533
576
|
reportSummary(diff, manifest);
|
|
577
|
+
// End-pointer to the next step (PVSKILL-002). Reached only after a clean apply;
|
|
578
|
+
// `--verify`/`--resume` short-circuit above (verify NEVER prints), an error path
|
|
579
|
+
// throws earlier, and headless `--yes` is suppressed (CI noise).
|
|
580
|
+
printProvisionPointer({ assumeYes: flags.yes });
|
|
534
581
|
}
|
|
535
582
|
finally {
|
|
536
583
|
// Keep the staged tarball if an update-state survives (a post-apply failure —
|
package/dist/index.js
CHANGED
|
@@ -11,15 +11,16 @@ 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 {
|
|
15
|
-
import { runDoctor } from './commands/doctor.js';
|
|
16
|
-
import { runLauncherPath } from './commands/launcher-path.js';
|
|
14
|
+
import { parseDoctorFlags, runDoctor, runLauncherDoctor } from './commands/doctor.js';
|
|
17
15
|
import { parseNewFlags, runNew } from './commands/new.js';
|
|
16
|
+
import { parseProvisionFlags, runProvision } from './commands/provision.js';
|
|
18
17
|
import { parsePublishArgs, runPublish } from './commands/publish.js';
|
|
19
18
|
import { secretsCommand } from './commands/secrets.js';
|
|
20
|
-
import { runStatus } from './commands/status.js';
|
|
19
|
+
import { parseStatusFlags, runStatus } from './commands/status.js';
|
|
21
20
|
import { parseUnpublishTargets, runUnpublish } from './commands/unpublish.js';
|
|
22
|
-
import { parseUpdateFlags, runUpdate } from './commands/update.js';
|
|
21
|
+
import { maybeOfferAdopt, parseUpdateFlags, runUpdate } from './commands/update.js';
|
|
22
|
+
import { detectRepo } from './lib/repo-detection.js';
|
|
23
|
+
import { runTuiMenu } from './tui/index.js';
|
|
23
24
|
const HELP = `
|
|
24
25
|
@timekast/factory — bootstrap y mantenimiento de proyectos derivados del Factory.
|
|
25
26
|
|
|
@@ -33,11 +34,11 @@ Comandos:
|
|
|
33
34
|
beta Atajo de \`update --beta\`: instala el cerebro del canal beta
|
|
34
35
|
status Reporta la versión instalada vs. la última disponible
|
|
35
36
|
doctor Detecta huérfanos, conflictos y rules sin importar + aviso de seguridad
|
|
37
|
+
(--launcher: verifica el entorno — Node ≥ 22, gh, gh auth, membresía org)
|
|
36
38
|
publish <qué> Publica el entregable (proposal|mockup|both) a proposals.timekast.mx
|
|
37
39
|
unpublish [qué] Baja del hub un entregable publicado (proposal|mockup|both; default both)
|
|
40
|
+
provision Aprovisiona la infra de un derivado (Neon/Vercel/GitHub/DNS) — flag-driven, --json
|
|
38
41
|
secrets <file> Mergea un archivo KEY=value de secretos de metodología a ~/.claude/.env (chmod 600)
|
|
39
|
-
launcher-path (interno) Imprime el path del launcher.applescript empacado (lo usa el bootstrap)
|
|
40
|
-
bootstrap-version (interno) Imprime/compara la versión del bootstrap (\`--have <ver>\` → ok|outdated)
|
|
41
42
|
|
|
42
43
|
Sobre \`new\`:
|
|
43
44
|
Pregunta el perfil de forma interactiva. Los flags \`--full\` / \`--core\` lo
|
|
@@ -70,17 +71,45 @@ Sobre \`update\`:
|
|
|
70
71
|
Un \`update\` sin \`--beta\` sobre un lockfile beta pide confirmación (y en headless
|
|
71
72
|
rechaza, sin degradar en silencio).
|
|
72
73
|
|
|
74
|
+
Sobre \`provision\`:
|
|
75
|
+
Aprovisiona la infra de un proyecto derivado. Flag-driven (sin prompts) y
|
|
76
|
+
headless-safe; la interacción la pone la skill \`tk-provision\`. Preflight
|
|
77
|
+
fail-closed (gh + tokens del gate + \`VERCEL_TOKEN\` con acceso al team);
|
|
78
|
+
\`neonctl\` es opcional (ausente → warning). Flags:
|
|
79
|
+
--services=a,b Servicios a crear (neon,vercel,github,dns)
|
|
80
|
+
--slug=<slug> Identificador del stack (nombre del proyecto + dominios interinos)
|
|
81
|
+
--team=<id> Team de Vercel (override; default = \`VERCEL_TEAM_ID\` del rail ~/.claude/.env)
|
|
82
|
+
--org-id=<id> Org de GitHub (override; default = org del Factory)
|
|
83
|
+
--domain=<dom> Dominio definitivo del cliente (diferible; standalone o inicial)
|
|
84
|
+
--dns=<ruta> Ruta DNS para --domain: org (default) | cliente-cf | registrar
|
|
85
|
+
--verify-timeout=<min> Timeout del sondeo de verificación de Vercel (default 10)
|
|
86
|
+
--confirm Pre-aprueba retirar el dominio interino tras verificar (headless-safe)
|
|
87
|
+
--ephemeral Marca el stack \`ephemeral\` (vs \`live\`) para \`--destroy\`
|
|
88
|
+
--dry-run Imprime el plan sin tocar ninguna API ni escribir estado
|
|
89
|
+
--resume Retoma desde \`.timekast/provision.json\`, salta pasos hechos
|
|
90
|
+
--adopt Reconstruye \`.timekast/provision.json\` de un derivado existente
|
|
91
|
+
consultando los providers EN SOLO-LECTURA (nunca crea/borra)
|
|
92
|
+
--destroy Destruye los recursos del derivado por lifecycle (solo sesión
|
|
93
|
+
interactiva — nunca headless). ephemeral → nuke; live → rehúsa
|
|
94
|
+
salvo --force con type-to-confirm. --dry-run lista sin borrar
|
|
95
|
+
--force Descarta el estado existente y empieza de cero (o sobreescribe en
|
|
96
|
+
--adopt; o autoriza el --destroy de un stack live con type-to-confirm)
|
|
97
|
+
--json Salida machine-readable en stdout
|
|
98
|
+
|
|
73
99
|
Sobre \`status\` y \`doctor\`:
|
|
74
100
|
Solo diagnostican — nunca modifican archivos. \`status\` lee el lockfile y
|
|
75
101
|
consulta la última versión publicada (ignorando prereleases); sin lockfile o
|
|
76
102
|
sin conexión, avisa y termina con éxito. \`doctor\` lista huérfanos y conflictos
|
|
77
103
|
pendientes y siempre imprime el aviso de seguridad de \`src/\`.
|
|
104
|
+
\`doctor --launcher\` verifica en su lugar el entorno (Node ≥ 22, gh instalado,
|
|
105
|
+
gh auth, membresía del org) — termina con código 1 si falla algún check, para
|
|
106
|
+
que un lanzador GUI / CI / el TUI puedan ramificar sobre el resultado.
|
|
78
107
|
|
|
79
108
|
Opciones:
|
|
80
109
|
-h, --help Muestra esta ayuda
|
|
81
110
|
-v, --version Muestra la versión del CLI
|
|
82
111
|
`.trim();
|
|
83
|
-
/** Read this
|
|
112
|
+
/** Read this CLI's version from its own package.json. */
|
|
84
113
|
function readVersion() {
|
|
85
114
|
try {
|
|
86
115
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -95,10 +124,17 @@ function readVersion() {
|
|
|
95
124
|
}
|
|
96
125
|
async function main(argv) {
|
|
97
126
|
const [command, ...rest] = argv;
|
|
98
|
-
|
|
127
|
+
// Explicit help flags always print the static HELP — never the TUI.
|
|
128
|
+
if (command === '-h' || command === '--help' || command === 'help') {
|
|
99
129
|
console.log(HELP);
|
|
100
130
|
return;
|
|
101
131
|
}
|
|
132
|
+
// No command at all → interactive TUI menu (LTUI-001). The menu degrades to
|
|
133
|
+
// HELP itself when there's no TTY (CI / pipe), so it never hangs.
|
|
134
|
+
if (command === undefined) {
|
|
135
|
+
await runTuiMenu({ printHelp: () => console.log(HELP) });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
102
138
|
if (command === '-v' || command === '--version' || command === 'version') {
|
|
103
139
|
console.log(readVersion());
|
|
104
140
|
return;
|
|
@@ -117,7 +153,14 @@ async function main(argv) {
|
|
|
117
153
|
return;
|
|
118
154
|
}
|
|
119
155
|
case 'update': {
|
|
120
|
-
|
|
156
|
+
const updateFlags = parseUpdateFlags(rest);
|
|
157
|
+
await runUpdate(updateFlags);
|
|
158
|
+
// PROV-007: after a successful update, OFFER the read-only `--adopt` backfill
|
|
159
|
+
// when this derivative has no provision state (interactive-only; `--yes` /
|
|
160
|
+
// headless → no-op). Lives at the dispatch layer so `runUpdate`'s prompt
|
|
161
|
+
// contract stays untouched.
|
|
162
|
+
const updateRoot = detectRepo(process.cwd()).repoRoot ?? process.cwd();
|
|
163
|
+
await maybeOfferAdopt(updateRoot, updateFlags);
|
|
121
164
|
return;
|
|
122
165
|
}
|
|
123
166
|
case 'beta': {
|
|
@@ -127,11 +170,25 @@ async function main(argv) {
|
|
|
127
170
|
return;
|
|
128
171
|
}
|
|
129
172
|
case 'status': {
|
|
130
|
-
|
|
173
|
+
// `--json` (TGUI-003): emit the StatusResult as JSON to stdout for the GUI.
|
|
174
|
+
// Without it, behavior is identical to before.
|
|
175
|
+
await runStatus({}, parseStatusFlags(rest));
|
|
131
176
|
return;
|
|
132
177
|
}
|
|
133
178
|
case 'doctor': {
|
|
134
|
-
|
|
179
|
+
const flags = parseDoctorFlags(rest);
|
|
180
|
+
if (flags.launcher) {
|
|
181
|
+
// `--json` (TGUI-003): emit the LauncherCheck[] to stdout and force exit
|
|
182
|
+
// 0 so the GUI can read it. The exit-1 contract is kept without `--json`.
|
|
183
|
+
await runLauncherDoctor({}, flags.json);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
runDoctor();
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
case 'provision': {
|
|
191
|
+
await runProvision(parseProvisionFlags(rest));
|
|
135
192
|
return;
|
|
136
193
|
}
|
|
137
194
|
case 'publish': {
|
|
@@ -143,22 +200,17 @@ async function main(argv) {
|
|
|
143
200
|
return;
|
|
144
201
|
}
|
|
145
202
|
case 'secrets': {
|
|
146
|
-
|
|
203
|
+
// Single shape: `factory secrets <file>` — merge a KEY=value file into the
|
|
204
|
+
// rail (~/.claude/.env). The org gate (`secrets pull`/`sync`) was removed;
|
|
205
|
+
// the future shared-secrets system is Infisical (a fresh branch).
|
|
206
|
+
const file = rest.filter((a) => !a.startsWith('-'))[0];
|
|
147
207
|
if (!file) {
|
|
148
208
|
throw new CLIError('Falta el archivo de secretos. Uso: `factory secrets <file>` ' +
|
|
149
|
-
'(un archivo `KEY=value`, p.ej. el master `secrets.env`)
|
|
209
|
+
'(un archivo `KEY=value`, p.ej. el master `secrets.env`) que se mergea a `~/.claude/.env`.');
|
|
150
210
|
}
|
|
151
211
|
await secretsCommand(file);
|
|
152
212
|
return;
|
|
153
213
|
}
|
|
154
|
-
case 'launcher-path': {
|
|
155
|
-
runLauncherPath();
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
case 'bootstrap-version': {
|
|
159
|
-
runBootstrapVersion(rest);
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
214
|
default:
|
|
163
215
|
throw new CLIError(`Comando desconocido: \`${command}\`.\nEjecuta \`factory --help\` para ver los comandos disponibles.`);
|
|
164
216
|
}
|
|
@@ -167,7 +219,11 @@ main(process.argv.slice(2)).then(() => {
|
|
|
167
219
|
process.exit(0);
|
|
168
220
|
}, (error) => {
|
|
169
221
|
if (error instanceof CLIError) {
|
|
170
|
-
|
|
222
|
+
// An empty message is intentional (the command already rendered its own
|
|
223
|
+
// actionable output, e.g. `doctor --launcher`'s per-check ❌ lines) — only
|
|
224
|
+
// the exit code matters; don't print a stray "✖ " line.
|
|
225
|
+
if (error.message)
|
|
226
|
+
console.error(`\n✖ ${error.message}`);
|
|
171
227
|
process.exit(error.exitCode);
|
|
172
228
|
}
|
|
173
229
|
// Unexpected error — surface it (this path is a bug, not a user error).
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare DNS REST helper for `factory provision` (PROV-005).
|
|
3
|
+
*
|
|
4
|
+
* A THIN, reusable Cloudflare client that mirrors the proven fetch shape of
|
|
5
|
+
* `vercel-env.ts` (`Authorization: Bearer ${token}` + a single central URL
|
|
6
|
+
* builder + an injectable `fetchImpl` seam). Its scope is DNS-ONLY: it resolves
|
|
7
|
+
* the `timekast.mx` zone id, looks up an existing CNAME, creates one
|
|
8
|
+
* (`proxied: false` so Vercel's own TLS terminates the domain), and PATCHes an
|
|
9
|
+
* existing record that was manually left `proxied: true` back to `false`.
|
|
10
|
+
*
|
|
11
|
+
* 🔴 Scope contract (issue §4 + the frozen plan): the token handed in here is the
|
|
12
|
+
* shared org Cloudflare token from the rail, minted SCOPED to the `timekast.mx` zone
|
|
13
|
+
* with DNS-edit only — never an account-wide token (a DNS-write leak over `timekast.mx` could
|
|
14
|
+
* repoint records, intercept MX, or mint certs). A 403 from any call therefore
|
|
15
|
+
* means "the token is out of scope for this zone" and the caller maps it to an
|
|
16
|
+
* actionable `CLIError` (issue §8 Gherkin) — it NEVER deletes a record to recover.
|
|
17
|
+
*
|
|
18
|
+
* What this module does NOT do:
|
|
19
|
+
* - Delete a DNS record. The idempotent path is create-or-PATCH; an existing
|
|
20
|
+
* record is never removed without explicit human confirmation (issue §8).
|
|
21
|
+
* - Read or log a token value. The token arrives as an argument and is handed
|
|
22
|
+
* straight to the `Authorization` header; it never reaches stdout.
|
|
23
|
+
*
|
|
24
|
+
* The live REST flow is exercised e2e by PROV-010; here every branch is driven by
|
|
25
|
+
* the unit test through the `fetchImpl` mock (issue §7).
|
|
26
|
+
*/
|
|
27
|
+
import { CLIError } from './cli-error.js';
|
|
28
|
+
/** Base URL of the Cloudflare REST API v4 (named constant — never an inline literal). */
|
|
29
|
+
export const CLOUDFLARE_API_BASE = 'https://api.cloudflare.com/client/v4';
|
|
30
|
+
/** The org DNS zone every provisioned interim domain lives under (issue §4). */
|
|
31
|
+
export const TIMEKAST_ZONE_NAME = 'timekast.mx';
|
|
32
|
+
/** CNAME target for a Vercel-hosted domain — Vercel's edge resolves it (issue §6). */
|
|
33
|
+
export const CNAME_VERCEL_TARGET = 'cname.vercel-dns.com';
|
|
34
|
+
/**
|
|
35
|
+
* Build the two interim domains for a slug (issue §4):
|
|
36
|
+
* - dev: `{slug}-dev.timekast.mx` → tracks the `develop` branch (preview)
|
|
37
|
+
* - prod: `{slug}.timekast.mx` → production
|
|
38
|
+
*
|
|
39
|
+
* Lives HERE (next to {@link TIMEKAST_ZONE_NAME}) so BOTH `provision.ts` (which
|
|
40
|
+
* creates these) and `provision-state.ts` (whose `--destroy` target re-derives the
|
|
41
|
+
* dev domain from the slug, since the committed state only records the prod one)
|
|
42
|
+
* share ONE definition — no circular import, no drift in the `-dev` convention.
|
|
43
|
+
*/
|
|
44
|
+
export function provisionDomains(slug) {
|
|
45
|
+
return {
|
|
46
|
+
dev: `${slug}-dev.${TIMEKAST_ZONE_NAME}`,
|
|
47
|
+
prod: `${slug}.${TIMEKAST_ZONE_NAME}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Timeout (ms) for every Cloudflare REST call — a dead network must not hang provision. */
|
|
51
|
+
const CLOUDFLARE_API_TIMEOUT_MS = 30_000;
|
|
52
|
+
/** A non-2xx (or `success: false`) response from the Cloudflare REST API. */
|
|
53
|
+
export class CloudflareApiError extends Error {
|
|
54
|
+
status;
|
|
55
|
+
/** The raw response body (best-effort) — useful context, never a secret. */
|
|
56
|
+
body;
|
|
57
|
+
constructor(status, body, context) {
|
|
58
|
+
super(`Cloudflare API ${status} en ${context}: ${body || '(sin cuerpo)'}`);
|
|
59
|
+
this.name = 'CloudflareApiError';
|
|
60
|
+
this.status = status;
|
|
61
|
+
this.body = body;
|
|
62
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Build the shared headers for every Cloudflare REST call (Bearer auth + JSON). */
|
|
66
|
+
function cloudflareHeaders(token) {
|
|
67
|
+
return {
|
|
68
|
+
Authorization: `Bearer ${token}`,
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Read a response body as text (best-effort) for error context — never throws. */
|
|
73
|
+
async function safeBody(response) {
|
|
74
|
+
try {
|
|
75
|
+
return (await response.text()).slice(0, 500);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Build a Cloudflare API URL. This is the ONE place a Cloudflare URL is assembled,
|
|
83
|
+
* so query params (e.g. the zone `name` filter) are merged in one consistent way.
|
|
84
|
+
*/
|
|
85
|
+
export function cloudflareUrl(pathname, query = {}) {
|
|
86
|
+
const qs = new URLSearchParams(query).toString();
|
|
87
|
+
return `${CLOUDFLARE_API_BASE}${pathname}${qs ? `?${qs}` : ''}`;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Make an authenticated Cloudflare API call. Surfaces a typed
|
|
91
|
+
* {@link CloudflareApiError} on a non-2xx HTTP status OR on a 2xx body whose
|
|
92
|
+
* `success` is `false` (Cloudflare returns 200 with `success: false` for some
|
|
93
|
+
* permission/validation failures, so the HTTP status alone is not enough).
|
|
94
|
+
*/
|
|
95
|
+
async function cloudflareApi(method, pathname, token, deps, body, query) {
|
|
96
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
97
|
+
const url = cloudflareUrl(pathname, query);
|
|
98
|
+
const init = {
|
|
99
|
+
method,
|
|
100
|
+
headers: cloudflareHeaders(token),
|
|
101
|
+
signal: AbortSignal.timeout(CLOUDFLARE_API_TIMEOUT_MS),
|
|
102
|
+
};
|
|
103
|
+
if (body !== undefined)
|
|
104
|
+
init.body = JSON.stringify(body);
|
|
105
|
+
const response = await fetchImpl(url, init);
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new CloudflareApiError(response.status, await safeBody(response), `${method} ${pathname}`);
|
|
108
|
+
}
|
|
109
|
+
let parsed;
|
|
110
|
+
try {
|
|
111
|
+
parsed = (await response.json());
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
parsed = {};
|
|
115
|
+
}
|
|
116
|
+
// Cloudflare may answer 200 with `{ success: false, errors: [...] }`.
|
|
117
|
+
if (parsed.success === false) {
|
|
118
|
+
throw new CloudflareApiError(response.status, JSON.stringify(parsed).slice(0, 500), `${method} ${pathname}`);
|
|
119
|
+
}
|
|
120
|
+
return parsed;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Resolve the zone id for {@link TIMEKAST_ZONE_NAME} via `GET /zones?name=`.
|
|
124
|
+
*
|
|
125
|
+
* The scoped shared org token can only see the `timekast.mx` zone, so the lookup
|
|
126
|
+
* returns exactly that zone (or nothing). An empty result means the zone is not
|
|
127
|
+
* visible to this token — either the token is out of scope or the zone id rotated
|
|
128
|
+
* (issue §8). Either way it is a clear, actionable failure, never a silent skip.
|
|
129
|
+
*
|
|
130
|
+
* @throws {CloudflareApiError} on any non-2xx / `success: false`.
|
|
131
|
+
* @throws {CLIError} when the zone is not found with this token (issue §8).
|
|
132
|
+
*/
|
|
133
|
+
export async function resolveZoneId(token, deps = {}) {
|
|
134
|
+
return resolveZoneIdForName(TIMEKAST_ZONE_NAME, token, deps);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Resolve the Cloudflare zone id for an ARBITRARY zone name via `GET /zones?name=`
|
|
138
|
+
* (PROV-006). The interim flow targets `timekast.mx` ({@link resolveZoneId} is the
|
|
139
|
+
* fixed-name shortcut); the client-domain flow targets the client's own apex zone,
|
|
140
|
+
* which may sit under the org token (`--dns=org`) or the client's own token
|
|
141
|
+
* (`--dns=cliente-cf`). The zone name passed here is the registrable apex derived
|
|
142
|
+
* from the client domain (`app.miapp.com` → `miapp.com`); a subdomain CNAME is
|
|
143
|
+
* created INSIDE that zone.
|
|
144
|
+
*
|
|
145
|
+
* @throws {CloudflareApiError} on any non-2xx / `success: false`.
|
|
146
|
+
* @throws {CLIError} when the zone is not visible to this token (issue §8).
|
|
147
|
+
*/
|
|
148
|
+
export async function resolveZoneIdForName(zoneName, token, deps = {}) {
|
|
149
|
+
const data = (await cloudflareApi('GET', '/zones', token, deps, undefined, {
|
|
150
|
+
name: zoneName,
|
|
151
|
+
}));
|
|
152
|
+
const zone = (data.result ?? []).find((z) => z.name === zoneName);
|
|
153
|
+
if (!zone?.id) {
|
|
154
|
+
throw new CLIError(`No se encontró la zona \`${zoneName}\` en Cloudflare con el token usado.\n` +
|
|
155
|
+
'Verifica que el token esté scopeado a esa zona (DNS-edit) y reintenta con `--resume`; ' +
|
|
156
|
+
'si usas `--dns=cliente-cf`, confirma que la API key del cliente cubre esa zona.');
|
|
157
|
+
}
|
|
158
|
+
return zone.id;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Derive the registrable apex zone from a fully-qualified domain (PROV-006). A
|
|
162
|
+
* Cloudflare zone is the apex (`miapp.com`); a CNAME for a subdomain
|
|
163
|
+
* (`app.miapp.com`) lives INSIDE that zone, so the zone lookup must use the apex,
|
|
164
|
+
* not the full host. This takes the LAST TWO labels (`a.b.miapp.com` → `miapp.com`).
|
|
165
|
+
* Multi-part public suffixes (`co.uk`) are out of scope for the interim flow; the
|
|
166
|
+
* `--dns=registrar` path sidesteps zone resolution entirely (manual records).
|
|
167
|
+
*/
|
|
168
|
+
export function apexZoneOf(domain) {
|
|
169
|
+
const labels = domain.split('.').filter(Boolean);
|
|
170
|
+
if (labels.length <= 2)
|
|
171
|
+
return labels.join('.');
|
|
172
|
+
return labels.slice(-2).join('.');
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Find an existing CNAME record for `name` in the zone (issue §8 idempotency).
|
|
176
|
+
* Returns the record when present (so the caller can detect a `proxied: true`
|
|
177
|
+
* record left by a manual edit and PATCH it) or `null` when absent.
|
|
178
|
+
*
|
|
179
|
+
* @throws {CloudflareApiError} on any non-2xx / `success: false`.
|
|
180
|
+
*/
|
|
181
|
+
export async function findCnameRecord(zoneId, name, token, deps = {}) {
|
|
182
|
+
const data = (await cloudflareApi('GET', `/zones/${encodeURIComponent(zoneId)}/dns_records`, token, deps, undefined, {
|
|
183
|
+
type: 'CNAME',
|
|
184
|
+
name,
|
|
185
|
+
}));
|
|
186
|
+
return (data.result ?? []).find((r) => r.name === name && r.type === 'CNAME') ?? null;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Create a CNAME record pointing `name` → {@link CNAME_VERCEL_TARGET} with
|
|
190
|
+
* `proxied: false` and `ttl: 1` (auto) — `POST /zones/{id}/dns_records` (issue §4).
|
|
191
|
+
* `proxied: false` is non-negotiable: a proxied record would terminate TLS at
|
|
192
|
+
* Cloudflare instead of letting Vercel issue+serve the cert for the domain.
|
|
193
|
+
*
|
|
194
|
+
* @throws {CloudflareApiError} on any non-2xx / `success: false`.
|
|
195
|
+
*/
|
|
196
|
+
export async function createCnameRecord(zoneId, name, token, deps = {}) {
|
|
197
|
+
await cloudflareApi('POST', `/zones/${encodeURIComponent(zoneId)}/dns_records`, token, deps, {
|
|
198
|
+
type: 'CNAME',
|
|
199
|
+
name,
|
|
200
|
+
content: CNAME_VERCEL_TARGET,
|
|
201
|
+
proxied: false,
|
|
202
|
+
ttl: 1,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* PATCH an existing CNAME record to `proxied: false` — `PATCH
|
|
207
|
+
* /zones/{id}/dns_records/{recordId}` (issue §8). Used when a record was left
|
|
208
|
+
* `proxied: true` by a manual edit: we flip the proxy flag in place. We NEVER
|
|
209
|
+
* delete the record to "fix" it (a destructive change requires confirmation).
|
|
210
|
+
*
|
|
211
|
+
* @throws {CloudflareApiError} on any non-2xx / `success: false`.
|
|
212
|
+
*/
|
|
213
|
+
export async function unproxyCnameRecord(zoneId, recordId, name, token, deps = {}) {
|
|
214
|
+
await cloudflareApi('PATCH', `/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(recordId)}`, token, deps, {
|
|
215
|
+
type: 'CNAME',
|
|
216
|
+
name,
|
|
217
|
+
content: CNAME_VERCEL_TARGET,
|
|
218
|
+
proxied: false,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Idempotently ensure a `proxied: false` CNAME for `name` exists in the zone
|
|
223
|
+
* (issue §4 + §8). Returns the outcome so the caller can narrate it (no-echo —
|
|
224
|
+
* a domain name is not a secret, but the token never crosses this boundary):
|
|
225
|
+
* - `created` — no record existed → POSTed a fresh `proxied: false` CNAME
|
|
226
|
+
* - `unproxied` — a `proxied: true` record existed → PATCHed it to `false`
|
|
227
|
+
* - `unchanged` — a `proxied: false` record already existed → nothing to do
|
|
228
|
+
*
|
|
229
|
+
* @throws {CloudflareApiError} on any non-2xx / `success: false` (caller maps it).
|
|
230
|
+
*/
|
|
231
|
+
export async function ensureCnameRecord(zoneId, name, token, deps = {}) {
|
|
232
|
+
const existing = await findCnameRecord(zoneId, name, token, deps);
|
|
233
|
+
if (!existing) {
|
|
234
|
+
await createCnameRecord(zoneId, name, token, deps);
|
|
235
|
+
return 'created';
|
|
236
|
+
}
|
|
237
|
+
if (existing.proxied) {
|
|
238
|
+
// A manually-edited record left proxied:true → flip it (never delete — §8).
|
|
239
|
+
await unproxyCnameRecord(zoneId, existing.id, name, token, deps);
|
|
240
|
+
return 'unproxied';
|
|
241
|
+
}
|
|
242
|
+
return 'unchanged';
|
|
243
|
+
}
|
package/dist/lib/constants.js
CHANGED
|
@@ -7,10 +7,21 @@
|
|
|
7
7
|
export const FACTORY_ORG = 'TimeKast';
|
|
8
8
|
/** The Factory monorepo, source of the distribution releases. */
|
|
9
9
|
export const FACTORY_REPO = 'TimeKast/TimeKast-Factory';
|
|
10
|
+
// NOTE: the org secrets gate constants (SECRETS_GATE_URL, GITHUB_DEVICE_CLIENT_ID,
|
|
11
|
+
// GITHUB_DEVICE_SCOPE, SECRETS_STATE_FILE) were REMOVED with the `secrets pull`/`sync`
|
|
12
|
+
// device-flow gate — that droplet effort is parked; the future is Infisical.
|
|
10
13
|
/** The static hub repo that hosts published proposals/mockups (`factory publish`). */
|
|
11
14
|
export const PROPOSALS_HUB_REPO = `${FACTORY_ORG}/proposals`;
|
|
12
15
|
/** HTTPS git URL of the hub (token is injected by the publish command). */
|
|
13
16
|
export const PROPOSALS_HUB_GIT = `https://github.com/${FACTORY_ORG}/proposals.git`;
|
|
17
|
+
/**
|
|
18
|
+
* End-pointer printed at the tail of a SUCCESSFUL `new` / `update` flow (PVSKILL-002):
|
|
19
|
+
* the next step a dev takes to finish setting up the environment. Shared by both
|
|
20
|
+
* commands so the copy is a single named constant, never an inline literal (CODING.md §5).
|
|
21
|
+
* Printed only on success (exit 0, after the last step) and gated against CI noise —
|
|
22
|
+
* see `printProvisionPointer` in each command.
|
|
23
|
+
*/
|
|
24
|
+
export const PROVISION_POINTER = 'Para terminar el setup, pide al agente /provision.';
|
|
14
25
|
/** Distribution profiles selectable in `new`. */
|
|
15
26
|
export const PROFILES = {
|
|
16
27
|
full: 'full',
|
package/dist/lib/env-merge.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Non-naive `.env` parser + merger for the `factory secrets` rail (SEC-001).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Two entry points, one engine:
|
|
5
|
+
* - `mergeEnvFile(sourcePath, target)` — merge a `.env` FILE on disk (the local
|
|
6
|
+
* master of `factory secrets <file>`).
|
|
7
|
+
* - `mergeEnvText(text, target)` — merge an in-memory `KEY=value` BLOB (a
|
|
8
|
+
* caller that already holds the text can merge without writing a temp file).
|
|
9
|
+
* `mergeEnvFile` reads its file and delegates to `mergeEnvText`; the merge logic
|
|
10
|
+
* below is written once.
|
|
11
|
+
*
|
|
12
|
+
* Merges a SOURCE `.env` blob into a TARGET (`~/.claude/.env` in production),
|
|
13
|
+
* idempotently and additively:
|
|
6
14
|
* - keys in source that are absent in target → ADDED
|
|
7
15
|
* - keys in source that already exist with a different value → UPDATED
|
|
8
16
|
* - keys in source whose value already matches target → SKIPPED (no churn)
|
|
@@ -131,6 +139,11 @@ function parseSource(text) {
|
|
|
131
139
|
* couple of small reads/writes, and sync keeps the chmod-before-write ordering
|
|
132
140
|
* race-free.
|
|
133
141
|
*
|
|
142
|
+
* Thin wrapper over {@link mergeEnvText}: reads + validates the SOURCE FILE, then
|
|
143
|
+
* delegates the merge to the text-level engine. `factory secrets <file>` (a local
|
|
144
|
+
* master on disk) uses this; a caller that already holds the text in memory uses
|
|
145
|
+
* `mergeEnvText` directly.
|
|
146
|
+
*
|
|
134
147
|
* @throws when the source is missing/unreadable, or either file is not UTF-8.
|
|
135
148
|
*/
|
|
136
149
|
export async function mergeEnvFile(sourcePath, targetPath) {
|
|
@@ -139,7 +152,31 @@ export async function mergeEnvFile(sourcePath, targetPath) {
|
|
|
139
152
|
if (!existsSync(sourcePath)) {
|
|
140
153
|
throw new Error(`No encontré el archivo fuente \`${sourcePath}\`.`);
|
|
141
154
|
}
|
|
142
|
-
|
|
155
|
+
// The only difference from a `pull` is WHERE the source text comes from (a file
|
|
156
|
+
// here, an HTTP body there). Once read, the merge is identical — so read here
|
|
157
|
+
// and hand the text to the shared engine. No reimplementation of the merge.
|
|
158
|
+
return mergeEnvText(readEnvText(sourcePath), targetPath);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Merge SOURCE TEXT (a `KEY=value` blob already in memory) into `targetPath`.
|
|
162
|
+
*
|
|
163
|
+
* This is the engine `mergeEnvFile` delegates to, kept as a separate text-level
|
|
164
|
+
* entry point so a caller that already holds the blob in memory can merge it
|
|
165
|
+
* straight in, so a secret value NEVER lands in a temp file or any intermediate
|
|
166
|
+
* path on the way to `~/.claude/.env`.
|
|
167
|
+
*
|
|
168
|
+
* Same contract as `mergeEnvFile` for everything downstream of the source read:
|
|
169
|
+
* additive/idempotent merge, first-`=` split, quoted values preserved, comments +
|
|
170
|
+
* untouched target keys preserved, trailing newline preserved, target created
|
|
171
|
+
* `0600` if absent and re-asserted `0600` if it already existed (Edge case §8 —
|
|
172
|
+
* a `0644` file edited by hand is corrected before any secret is written).
|
|
173
|
+
*
|
|
174
|
+
* @throws when the target is a symlink or not UTF-8. (The source is in-memory
|
|
175
|
+
* text, so there is no "source missing" failure mode here — the caller owns
|
|
176
|
+
* validating that the HTTP body is well-formed before calling.)
|
|
177
|
+
*/
|
|
178
|
+
export async function mergeEnvText(text, targetPath) {
|
|
179
|
+
const { pairs: sourcePairs, invalid } = parseSource(text);
|
|
143
180
|
// Reject a symlink target explicitly (SEC-001 §8): writing through a symlink
|
|
144
181
|
// could redirect secrets outside `~/.claude/`. We refuse rather than follow.
|
|
145
182
|
if (existsSync(targetPath) && lstatSync(targetPath).isSymbolicLink()) {
|