@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.
- package/dist/commands/doctor.js +39 -0
- package/dist/commands/provision.js +561 -51
- package/dist/commands/update.js +11 -0
- package/dist/lib/initial-migration.js +131 -0
- package/dist/lib/neon-api.js +45 -14
- package/dist/lib/retrofit-reminder.js +228 -0
- package/dist/lib/vercel-env.js +99 -10
- package/package.json +1 -1
package/dist/commands/update.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/lib/neon-api.js
CHANGED
|
@@ -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).
|
|
233
|
-
*
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/lib/vercel-env.js
CHANGED
|
@@ -304,24 +304,113 @@ export async function removeVercelDomain(projectId, name, token, teamId, deps =
|
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
306
|
/**
|
|
307
|
-
*
|
|
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
|
-
*
|
|
314
|
-
*
|
|
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 = {}
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|