@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.
@@ -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';
@@ -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 `launcher-v*`
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
  }
@@ -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 { runBootstrapVersion } from './commands/bootstrap-version.js';
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 package's version from its own package.json. */
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
- if (command === undefined || command === '-h' || command === '--help' || command === 'help') {
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
- await runUpdate(parseUpdateFlags(rest));
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
- await runStatus();
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
- runDoctor();
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
- const file = rest.find((arg) => !arg.startsWith('-'));
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
- console.error(`\n✖ ${error.message}`);
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
+ }
@@ -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',
@@ -1,8 +1,16 @@
1
1
  /**
2
2
  * Non-naive `.env` parser + merger for the `factory secrets` rail (SEC-001).
3
3
  *
4
- * Merges a SOURCE `.env`-style file into a TARGET (`~/.claude/.env` in
5
- * production), idempotently and additively:
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
- const { pairs: sourcePairs, invalid } = parseSource(readEnvText(sourcePath));
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()) {