@timekast/factory 1.23.1 โ†’ 1.25.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.
@@ -48,6 +48,7 @@ import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLo
48
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
+ import { printRetrofitReminder } from '../lib/retrofit-reminder.js';
51
52
  import { hasProvisionState } from '../lib/provision-state.js';
52
53
  import { detectRepo } from '../lib/repo-detection.js';
53
54
  import { downloadBetaProfileTarball, downloadProfileTarball, stageProfileTarball, } from '../lib/unpack.js';
@@ -849,6 +850,16 @@ export async function runUpdate(flags, deps = {}) {
849
850
  // the prompt, passed `--theirs-all`, or ran headless โ€” there is no second path. The
850
851
  // package.json mutations ride the SAME closing summary, not three loose notices.
851
852
  reportSummary(diff, manifest, theirsConflicts, pkgMutations);
853
+ // ๐Ÿ”ด The retrofit reminder, on EVERY applied update and regardless of how conflicts
854
+ // were resolved โ€” `--theirs-all`, `--mine-all`, per-file answers or none at all. A
855
+ // retrofit is not a conflict: it is work this command never attempted, living outside
856
+ // the tree it is allowed to touch. Deliberately NOT suppressed headless, unlike the
857
+ // pointer below: an agent updating unattended is the reader least able to notice a
858
+ // missing manual step on its own. Read from disk, so it lists what just arrived.
859
+ // `oldLock.version` is the version BEFORE this update, which is what separates the guides
860
+ // that just arrived from the ones that were already sitting there. A legacy repo carries
861
+ // `0.0.0`, so every guide reads as new โ€” correct: it has seen none of them.
862
+ printRetrofitReminder({ rootDir, fromVersion: oldLock.version });
852
863
  // End-pointer to the next step (PVSKILL-002). Reached only after a clean apply;
853
864
  // `--verify`/`--resume` short-circuit above (verify NEVER prints), an error path
854
865
  // throws earlier, and headless `--yes` is suppressed (CI noise).
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The initial-migration predicate and the canonical "is this checkout the Factory"
3
+ * signal (GAP-001, fixed by `project/planning/decisions/ADR-003-factory-signal.md`).
4
+ *
5
+ * WHY THIS MODULE EXISTS. `factory provision` never ran `db:generate`, so a fresh
6
+ * derivative was born with a schema and no migrations while its `vercel-build`
7
+ * (`pnpm db:migrate && pnpm build`) migrated an empty database on the first deploy.
8
+ * The provision step that closes that gap has to decide, from disk alone, whether
9
+ * generating and committing a `0000` is the right thing to do โ€” and the cost of a
10
+ * wrong "yes" is asymmetric.
11
+ *
12
+ * ๐Ÿ”ด THE SIGNAL FAILS TOWARD "DO NOT TOUCH". `.timekast/lockfile.json` is written by
13
+ * `new`/`add`/`update` in every derivative and does NOT exist in the Factory, so its
14
+ * ABSENCE means "this is the Factory, or a checkout that cannot be identified" and the
15
+ * predicate answers `false`. A false positive INSIDE the Factory would commit a `0000`
16
+ * here, and since the `full` distribution profile is `include: ["**"]` without excluding
17
+ * `src/lib/db/migrations/`, that `.sql` would ride the tarball into every new derivative
18
+ * โ€” breaking BR-FACTORY-005 from the origin. A false negative only leaves the migration
19
+ * ungenerated, which the dev fixes with one `pnpm db:generate`.
20
+ *
21
+ * If this signal ever disagrees with the markdown detection `/deploy` uses (`is_factory`
22
+ * in `project/planning/project-config.md`), the ABSENCE OF THE LOCKFILE WINS and nothing
23
+ * is mutated โ€” see ADR-003, which is the SSOT of that tie-break, not this comment.
24
+ *
25
+ * The predicate is intentionally PURE (it takes resolved facts, not a directory), so the
26
+ * `scripts/tools/` side of the distribution frontier can CALQUE it โ€” never import it โ€”
27
+ * the same way `backlog-parse.ts` calques `update-board.ts`.
28
+ */
29
+ import { existsSync, readdirSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import { hasLockfile } from './lockfile.js';
32
+ /** Drizzle schema directory of a kit derivative (`drizzle.config.ts` `schema`). */
33
+ export const SCHEMA_DIR = 'src/lib/db/schema';
34
+ /**
35
+ * Drizzle migrations output directory (`drizzle.config.ts` `out`). Doubles as the
36
+ * COMMIT PATHSPEC of the provision step: with a shared index (multi-agent, `--resume`,
37
+ * `--adopt` over a live repo) a bare `git commit` would swallow whatever else is staged
38
+ * (GIT.md ยง3.6.1).
39
+ */
40
+ export const MIGRATIONS_DIR = 'src/lib/db/migrations';
41
+ /** Manifest of a Node derivative โ€” its absence means there is nothing to install. */
42
+ export const PACKAGE_JSON_FILE = 'package.json';
43
+ /** The kit's package manager. Its lockfile is the positive signal for a pnpm project. */
44
+ export const PNPM_LOCKFILE = 'pnpm-lock.yaml';
45
+ /** Lockfiles of the other package managers โ€” present WITHOUT pnpm's means "not ours". */
46
+ export const FOREIGN_LOCKFILES = ['package-lock.json', 'yarn.lock', 'bun.lockb'];
47
+ /** Extension of a generated drizzle migration. */
48
+ export const MIGRATION_SQL_EXT = '.sql';
49
+ /**
50
+ * Should the initial migration be generated for this checkout?
51
+ *
52
+ * PURE by design (see the module header): the caller resolves the facts, this decides.
53
+ * `isFactory` is checked FIRST and unconditionally โ€” the fail-closed rule outranks
54
+ * every other consideration, including a project-config that claims otherwise.
55
+ */
56
+ export function needsInitialMigration({ hasSchema, hasMigrations, isFactory, }) {
57
+ if (isFactory)
58
+ return false; // fail-closed: absence of the lockfile always wins
59
+ if (!hasSchema)
60
+ return false; // nothing to generate from
61
+ return !hasMigrations; // idempotence: an existing `.sql` means it was already done
62
+ }
63
+ /**
64
+ * The canonical machine signal (ADR-003): a derivative HAS `.timekast/lockfile.json`
65
+ * (written by `new`/`add`/`update`); the Factory does not. Absent โ†’ treat as Factory.
66
+ */
67
+ export function isFactoryCheckout(rootDir) {
68
+ return !hasLockfile(rootDir);
69
+ }
70
+ /** True when the derivative ships a Drizzle schema directory. */
71
+ export function hasSchemaDir(rootDir) {
72
+ return existsSync(join(rootDir, SCHEMA_DIR));
73
+ }
74
+ /** True when at least one generated `.sql` already lives in the migrations directory. */
75
+ export function hasGeneratedMigrations(rootDir) {
76
+ const dir = join(rootDir, MIGRATIONS_DIR);
77
+ if (!existsSync(dir))
78
+ return false;
79
+ try {
80
+ return readdirSync(dir).some((entry) => entry.toLowerCase().endsWith(MIGRATION_SQL_EXT));
81
+ }
82
+ catch {
83
+ return false; // unreadable directory reads as "cannot confirm" โ†’ do not generate
84
+ }
85
+ }
86
+ /** Resolve the three facts from disk, ready for {@link needsInitialMigration}. */
87
+ export function readMigrationFacts(rootDir) {
88
+ return {
89
+ hasSchema: hasSchemaDir(rootDir),
90
+ hasMigrations: hasGeneratedMigrations(rootDir),
91
+ isFactory: isFactoryCheckout(rootDir),
92
+ };
93
+ }
94
+ /**
95
+ * The es-MX narration per skip reason. Kept next to the reasons (and not inlined at the
96
+ * call site) so the copy is a named constant, never a magic string (CODING.md ยง5).
97
+ */
98
+ export const MIGRATION_SKIP_MESSAGES = {
99
+ factory: 'No hay `.timekast/lockfile.json`: este checkout no se identifica como derivado ' +
100
+ '(podrรญa ser el Factory) โ€” omito la migraciรณn inicial.',
101
+ 'no-schema': `No hay \`${SCHEMA_DIR}/\` en el repo โ€” omito la migraciรณn inicial.`,
102
+ 'has-migrations': `Ya hay migraciones en \`${MIGRATIONS_DIR}/\` โ€” no genero otra.`,
103
+ 'no-package-json': `No hay \`${PACKAGE_JSON_FILE}\` en el repo โ€” omito la migraciรณn inicial.`,
104
+ 'not-pnpm': 'El repo usa otro gestor de paquetes (no hay `pnpm-lock.yaml`) โ€” omito la migraciรณn ' +
105
+ 'inicial; genรฉrala con el tooling del proyecto.',
106
+ };
107
+ /**
108
+ * Resolve {@link needsInitialMigration} plus the two tolerance edges the kit owes a
109
+ * divergent derivative (BR-FACTORY-006): no `package.json`, or a non-pnpm project. Both
110
+ * warn and continue โ€” they never abort the provisioning.
111
+ *
112
+ * This does NOT evaluate the greenfield guard (whether the RUN creates birth
113
+ * infrastructure): that one reads the plan, not the disk, and lives with the plan.
114
+ */
115
+ export function decideInitialMigration(rootDir) {
116
+ const facts = readMigrationFacts(rootDir);
117
+ if (facts.isFactory)
118
+ return { run: false, reason: 'factory' };
119
+ if (!facts.hasSchema)
120
+ return { run: false, reason: 'no-schema' };
121
+ if (facts.hasMigrations)
122
+ return { run: false, reason: 'has-migrations' };
123
+ if (!existsSync(join(rootDir, PACKAGE_JSON_FILE))) {
124
+ return { run: false, reason: 'no-package-json' };
125
+ }
126
+ const foreign = FOREIGN_LOCKFILES.some((lock) => existsSync(join(rootDir, lock)));
127
+ if (foreign && !existsSync(join(rootDir, PNPM_LOCKFILE))) {
128
+ return { run: false, reason: 'not-pnpm' };
129
+ }
130
+ return { run: true };
131
+ }
@@ -225,29 +225,60 @@ export async function getConnectionUri(apiKey, projectId, branchId, pooled, deps
225
225
  }
226
226
  return data.uri;
227
227
  }
228
+ /** Page size for the project listing โ€” Neon's documented maximum (`limit` โ‰ค 400). */
229
+ const NEON_PROJECTS_PAGE_SIZE = 400;
230
+ /** Hard stop on the pagination loop โ€” 20 full pages is 8000 projects, far past any org. */
231
+ const NEON_PROJECTS_MAX_PAGES = 20;
228
232
  /**
229
233
  * List the org's Neon projects โ€” `GET /projects` (PROV-007 `--adopt`). STRICTLY
230
234
  * read-only: it never POSTs/PATCHes/DELETEs. The caller filters by slug/name to
231
235
  * reconstruct the adopted state and disambiguates when more than one matches
232
- * (issue ยง4). Neon paginates with `?limit=`; one page (default 100) covers the
233
- * org's project list for the adopt use case.
236
+ * (issue ยง4).
237
+ *
238
+ * Two things this endpoint does NOT forgive, both learned the hard way from an
239
+ * adopt that recorded `neonProjectId: unknown` on a project that plainly exists:
240
+ *
241
+ * - `org_id` is REQUIRED for an org-scoped API key โ€” the same requirement
242
+ * {@link createNeonProject} already honors. Without it Neon answers `400
243
+ * org_id is required`, so the adopt path had never once resolved a project.
244
+ * - The default page size is 10. An org with a real fleet needs the FULL list,
245
+ * or the answer to "does this project exist" is decided by how recently it
246
+ * was touched. Pages are walked to exhaustion via `pagination.cursor`.
234
247
  *
235
248
  * @throws {NeonApiError} on any non-2xx (caller records the field as `unknown`).
236
249
  */
237
- export async function listNeonProjects(apiKey, deps = {}) {
250
+ export async function listNeonProjects(apiKey, orgId, deps = {}) {
238
251
  const fetchImpl = deps.fetchImpl ?? fetch;
239
- const response = await fetchImpl(`${NEON_API_BASE}/projects`, {
240
- method: 'GET',
241
- headers: neonHeaders(apiKey),
242
- signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
243
- });
244
- if (!response.ok) {
245
- throw new NeonApiError(response.status, await safeBody(response), 'GET /projects');
252
+ const collected = [];
253
+ let cursor;
254
+ for (let page = 0; page < NEON_PROJECTS_MAX_PAGES; page += 1) {
255
+ const query = new URLSearchParams({ limit: String(NEON_PROJECTS_PAGE_SIZE) });
256
+ if (orgId)
257
+ query.set('org_id', orgId);
258
+ if (cursor)
259
+ query.set('cursor', cursor);
260
+ const response = await fetchImpl(`${NEON_API_BASE}/projects?${query.toString()}`, {
261
+ method: 'GET',
262
+ headers: neonHeaders(apiKey),
263
+ signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
264
+ });
265
+ if (!response.ok) {
266
+ throw new NeonApiError(response.status, await safeBody(response), 'GET /projects');
267
+ }
268
+ const data = (await response.json());
269
+ const projects = data.projects ?? [];
270
+ for (const p of projects) {
271
+ if (p.id && p.name)
272
+ collected.push({ id: p.id, name: p.name });
273
+ }
274
+ // Neon returns a cursor even on the LAST page, so a short page โ€” not a missing
275
+ // cursor โ€” is what ends the walk; the cursor guard only catches a truncated body.
276
+ const next = data.pagination?.cursor;
277
+ if (projects.length < NEON_PROJECTS_PAGE_SIZE || !next || next === cursor)
278
+ break;
279
+ cursor = next;
246
280
  }
247
- const data = (await response.json());
248
- return (data.projects ?? [])
249
- .filter((p) => Boolean(p.id) && Boolean(p.name))
250
- .map((p) => ({ id: p.id, name: p.name }));
281
+ return collected;
251
282
  }
252
283
  /**
253
284
  * List a Neon project's branches โ€” `GET /projects/{id}/branches` (PROV-007
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The retrofit reminder printed at the tail of every applied `factory update`.
3
+ *
4
+ * WHY IT EXISTS. `update` refreshes the brain mechanically: it copies files, and when a file
5
+ * conflicts it asks and applies whichever side the dev picked. What it CANNOT do is the work
6
+ * that lives outside the tracked tree โ€” a step to paste into a workflow the kit is not allowed
7
+ * to touch, a config the derivative owns, a decision only the project can make. Those ship as
8
+ * retrofit guides, and a guide nobody is told about is a guide nobody reads: the update ends
9
+ * "โœ” actualizado", the dev moves on, and the half of the change that needed hands never lands.
10
+ *
11
+ * ๐Ÿ”ด PRINTED ON EVERY OUTCOME, unlike its sibling {@link printProvisionPointer}. `--theirs-all`,
12
+ * `--mine-all`, per-file choices, or no conflicts at all โ€” the reminder does not depend on any
13
+ * of that, because a retrofit is not a conflict: it is work the update never attempted. And it
14
+ * is NOT suppressed headless. The pointer suppresses there to keep CI logs quiet; this one does
15
+ * the opposite, because the agent running the update unattended is precisely the reader who
16
+ * cannot notice a missing step on its own, and an agent reads the transcript it is handed.
17
+ *
18
+ * The list is read from disk AFTER the apply, so it reflects the guides that just arrived
19
+ * rather than a list baked into this binary that would drift from what the kit ships.
20
+ */
21
+ import { readdirSync, readFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ /** Where the kit ships its retrofit guides, inside the derivative. */
24
+ export const RETROFITS_DIR = '.claude/docs/retrofits';
25
+ /**
26
+ * The blockquote fields a guide uses to say who it is for, in the order they are tried.
27
+ *
28
+ * The corpus is heterogeneous by history โ€” some guides lead with `Aplica si:`, older ones with
29
+ * `Para quiรฉn:` or `Audience:`. Rather than rewrite ten documents to satisfy a parser, the
30
+ * parser accepts what they already say, and a guide matching none of these is still LISTED,
31
+ * just without its one-liner. Silence about a guide would be the one failure that matters.
32
+ */
33
+ const APPLICABILITY_FIELDS = [
34
+ 'Aplica si:',
35
+ 'Aplica a:',
36
+ 'Para quiรฉn:',
37
+ 'Para:',
38
+ 'Audience:',
39
+ 'Quรฉ es:',
40
+ 'Para quien:',
41
+ ];
42
+ /** How much of the applicability line survives โ€” enough to decide, short enough to scan. */
43
+ const SUMMARY_MAX = 96;
44
+ /**
45
+ * The field every guide declares: the first kit release that shipped it.
46
+ *
47
+ * Normalized across the corpus in v11.9 by asking git which release tag CONTAINS the commit
48
+ * that created each file (`git tag --contains`), rather than inferring it from dates. It is
49
+ * what lets the reminder separate "arrived in THIS update" from "was already here".
50
+ */
51
+ const SHIPPED_SINCE_FIELD = 'Disponible desde:';
52
+ /**
53
+ * Compare two dotted versions. Returns >0 when `a` is newer.
54
+ *
55
+ * Deliberately naive โ€” no pre-release handling: these are kit release numbers, and a guide is
56
+ * never stamped with a beta. Non-numeric junk sorts as 0 rather than throwing, because a
57
+ * malformed field must degrade to "cannot tell", never break the reminder.
58
+ */
59
+ export function compareVersions(a, b) {
60
+ const pa = a.split('.').map((n) => Number.parseInt(n, 10) || 0);
61
+ const pb = b.split('.').map((n) => Number.parseInt(n, 10) || 0);
62
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
63
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
64
+ if (diff !== 0)
65
+ return diff;
66
+ }
67
+ return 0;
68
+ }
69
+ /** Pull the `Disponible desde: kit vX.Y.Z` version out of a guide's header. */
70
+ export function extractSince(markdown) {
71
+ for (const line of markdown.split('\n').slice(0, 20)) {
72
+ const at = line.indexOf(SHIPPED_SINCE_FIELD);
73
+ if (at === -1)
74
+ continue;
75
+ const match = line.slice(at).match(/v?(\d+\.\d+(?:\.\d+)?)/);
76
+ if (match)
77
+ return match[1];
78
+ }
79
+ return null;
80
+ }
81
+ /**
82
+ * Strip markdown emphasis and links down to the words, for a terminal line.
83
+ *
84
+ * ๐Ÿ”ด Only `**` and backticks come out. A lone `_` or `*` is left alone, because in this corpus
85
+ * both are punctuation of an IDENTIFIER far more often than emphasis: stripping them turned
86
+ * `NEXT_PUBLIC_AUTH_*` into `NEXTPUBLICAUTH` inside the very guide about that variable. Leaving
87
+ * a stray emphasis marker on screen is the cheaper mistake. These guides bold with `**`.
88
+ */
89
+ function plainText(raw) {
90
+ return raw
91
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // [text](link) โ†’ text
92
+ .replace(/\*\*/g, '') // bold โ€” the only emphasis these headers use
93
+ .replace(/`/g, '')
94
+ .replace(/\s+/g, ' ')
95
+ .trim();
96
+ }
97
+ /**
98
+ * Pull the applicability one-liner out of a guide's header.
99
+ *
100
+ * Only the first lines are scanned: every guide states this in its opening blockquote, and
101
+ * reading further would start matching prose from the body.
102
+ */
103
+ export function extractApplicability(markdown) {
104
+ const head = markdown.split('\n').slice(0, 12);
105
+ for (const field of APPLICABILITY_FIELDS) {
106
+ for (const line of head) {
107
+ const at = line.indexOf(field);
108
+ if (at === -1)
109
+ continue;
110
+ const text = plainText(line.slice(at + field.length));
111
+ if (!text)
112
+ continue;
113
+ return text.length > SUMMARY_MAX ? `${text.slice(0, SUMMARY_MAX - 1)}โ€ฆ` : text;
114
+ }
115
+ }
116
+ // The "Retrofit shipped (โ€ฆ) โ€” aplica a X" shape of the older guides.
117
+ for (const line of head) {
118
+ const dash = line.match(/โ€”\s*(aplica a .+)$/i);
119
+ if (dash) {
120
+ const text = plainText(dash[1]);
121
+ return text.length > SUMMARY_MAX ? `${text.slice(0, SUMMARY_MAX - 1)}โ€ฆ` : text;
122
+ }
123
+ }
124
+ return null;
125
+ }
126
+ /**
127
+ * The guides present in this checkout, alphabetical. An absent or empty directory yields `[]`
128
+ * โ€” a `core`-profile derivative does not receive them, and it must not be told to read a
129
+ * directory it does not have.
130
+ */
131
+ export function listRetrofits(rootDir, deps = {}) {
132
+ const readDir = deps.readDir ?? ((dir) => readdirSync(dir));
133
+ const readFile = deps.readFile ?? ((file) => readFileSync(file, 'utf8'));
134
+ let names;
135
+ try {
136
+ names = readDir(join(rootDir, RETROFITS_DIR));
137
+ }
138
+ catch {
139
+ return []; // no directory โ†’ nothing to remind about
140
+ }
141
+ return names
142
+ .filter((n) => n.endsWith('.md'))
143
+ .sort()
144
+ .map((name) => {
145
+ const path = `${RETROFITS_DIR}/${name}`;
146
+ let applies = null;
147
+ let since = null;
148
+ try {
149
+ const markdown = readFile(join(rootDir, RETROFITS_DIR, name));
150
+ applies = extractApplicability(markdown);
151
+ since = extractSince(markdown);
152
+ }
153
+ catch {
154
+ // unreadable โ†’ still listed, just bare
155
+ }
156
+ return { slug: name.replace(/\.md$/, ''), path, applies, since };
157
+ });
158
+ }
159
+ /**
160
+ * Split the guides into the ones this update just brought and the ones already there.
161
+ *
162
+ * `fromVersion` is the lockfile's version BEFORE the update. Without it โ€” a first install, or
163
+ * a lockfile too old to say โ€” everything lands in `carried`, which reads as "review them all"
164
+ * and is the honest answer: the reminder cannot know what you already saw.
165
+ *
166
+ * ๐Ÿ”ด A guide whose header states no version also lands in `carried`, NEVER in `arrived`. Both
167
+ * buckets are shown, so nothing is hidden either way; putting an unknown in `arrived` would
168
+ * cry wolf on every single update forever, which is how a notice trains people to skip it.
169
+ */
170
+ export function splitByArrival(entries, fromVersion) {
171
+ if (!fromVersion)
172
+ return { arrived: [], carried: entries };
173
+ const arrived = entries.filter((e) => e.since && compareVersions(e.since, fromVersion) > 0);
174
+ const carried = entries.filter((e) => !arrived.includes(e));
175
+ return { arrived, carried };
176
+ }
177
+ /** Render the reminder, or `null` when this checkout ships no guides. */
178
+ export function renderRetrofitReminder(entries, fromVersion = null) {
179
+ if (entries.length === 0)
180
+ return null;
181
+ const { arrived, carried } = splitByArrival(entries, fromVersion);
182
+ const rule = 'โ•'.repeat(74);
183
+ const lines = [
184
+ '',
185
+ rule,
186
+ ' โš ๏ธ REVISA LAS GUรAS DE RETROFIT ANTES DE DAR EL UPDATE POR TERMINADO',
187
+ rule,
188
+ '',
189
+ ' El update mueve archivos. Lo que NO puede mover es lo que vive fuera del รกrbol',
190
+ ' que el kit controla: un step en TU workflow, un config que es tuyo, una decisiรณn',
191
+ ' que solo tu proyecto puede tomar. Eso viaja como guรญa, y NADA de esto se aplicรณ.',
192
+ '',
193
+ ];
194
+ const describe = (entry) => {
195
+ const out = [` ยท ${entry.slug}`];
196
+ if (entry.applies)
197
+ out.push(` ${entry.applies}`);
198
+ return out;
199
+ };
200
+ if (arrived.length > 0) {
201
+ lines.push(` โ–ธ NUEVAS EN ESTE UPDATE (${arrived.length}) โ€” empieza por aquรญ:`, '');
202
+ for (const entry of arrived)
203
+ lines.push(...describe(entry));
204
+ lines.push('');
205
+ }
206
+ if (carried.length > 0) {
207
+ // The heading changes with what we can claim: with no `fromVersion` there is no "already
208
+ // had", only "here they are".
209
+ lines.push(arrived.length > 0
210
+ ? ` โ–ธ Ya estaban (${carried.length}) โ€” por si nunca las aplicaste:`
211
+ : ` โ–ธ Guรญas disponibles (${carried.length}):`, '');
212
+ for (const entry of carried)
213
+ lines.push(...describe(entry));
214
+ lines.push('');
215
+ }
216
+ lines.push(` No todas te aplican: cada una abre diciendo a quiรฉn le toca. En ${RETROFITS_DIR}/`, ' Si trabajas con un agente, pรญdele que las lea y te diga cuรกles te aplican.', rule, '');
217
+ return lines.join('\n');
218
+ }
219
+ /**
220
+ * Print the reminder. Called on EVERY applied update โ€” see the module header for why this one
221
+ * has no headless guard and no outcome condition.
222
+ */
223
+ export function printRetrofitReminder(opts) {
224
+ const log = opts.log ?? ((m) => console.log(m));
225
+ const rendered = renderRetrofitReminder(listRetrofits(opts.rootDir, opts), opts.fromVersion ?? null);
226
+ if (rendered)
227
+ log(rendered);
228
+ }
@@ -304,24 +304,113 @@ export async function removeVercelDomain(projectId, name, token, teamId, deps =
304
304
  }
305
305
  }
306
306
  /**
307
- * List the team's Vercel projects โ€” `GET /v9/projects?teamId=&limit=20` (PROV-007
307
+ * Delete a Vercel PROJECT over REST, with the same token that created it.
308
+ *
309
+ * ๐Ÿ”ด Why REST and not the dev's `vercel remove`. The project is CREATED by
310
+ * {@link createVercelProject} with the rail token and a `teamId`, so it lives inside the
311
+ * team. `vercel remove` runs against whatever scope the dev's CLI session happens to be
312
+ * authenticated to โ€” in a measured teardown that was a personal scope, so the delete failed
313
+ * with "Not authorized โ€ฆ under scope" and left a live project nobody was going to look for.
314
+ * Create and delete have to be able to reach the same place.
315
+ *
316
+ * This is not a widening of the rail token's blast radius, and it is not a new policy either:
317
+ * {@link removeVercelDomain}, called from the very same teardown, already deletes over REST
318
+ * with this token. The project delete was the outlier.
319
+ *
320
+ * Idempotency (same contract as its domain sibling): a 404 means it is already gone and
321
+ * resolves to `'absent'`, so a re-run after a partial teardown is a no-op.
322
+ *
323
+ * @throws {VercelApiError} on any non-2xx EXCEPT 404 (which โ†’ `'absent'`).
324
+ */
325
+ export async function removeVercelProject(projectId, token, teamId, deps = {}) {
326
+ try {
327
+ await vercelApi('DELETE', `/v9/projects/${encodeURIComponent(projectId)}`, token, teamId, deps);
328
+ return 'removed';
329
+ }
330
+ catch (error) {
331
+ if (error instanceof VercelApiError && error.status === 404) {
332
+ return 'absent';
333
+ }
334
+ throw error;
335
+ }
336
+ }
337
+ /** Page size for the project listing โ€” Vercel's documented maximum (`limit` โ‰ค 100). */
338
+ const VERCEL_PROJECTS_PAGE_SIZE = 100;
339
+ /** Hard stop on the pagination loop โ€” 20 full pages is 2000 projects, far past any team. */
340
+ const VERCEL_PROJECTS_MAX_PAGES = 20;
341
+ /**
342
+ * Look up ONE Vercel project by name โ€” `GET /v9/projects/{idOrName}` (PROV-007
343
+ * `--adopt`). Vercel accepts the project NAME in that path slot, which is the
344
+ * cheap exact answer to "is there a project called `{slug}`": one request, no
345
+ * pagination, no substring heuristic. Returns `null` on a 404 (no such project)
346
+ * so the caller can fall back to the fuzzy listing below.
347
+ *
348
+ * @throws {VercelApiError} on any non-2xx OTHER than 404 (401/403 must not be
349
+ * read as "absent" โ€” a bad token is a different fact from a missing project).
350
+ */
351
+ export async function getVercelProjectByName(name, token, teamId, deps = {}) {
352
+ try {
353
+ const data = (await vercelApi('GET', `/v9/projects/${encodeURIComponent(name)}`, token, teamId, deps));
354
+ return data.id && data.name ? { id: data.id, name: data.name } : null;
355
+ }
356
+ catch (error) {
357
+ if (error instanceof VercelApiError && error.status === 404)
358
+ return null;
359
+ throw error;
360
+ }
361
+ }
362
+ /**
363
+ * List the team's Vercel projects โ€” `GET /v9/projects?teamId=` (PROV-007
308
364
  * `--adopt`). STRICTLY read-only: it never POSTs/PATCHes/DELETEs anything. The
309
365
  * caller filters the returned summaries by slug/name to reconstruct the adopted
310
366
  * state; an exact-name match is preferred, but every project name that CONTAINS
311
367
  * the slug is returned as a candidate so the caller can disambiguate (issue ยง4).
312
368
  *
313
- * `limit` is threaded as a query param (default 20 โ€” issue ยง6); `teamId` flows
314
- * through {@link vercelUrl} like every other call. Used by `--adopt` only.
369
+ * Pages are walked to exhaustion (`pagination.next` โ†’ `until`). It used to take
370
+ * the first 20 and stop, which on a real team meant the listing answered "are you
371
+ * among the 20 most recently touched projects", not "do you exist" โ€” a project
372
+ * further down was recorded `unknown` with no way to tell that from absent.
315
373
  *
316
374
  * @throws {VercelApiError} on any non-2xx (caller maps it / records `unknown`).
317
375
  */
318
- export async function listVercelProjects(token, teamId, deps = {}, limit = 20) {
319
- const data = (await vercelApi('GET', '/v9/projects', token, teamId, deps, undefined, {
320
- limit: String(limit),
321
- }));
322
- return (data.projects ?? [])
323
- .filter((p) => Boolean(p.id) && Boolean(p.name))
324
- .map((p) => ({ id: p.id, name: p.name }));
376
+ export async function listVercelProjects(token, teamId, deps = {}) {
377
+ const collected = [];
378
+ let until;
379
+ for (let page = 0; page < VERCEL_PROJECTS_MAX_PAGES; page += 1) {
380
+ const query = { limit: String(VERCEL_PROJECTS_PAGE_SIZE) };
381
+ if (until !== undefined)
382
+ query.until = String(until);
383
+ const data = (await vercelApi('GET', '/v9/projects', token, teamId, deps, undefined, query));
384
+ for (const p of data.projects ?? []) {
385
+ if (p.id && p.name)
386
+ collected.push({ id: p.id, name: p.name });
387
+ }
388
+ const next = data.pagination?.next;
389
+ if (next === undefined || next === null || next === until)
390
+ break;
391
+ until = next;
392
+ }
393
+ return collected;
394
+ }
395
+ /**
396
+ * Apex of the hosts Vercel mints on its own (`{project}.vercel.app`). Nobody
397
+ * created these and nobody can delete them, so they are the LAST choice when
398
+ * reconstructing a project's domains and are skipped by the teardown.
399
+ */
400
+ export const VERCEL_AUTO_APEX = 'vercel.app';
401
+ /**
402
+ * List the domains attached to a Vercel project โ€” `GET /v9/projects/{id}/domains`
403
+ * (PROV-007 `--adopt`). STRICTLY read-only. This is the authority on where a
404
+ * derived app actually answers: a project on a client's own domain has no CNAME
405
+ * in any org zone, so DNS alone cannot reconstruct its hosts.
406
+ *
407
+ * @throws {VercelApiError} on any non-2xx (caller degrades, never fails the adopt).
408
+ */
409
+ export async function listProjectDomains(projectId, token, teamId, deps = {}) {
410
+ const data = (await vercelApi('GET', `/v9/projects/${encodeURIComponent(projectId)}/domains`, token, teamId, deps));
411
+ return (data.domains ?? [])
412
+ .filter((d) => Boolean(d.name))
413
+ .map((d) => ({ name: d.name, apexName: d.apexName ?? '', gitBranch: d.gitBranch ?? null }));
325
414
  }
326
415
  /**
327
416
  * List the project's existing env vars (issue ยง8 idempotency: we PATCH a var that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "1.23.1",
3
+ "version": "1.25.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",