@timekast/factory 1.22.0 → 1.23.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.
@@ -36,11 +36,11 @@ import { applyPlan, defaultBackupDir, validateStagedManifest } from '../lib/atom
36
36
  import { CLIError } from '../lib/cli-error.js';
37
37
  import { CLAUDE_MD_FILE, PROFILES } from '../lib/constants.js';
38
38
  import { hasLockfile, writeInitialLockfile } from '../lib/lockfile.js';
39
- import { detectProfile, emptyShippedE2EPort, insertFactoryUpdateScript, } from '../lib/package-json.js';
39
+ import { detectProfile, maintainKitScripts } from '../lib/package-json.js';
40
40
  import { runPreflight } from '../lib/preflight.js';
41
41
  import { detectRepo } from '../lib/repo-detection.js';
42
42
  import { downloadProfileTarball, stageProfileTarball } from '../lib/unpack.js';
43
- import { runUpdate } from './update.js';
43
+ import { reportPkgMutations, runUpdate } from './update.js';
44
44
  /** Message shown when `add` runs outside any git repo context. */
45
45
  const NO_REPO_MESSAGE = 'No hay repositorio git aquí. Ejecuta `git init` primero, o usa `factory new` para crear un proyecto desde cero.';
46
46
  /** Parse `add`'s argv slice into flags. Unknown flags are ignored here. */
