@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.
- package/dist/commands/add.js +26 -7
- package/dist/commands/provision.js +90 -31
- package/dist/commands/update.js +250 -18
- package/dist/lib/cloudflare-api.js +125 -17
- package/dist/lib/constants.js +44 -16
- package/dist/lib/package-json.js +135 -14
- package/dist/lib/provision-state.js +10 -8
- package/package.json +1 -1
package/dist/commands/add.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 \`${
|
|
1948
|
+
throw cloudflareErrorToCliError(error, `resolver la zona de \`${domain}\``);
|
|
1943
1949
|
}
|
|
1944
|
-
throw error; //
|
|
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
|
-
|
|
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}
|
|
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
|
|
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 \`${
|
|
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
|
|
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
|
|
2334
|
-
*
|
|
2335
|
-
*
|
|
2336
|
-
*
|
|
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
|
-
|
|
2347
|
-
|
|
2348
|
-
const
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
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
|
-
|
|
2357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2833
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/update.js
CHANGED
|
@@ -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,
|
|
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---
|
|
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
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
424
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
13
|
-
*
|
|
14
|
-
* repoint records, intercept MX, or mint certs).
|
|
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
|
-
/**
|
|
35
|
-
|
|
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
|
-
*
|
|
41
|
-
* -
|
|
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
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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
|
|
143
|
-
* fixed-name shortcut)
|
|
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
|
-
*
|
|
170
|
-
*
|
|
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`
|
package/dist/lib/constants.js
CHANGED
|
@@ -170,20 +170,20 @@ export const GENERATOR_SCRIPTS = [
|
|
|
170
170
|
},
|
|
171
171
|
];
|
|
172
172
|
/**
|
|
173
|
-
*
|
|
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
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
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.
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
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
|
+
};
|
package/dist/lib/package-json.js
CHANGED
|
@@ -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,
|
|
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)
|
|
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 {
|
|
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
|
|
371
|
-
// alongside prod, but it stays derivable
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
// `
|
|
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.
|
|
379
|
+
? realDomain(existingProvisionDomains(step.projectName, step.dns).dev)
|
|
378
380
|
: null;
|
|
379
381
|
const domains = [
|
|
380
382
|
realDomain(step.dns?.domain),
|