@@ -160,19 +160,38 @@ export async function runAdd(flags = { core: false, full: false }) {
160
160
  // installed). We NEVER create a package.json in a non-Node repo.
161
161
  const pkgPath = path.join(root, 'package.json');
162
162
  let scriptAction = 'none';
163
+ // What the maintenance actually MOVED in this repo's package.json, reported below with the very
164
+ // function `update` reports from. Empty until proven otherwise, so a repo without package.json
165
+ // (or with an invalid one) says nothing rather than claiming a no-op.
166
+ let pkgMutations = { inserted: [], retired: [], e2ePortEmptied: false };
163
167
  if (existsSync(pkgPath)) {
164
168
  try {
165
- scriptAction = insertFactoryUpdateScript(pkgPath).action;
166
- // Same maintenance `update` does: a repo that adopts the brain now gets the shipped
167
- // `ports.e2e: 3006` emptied here, instead of waiting for a later `factory update`.
168
- // Conditional — a port the dev chose is never touched.
169
- emptyShippedE2EPort(pkgPath);
169
+ // The SAME maintenance `update` does, and now literally the same function
170
+ // (`maintainKitScripts`) rather than a hand-copied subset of it. The subset was the bug:
171
+ // this call site inserted the aliases and emptied the port but never retired anything, so
172
+ // a legacy repo adopting the brain while `scripts/tools/e2e-direct.ts` was still on disk
173
+ // got `test:e2e:direct` / `test:e2e:ui` — the two runner bypasses — WRITTEN by the tool
174
+ // that exists to retire them.
175
+ const maintenance = maintainKitScripts(pkgPath);
176
+ scriptAction = maintenance.insert.action;
177
+ // 🔴 The other half of that same defect: this call site read `insert.action` and threw the
178
+ // rest away, so the removals happened in silence. `removeRetiredScripts` is explicit about
179
+ // why that is not acceptable — "a dev who used one of these daily must be told it went, and
180
+ // by name" — and `update` already tells them. `add` does now too, with the same sentences.
181
+ pkgMutations = {
182
+ // Deliberately not `maintenance.inserted`: on a first install the alias is NEW, not "put
183
+ // back", and this command announces it in its own words three lines below.
184
+ inserted: [],
185
+ retired: maintenance.retired,
186
+ e2ePortEmptied: maintenance.portAction === 'emptied',
187
+ };
170
188
  }
171
189
  catch {
172
190
  scriptAction = 'none'; // invalid package.json — skip the alias, brain still installed
173
191
  }
174
192
  }
175
193
  console.log(`\n✔ Cerebro \`${profile}\` v${version} instalado. Lockfile escrito en \`.timekast/lockfile.json\`.`);
194
+ reportPkgMutations(pkgMutations);
176
195
  if (scriptAction === 'added' || scriptAction === 'already-correct') {
177
196
  console.log('Para actualizar: `pnpm factory:update` (alias de `npx @timekast/factory update`).');
178
197
  }
@@ -25,7 +25,7 @@ import { execa } from 'execa';
25
25
  import prompts from 'prompts';
26
26
  import { FACTORY_ORG } from '../lib/constants.js';
27
27
  import { CLIError } from '../lib/cli-error.js';
28
- import { apexZoneOf, CloudflareApiError, CLOUDFLARE_API_BASE, deleteDnsRecord, ensureCnameRecord, ensureDnsRecord, findCnameRecord, resolveZoneId, resolveZoneIdForName, CNAME_VERCEL_TARGET, TIMEKAST_ZONE_NAME, provisionDomains, } from '../lib/cloudflare-api.js';
28
+ import { apexZoneOf, CloudflareApiError, CLOUDFLARE_API_BASE, deleteDnsRecord, ensureCnameRecord, ensureDnsRecord, findCnameRecord, resolveZoneId, resolveZoneIdForName, resolveZoneIdForDomain, CNAME_VERCEL_TARGET, TIMEKAST_ZONE_NAME, provisionDomains, existingProvisionDomains, ORG_ZONE_NAMES, } from '../lib/cloudflare-api.js';
29
29
  import { createDevelopBranch, createNeonProject, createProjectApiKey, deleteNeonApiKeysByName, deleteNeonProject, getConnectionUri, listNeonBranches, listNeonProjects, NeonApiError, NEON_API_BASE, NEON_DEVELOP_BRANCH, } from '../lib/neon-api.js';
30
30
  import { createBacklogProject, deleteBacklogProject, mintBacklogKey, BacklogApiError, } from '../lib/backlog-api.js';
31
31
  import { defaultEnvPath, readEnvKeyNames, readVercelToken, runProvisionPreflight, } from '../lib/provision-preflight.js';
@@ -885,7 +885,10 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
885
885
  // re-points the PRODUCTION WEBAUTHN_RP_ID too (`updateDomainEnv`, BOOT-004).
886
886
  // MFA_ENCRYPTION_KEY is minted independently per env (base64url) so a derivative
887
887
  // with MFA enabled never boots broken (P-1).
888
- const rpDomains = provisionDomains(state.projectName);
888
+ // From the state when it already has one (DOM-001): re-deriving the host of an
889
+ // existing project would move its rpID to another zone, and a passkey dies with
890
+ // its rpID. A first provision has no `dns` yet → falls back to the current zone.
891
+ const rpDomains = existingProvisionDomains(state.projectName, state.dns);
889
892
  const plan = buildEnvPlan({
890
893
  mainDatabaseUrl: neonSecrets.mainPooled,
891
894
  developDatabaseUrl: neonSecrets.developPooled,
@@ -1931,17 +1934,20 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
1931
1934
  }
1932
1935
  else {
1933
1936
  const cfToken = await resolveDnsToken(route, envPath, deps.headless ?? false);
1934
- const zoneName = apexZoneOf(domain);
1935
1937
  const cloudflareDeps = { fetchImpl };
1936
1938
  let zoneId;
1937
1939
  try {
1938
- zoneId = await resolveZoneIdForName(zoneName, cfToken, cloudflareDeps);
1940
+ // The client's domain, whose shape we do NOT control: `resolveZoneIdForDomain`
1941
+ // asks Cloudflare which candidate is a real zone instead of assuming the last
1942
+ // two labels (DOM-002) — `app.miapp.com.mx` belongs to `miapp.com.mx`, and
1943
+ // guessing `com.mx` used to fail the whole step.
1944
+ zoneId = await resolveZoneIdForDomain(domain, cfToken, cloudflareDeps);
1939
1945
  }
1940
1946
  catch (error) {
1941
1947
  if (error instanceof CloudflareApiError) {
1942
- throw cloudflareErrorToCliError(error, `resolver la zona \`${zoneName}\``);
1948
+ throw cloudflareErrorToCliError(error, `resolver la zona de \`${domain}\``);
1943
1949
  }
1944
- throw error; // resolveZoneIdForName's own CLIError (zone not found) propagates.
1950
+ throw error; // resolveZoneIdForDomain's own CLIError (no candidate) propagates.
1945
1951
  }
1946
1952
  resendDns = { zoneId, token: cfToken };
1947
1953
  try {
@@ -2012,7 +2018,10 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
2012
2018
  * detach failed (the interim is still live — its CNAME must keep resolving).
2013
2019
  */
2014
2020
  async function retireInterimDomain(projectId, token, teamId, state, deps, fetchImpl) {
2015
- const interim = provisionDomains(state.projectName).prod;
2021
+ // From the STATE, not from the slug: a project provisioned before DOM-001 lives on
2022
+ // `timekast.mx`, and retiring a `.com` host it never had would report success while
2023
+ // the real interim stayed live.
2024
+ const interim = existingProvisionDomains(state.projectName, state.dns).prod;
2016
2025
  let approved = deps.preConfirmed === true;
2017
2026
  if (!approved) {
2018
2027
  if (deps.headless) {
@@ -2044,10 +2053,17 @@ async function retireInterimDomain(projectId, token, teamId, state, deps, fetchI
2044
2053
  await deleteInterimCname(interim, deps.envPath ?? defaultEnvPath(), fetchImpl);
2045
2054
  }
2046
2055
  /**
2047
- * Delete the interim CNAME (`{slug}.timekast.mx`) from the org Cloudflare zone
2056
+ * Delete the interim CNAME (`{slug}.<org zone>`) from the org Cloudflare zone
2048
2057
  * after Vercel detached the domain (BOOT-007). Without this, the record stays
2049
2058
  * orphaned: it keeps resolving to `cname.vercel-dns.com` while no Vercel project
2050
- * answers for the host → a 404 forever, dead weight in the `timekast.mx` zone.
2059
+ * answers for the host → a 404 forever, dead weight in the org zone.
2060
+ *
2061
+ * 🔴 The zone is derived from the domain being retired ({@link apexZoneOf}), NOT
2062
+ * from {@link TIMEKAST_ZONE_NAME} (DOM-001). A project provisioned before that
2063
+ * change lives on `timekast.mx`, which stays live; resolving the fixed CURRENT
2064
+ * zone would look the record up in `timekast.com`, not find it there, and report a
2065
+ * clean retire while the real record stayed orphaned in `.mx` forever. The domain
2066
+ * arrives from the provision state, so it is always the zone the record is in.
2051
2067
  *
2052
2068
  * Runs ONLY inside the already-confirmed retire block (a declined prompt never
2053
2069
  * reaches here). Best-effort by design (issue §4 + §8): EVERY failure — missing
@@ -2060,9 +2076,10 @@ async function retireInterimDomain(projectId, token, teamId, state, deps, fetchI
2060
2076
  * touched: only the prod interim name is looked up.
2061
2077
  */
2062
2078
  async function deleteInterimCname(interim, envPath, fetchImpl) {
2079
+ const zoneName = apexZoneOf(interim);
2063
2080
  const warnRetryable = (reason) => {
2064
2081
  console.warn(`⚠ No se pudo borrar el CNAME interino \`${interim}\` en Cloudflare (${reason}).\n` +
2065
- ` El registro queda en la zona \`${TIMEKAST_ZONE_NAME}\`; bórralo a mano o reintenta. El paso NO falla por esto.`);
2082
+ ` El registro queda en la zona \`${zoneName}\`; bórralo a mano o reintenta. El paso NO falla por esto.`);
2066
2083
  };
2067
2084
  const cloudflareToken = readCloudflareApiKey(envPath);
2068
2085
  if (!cloudflareToken) {
@@ -2071,7 +2088,7 @@ async function deleteInterimCname(interim, envPath, fetchImpl) {
2071
2088
  }
2072
2089
  const cloudflareDeps = { fetchImpl };
2073
2090
  try {
2074
- const zoneId = await resolveZoneId(cloudflareToken, cloudflareDeps);
2091
+ const zoneId = await resolveZoneIdForName(zoneName, cloudflareToken, cloudflareDeps);
2075
2092
  const record = await findCnameRecord(zoneId, interim, cloudflareToken, cloudflareDeps);
2076
2093
  // Same outcome narration as the Vercel retire above (no-echo — only the
2077
2094
  // domain NAME prints; the token never crosses this boundary).
@@ -2330,10 +2347,21 @@ async function adoptGithub(slug, deps) {
2330
2347
  }
2331
2348
  }
2332
2349
  /**
2333
- * Reconstruct the interim domains' DNS presence (read-only). The domain NAMES are
2334
- * always derivable from the slug (never `unknown`); this only CONFIRMS the CNAMEs
2335
- * exist (a missing/unauthorized Cloudflare token logs a notice and continues — the
2336
- * domains are still recorded by name). Returns the `{ dev, prod }` names regardless.
2350
+ * Reconstruct the interim domains (read-only) — DISCOVERED, not assumed (DOM-002).
2351
+ *
2352
+ * `--adopt` is the rescue path: the stack exists but its provision state is gone,
2353
+ * so this rebuilds it by asking each provider. For domains it used to SKIP the
2354
+ * asking and derive the names from the slug, which was harmless while one org zone
2355
+ * existed. It stopped being harmless when the org moved to `timekast.com` and
2356
+ * `timekast.mx` stayed live: adopting a project born on `.mx` would have written a
2357
+ * `.com` host it never had — and since every other path (retire, destroy, the
2358
+ * WebAuthn rpID) now reads the state, the wrong host would enter poisoned at the
2359
+ * source, through the very door DOM-001 closed from the other side.
2360
+ *
2361
+ * So it probes the org zones in order and keeps the one whose CNAMEs actually
2362
+ * exist. Deriving from the slug survives only as the LAST resort — no token, no
2363
+ * zone reachable, or a project whose CNAMEs are genuinely absent — where naming a
2364
+ * domain by convention is all anyone can do, and the narration says so.
2337
2365
  */
2338
2366
  async function adoptDns(slug, deps) {
2339
2367
  const domains = provisionDomains(slug);
@@ -2343,20 +2371,43 @@ async function adoptDns(slug, deps) {
2343
2371
  'confirmar sus CNAMEs (es read-only; corre `factory secrets <archivo>` si quieres la verificación).');
2344
2372
  return domains;
2345
2373
  }
2346
- try {
2347
- const cfDeps = { fetchImpl: deps.fetchImpl };
2348
- const zoneId = await resolveZoneId(cfToken, cfDeps);
2349
- for (const name of [domains.dev, domains.prod]) {
2350
- const record = await findCnameRecord(zoneId, name, cfToken, cfDeps);
2351
- console.log(record
2352
- ? ` ✔ CNAME ${name} encontrado (${record.content})`
2353
- : ` · CNAME ${name} no encontrado (lo registro por nombre igual)`);
2374
+ const cfDeps = { fetchImpl: deps.fetchImpl };
2375
+ for (const zone of ORG_ZONE_NAMES) {
2376
+ const candidate = { dev: `${slug}-dev.${zone}`, prod: `${slug}.${zone}` };
2377
+ let zoneId;
2378
+ try {
2379
+ zoneId = await resolveZoneIdForName(zone, cfToken, cfDeps);
2380
+ }
2381
+ catch {
2382
+ // Zone out of the token's scope or gone: not this one, try the next.
2383
+ continue;
2384
+ }
2385
+ try {
2386
+ const found = [];
2387
+ for (const name of [candidate.dev, candidate.prod]) {
2388
+ const record = await findCnameRecord(zoneId, name, cfToken, cfDeps);
2389
+ if (record) {
2390
+ found.push(name);
2391
+ console.log(` ✔ CNAME ${name} encontrado (${record.content})`);
2392
+ }
2393
+ }
2394
+ // EITHER domain is enough to place the project: a half-retired stack (prod
2395
+ // interim already retired, dev still up) still belongs to this zone.
2396
+ if (found.length > 0) {
2397
+ if (zone !== TIMEKAST_ZONE_NAME) {
2398
+ console.log(` ℹ El proyecto vive en la zona \`${zone}\` (anterior a la mudanza del org) — ` +
2399
+ 'registro sus dominios ahí, no en la zona actual.');
2400
+ }
2401
+ return candidate;
2402
+ }
2403
+ }
2404
+ catch {
2405
+ // A read-only DNS check failure must never fail the adopt (issue §4).
2406
+ console.warn(`⚠ No se pudo verificar los CNAMEs en \`${zone}\` (read-only) — continúo.`);
2354
2407
  }
2355
2408
  }
2356
- catch {
2357
- // A read-only DNS check failure must never fail the adopt (issue §4).
2358
- console.warn(`⚠ No se pudo verificar los CNAMEs en Cloudflare (read-only) — continúo.`);
2359
- }
2409
+ console.log(` · Sin CNAMEs en ninguna zona del org (${ORG_ZONE_NAMES.join(', ')}) — ` +
2410
+ 'registro los dominios por nombre en la zona actual.');
2360
2411
  return domains;
2361
2412
  }
2362
2413
  /** Print the reconstructed adopt state in plain es-MX (the non-`--json` narration). */
@@ -2790,7 +2841,11 @@ async function teardownProviders(target, deps) {
2790
2841
  // domain configured → nothing to remove; a single provider failure continues).
2791
2842
  const vercelToken = readVercelToken(deps.envPath);
2792
2843
  const cfToken = readCloudflareApiKey(deps.envPath);
2793
- let zoneId = null;
2844
+ // Cached PER ZONE, not one id for the whole loop (DOM-002). A project's domains
2845
+ // can span zones — the org interim and the client's own — and a single cached id
2846
+ // made the second domain get looked up inside the first one's zone, so its CNAME
2847
+ // was never found and stayed behind. Keyed by domain→zone-id, resolved on demand.
2848
+ const zoneIdByDomain = new Map();
2794
2849
  for (const domain of target.domains) {
2795
2850
  // The domain is registered in the state, so there IS a resource to remove. If
2796
2851
  // a prerequisite is missing (FIX 3: e.g. `scope` null on an adopted stack, or
@@ -2829,10 +2884,12 @@ async function teardownProviders(target, deps) {
2829
2884
  }
2830
2885
  else {
2831
2886
  try {
2832
- if (zoneId === null) {
2833
- zoneId = await resolveZoneIdForName(apexZoneOf(domain), cfToken, {
2887
+ let zoneId = zoneIdByDomain.get(domain);
2888
+ if (zoneId === undefined) {
2889
+ zoneId = await resolveZoneIdForDomain(domain, cfToken, {
2834
2890
  fetchImpl: deps.fetchImpl,
2835
2891
  });
2892
+ zoneIdByDomain.set(domain, zoneId);
2836
2893
  }
2837
2894
  outcomes.push(await destroyCloudflareRecord(domain, zoneId, cfToken, deps.fetchImpl));
2838
2895
  }
@@ -3091,7 +3148,9 @@ export async function runProvision(flags) {
3091
3148
  if (steps.length > 0)
3092
3149
  printPlan(result);
3093
3150
  if (hasDomain) {
3094
- const interim = provisionDomains(projectName).prod;
3151
+ // Same rule as the real retire (DOM-001): the plan must print the interim the
3152
+ // project ACTUALLY has, not one re-derived in the current org zone.
3153
+ const interim = existingProvisionDomains(projectName, state?.dns).prod;
3095
3154
  printClientDomainPlan(flags.domain, interim, flags.dns, flags.confirm);
3096
3155
  }
3097
3156
  }
@@ -45,7 +45,7 @@ import { CLAUDE_MD_FILE, GITATTRIBUTES_FILE, LOCKFILE_FILE, PRETTIERIGNORE_FILE,
45
45
  import { decideCommit } from '../lib/commit-decision.js';
46
46
  import { extractManagedBlock, syncManagedBlock } from '../lib/gitattributes.js';
47
47
  import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLockfile, writeLockfile, } from '../lib/lockfile.js';
48
- import { detectProfile, emptyShippedE2EPort, insertFactoryUpdateScript, setAgentKitVersion, } from '../lib/package-json.js';
48
+ import { detectProfile, maintainKitScripts, setAgentKitVersion } from '../lib/package-json.js';
49
49
  import { runPreflight } from '../lib/preflight.js';
50
50
  import { printProvisionPointer } from '../lib/provision-pointer.js';
51
51
  import { hasProvisionState } from '../lib/provision-state.js';
@@ -104,6 +104,140 @@ function hashDiskPaths(rootDir, paths) {
104
104
  }
105
105
  return map;
106
106
  }
107
+ /** Unchanged lines kept around each change in the `[3] Ver diff` view. */
108
+ const DIFF_CONTEXT_LINES = 3;
109
+ /**
110
+ * Ceiling on how many diff lines `[3] Ver diff` prints. The point of the view is that a
111
+ * 6000-line file stops being dumped twice; a pathological conflict where almost every line
112
+ * differs must not reintroduce that, so the render stops here and says how much it cut.
113
+ */
114
+ const DIFF_MAX_LINES = 200;
115
+ /**
116
+ * Ceiling on the LCS table (rows × columns) the line matcher may allocate. Past it the two
117
+ * sides are reported as a block replacement instead — coarser, but bounded. The printed
118
+ * output is capped by {@link DIFF_MAX_LINES} either way.
119
+ */
120
+ const DIFF_MAX_LCS_CELLS = 250_000;
121
+ /** Marker printed where unchanged lines were skipped between two hunks. */
122
+ const DIFF_ELLIPSIS = ' …';
123
+ /** Split into lines with CRLF normalized and no phantom trailing empty line. */
124
+ function splitLines(text) {
125
+ const lines = text.replace(/\r\n/g, '\n').split('\n');
126
+ if (lines.length > 0 && lines[lines.length - 1] === '')
127
+ lines.pop();
128
+ return lines;
129
+ }
130
+ /**
131
+ * Longest-common-subsequence ops for two SMALL line arrays. Only ever called on the
132
+ * differing middle after the common prefix/suffix is trimmed, and only when the table fits
133
+ * under {@link DIFF_MAX_LCS_CELLS} — so the quadratic table stays bounded by construction.
134
+ */
135
+ function lcsOps(a, b) {
136
+ const cols = b.length + 1;
137
+ const table = new Uint32Array((a.length + 1) * cols);
138
+ for (let i = a.length - 1; i >= 0; i--) {
139
+ for (let j = b.length - 1; j >= 0; j--) {
140
+ table[i * cols + j] =
141
+ a[i] === b[j]
142
+ ? table[(i + 1) * cols + (j + 1)] + 1
143
+ : Math.max(table[(i + 1) * cols + j], table[i * cols + (j + 1)]);
144
+ }
145
+ }
146
+ const ops = [];
147
+ let i = 0;
148
+ let j = 0;
149
+ while (i < a.length && j < b.length) {
150
+ if (a[i] === b[j]) {
151
+ ops.push({ sign: ' ', text: a[i] });
152
+ i++;
153
+ j++;
154
+ }
155
+ else if (table[(i + 1) * cols + j] >= table[i * cols + (j + 1)]) {
156
+ ops.push({ sign: '-', text: a[i++] });
157
+ }
158
+ else {
159
+ ops.push({ sign: '+', text: b[j++] });
160
+ }
161
+ }
162
+ while (i < a.length)
163
+ ops.push({ sign: '-', text: a[i++] });
164
+ while (j < b.length)
165
+ ops.push({ sign: '+', text: b[j++] });
166
+ return ops;
167
+ }
168
+ /** Full op list for two files: common prefix + matched middle + common suffix. */
169
+ function diffLines(a, b) {
170
+ const max = Math.min(a.length, b.length);
171
+ let prefix = 0;
172
+ while (prefix < max && a[prefix] === b[prefix])
173
+ prefix++;
174
+ let suffix = 0;
175
+ while (suffix < max - prefix && a[a.length - 1 - suffix] === b[b.length - 1 - suffix])
176
+ suffix++;
177
+ const midA = a.slice(prefix, a.length - suffix);
178
+ const midB = b.slice(prefix, b.length - suffix);
179
+ const middle = midA.length * midB.length <= DIFF_MAX_LCS_CELLS
180
+ ? lcsOps(midA, midB)
181
+ : [
182
+ ...midA.map((text) => ({ sign: '-', text })),
183
+ ...midB.map((text) => ({ sign: '+', text })),
184
+ ];
185
+ return [
186
+ ...a.slice(0, prefix).map((text) => ({ sign: ' ', text })),
187
+ ...middle,
188
+ ...a.slice(a.length - suffix).map((text) => ({ sign: ' ', text })),
189
+ ];
190
+ }
191
+ /**
192
+ * Render what CHANGED between the local file and the Factory's, not both files. The old
193
+ * view dumped each side whole, so for the e2e runner (~6000 lines) it was unreadable and
194
+ * nobody used it — everyone answered "take the Factory's" blind, which is how local work
195
+ * disappeared without anyone reading it first.
196
+ *
197
+ * `-` is the local line, `+` the Factory's, with {@link DIFF_CONTEXT_LINES} of context around
198
+ * each change and `…` where unchanged stretches were skipped. Exported so the CLI suite can
199
+ * assert the bound directly (no file is ever printed whole).
200
+ */
201
+ export function renderConflictDiff(local, incoming) {
202
+ const ops = diffLines(splitLines(local), splitLines(incoming));
203
+ const keep = new Array(ops.length).fill(false);
204
+ let added = 0;
205
+ let removed = 0;
206
+ ops.forEach((op, idx) => {
207
+ if (op.sign === ' ')
208
+ return;
209
+ if (op.sign === '+')
210
+ added++;
211
+ else
212
+ removed++;
213
+ const from = Math.max(0, idx - DIFF_CONTEXT_LINES);
214
+ const to = Math.min(ops.length - 1, idx + DIFF_CONTEXT_LINES);
215
+ for (let k = from; k <= to; k++)
216
+ keep[k] = true;
217
+ });
218
+ if (added === 0 && removed === 0) {
219
+ return 'Sin diferencias de contenido: solo cambió el formato (fin de línea o espacios al final).';
220
+ }
221
+ const out = [];
222
+ let cut = 0;
223
+ let skipping = false;
224
+ for (let idx = 0; idx < ops.length; idx++) {
225
+ if (!keep[idx]) {
226
+ if (!skipping && out.length > 0)
227
+ out.push(DIFF_ELLIPSIS);
228
+ skipping = true;
229
+ continue;
230
+ }
231
+ skipping = false;
232
+ if (out.length >= DIFF_MAX_LINES) {
233
+ cut++;
234
+ continue;
235
+ }
236
+ out.push(`${ops[idx].sign} ${ops[idx].text}`);
237
+ }
238
+ const tail = `(${removed} línea(s) tuya(s) reemplazada(s), ${added} del Factory${cut > 0 ? `; ${cut} línea(s) de diff no mostradas` : ''})`;
239
+ return `${out.join('\n')}\n${tail}`;
240
+ }
107
241
  /** Resolve a single conflict via prompt — NO default option (design §7.3). */
108
242
  async function resolveConflict(conflict, stagedDir, rootDir) {
109
243
  for (;;) {
@@ -126,7 +260,8 @@ async function resolveConflict(conflict, stagedDir, rootDir) {
126
260
  if (choice === 'diff') {
127
261
  const local = readFileSync(path.join(rootDir, conflict.path), 'utf8');
128
262
  const incoming = readFileSync(path.join(stagedDir, conflict.path), 'utf8');
129
- console.log(`\n--- local (${conflict.path}) ---\n${local}\n--- Factory ---\n${incoming}\n`);
263
+ console.log(`\n--- diff de ${conflict.path} — «-» lo tuyo, «+» lo del Factory ---\n` +
264
+ `${renderConflictDiff(local, incoming)}\n`);
130
265
  continue;
131
266
  }
132
267
  return choice;
@@ -262,9 +397,12 @@ async function runResume(rootDir, flags) {
262
397
  writeLockfile(rootDir, newLock);
263
398
  clearUpdateState(rootDir);
264
399
  // Maintain dotfiles BEFORE removing the staged dir (the .gitattributes source).
265
- maintainDerivedDotfiles(rootDir, state.stagedDir, newLock.version);
400
+ const pkgMutations = maintainDerivedDotfiles(rootDir, state.stagedDir, newLock.version);
266
401
  await maybeCommitBrain(rootDir, newLock.version, [...state.plan.writes, ...state.plan.deletes], flags);
267
402
  rmSync(state.stagedDir, { recursive: true, force: true });
403
+ // A resumed update maintains package.json exactly like a normal one, so it owes the dev the
404
+ // same account of what it moved there (`BND-004`).
405
+ reportPkgMutations(pkgMutations);
268
406
  console.log('✔ `update` retomado y completado.');
269
407
  // A resumed update that succeeds completed the setup → show the end-pointer
270
408
  // (PVSKILL-002 §8 edge: `--resume` SÍ lo muestra). Suppressed only in headless
@@ -309,9 +447,14 @@ function warnOnScriptConflict(action, _pkgPath) {
309
447
  * pre-commit hook could reformat the freshly-installed kit files.
310
448
  */
311
449
  function maintainDerivedDotfiles(rootDir, stagedDir, agentKitVersion) {
312
- maintainDerivedPkg(rootDir, agentKitVersion);
450
+ const pkgMutations = maintainDerivedPkg(rootDir, agentKitVersion);
313
451
  syncDerivedGitattributes(rootDir, stagedDir);
314
452
  syncDerivedPrettierignore(rootDir, stagedDir);
453
+ return pkgMutations;
454
+ }
455
+ /** A run that mutated nothing (no package.json, or the maintenance failed and warned). */
456
+ function emptyPkgMutations() {
457
+ return { inserted: [], retired: [], e2ePortEmptied: false };
315
458
  }
316
459
  /**
317
460
  * Maintain the derived project's package.json: ensure the `factory:*` scripts
@@ -322,25 +465,37 @@ function maintainDerivedDotfiles(rootDir, stagedDir, agentKitVersion) {
322
465
  * package.json). `factoryVersion` (the frozen birth stamp) and `version` (the app
323
466
  * semver) are untouched. No-op (not an error) when the repo has no package.json
324
467
  * (a non-Node derivative).
468
+ *
469
+ * Returns what it MUTATED, so the closing summary can name it (`BND-004`). Every one of these
470
+ * edits used to happen in silence, in both directions: aliases appearing, aliases disappearing,
471
+ * a port emptied — the same failure mode the whole epic exists to close.
325
472
  */
326
473
  function maintainDerivedPkg(rootDir, agentKitVersion) {
327
474
  const pkgPath = path.join(rootDir, 'package.json');
328
475
  if (!existsSync(pkgPath))
329
- return;
476
+ return emptyPkgMutations();
330
477
  // Tolerant (mirrors `add`): a malformed package.json must NOT throw AFTER the
331
478
  // brain was already applied + the lockfile written — the install succeeded; the
332
479
  // script/version mirror is best-effort. Warn and move on.
333
480
  try {
334
- warnOnScriptConflict(insertFactoryUpdateScript(pkgPath).action, pkgPath);
481
+ // The kit-owned maintenance itself — insertion, retirement, port — lives in ONE place
482
+ // (`maintainKitScripts`), with the order and the reconciliation argued there. `add` performs
483
+ // the same maintenance on the same kind of repo and used to replicate only half of it; a
484
+ // shared function is what keeps a third caller from diverging again.
485
+ const maintenance = maintainKitScripts(pkgPath);
486
+ warnOnScriptConflict(maintenance.insert.action, pkgPath);
487
+ // Update-only: `add` installs a brain without an `agentKitVersion` to mirror yet.
335
488
  setAgentKitVersion(pkgPath, agentKitVersion);
336
- // Retire the E2E port the kit used to ship (`ports.e2e: 3006`) so the runner that just
337
- // arrived via `scripts/**` can derive one per checkout. Conditional and silent: it only
338
- // ever touches that exact value, so a dev-chosen port survives every update untouched.
339
- emptyShippedE2EPort(pkgPath);
489
+ return {
490
+ inserted: maintenance.inserted,
491
+ retired: maintenance.retired,
492
+ e2ePortEmptied: maintenance.portAction === 'emptied',
493
+ };
340
494
  }
341
495
  catch {
342
496
  console.warn('Aviso: no se pudo actualizar package.json (¿JSON inválido?); el cerebro se instaló igual. ' +
343
497
  'Corrige package.json y vuelve a correr `factory update` para sincronizar agentKitVersion.');
498
+ return emptyPkgMutations();
344
499
  }
345
500
  }
346
501
  /**
@@ -400,6 +555,14 @@ function syncDerivedPrettierignore(rootDir, stagedDir) {
400
555
  console.warn('Aviso: no se pudo sincronizar `.prettierignore`; el cerebro se instaló igual.');
401
556
  }
402
557
  }
558
+ /**
559
+ * The kit's own test folder. It stopped shipping to derivatives (`BND-003`): those tests
560
+ * validate KIT tooling, and inside a derivative they ran against a `src/` that may have
561
+ * diverged. Named here because the closing summary has to tell a derivative that edited one
562
+ * of them that it is now theirs alone — the kit no longer tracks it, so nothing will ever
563
+ * update it again. Trailing slash so the prefix match cannot catch a sibling path.
564
+ */
565
+ const KIT_TESTS_DIR = 'scripts/tools/__tests__/';
403
566
  /** Exit code of `--dry-run` when the preview found conflicts (0 = none). Lets a
404
567
  * non-interactive caller (the GUI) branch on the result instead of clobbering. */
405
568
  const DRY_RUN_CONFLICTS_EXIT = 2;
@@ -412,7 +575,9 @@ const DRY_RUN_CONFLICTS_EXIT = 2;
412
575
  */
413
576
  function reportDryRun(diff, manifest) {
414
577
  console.log(`\n🔍 Vista previa de update a v${manifest.version} — no se modificó nada:`);
415
- reportSummary(diff, manifest);
578
+ // Nothing was applied: no conflict was resolved (so nothing was overwritten) and the
579
+ // package.json maintenance never ran (so nothing moved there either).
580
+ reportSummary(diff, manifest, [], emptyPkgMutations());
416
581
  if (diff.conflicts.length > 0) {
417
582
  console.log('\nArchivos en conflicto (los modificaste tú y el Factory también los cambió):');
418
583
  for (const c of diff.conflicts)
@@ -420,18 +585,81 @@ function reportDryRun(diff, manifest) {
420
585
  console.log('\nPara resolverlos, corre `factory update` (sin `--dry-run`): pregunta archivo por archivo.');
421
586
  }
422
587
  }
423
- /** Print the deletes + kept-retired (design §7.6 — visible) + a one-line summary. */
424
- function reportSummary(diff, manifest) {
588
+ /**
589
+ * Name what the update did to `package.json` — in BOTH directions (`BND-004`).
590
+ *
591
+ * Only real mutations print, and each one exactly once: an alias that was already correct, a
592
+ * retired key the dev had pointed somewhere of their own (preserved, not removed), a port they
593
+ * chose, a second run over an already-empty port — all silent, because they are no-ops. That is
594
+ * what keeps this reportable instead of a log of everything the update looked at, and what makes
595
+ * it idempotent: the run after this one says nothing.
596
+ *
597
+ * A `conflict` (a divergent `factory:update`) is NOT here: it has its own existing warning
598
+ * (`warnOnScriptConflict`), and it is not an insertion — nothing changed.
599
+ *
600
+ * EXPORTED because `add` performs the SAME maintenance (`maintainKitScripts`) on the same kind of
601
+ * repo, and used to consume only `insert.action` — so a legacy repo adopting the brain had its
602
+ * `test:e2e:direct` / `test:e2e:ui` removed and its `ports.e2e` emptied in silence, which is the
603
+ * exact defect this function exists to close on the other command. One report, two callers: a
604
+ * second copy of these sentences would drift the day one of them is reworded.
605
+ */
606
+ export function reportPkgMutations(m) {
607
+ if (m.inserted.length > 0) {
608
+ console.log(`\nRepuestos en \`package.json\` — faltaban o apuntaban a un comando viejo del kit: ${m.inserted.join(', ')}.`);
609
+ }
610
+ if (m.retired.length > 0) {
611
+ console.log(`\nRetirados de \`package.json\`: ${m.retired.join(', ')}.`);
612
+ console.log(' Ese atajo se retiró; el flujo con guard es `pnpm test:e2e` (pasa por el runner, que arma su base de datos desechable).');
613
+ }
614
+ if (m.e2ePortEmptied) {
615
+ console.log('\nSe vació el valor de `ports.e2e` en `package.json` — el puerto que shippeaba el kit (la clave se conserva).');
616
+ console.log(' El runner de E2E deriva ahora un puerto de la ruta de este checkout, así dos copias del repo corren su suite a la vez. ' +
617
+ 'Pon un número ahí para fijar uno.');
618
+ }
619
+ }
620
+ /**
621
+ * Print the deletes + kept-retired (design §7.6 — visible) + what got OVERWRITTEN over local
622
+ * work + what moved in `package.json` + a one-line summary.
623
+ *
624
+ * `overwrittenLocal` is the list of conflicts resolved as "take the Factory's": the only
625
+ * files in the run that HAD local changes and lost them. A file the dev never touched is not
626
+ * in it — this is not a log of everything the update wrote, it is the short list where a
627
+ * `git diff` right now still shows what was there. That is the whole recovery story: the
628
+ * three derivatives measured with drift had their work committed, so git already covers
629
+ * getting it back; what was missing was anyone finding out it went (`BND-002 §1`).
630
+ */
631
+ function reportSummary(diff, manifest, overwrittenLocal, pkgMutations) {
632
+ reportPkgMutations(pkgMutations);
425
633
  if (diff.deleteSilent.length > 0) {
426
634
  console.log('\nArchivos retirados por el Factory en esta versión:');
427
635
  for (const e of diff.deleteSilent)
428
636
  console.log(` Archivos retirados: ${e.path}`);
429
637
  }
430
- if (diff.keptRetiredLocal.length > 0) {
638
+ // Kept-retired files are split in two, because the kit's own tests need a different
639
+ // sentence: they are not "a file you edited that the kit dropped", they are a test of the
640
+ // KIT's tooling now stranded inside your repo, running on every `pnpm test` against a
641
+ // `src/` the kit no longer knows (`BND-003 §6`).
642
+ const orphanKitTests = diff.keptRetiredLocal.filter((e) => e.path.startsWith(KIT_TESTS_DIR));
643
+ const keptOther = diff.keptRetiredLocal.filter((e) => !e.path.startsWith(KIT_TESTS_DIR));
644
+ if (keptOther.length > 0) {
431
645
  console.log('\nConservados con tus cambios — ya no los gestiona el Factory:');
432
- for (const e of diff.keptRetiredLocal)
646
+ for (const e of keptOther)
433
647
  console.log(` Conservado (editado localmente): ${e.path}`);
434
648
  }
649
+ if (orphanKitTests.length > 0) {
650
+ console.log('\nTests del kit que editaste y quedaron huérfanos — ya no los gestiona el Factory:');
651
+ for (const e of orphanKitTests)
652
+ console.log(` Huérfano (test del kit, editado por ti): ${e.path}`);
653
+ console.log(' El kit dejó de shippear sus propios tests: probaban herramientas del kit contra un ' +
654
+ '`src/` que tu proyecto pudo haber cambiado. Los tuyos siguen corriendo en tu `pnpm test`; ' +
655
+ 'mantenerlos (o borrarlos) ya es decisión tuya.');
656
+ }
657
+ if (overwrittenLocal.length > 0) {
658
+ console.log('\nSobrescritos con la versión del Factory — tenías cambios locales ahí:');
659
+ for (const p of overwrittenLocal)
660
+ console.log(` Sobrescrito (tenía cambios locales): ${p}`);
661
+ console.log(' Para ver qué había, corre `git diff` sobre esas rutas antes de commitear el update.');
662
+ }
435
663
  const unchanged = diff.unchanged.length > 0 ? ` (${diff.unchanged.length} sin cambios)` : '';
436
664
  const kept = diff.keptRetiredLocal.length > 0 ? `, ${diff.keptRetiredLocal.length} conservados` : '';
437
665
  console.log(`\n✔ update a v${manifest.version} (perfil ${manifest.profile}): ${diff.add.length} agregados, ` +
@@ -615,9 +843,12 @@ export async function runUpdate(flags, deps = {}) {
615
843
  // update uses `stampBirth` alone, so a prior `beta` is cleared by omission.
616
844
  writeLockfile(rootDir, flags.beta ? stampBeta(oldLock, manifest) : stampBirth(oldLock, manifest));
617
845
  clearUpdateState(rootDir);
618
- maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
846
+ const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
619
847
  await maybeCommitBrain(rootDir, manifest.version, [...plan.writes, ...plan.deletes], flags);
620
- reportSummary(diff, manifest);
848
+ // `theirsConflicts` IS the overwritten-local list: same source whether the dev answered
849
+ // the prompt, passed `--theirs-all`, or ran headless — there is no second path. The
850
+ // package.json mutations ride the SAME closing summary, not three loose notices.
851
+ reportSummary(diff, manifest, theirsConflicts, pkgMutations);
621
852
  // End-pointer to the next step (PVSKILL-002). Reached only after a clean apply;
622
853
  // `--verify`/`--resume` short-circuit above (verify NEVER prints), an error path
623
854
  // throws earlier, and headless `--yes` is suppressed (CI noise).
@@ -716,7 +947,8 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
716
947
  // the just-installed files) PLUS the birth stamp. A legacy auto-register is the
717
948
  // first sync this repo records, so the birth seal = the manifest's version.
718
949
  writeLockfile(rootDir, { ...manifest, factoryVersion: manifest.version });
719
- maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
950
+ const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
951
+ reportPkgMutations(pkgMutations);
720
952
  if (claudeMdExists) {
721
953
  console.log('\nConservé tu `CLAUDE.md` (no se sobrescribió). Verifica que importe las rules del kit ' +
722
954
  '(`@.claude/rules/*`); corre `factory doctor` para detectar rules sin importar.');
@@ -4,14 +4,17 @@
4
4
  * A THIN, reusable Cloudflare client that mirrors the proven fetch shape of
5
5
  * `vercel-env.ts` (`Authorization: Bearer ${token}` + a single central URL
6
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
7
+ * the org zone id, looks up an existing CNAME, creates one
8
8
  * (`proxied: false` so Vercel's own TLS terminates the domain), and PATCHes an
9
9
  * existing record that was manually left `proxied: true` back to `false`.
10
10
  *
11
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
12
+ * shared org Cloudflare token from the rail, minted SCOPED to the org zones with
13
+ * DNS-edit only — never an account-wide token (a DNS-write leak over an org zone could
14
+ * repoint records, intercept MX, or mint certs). Since DOM-001 the org zone is
15
+ * `timekast.com`, but `timekast.mx` stays live with already-provisioned projects on
16
+ * it, so the token must be scoped to BOTH: the retire/destroy paths reach into
17
+ * whichever zone the project's own domain belongs to. A 403 from any call therefore
15
18
  * means "the token is out of scope for this zone" and the caller maps it to an
16
19
  * actionable `CLIError` (issue §8 Gherkin) — it NEVER deletes a record to recover.
17
20
  *
@@ -31,14 +34,28 @@
31
34
  import { CLIError } from './cli-error.js';
32
35
  /** Base URL of the Cloudflare REST API v4 (named constant — never an inline literal). */
33
36
  export const CLOUDFLARE_API_BASE = 'https://api.cloudflare.com/client/v4';
34
- /** The org DNS zone every provisioned interim domain lives under (issue §4). */
35
- export const TIMEKAST_ZONE_NAME = 'timekast.mx';
37
+ /**
38
+ * The org DNS zone every NEWLY provisioned interim domain lives under (issue §4).
39
+ *
40
+ * DOM-001 moved it from `timekast.mx` to `timekast.com`. `timekast.mx` was NOT
41
+ * retired — it redirects to `.com` and keeps serving the projects already
42
+ * provisioned under it. So this constant is the default for what is BORN from now
43
+ * on, never an assumption about where an EXISTING project lives: any path that
44
+ * touches an already-provisioned domain derives its zone from that domain
45
+ * ({@link apexZoneOf}), not from here.
46
+ */
47
+ export const TIMEKAST_ZONE_NAME = 'timekast.com';
36
48
  /** CNAME target for a Vercel-hosted domain — Vercel's edge resolves it (issue §6). */
37
49
  export const CNAME_VERCEL_TARGET = 'cname.vercel-dns.com';
38
50
  /**
39
- * Build the two interim domains for a slug (issue §4):
40
- * - dev: `{slug}-dev.timekast.mx` → tracks the `develop` branch (preview)
41
- * - prod: `{slug}.timekast.mx` → production
51
+ * Build the two interim domains for a slug (issue §4), under the CURRENT
52
+ * {@link TIMEKAST_ZONE_NAME} — i.e. what a NEW project gets:
53
+ * - dev: `{slug}-dev.timekast.com` → tracks the `develop` branch (preview)
54
+ * - prod: `{slug}.timekast.com` → production
55
+ *
56
+ * 🔴 Never call this to re-derive the domains of an ALREADY provisioned project:
57
+ * one born before DOM-001 lives on `timekast.mx` and this would hand back the
58
+ * wrong host. Read those from the provision state instead.
42
59
  *
43
60
  * Lives HERE (next to {@link TIMEKAST_ZONE_NAME}) so BOTH `provision.ts` (which
44
61
  * creates these) and `provision-state.ts` (whose `--destroy` target re-derives the
@@ -51,6 +68,37 @@ export function provisionDomains(slug) {
51
68
  prod: `${slug}.${TIMEKAST_ZONE_NAME}`,
52
69
  };
53
70
  }
71
+ /**
72
+ * Every org zone a project may already live in, CURRENT FIRST (DOM-002).
73
+ *
74
+ * `timekast.mx` is not dead weight: it kept serving every project provisioned
75
+ * before the org moved to `.com`, so a rescue path that reconstructs a project's
76
+ * domains has to look there too. Ordered so the current zone always wins a tie —
77
+ * a name that somehow exists in both resolves to where new projects are born.
78
+ *
79
+ * A future move appends here; nothing else needs to learn about it.
80
+ */
81
+ export const ORG_ZONE_NAMES = [TIMEKAST_ZONE_NAME, 'timekast.mx'];
82
+ /**
83
+ * The interim domains of an ALREADY provisioned project — the counterpart of
84
+ * {@link provisionDomains}, which is for what is BORN now (DOM-001).
85
+ *
86
+ * Reads what the provision state recorded and only falls back to deriving from the
87
+ * CURRENT org zone when the state has nothing. That order is the whole point: a
88
+ * project provisioned before the org moved to `timekast.com` lives on
89
+ * `timekast.mx`, which stays live, so re-deriving its host from the slug would
90
+ * hand back a domain that never existed — and the caller would retire, tear down
91
+ * or gate a passkey on the wrong host, silently.
92
+ *
93
+ * The dev fallback follows the PROD domain's zone (not the current constant) so a
94
+ * state written before `devDomain` existed still resolves inside its own zone.
95
+ */
96
+ export function existingProvisionDomains(slug, dns) {
97
+ const fallback = provisionDomains(slug);
98
+ const prod = dns?.domain ?? fallback.prod;
99
+ const dev = dns?.devDomain ?? `${slug}-dev.${apexZoneOf(prod)}`;
100
+ return { dev, prod };
101
+ }
54
102
  /** Timeout (ms) for every Cloudflare REST call — a dead network must not hang provision. */
55
103
  const CLOUDFLARE_API_TIMEOUT_MS = 30_000;
56
104
  /** A non-2xx (or `success: false`) response from the Cloudflare REST API. */
@@ -126,10 +174,14 @@ async function cloudflareApi(method, pathname, token, deps, body, query) {
126
174
  /**
127
175
  * Resolve the zone id for {@link TIMEKAST_ZONE_NAME} via `GET /zones?name=`.
128
176
  *
129
- * The scoped shared org token can only see the `timekast.mx` zone, so the lookup
130
- * returns exactly that zone (or nothing). An empty result means the zone is not
131
- * visible to this token — either the token is out of scope or the zone id rotated
132
- * (issue §8). Either way it is a clear, actionable failure, never a silent skip.
177
+ * The scoped shared org token sees the org zones only, so the lookup returns
178
+ * exactly that zone (or nothing). An empty result means the zone is not visible to
179
+ * this token — either the token is out of scope or the zone id rotated (issue §8).
180
+ * Either way it is a clear, actionable failure, never a silent skip.
181
+ *
182
+ * Use ONLY for what is born now (the current org zone). For a domain that already
183
+ * exists, resolve its own zone with {@link resolveZoneIdForName} + {@link apexZoneOf}
184
+ * — a project provisioned before DOM-001 lives on `timekast.mx`, not here.
133
185
  *
134
186
  * @throws {CloudflareApiError} on any non-2xx / `success: false`.
135
187
  * @throws {CLIError} when the zone is not found with this token (issue §8).
@@ -139,8 +191,10 @@ export async function resolveZoneId(token, deps = {}) {
139
191
  }
140
192
  /**
141
193
  * Resolve the Cloudflare zone id for an ARBITRARY zone name via `GET /zones?name=`
142
- * (PROV-006). The interim flow targets `timekast.mx` ({@link resolveZoneId} is the
143
- * fixed-name shortcut); the client-domain flow targets the client's own apex zone,
194
+ * (PROV-006). The interim-CREATION flow targets the current org zone
195
+ * ({@link resolveZoneId} is the fixed-name shortcut), and the interim-RETIRE flow
196
+ * targets whichever org zone the existing domain belongs to (DOM-001 — it may be
197
+ * `timekast.mx`); the client-domain flow targets the client's own apex zone,
144
198
  * which may sit under the org token (`--dns=org`) or the client's own token
145
199
  * (`--dns=cliente-cf`). The zone name passed here is the registrable apex derived
146
200
  * from the client domain (`app.miapp.com` → `miapp.com`); a subdomain CNAME is
@@ -166,8 +220,12 @@ export async function resolveZoneIdForName(zoneName, token, deps = {}) {
166
220
  * Cloudflare zone is the apex (`miapp.com`); a CNAME for a subdomain
167
221
  * (`app.miapp.com`) lives INSIDE that zone, so the zone lookup must use the apex,
168
222
  * not the full host. This takes the LAST TWO labels (`a.b.miapp.com` → `miapp.com`).
169
- * Multi-part public suffixes (`co.uk`) are out of scope for the interim flow; the
170
- * `--dns=registrar` path sidesteps zone resolution entirely (manual records).
223
+ *
224
+ * ⚠️ A GUESS, and a wrong one under a multi-part public suffix: `app.miapp.com.mx`
225
+ * yields `com.mx`, which is nobody's zone. Prefer {@link resolveZoneIdForDomain},
226
+ * which asks Cloudflare instead of guessing (DOM-002). This stays exported because
227
+ * it is the right answer for a name already known to sit in a second-level zone
228
+ * (the org's own domains) and because it seeds the candidate list.
171
229
  */
172
230
  export function apexZoneOf(domain) {
173
231
  const labels = domain.split('.').filter(Boolean);
@@ -175,6 +233,56 @@ export function apexZoneOf(domain) {
175
233
  return labels.join('.');
176
234
  return labels.slice(-2).join('.');
177
235
  }
236
+ /**
237
+ * The zone-name candidates for a domain, MOST SPECIFIC FIRST (DOM-002).
238
+ *
239
+ * `app.miapp.com.mx` → `['app.miapp.com.mx', 'miapp.com.mx', 'com.mx']`. Walking
240
+ * this order and keeping the first that EXISTS is what makes a `.com.mx` client
241
+ * resolve correctly without shipping a public-suffix list: the registry of what is
242
+ * a real zone is Cloudflare's, not a table we would have to keep current.
243
+ *
244
+ * Stops at two labels — a single label is never a zone anyone owns here.
245
+ */
246
+ export function zoneCandidatesOf(domain) {
247
+ const labels = domain.split('.').filter(Boolean);
248
+ const candidates = [];
249
+ for (let i = 0; i <= labels.length - 2; i++) {
250
+ candidates.push(labels.slice(i).join('.'));
251
+ }
252
+ return candidates;
253
+ }
254
+ /**
255
+ * Resolve the Cloudflare zone id that actually CONTAINS `domain` (DOM-002), by
256
+ * trying {@link zoneCandidatesOf} most-specific-first and returning the first hit.
257
+ *
258
+ * Replaces `resolveZoneIdForName(apexZoneOf(domain))` wherever the domain is a
259
+ * client's and its shape is not known in advance. Costs one extra lookup only when
260
+ * the first guess misses, and the miss is exactly the case that used to fail:
261
+ * `app.miapp.com.mx` asks for `miapp.com.mx` before ever considering `com.mx`.
262
+ *
263
+ * @throws {CloudflareApiError} on any non-2xx / `success: false` that is not a
264
+ * plain "not found" — a 403 must surface as an out-of-scope token, never be
265
+ * swallowed as "try the next candidate".
266
+ * @throws {CLIError} when NO candidate is visible to this token.
267
+ */
268
+ export async function resolveZoneIdForDomain(domain, token, deps = {}) {
269
+ const candidates = zoneCandidatesOf(domain);
270
+ for (const candidate of candidates) {
271
+ try {
272
+ return await resolveZoneIdForName(candidate, token, deps);
273
+ }
274
+ catch (error) {
275
+ // Only "this zone is not visible" is a reason to try the next candidate.
276
+ // An API-level failure (403 out-of-scope, 5xx) propagates untouched.
277
+ if (error instanceof CloudflareApiError)
278
+ throw error;
279
+ }
280
+ }
281
+ throw new CLIError(`No se encontró en Cloudflare ninguna zona que contenga \`${domain}\` con el token usado.\n` +
282
+ `Se probó: ${candidates.join(', ')}.\n` +
283
+ 'Verifica que el token esté scopeado a la zona (DNS-edit) y reintenta con `--resume`; ' +
284
+ 'si usas `--dns=cliente-cf`, confirma que la API key del cliente cubre esa zona.');
285
+ }
178
286
  /**
179
287
  * Find an existing CNAME record for `name` in the zone (issue §8 idempotency).
180
288
  * Returns the record when present (so the caller can detect a `proxied: true`
@@ -170,20 +170,20 @@ export const GENERATOR_SCRIPTS = [
170
170
  },
171
171
  ];
172
172
  /**
173
- * Kit-local E2E debugging aliases — raw Playwright, but through the wrapper that arms the
174
- * `server-only` / `client-only` compile-guard stub (`scripts/tools/e2e-direct.ts`).
173
+ * The RETIRED E2E debugging aliases, and the values the kit shipped for them.
175
174
  *
176
- * `pnpm test:e2e` gets the stub from `e2e-runner.ts`; these two used to shell `playwright
177
- * test` straight from `package.json` and therefore did not, so a spec that transitively
178
- * reached a server-guarded module died at load with `No tests found` — but only here, never
179
- * under the full runner.
175
+ * 🔴 NOTHING INSERTS THESE ANY MORE. They were the raw-Playwright entry points, last shipped
176
+ * as a wrapper that armed the `server-only` / `client-only` compile-guard stub
177
+ * (`scripts/tools/e2e-direct.ts`). Both the aliases and that wrapper were retired: they
178
+ * bypassed the E2E runner by design, which is what made them the visible path to running the
179
+ * suite against whatever database `.env.local` names. The kit no longer ships the file, so
180
+ * `kitLocalScripts` no longer offers the aliases — the insertion branch was removed rather
181
+ * than left dormant behind a gate that only opens in a derivative which kept its own copy of
182
+ * the wrapper (exactly the repo that most needed them gone).
180
183
  *
181
- * FILE-GATED, exactly like `GENERATOR_SCRIPTS`: the alias is only ensured when the wrapper
182
- * it shells actually exists in the repo, so a `core`/non-Factory derivative never receives a
183
- * script pointing at a file it lacks. Both gate on the SAME file (one wrapper, two entry
184
- * points — `--ui` is just an argument forwarded to Playwright). Insert-if-missing; a dev's
185
- * own value is never overwritten, and the previously-shipped raw commands self-heal through
186
- * `OBSOLETE_SCRIPT_CMDS` below.
184
+ * What these constants are FOR now: naming the values `RETIRED_SCRIPT_CMDS` matches when it
185
+ * deletes the entries from a derivative's `package.json`. `E2E_DIRECT_SCRIPT_FILE` survives
186
+ * as the file those commands shelled, which is what makes the commands themselves readable.
187
187
  */
188
188
  export const E2E_DIRECT_SCRIPT_FILE = 'scripts/tools/e2e-direct.ts';
189
189
  export const E2E_DIRECT_SCRIPT_NAME = 'test:e2e:direct';
@@ -258,10 +258,12 @@ export const OBSOLETE_SCRIPT_CMDS = {
258
258
  [GENERATE_REFERENCE_NAME]: [
259
259
  GENERATOR_SCRIPTS.map((g) => `node ${g.file.replace(/\.ts$/, '.mjs')}`).join(' && '),
260
260
  ],
261
- // The raw Playwright invocations the kit shipped before `e2e-direct.ts` existed. They run,
262
- // which is what makes them worth healing: they simply run WITHOUT the compile-guard stub,
263
- // so the failure they produce is a spec reporting `No tests found` rather than an error a
264
- // dev can act on. A derivative that pointed either alias somewhere of its own keeps it.
261
+ // The raw Playwright invocations the kit shipped before `e2e-direct.ts` existed. These two
262
+ // are NOT healed any more — their script is retired, not obsolete, so nothing offers them
263
+ // for insertion. They stay registered here because `RETIRED_SCRIPT_CMDS` reuses the list
264
+ // (rather than restating it) as "every value the kit ever shipped for this key", which is
265
+ // what the removal matches on. A derivative that pointed either alias somewhere of its own
266
+ // keeps it, there as here.
265
267
  [E2E_DIRECT_SCRIPT_NAME]: ['playwright test'],
266
268
  [E2E_UI_SCRIPT_NAME]: ['playwright test --ui'],
267
269
  // Every heap ceiling the kit shipped for the dev server before the current one. Listed as
@@ -269,3 +271,29 @@ export const OBSOLETE_SCRIPT_CMDS = {
269
271
  // appended a flag of its own, or picked a ceiling the kit never shipped, is left alone.
270
272
  [DEV_NEXT_SCRIPT_NAME]: DEV_NEXT_OBSOLETE_HEAPS_MB.map((mb) => `NODE_OPTIONS='--max-old-space-size=${mb}' next dev --turbopack`),
271
273
  };
274
+ /**
275
+ * Update-time RETIREMENT registry: kit-owned scripts the kit no longer ships at all, and every
276
+ * value it ever shipped for them. `removeRetiredScripts` deletes the entry when the value on
277
+ * disk is one of these, and leaves it alone when it is not.
278
+ *
279
+ * The distinction from `OBSOLETE_SCRIPT_CMDS` above is the destination, not the discipline:
280
+ * obsolete = the script still exists, its invocation changed → HEAL to the current command;
281
+ * retired = the script is gone → REMOVE the entry, because the file it shells was deleted from
282
+ * the kit and the alias would be dangling on arrival.
283
+ *
284
+ * `test:e2e:direct` / `test:e2e:ui` were the raw-Playwright debugging entry points. They bypass
285
+ * the E2E runner by design, which is exactly what made them the visible path to running the
286
+ * suite against whatever database `.env.local` names — the failure this retirement is part of
287
+ * closing. The values listed are the wrapper command the kit shipped last PLUS the historic raw
288
+ * ones already registered above (reused, not restated: in the measured fleet 6 of 8 repos carry
289
+ * the raw form and only one the wrapper, so matching only the current value would leave the more
290
+ * dangerous shape in place almost everywhere). Anything else in that key is the dev's own and is
291
+ * preserved — same rule as everywhere else in this file.
292
+ */
293
+ export const RETIRED_SCRIPT_CMDS = {
294
+ [E2E_DIRECT_SCRIPT_NAME]: [
295
+ E2E_DIRECT_SCRIPT_CMD,
296
+ ...(OBSOLETE_SCRIPT_CMDS[E2E_DIRECT_SCRIPT_NAME] ?? []),
297
+ ],
298
+ [E2E_UI_SCRIPT_NAME]: [E2E_UI_SCRIPT_CMD, ...(OBSOLETE_SCRIPT_CMDS[E2E_UI_SCRIPT_NAME] ?? [])],
299
+ };
@@ -8,7 +8,7 @@
8
8
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { CLIError } from './cli-error.js';
11
- import { DEV_NEXT_GATE_FILE, DEV_NEXT_SCRIPT_CMD, DEV_NEXT_SCRIPT_NAME, E2E_DIRECT_SCRIPT_CMD, E2E_DIRECT_SCRIPT_FILE, E2E_DIRECT_SCRIPT_NAME, E2E_UI_SCRIPT_CMD, E2E_UI_SCRIPT_NAME, FACTORY_SCRIPTS, GENERATE_REFERENCE_CMD, GENERATE_REFERENCE_GATE_FILE, GENERATE_REFERENCE_NAME, GENERATOR_SCRIPTS, INVITE_ADMIN_SCRIPT_CMD, INVITE_ADMIN_SCRIPT_FILE, INVITE_ADMIN_SCRIPT_NAME, OBSOLETE_SCRIPT_CMDS, PORTS_E2E_COMMENT, PORTS_E2E_COMMENT_KEY, PORTS_E2E_KEY, PORTS_KEY, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, SHIPPED_E2E_PORT, UPDATE_SCRIPT_NAME, } from './constants.js';
11
+ import { DEV_NEXT_GATE_FILE, DEV_NEXT_SCRIPT_CMD, DEV_NEXT_SCRIPT_NAME, FACTORY_SCRIPTS, GENERATE_REFERENCE_CMD, GENERATE_REFERENCE_GATE_FILE, GENERATE_REFERENCE_NAME, GENERATOR_SCRIPTS, INVITE_ADMIN_SCRIPT_CMD, INVITE_ADMIN_SCRIPT_FILE, INVITE_ADMIN_SCRIPT_NAME, OBSOLETE_SCRIPT_CMDS, PORTS_E2E_COMMENT, PORTS_E2E_COMMENT_KEY, PORTS_E2E_KEY, PORTS_KEY, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, RETIRED_SCRIPT_CMDS, SHIPPED_E2E_PORT, UPDATE_SCRIPT_NAME, } from './constants.js';
12
12
  /**
13
13
  * Auto-detect the profile to install into an existing repo from its
14
14
  * `package.json`: a `factoryVersion` field means the repo was born from the
@@ -63,14 +63,19 @@ export function parsePackageJson(raw) {
63
63
  * each if missing and NEVER overwriting a divergent value (§7.4). Shared by
64
64
  * `applyPackageJsonEdits` (the `new` flow) and `insertFactoryUpdateScript`
65
65
  * (the `update` flow). Returns whether anything changed (so the caller writes
66
- * only on a real edit) + the result for the primary `factory:update` script
67
- * (which drives the install messaging).
66
+ * only on a real edit), the result for the primary `factory:update` script
67
+ * (which drives the install messaging), and the per-script detail.
68
+ *
69
+ * The per-script results were computed here all along and then dropped on the floor at the end
70
+ * of each loop turn — only `factory:update` survived. Keeping them is what lets `update` name
71
+ * the aliases it put back instead of moving a dev's `package.json` in silence (`BND-004`).
68
72
  */
69
73
  function ensureFactoryScripts(pkg, extraScripts = {}) {
70
74
  const scripts = pkg.scripts ?? {};
71
75
  pkg.scripts = scripts;
72
76
  let changed = false;
73
77
  let primary = { action: 'already-correct' };
78
+ const perScript = {};
74
79
  for (const [name, cmd] of Object.entries({ ...FACTORY_SCRIPTS, ...extraScripts })) {
75
80
  const existing = scripts[name];
76
81
  let result;
@@ -93,10 +98,11 @@ function ensureFactoryScripts(pkg, extraScripts = {}) {
93
98
  else {
94
99
  result = { action: 'conflict', existingValue: existing };
95
100
  }
101
+ perScript[name] = result;
96
102
  if (name === UPDATE_SCRIPT_NAME)
97
103
  primary = result;
98
104
  }
99
- return { changed, primary };
105
+ return { changed, primary, perScript };
100
106
  }
101
107
  /**
102
108
  * The kit-local scripts to ensure for THIS repo, each file-gated so a
@@ -110,14 +116,22 @@ function ensureFactoryScripts(pkg, extraScripts = {}) {
110
116
  * tracked set on `update`/`beta`, so an existing derivative self-heals the
111
117
  * aliases without the dev hand-editing `package.json`.
112
118
  * - `generate:reference` — the aggregate that runs the whole set in one command.
113
- * - `test:e2e:direct` / `test:e2e:ui` — the two raw-Playwright debugging entry
114
- * points, routed through the wrapper that arms the compile-guard stub. Both
115
- * gate on that one wrapper file.
116
119
  * - `dev:next` — the dev server the launcher shells, whose heap ceiling the kit
117
120
  * owns. Gated on the launcher (`dev.mjs`), which is what invokes it.
118
121
  *
119
122
  * Insert-if-missing for all: a dev's own value for any of these is never
120
123
  * overwritten (see `ensureFactoryScripts`).
124
+ *
125
+ * 🔴 WHAT IS DELIBERATELY NOT HERE: `test:e2e:direct` / `test:e2e:ui`. They used to be
126
+ * inserted from this function, file-gated on `scripts/tools/e2e-direct.ts` — a file the kit
127
+ * NO LONGER SHIPS. The gate was therefore shut everywhere except in a derivative that kept
128
+ * an edited copy of the wrapper, which is precisely the repo where the two runner bypasses
129
+ * had to go: `maintainKitScripts` inserted them and `removeRetiredScripts` took them away
130
+ * again inside the same pass. Insertion for a deleted file is not a dormant branch, it is a
131
+ * live one aimed at nothing, so it is gone. THE RETIREMENT STAYS: `RETIRED_SCRIPT_CMDS` (and
132
+ * the `OBSOLETE_SCRIPT_CMDS` entries it reuses) is what removes those aliases from the
133
+ * derivatives that still carry them in their `package.json`, and it decides from the VALUE,
134
+ * never from a file gate.
121
135
  */
122
136
  export function kitLocalScripts(rootDir) {
123
137
  const has = (relPath) => existsSync(path.join(rootDir, relPath));
@@ -132,10 +146,6 @@ export function kitLocalScripts(rootDir) {
132
146
  }
133
147
  if (has(GENERATE_REFERENCE_GATE_FILE))
134
148
  scripts[GENERATE_REFERENCE_NAME] = GENERATE_REFERENCE_CMD;
135
- if (has(E2E_DIRECT_SCRIPT_FILE)) {
136
- scripts[E2E_DIRECT_SCRIPT_NAME] = E2E_DIRECT_SCRIPT_CMD;
137
- scripts[E2E_UI_SCRIPT_NAME] = E2E_UI_SCRIPT_CMD;
138
- }
139
149
  if (has(DEV_NEXT_GATE_FILE))
140
150
  scripts[DEV_NEXT_SCRIPT_NAME] = DEV_NEXT_SCRIPT_CMD;
141
151
  return scripts;
@@ -196,17 +206,18 @@ export function applyPackageJsonEdits(raw, name, extraScripts = {}) {
196
206
  * present (correct or divergent) the file is left byte-identical.
197
207
  *
198
208
  * @param pkgPath Absolute path to the derived project's `package.json`.
199
- * @returns The result for the primary `factory:update` script (+ existing value on conflict).
209
+ * @returns The result for the primary `factory:update` script (+ existing value on conflict),
210
+ * widened with `perScript` — what happened to every other alias in the same pass.
200
211
  */
201
212
  export function insertFactoryUpdateScript(pkgPath) {
202
213
  const raw = readFileSync(pkgPath, 'utf8');
203
214
  const indent = detectIndent(raw);
204
215
  const pkg = parsePackageJson(raw);
205
- const { changed, primary } = ensureFactoryScripts(pkg, kitLocalScripts(path.dirname(pkgPath)));
216
+ const { changed, primary, perScript } = ensureFactoryScripts(pkg, kitLocalScripts(path.dirname(pkgPath)));
206
217
  if (changed) {
207
218
  writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`, 'utf8');
208
219
  }
209
- return primary;
220
+ return { ...primary, perScript };
210
221
  }
211
222
  /**
212
223
  * Retire the E2E port the kit used to ship in a derivative's `package.json`: KEEP the key,
@@ -266,6 +277,116 @@ export function emptyShippedE2EPort(pkgPath) {
266
277
  writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`, 'utf8');
267
278
  return 'emptied';
268
279
  }
280
+ /**
281
+ * Retire the kit-owned scripts the kit no longer ships (`RETIRED_SCRIPT_CMDS`) from a
282
+ * derivative's `package.json`: REMOVE the entry outright when its value is one the kit shipped,
283
+ * leave it untouched when it is not.
284
+ *
285
+ * WHY REMOVE RATHER THAN EMPTY. This is the deliberate contrast with `emptyShippedE2EPort`
286
+ * below, whose docstring argues for keeping the key: there, `"e2e": null` is a SIGNAL the runner
287
+ * reads, so the key is the contract and only the value retires. Here nothing reads the key — the
288
+ * script's whole purpose was to shell a file that no longer exists — so an emptied entry would
289
+ * be an alias that fails on invocation instead of one that is honestly gone. Same discipline
290
+ * (exact match, kit values only, idempotent), different mechanism.
291
+ *
292
+ * WHY IT IS NOT FILE-GATED. `kitLocalScripts` gates the INSERTION of these aliases on
293
+ * `scripts/tools/e2e-direct.ts` being present, and `update` deletes that file before it reaches
294
+ * this code — so a gate here would be false exactly when the removal is needed, and the primitive
295
+ * would never fire on anyone. It runs unconditionally and decides from the VALUE alone.
296
+ *
297
+ * WHY IT RETURNS THE DETAIL. A dev who used one of these daily must be told it went, and by
298
+ * name. This function only produces the fact; wiring it into the update's closing summary is
299
+ * `BND-004`'s job. Being born with the right return shape is what keeps that from being a
300
+ * second edit to this function — the defect `ensureFactoryScripts` has today, where the per-key
301
+ * result is computed and then thrown away.
302
+ *
303
+ * Surgical like the rest of this module: reads, deletes only those keys, re-serializes with the
304
+ * original indentation + trailing newline, and writes ONLY when something was actually removed.
305
+ *
306
+ * @param pkgPath Absolute path to the derived project's `package.json`.
307
+ * @returns What happened to each retired key — the input `BND-004` reports from.
308
+ */
309
+ export function removeRetiredScripts(pkgPath) {
310
+ const raw = readFileSync(pkgPath, 'utf8');
311
+ const indent = detectIndent(raw);
312
+ const pkg = parsePackageJson(raw);
313
+ const scripts = pkg.scripts;
314
+ const report = {};
315
+ // No `scripts` block at all (a non-Node-ish package.json): nothing to retire, nothing written.
316
+ if (!scripts) {
317
+ for (const name of Object.keys(RETIRED_SCRIPT_CMDS))
318
+ report[name] = 'absent';
319
+ return report;
320
+ }
321
+ let changed = false;
322
+ for (const [name, shippedValues] of Object.entries(RETIRED_SCRIPT_CMDS)) {
323
+ const existing = scripts[name];
324
+ if (existing === undefined) {
325
+ report[name] = 'absent';
326
+ }
327
+ else if (shippedValues.includes(existing)) {
328
+ delete scripts[name];
329
+ changed = true;
330
+ report[name] = 'removed';
331
+ }
332
+ else {
333
+ report[name] = 'preserved';
334
+ }
335
+ }
336
+ if (changed) {
337
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`, 'utf8');
338
+ }
339
+ return report;
340
+ }
341
+ /**
342
+ * The kit-owned maintenance of a derivative's `package.json`, in the ONE order that is correct.
343
+ *
344
+ * 🔴 WHY THIS IS A SHARED FUNCTION AND NOT A SEQUENCE OF CALLS AT EACH SITE. `update` and `add`
345
+ * both perform this maintenance on a repo that already exists, and `add` only replicated half of
346
+ * it: it inserted the aliases and emptied the port, and never retired anything. In a legacy repo
347
+ * that adopts the brain with `factory add` while `scripts/tools/e2e-direct.ts` is still on disk,
348
+ * `kitLocalScripts` opens its file gate and WRITES `test:e2e:direct` / `test:e2e:ui` — the two
349
+ * aliases that bypass the E2E runner, reinstalled by the very tool that came to retire them. The
350
+ * comment at that call site said "same maintenance `update` does"; the code did not, and nothing
351
+ * could tell. A third caller tomorrow would diverge the same way.
352
+ *
353
+ * ORDER MATTERS — the retirement runs AFTER the insertion, and it is the cheap half of the
354
+ * invariant. `kitLocalScripts` no longer knows how to insert the retired aliases at all (its
355
+ * docstring says why), so today no key can be added and removed in one pass; keeping the
356
+ * retirement last is what makes that stay true if a future retired script is also insertable —
357
+ * retiring first would let the insertion put it straight back.
358
+ *
359
+ * RECONCILED BEFORE RETURNING, so a caller's summary cannot contradict itself when a key IS both
360
+ * inserted and removed in the same run:
361
+ * · added → removed : never existed for the dev, still does not → report NEITHER.
362
+ * · healed/already-correct → removed: they had it, now it is gone → report RETIRED.
363
+ * · inserted and not removed : → report INSERTED.
364
+ *
365
+ * (The first line is unreachable with the CURRENT registries — nothing is both insertable and
366
+ * retired — which is the point: it is the reconciliation that keeps the two directions from ever
367
+ * contradicting each other, not a branch anybody is expected to hit today.)
368
+ *
369
+ * Every primitive it calls re-reads the file, so each step sees what the previous one wrote.
370
+ * Throws what they throw (a `CLIError` on invalid JSON) — the caller decides how tolerant to be.
371
+ *
372
+ * @param pkgPath Absolute path to the derived project's `package.json`.
373
+ */
374
+ export function maintainKitScripts(pkgPath) {
375
+ const insert = insertFactoryUpdateScript(pkgPath);
376
+ const retiredReport = removeRetiredScripts(pkgPath);
377
+ // Retire the E2E port the kit used to ship (`ports.e2e: 3006`) so the runner that arrives via
378
+ // `scripts/**` can derive one per checkout. Conditional: it only ever touches that exact
379
+ // value, so a dev-chosen port survives untouched.
380
+ const portAction = emptyShippedE2EPort(pkgPath);
381
+ const removedKeys = new Set(Object.entries(retiredReport)
382
+ .filter(([, action]) => action === 'removed')
383
+ .map(([name]) => name));
384
+ const inserted = Object.entries(insert.perScript)
385
+ .filter(([name, r]) => (r.action === 'added' || r.action === 'healed') && !removedKeys.has(name))
386
+ .map(([name]) => name);
387
+ const retired = [...removedKeys].filter((name) => insert.perScript[name]?.action !== 'added');
388
+ return { insert, retiredReport, portAction, inserted, retired };
389
+ }
269
390
  /**
270
391
  * Surgically set the derived project's `package.json.agentKitVersion` to the
271
392
  * installed brain version, mirroring the lockfile's `version`. The Factory's own
@@ -34,7 +34,7 @@
34
34
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
35
35
  import path from 'node:path';
36
36
  import { CLIError } from './cli-error.js';
37
- import { provisionDomains } from './cloudflare-api.js';
37
+ import { existingProvisionDomains } from './cloudflare-api.js';
38
38
  import { TIMEKAST_DIR } from './constants.js';
39
39
  /**
40
40
  * Directory (repo-root relative) the provision state lives in. This is the SAME
@@ -367,14 +367,16 @@ export function readDestroyTarget(rootDir) {
367
367
  'Bórralo o reconstrúyelo con `--adopt`.');
368
368
  }
369
369
  const lifecycle = step.lifecycle === 'ephemeral' ? 'ephemeral' : 'live';
370
- // The `dns` block now records the interim DEV domain (`{slug}-dev.timekast.mx`)
371
- // alongside prod, but it stays derivable from the slug. Prefer the persisted value
372
- // and fall back to deriving it (state files written before `devDomain` existed) so
373
- // `--destroy` tears down the dev CNAME instead of orphaning it. Gated on `dns` being
374
- // present (no DNS step → no dev domain to remove); passed through the same
375
- // `realDomain` shape guard as every other low-trust domain field.
370
+ // The `dns` block now records the interim DEV domain (`{slug}-dev.<org zone>`)
371
+ // alongside prod, but it stays derivable. Prefer the persisted value and fall back
372
+ // to deriving it (state files written before `devDomain` existed) so `--destroy`
373
+ // tears down the dev CNAME instead of orphaning it. The fallback derives inside the
374
+ // PROD domain's own zone (`existingProvisionDomains`, DOM-001) — a project born
375
+ // before the org moved to `timekast.com` must not have a `.com` dev host invented
376
+ // for it. Gated on `dns` being present (no DNS step → no dev domain to remove);
377
+ // passed through the same `realDomain` shape guard as every other low-trust field.
376
378
  const devDomain = step.dns
377
- ? realDomain(step.dns.devDomain ?? provisionDomains(step.projectName).dev)
379
+ ? realDomain(existingProvisionDomains(step.projectName, step.dns).dev)
378
380
  : null;
379
381
  const domains = [
380
382
  realDomain(step.dns?.domain),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",