create-ailk 0.28.0 → 0.29.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.
Files changed (37) hide show
  1. package/README.md +124 -1
  2. package/dist/cli.js +92 -2
  3. package/dist/lib/chrome-readers.d.ts +21 -5
  4. package/dist/lib/chrome-readers.js +128 -20
  5. package/dist/lib/declared-env.d.ts +6 -0
  6. package/dist/lib/declared-env.js +2 -0
  7. package/dist/lib/import-graph.d.ts +16 -0
  8. package/dist/lib/import-graph.js +53 -0
  9. package/dist/lib/import-mdx.d.ts +59 -0
  10. package/dist/lib/import-mdx.js +452 -0
  11. package/dist/migrate-content.d.ts +45 -0
  12. package/dist/migrate-content.js +215 -0
  13. package/dist/seed-workspace.d.ts +71 -0
  14. package/dist/seed-workspace.js +242 -0
  15. package/dist/template-record.d.ts +13 -4
  16. package/dist/template-record.js +8 -4
  17. package/dist/update.d.ts +32 -2
  18. package/dist/update.js +357 -44
  19. package/package.json +3 -3
  20. package/templates/apps/api/CLAUDE.md +1 -1
  21. package/templates/apps/mcp/CLAUDE.md +16 -12
  22. package/templates/apps/web/CLAUDE.md +11 -1
  23. package/templates/apps/web/app/[locale]/[slug]/page.tsx +4 -0
  24. package/templates/apps/web/app/[locale]/alternatives/page.tsx +9 -1
  25. package/templates/apps/web/app/[locale]/careers/page.tsx +23 -15
  26. package/templates/apps/web/app/[locale]/clients/page.tsx +13 -1
  27. package/templates/apps/web/app/[locale]/faq/page.tsx +9 -0
  28. package/templates/apps/web/app/[locale]/glossary/page.tsx +3 -0
  29. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +1 -0
  30. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +1 -0
  31. package/templates/apps/web/app/[locale]/templates/page.tsx +6 -0
  32. package/templates/apps/web/app/[locale]/testimonials/page.tsx +13 -1
  33. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +1 -0
  34. package/templates/apps/web/lib/hub-children.tsx +38 -9
  35. package/templates/apps/web/messages/en.json +22 -2
  36. package/templates/tools/declared-env.ts +7 -0
  37. package/versions.json +48 -48
package/dist/update.js CHANGED
@@ -10,10 +10,16 @@
10
10
  * version's copy — read from this package's own `templates/` when that is
11
11
  * the running version, else re-materialised from the published npm tarball
12
12
  * (`lib/fetch-template-snapshot.ts`) — and the running version's copy. A
13
- * conflict leaves the file untouched and is listed under "Needs review". Every
14
- * template test that imports a file left under review or skipped is held at
15
- * the site's version with it, so a run never leaves a test newer than the
16
- * code it exercises (#8704). A site with no record gets one backfilled from
13
+ * conflict leaves the file untouched and is listed under "Needs review" — but
14
+ * a `apps/web/messages/` catalog takes the template's new keys and keeps the
15
+ * site's values, reviewed only on a key-level conflict (#10173). Every
16
+ * template file this run would write that imports, transitively, a file left
17
+ * under review or skipped — or a file the site will not have — is held at the
18
+ * site's version with it, so a run never writes code newer than what it
19
+ * imports (#8704 for tests, #10173 for the rest). The env vars the written
20
+ * files read gain `project.yaml` `env_contract` rows (#10172), and a record
21
+ * row at a path no longer recorded is dropped, not refused (#10173). A site
22
+ * with no record gets one backfilled from
17
23
  * `.ailk-site-manifest.json`'s `patternVersion` on its first run — one row
18
24
  * per in-scope path of that version's snapshot, on disk or not (#9527) —
19
25
  * then updates normally. A site scaffolded before `@working-theory/mcp-server`
@@ -35,13 +41,16 @@
35
41
  * Pure module: no `import.meta.url` — the CLI supplies `templatesDir`, the
36
42
  * `adopt.ts` / `add-route.ts` split.
37
43
  */
38
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync, writeFileSync, } from "node:fs";
44
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync, statSync, writeFileSync, } from "node:fs";
39
45
  import { dirname, join, relative, resolve, sep } from "node:path";
40
46
  import { parseArgs } from "node:util";
41
47
  import { AddModuleRefusedError, readArchitectureHeader } from "./add-module-core.js";
42
48
  import { headingViolations, mdxBodyRule, migrateBodyHeadings } from "./body-heading.js";
43
49
  import { buildScaffoldMarkers, isExcluded, isTextFile, renderTemplateFile, templateDestination } from "./copy.js";
50
+ import { EnvContractError, planEnvRows, readEnvContract, readManifestTopology, } from "./lib/declared-env.js";
51
+ import { ENV_CATALOG } from "./lib/env-catalog.js";
44
52
  import { fetchTemplateSnapshot } from "./lib/fetch-template-snapshot.js";
53
+ import { exportsName, listFilesUnder, namedImportsOf } from "./lib/import-graph.js";
45
54
  import { mergeThreeWay } from "./lib/merge-three-way.js";
46
55
  import { publicTreeRemoved } from "./lib/site-modules.js";
47
56
  import { MODULE_SLICES } from "./module-architecture.js";
@@ -170,6 +179,7 @@ function containsKnownPath(prefix, knownPaths) {
170
179
  const TEST_FILE = /(^|\/)__tests__\/|\.(test|spec)\.[cm]?[jt]sx?$/;
171
180
  /** Module specifiers `import`/`export … from`, `import()`, `require()` and `jest.mock()` name. */
172
181
  const IMPORT_SPECIFIER = /\bfrom\s*["']([^"']+)["']|\bimport\s*["']([^"']+)["']|\b(?:import|require|jest\.mock|jest\.requireActual)\s*\(\s*["']([^"']+)["']/g;
182
+ const WEB_ROOT = "apps/web/";
173
183
  const RESOLVE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"];
174
184
  /**
175
185
  * The record-style paths a relative specifier in `fromPath` can resolve to:
@@ -177,8 +187,10 @@ const RESOLVE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs"
177
187
  * swapped for its TypeScript source, and as a directory's `index.*`.
178
188
  */
179
189
  function relativeImportCandidates(fromPath, specifier) {
180
- const parts = fromPath.split("/").slice(0, -1);
181
- for (const segment of specifier.split("/")) {
190
+ // `@/x` is apps/web's `x` — its tsconfig `paths`; no other app declares one.
191
+ const alias = specifier.startsWith("@/") && fromPath.startsWith(WEB_ROOT);
192
+ const parts = alias ? ["apps", "web"] : fromPath.split("/").slice(0, -1);
193
+ for (const segment of (alias ? specifier.slice(2) : specifier).split("/")) {
182
194
  if (segment === "" || segment === ".")
183
195
  continue;
184
196
  if (segment === "..")
@@ -196,13 +208,24 @@ function relativeImportCandidates(fromPath, specifier) {
196
208
  out.push(...RESOLVE_EXTENSIONS.map((ext) => `${target}/index${ext}`));
197
209
  return out;
198
210
  }
199
- /** The files of `held` that `text`, the source of `fromPath`, imports by relative specifier. */
200
- function importedHeldFiles(fromPath, text, held) {
211
+ /** Whether `specifier` names a site file: relative, or `@/` from an apps/web file. */
212
+ function isLocal(fromPath, specifier) {
213
+ return specifier.startsWith(".") || (specifier.startsWith("@/") && fromPath.startsWith(WEB_ROOT));
214
+ }
215
+ /** The local module specifiers `text` names. */
216
+ function localSpecifiers(fromPath, text) {
201
217
  const out = new Set();
202
218
  for (const match of text.matchAll(IMPORT_SPECIFIER)) {
203
219
  const specifier = match[1] ?? match[2] ?? match[3];
204
- if (specifier === undefined || !specifier.startsWith("."))
205
- continue;
220
+ if (specifier !== undefined && isLocal(fromPath, specifier))
221
+ out.add(specifier);
222
+ }
223
+ return [...out];
224
+ }
225
+ /** The files of `held` that `text`, the source of `fromPath`, imports by local specifier. */
226
+ function importedHeldFiles(fromPath, text, held) {
227
+ const out = new Set();
228
+ for (const specifier of localSpecifiers(fromPath, text)) {
206
229
  for (const candidate of relativeImportCandidates(fromPath, specifier)) {
207
230
  if (held.has(candidate))
208
231
  out.add(candidate);
@@ -210,6 +233,57 @@ function importedHeldFiles(fromPath, text, held) {
210
233
  }
211
234
  return [...out];
212
235
  }
236
+ /** The `apps/web/messages/` catalogs next-intl loads by locale, never by import. */
237
+ const MESSAGES_JSON = /^apps\/web\/messages\/(?:[^/]+\/)*[^/]+\.json$/;
238
+ const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
239
+ function isPlainObject(value) {
240
+ return typeof value === "object" && value !== null && !Array.isArray(value);
241
+ }
242
+ /**
243
+ * The site's messages catalog with every key the template's copy has and it
244
+ * lacks (#10173): the site's values always win, nothing is removed, and the
245
+ * keys it gains follow its own. A key that is an object on one side and a
246
+ * value on the other is a conflict, and nothing is merged.
247
+ */
248
+ export function mergeMessagesAdditively(ours, theirs) {
249
+ let oursDoc;
250
+ let theirsDoc;
251
+ try {
252
+ oursDoc = JSON.parse(ours);
253
+ theirsDoc = JSON.parse(theirs);
254
+ }
255
+ catch (err) {
256
+ return { status: "invalid", reason: `not JSON: ${err.message}` };
257
+ }
258
+ if (!isPlainObject(oursDoc) || !isPlainObject(theirsDoc)) {
259
+ return { status: "invalid", reason: "the root is not an object" };
260
+ }
261
+ const conflicts = [];
262
+ let added = 0;
263
+ const walk = (o, t, at) => {
264
+ for (const [key, value] of Object.entries(t)) {
265
+ if (UNSAFE_KEYS.has(key))
266
+ continue; // never written through, so never a prototype change
267
+ const path = at === "" ? key : `${at}.${key}`;
268
+ if (!Object.prototype.hasOwnProperty.call(o, key)) {
269
+ o[key] = value;
270
+ added++;
271
+ continue;
272
+ }
273
+ const mine = o[key];
274
+ if (isPlainObject(mine) && isPlainObject(value))
275
+ walk(mine, value, path);
276
+ else if (isPlainObject(mine) || isPlainObject(value))
277
+ conflicts.push(path);
278
+ }
279
+ };
280
+ walk(oursDoc, theirsDoc, "");
281
+ if (conflicts.length > 0)
282
+ return { status: "conflict", keys: conflicts };
283
+ const indent = /\n(\t| +)"/.exec(ours)?.[1] ?? 2;
284
+ const text = JSON.stringify(oursDoc, null, indent) + (ours.endsWith("\n") ? "\n" : "");
285
+ return { status: "merged", text: added === 0 ? ours : text, added };
286
+ }
213
287
  // ── The MCP move (#9567): a copied apps/mcp becomes the boot set ──────────
214
288
  const MCP_SERVER_PACKAGE = "@working-theory/mcp-server";
215
289
  const MCP_PACKAGE_JSON = "apps/mcp/package.json";
@@ -789,9 +863,14 @@ export async function updateSite(opts) {
789
863
  const recordPath = join(targetDir, TEMPLATE_RECORD_PATH);
790
864
  let record;
791
865
  let backfillPatternVersion;
866
+ // #10173 — a row at a path a later create-ailk no longer records (a
867
+ // generated file) is dropped and reported, never a refusal of the run.
868
+ const droppedRows = [];
792
869
  if (existsSync(recordPath)) {
793
870
  try {
794
- record = parseTemplateRecord(readFileSync(recordPath, "utf8"));
871
+ record = parseTemplateRecord(readFileSync(recordPath, "utf8"), {
872
+ onUnrecordable: (key) => droppedRows.push(key),
873
+ });
795
874
  }
796
875
  catch (err) {
797
876
  throw new UpdateRefusedError(err.message);
@@ -1081,6 +1160,26 @@ export async function updateSite(opts) {
1081
1160
  nextFiles[path] = { sha256: newHash, version };
1082
1161
  merged.push(path);
1083
1162
  }
1163
+ else if (mergeResult.status === "conflict" && MESSAGES_JSON.test(path)) {
1164
+ // #10173 — a messages catalog takes the template's new keys and keeps
1165
+ // every value the site has; only a key-level conflict is reviewed.
1166
+ const additive = mergeMessagesAdditively(diskBytes.toString("utf8"), Buffer.from(newBytes).toString("utf8"));
1167
+ if (additive.status === "conflict") {
1168
+ review.push({
1169
+ path,
1170
+ reason: `conflicts with the template change ${rec.version} → ${version} at key ${additive.keys.join(", ")}`,
1171
+ });
1172
+ }
1173
+ else if (additive.status === "invalid") {
1174
+ review.push({ path, reason: `the additive pass could not run: ${additive.reason}` });
1175
+ }
1176
+ else {
1177
+ if (additive.added > 0)
1178
+ writes.push({ path, bytes: Buffer.from(additive.text, "utf8") });
1179
+ nextFiles[path] = { sha256: newHash, version };
1180
+ merged.push(path);
1181
+ }
1182
+ }
1084
1183
  else if (mergeResult.status === "conflict") {
1085
1184
  review.push({
1086
1185
  path,
@@ -1151,46 +1250,209 @@ export async function updateSite(opts) {
1151
1250
  // exercising the held file is enough. A held test-support file
1152
1251
  // (`__tests__/fixtures.ts`) holds the tests importing it in turn, until no
1153
1252
  // further test is held.
1253
+ //
1254
+ // #10173 — the same holds for every other template file this run would
1255
+ // write: one importing a held file stays at the site's version (or, when
1256
+ // new, is not added), so the written tree never imports an export the held
1257
+ // file lacks. A written file whose relative import names a file the site
1258
+ // will not have once the run lands is held too, under that missing path.
1259
+ // Only the bytes being written are read for a non-test file: the site's
1260
+ // own copy already compiles against what it has.
1154
1261
  const heldFiles = new Set([...review, ...skipped].map((row) => row.path));
1262
+ // The files left alone outright, before any hold-back joins them.
1263
+ const leftAlone = new Set(heldFiles);
1264
+ const writtenBytes = new Map(writes.map((w) => [w.path, w.bytes]));
1265
+ // The moves' removals (#9567, #9566) land too: never code that stays.
1266
+ const deleting = new Set([...deletes, ...(mcpMove?.deletes ?? []), ...(apiMove?.deletes ?? [])]);
1267
+ const adding = new Set(added);
1155
1268
  const heldBackByFile = new Map();
1156
- const heldTests = new Set();
1157
- const moving = [...updated, ...merged, ...added, ...removed, ...current]
1158
- .filter((path) => TEST_FILE.test(path))
1269
+ const heldPaths = new Set();
1270
+ const moving = [...new Set([...updated, ...merged, ...added, ...removed, ...current])].sort();
1271
+ const isFileCache = new Map();
1272
+ const isFileOnDisk = (path) => {
1273
+ let hit = isFileCache.get(path);
1274
+ if (hit === undefined) {
1275
+ try {
1276
+ hit = statSync(join(targetDir, ...path.split("/"))).isFile();
1277
+ }
1278
+ catch {
1279
+ hit = false;
1280
+ }
1281
+ isFileCache.set(path, hit);
1282
+ }
1283
+ return hit;
1284
+ };
1285
+ // What the run rewrites after the record's writes, never from the template.
1286
+ const rewrittenLater = new Set([
1287
+ MODULE_FLAGS_PATH,
1288
+ PAID_MCP_TOOLS_PATH,
1289
+ ...(mcpMove?.seam !== undefined ? [MCP_SEAM_PATH] : []),
1290
+ ]);
1291
+ /** Whether `path` is a file once this run's writes and deletes, less the held ones, land. */
1292
+ const presentAfter = (path) => {
1293
+ if (heldPaths.has(path))
1294
+ return isFileOnDisk(path);
1295
+ if (adding.has(path) || rewrittenLater.has(path))
1296
+ return true;
1297
+ if (deleting.has(path))
1298
+ return false;
1299
+ return isFileOnDisk(path);
1300
+ };
1301
+ const sourcesOf = new Map();
1302
+ for (const path of moving) {
1303
+ const writing = writtenBytes.get(path);
1304
+ const written = writing !== undefined ? writing.toString("utf8") : undefined;
1305
+ const newBytes = newMap.get(path);
1306
+ const newText = newBytes !== undefined ? Buffer.from(newBytes).toString("utf8") : undefined;
1307
+ const all = [];
1308
+ if (TEST_FILE.test(path)) {
1309
+ const abs = join(targetDir, ...path.split("/"));
1310
+ if (existsSync(abs))
1311
+ all.push(readFileSync(abs, "utf8"));
1312
+ if (newText !== undefined)
1313
+ all.push(newText);
1314
+ if (written !== undefined)
1315
+ all.push(written);
1316
+ }
1317
+ else {
1318
+ const text = written ?? newText;
1319
+ if (text !== undefined)
1320
+ all.push(text);
1321
+ }
1322
+ sourcesOf.set(path, { all, written });
1323
+ }
1324
+ const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
1325
+ const hold = (path, file, missing) => {
1326
+ const entry = heldBackByFile.get(file) ?? { tests: [], importers: [], missing };
1327
+ const list = TEST_FILE.test(path) ? entry.tests : entry.importers;
1328
+ if (!list.includes(path))
1329
+ list.push(path);
1330
+ heldBackByFile.set(file, entry);
1331
+ };
1332
+ const importsCache = new Map();
1333
+ const namedImportsAt = (path, text) => {
1334
+ let hit = importsCache.get(path);
1335
+ if (hit === undefined)
1336
+ importsCache.set(path, (hit = namedImportsOf(text)));
1337
+ return hit;
1338
+ };
1339
+ const textCache = new Map();
1340
+ const diskText = (path) => {
1341
+ if (!textCache.has(path)) {
1342
+ textCache.set(path, isFileOnDisk(path) ? readFileSync(join(targetDir, ...path.split("/")), "utf8") : undefined);
1343
+ }
1344
+ return textCache.get(path);
1345
+ };
1346
+ // Every app source file this run does not write: the code that keeps the
1347
+ // site's version and must still find what it imports.
1348
+ const stayingFiles = [...listFilesUnder(targetDir, ["apps/web", "apps/api", "apps/mcp"])]
1349
+ .filter((path) => !writtenBytes.has(path) && !deleting.has(path))
1159
1350
  .sort();
1160
- for (let grew = heldFiles.size > 0; grew;) {
1351
+ let grew = true;
1352
+ const holdPath = (path) => {
1353
+ heldPaths.add(path);
1354
+ heldFiles.add(path);
1355
+ grew = true;
1356
+ };
1357
+ while (grew) {
1161
1358
  grew = false;
1162
1359
  for (const path of moving) {
1163
- if (heldTests.has(path))
1360
+ if (heldPaths.has(path))
1164
1361
  continue;
1165
- const sources = [];
1166
- const abs = join(targetDir, ...path.split("/"));
1167
- if (existsSync(abs))
1168
- sources.push(readFileSync(abs, "utf8"));
1169
- const newBytes = newMap.get(path);
1170
- if (newBytes !== undefined)
1171
- sources.push(Buffer.from(newBytes).toString("utf8"));
1172
- const imported = new Set(sources.flatMap((text) => importedHeldFiles(path, text, heldFiles)));
1173
- if (imported.size === 0)
1362
+ const { all, written } = sourcesOf.get(path);
1363
+ const imported = new Set(all.flatMap((text) => importedHeldFiles(path, text, heldFiles)));
1364
+ const missing = new Set();
1365
+ if (written !== undefined && !TEST_FILE.test(path)) {
1366
+ for (const specifier of localSpecifiers(path, written)) {
1367
+ const candidates = relativeImportCandidates(path, specifier);
1368
+ if (candidates.some((c) => heldFiles.has(c) || presentAfter(c)))
1369
+ continue;
1370
+ // A dot-directory (`.source/`, `.next/`) is build output the site
1371
+ // generates, never a file update could have carried.
1372
+ if (/(^|\/)\.[^./][^/]*\//.test(candidates[0]))
1373
+ continue;
1374
+ missing.add(candidates[0]);
1375
+ }
1376
+ }
1377
+ // A named import the file it resolves to — staying at the site's
1378
+ // version — does not export (a regenerated nav-config, say).
1379
+ const lacking = new Set();
1380
+ if (written !== undefined) {
1381
+ for (const { specifier, names } of namedImportsOf(written)) {
1382
+ if (!isLocal(path, specifier))
1383
+ continue;
1384
+ const target = relativeImportCandidates(path, specifier).find(isFileOnDisk);
1385
+ if (target === undefined || heldFiles.has(target) || rewrittenLater.has(target))
1386
+ continue;
1387
+ if (writtenBytes.has(target) && !heldPaths.has(target))
1388
+ continue;
1389
+ if (deleting.has(target))
1390
+ continue; // the missing check above owns it
1391
+ if (!SOURCE_FILE.test(target))
1392
+ continue;
1393
+ const text = diskText(target);
1394
+ if (text !== undefined && names.some((name) => !exportsName(text, name)))
1395
+ lacking.add(target);
1396
+ }
1397
+ }
1398
+ if (imported.size === 0 && missing.size === 0 && lacking.size === 0)
1399
+ continue;
1400
+ holdPath(path);
1401
+ for (const file of imported)
1402
+ hold(path, file, false);
1403
+ for (const file of missing)
1404
+ hold(path, file, true);
1405
+ for (const file of lacking)
1406
+ hold(path, file, false);
1407
+ }
1408
+ // The reverse: a file staying at the site's version imports, by name,
1409
+ // something the written copy of its target no longer exports.
1410
+ for (const staying of [...stayingFiles, ...heldPaths]) {
1411
+ const text = diskText(staying);
1412
+ if (text === undefined)
1413
+ continue;
1414
+ // A file left alone keeps the code it was written against: every
1415
+ // template file it imports stays with it, whatever that file exports.
1416
+ if (leftAlone.has(staying)) {
1417
+ for (const specifier of localSpecifiers(staying, text)) {
1418
+ const target = relativeImportCandidates(staying, specifier).find((c) => writtenBytes.has(c) && !heldPaths.has(c));
1419
+ if (target === undefined || !SOURCE_FILE.test(target))
1420
+ continue;
1421
+ holdPath(target);
1422
+ hold(target, staying, false);
1423
+ }
1174
1424
  continue;
1175
- heldTests.add(path);
1176
- heldFiles.add(path);
1177
- grew = true;
1178
- for (const file of imported) {
1179
- heldBackByFile.set(file, [...(heldBackByFile.get(file) ?? []), path]);
1425
+ }
1426
+ for (const { specifier, names } of namedImportsAt(staying, text)) {
1427
+ if (!isLocal(staying, specifier))
1428
+ continue;
1429
+ const target = relativeImportCandidates(staying, specifier).find((c) => writtenBytes.has(c) && !heldPaths.has(c));
1430
+ if (target === undefined || !SOURCE_FILE.test(target))
1431
+ continue;
1432
+ const next = writtenBytes.get(target).toString("utf8");
1433
+ if (names.every((name) => exportsName(next, name)))
1434
+ continue;
1435
+ holdPath(target);
1436
+ hold(target, staying, false);
1180
1437
  }
1181
1438
  }
1182
1439
  }
1183
- for (const path of heldTests) {
1440
+ for (const path of heldPaths) {
1184
1441
  const recorded = record.files[path];
1185
1442
  if (recorded !== undefined)
1186
1443
  nextFiles[path] = recorded;
1187
1444
  else
1188
1445
  delete nextFiles[path];
1189
1446
  }
1190
- const notHeld = (path) => !heldTests.has(path);
1447
+ const notHeld = (path) => !heldPaths.has(path);
1191
1448
  const heldBack = [...heldBackByFile.entries()]
1192
1449
  .sort(([a], [b]) => a.localeCompare(b))
1193
- .map(([file, tests]) => ({ file, tests }));
1450
+ .map(([file, { tests, importers, missing }]) => ({
1451
+ file,
1452
+ tests: tests.sort(),
1453
+ ...(importers.length > 0 ? { importers: importers.sort() } : {}),
1454
+ ...(missing ? { missing: true } : {}),
1455
+ }));
1194
1456
  // ── Apply: writes and removals, then the record (D4 Order) ───────────
1195
1457
  // #9566 — the API move's removals first: a retired file at a boot-set
1196
1458
  // path is overwritten by the write that follows, never deleted after it.
@@ -1216,7 +1478,9 @@ export async function updateSite(opts) {
1216
1478
  removed: removed.filter(notHeld),
1217
1479
  current: current.filter(notHeld),
1218
1480
  };
1219
- const recordChanged = backfilled !== undefined || Object.values(moved).some((paths) => paths.length > 0);
1481
+ const recordChanged = backfilled !== undefined ||
1482
+ droppedRows.length > 0 ||
1483
+ Object.values(moved).some((paths) => paths.length > 0);
1220
1484
  if (recordChanged) {
1221
1485
  writeFileSync(recordPath, serializeTemplateRecord({ schema: 1, projectName: record.projectName, files: nextFiles }));
1222
1486
  }
@@ -1262,6 +1526,35 @@ export async function updateSite(opts) {
1262
1526
  writeFileSync(join(targetDir, ...WEB_PACKAGE_JSON.split("/")), webPackageJson);
1263
1527
  moved.updated.push(WEB_PACKAGE_JSON);
1264
1528
  }
1529
+ // #10172 — the env vars the written files read get an env_contract row
1530
+ // when the site's project.yaml has a block and no row for them, so the
1531
+ // code update brings never fails `env:declared`. Rows are appended, never
1532
+ // edited; a hub with no block is not checked and gains none.
1533
+ const envRowsAdded = [];
1534
+ const projectYamlPath = join(targetDir, "project.yaml");
1535
+ const writtenPaths = new Set(writes.map((w) => w.path).filter(notHeld));
1536
+ if (writtenPaths.size > 0 && existsSync(projectYamlPath)) {
1537
+ const yamlText = readFileSync(projectYamlPath, "utf8");
1538
+ const topology = readManifestTopology(targetDir);
1539
+ if (readEnvContract(yamlText).present && "topology" in topology) {
1540
+ try {
1541
+ const plan = planEnvRows(targetDir, yamlText, {
1542
+ topology: topology.topology,
1543
+ catalog: ENV_CATALOG,
1544
+ onlyFiles: writtenPaths,
1545
+ });
1546
+ if (plan.added.length > 0) {
1547
+ writeFileSync(projectYamlPath, plan.text);
1548
+ envRowsAdded.push(...plan.added);
1549
+ }
1550
+ }
1551
+ catch (err) {
1552
+ if (!(err instanceof EnvContractError))
1553
+ throw err;
1554
+ review.push({ path: "project.yaml", reason: `no env_contract rows added: ${err.message}` });
1555
+ }
1556
+ }
1557
+ }
1265
1558
  // #7495 D6 — the page body owns its H1: give every heading-less mdx-body
1266
1559
  // page its title and description, read the rule after the writes so a
1267
1560
  // route this run added counts. Its rows join `review` after `heldFiles`
@@ -1286,6 +1579,8 @@ export async function updateSite(opts) {
1286
1579
  heldBack,
1287
1580
  headingsAdded: headings.written.map(sitePath),
1288
1581
  ...(backfilled !== undefined ? { backfilled } : {}),
1582
+ ...(envRowsAdded.length > 0 ? { envRowsAdded } : {}),
1583
+ ...(droppedRows.length > 0 ? { droppedRows: droppedRows.sort() } : {}),
1289
1584
  };
1290
1585
  }
1291
1586
  /** The printed report (D9): one section per non-empty list, in order. */
@@ -1312,9 +1607,18 @@ export function formatUpdateReport(result) {
1312
1607
  lines.push(`Needs review (${result.review.length}):`);
1313
1608
  lines.push(...result.review.map((row) => ` ${row.path} — ${row.reason}`));
1314
1609
  }
1315
- for (const { file, tests } of result.heldBack) {
1316
- lines.push(`Held back with ${file} (${tests.length}):`);
1317
- lines.push(...tests.map((path) => ` ${path}`));
1610
+ for (const { file, tests, importers = [], missing } of result.heldBack) {
1611
+ const held = [...tests, ...importers].sort();
1612
+ lines.push(`Held back with ${file}${missing ? " (not in this site)" : ""} (${held.length}):`);
1613
+ lines.push(...held.map((path) => ` ${path}`));
1614
+ }
1615
+ if (result.envRowsAdded !== undefined) {
1616
+ lines.push(`project.yaml env_contract rows added (${result.envRowsAdded.length}):`);
1617
+ lines.push(...result.envRowsAdded.map((name) => ` ${name}`));
1618
+ }
1619
+ if (result.droppedRows !== undefined) {
1620
+ lines.push(`Dropped from ${TEMPLATE_RECORD_PATH}, no longer recorded (${result.droppedRows.length}):`);
1621
+ lines.push(...result.droppedRows.map((path) => ` ${path}`));
1318
1622
  }
1319
1623
  if (result.skipped.length > 0) {
1320
1624
  lines.push(`Skipped (${result.skipped.length}):`);
@@ -1328,7 +1632,9 @@ export function formatUpdateReport(result) {
1328
1632
  result.current.length === 0 &&
1329
1633
  result.headingsAdded.length === 0 &&
1330
1634
  result.review.length === 0 &&
1331
- result.skipped.length === 0;
1635
+ result.skipped.length === 0 &&
1636
+ result.envRowsAdded === undefined &&
1637
+ result.droppedRows === undefined;
1332
1638
  if (nothingHappened) {
1333
1639
  return `[create-ailk] Already up to date (create-ailk ${result.version}).\n`;
1334
1640
  }
@@ -1359,11 +1665,18 @@ untouched apps/web, apps/api or apps/mcp file, or a root tools/jest file the
1359
1665
  app jest configs require, whose template copy changed is overwritten. An
1360
1666
  edited file whose template copy also changed is three-way merged with
1361
1667
  \`git\` (git must be on PATH); a conflict leaves the file alone and is
1362
- listed under "Needs review". Every template test that imports a file
1363
- left under "Needs review" or "Skipped" (by a relative import) is held at the
1364
- site's version with it, its record entry is not advanced, and it is listed
1365
- under "Held back with <file>" — reconcile the file, then run update again to
1366
- bring its tests forward. Every other file in the run still updates.
1668
+ listed under "Needs review"; an apps/web/messages catalog instead gains the
1669
+ template's new keys and keeps every value it has, and is reviewed only when a
1670
+ key is an object on one side and a value on the other. Every template file,
1671
+ test or not, that imports a file left under "Needs review" or "Skipped" (by a
1672
+ relative or @/ import, transitively) is held at the site's version with it,
1673
+ its record entry is not advanced, and it is listed under "Held back with
1674
+ <file>" — reconcile the file, then run update again to bring it forward. A
1675
+ written file whose import names a file the site will not have is held the
1676
+ same way, under "Held back with <file> (not in this site)". Every other file
1677
+ in the run still updates. When project.yaml has an env_contract block, each
1678
+ env var the written files read that has no row gains one; no row is edited.
1679
+ A record row at a path create-ailk no longer records is dropped and listed.
1367
1680
  A site with no record gets one on its first run, from the version its
1368
1681
  .ailk-site-manifest.json already records. A site scaffolded before
1369
1682
  @working-theory/mcp-server has its copied apps/mcp moved to the boot set:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ailk",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Scaffolder for AI Launch Kit — `pnpm create ailk@latest <dir>` produces a starter repo consuming @working-theory/* from npm (per ADR 0007).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -28,8 +28,8 @@
28
28
  "tar": "^7.5.21"
29
29
  },
30
30
  "devDependencies": {
31
- "@working-theory/schema": "0.50.0",
32
- "@working-theory/templates": "0.50.0"
31
+ "@working-theory/schema": "0.51.0",
32
+ "@working-theory/templates": "0.51.0"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",
@@ -119,7 +119,7 @@ Routes are versioned under `/v1/*`:
119
119
 
120
120
  **`AILK_OPERATOR_EMAIL` is REQUIRED for a `workspace`-module deployment** (#5515 item 1), exactly as it already is for the review dashboard. On a build with the `workspace` module and WITHOUT `multi-tenant`, `server.ts`'s `tenantPreHandlerForBuild()` selects the base guard (`src/middleware/default-tenant.ts` → `resolveDefaultTenantForOperator`), which fails CLOSED when the pin is unset: every `/v1/workspaces/*` route answers 403 to everyone — so the module's Admin pages (`apps/web/app/[locale]/(authed)/admin*`, renamed from `workspace` by #5637) render their shared `ForbiddenState` — and — the reason this matters — the TOFU operator bind (`services/default-tenant.ts#bindOperatorMembership`, #3631) is never reached, so the first account to load the settings page cannot seat ITSELF as the sole owner off a plain GET. With `multi-tenant` present, a session that has activated a workspace resolves from it and its Membership, unchanged; a session with NO active workspace (every `apps/web` request) goes through the same `resolveDefaultTenantForOperator`, so on a one-workspace deployment the pin is REQUIRED there too — unset, nobody is seated and those requests answer 403 (#7523). The `kind: "all"` waitlist read likewise needs the caller to BE the pin, not merely a sentinel-org owner. `.env.example` § Single-tenant operator pin carries the operator-facing copy; `src/middleware/__tests__/default-tenant.test.ts` § "#5515 item 1" is the objective record.
121
121
 
122
- The workspace site-config routes are **identity-bound tenant routes** (guarded by the tenant preHandler; the write scope is the caller's active workspace, never a body-supplied id). Like the sibling `/v1/workspaces/*` routes they have **no anonymous MCP twin** — the OSS MCP surface is identity-blind (`apps/mcp/CLAUDE.md`), so the "1:1 MCP tool" rule above does not apply to them; the tenant-scoped authed MCP surface is tracked in #3591. The `/v1/billing/*` and `/v1/waitlist-experiments/*` routes are the same shape — identity-bound tenant routes, admin/settings-facing rather than agent-facing, no MCP twin.
122
+ The workspace site-config routes are **identity-bound tenant routes** (guarded by the tenant preHandler; the scope is the workspace the request names — the path's `:workspaceId`, or on a route with none the required `x-ailk-workspace-scope` header — checked against the caller's Membership on every request, never a body-supplied id and never the session's old active-workspace pointer, #10165). Like the sibling `/v1/workspaces/*` routes they have **no anonymous MCP twin** — the OSS MCP surface is identity-blind (`apps/mcp/CLAUDE.md`), so the "1:1 MCP tool" rule above does not apply to them; the tenant-scoped authed MCP surface is tracked in #3591. The `/v1/billing/*` and `/v1/waitlist-experiments/*` routes are the same shape — identity-bound tenant routes, admin/settings-facing rather than agent-facing, no MCP twin.
123
123
 
124
124
  AEO Layer 1 scoring runs both ways: locally as a CLI command (`ailk audit`, filesystem content-root input) AND behind the anonymous `/v1/aeo/score` HTTP endpoint above (external-URL input, #4666) — the CLI's content-root audit and the route's crawler-fed score are two different input paths over the same underlying scorer, not a contradiction.
125
125
 
@@ -124,16 +124,18 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
124
124
  **Maintenance (2):** `audit`, `create_project` (stdio only, #9965 D13)
125
125
  **Flow (2):** `get_flow`, `submit_flow_step` — module-gated (`module: "signal-collectors"`, D-48, #5366) and licence-feature-tagged (`feature: "flows-deliverables"`, #7136): identity-blind like every tool in this list, registered only when the signal-collectors module is present, and gated on the deployment's `flows-deliverables` licence feature under `MCP_FEATURE_GATE_MODE`, the same shape Booking below has. `submit_flow_step` takes the HTTP write branch (its own daily cap, `MCP_ABUSE_SUBMIT_FLOW_STEP_DAILY_CAP`, default 50 — #8071)
126
126
  **Booking (4):** `list_availability`, `get_event_meta`, `list_meetings`, `create_booking` — module-gated (`module: "scheduling"`) and licence-feature-tagged (`feature: "scheduling"`, #7136), gated on the deployment's `scheduling` licence feature under `MCP_FEATURE_GATE_MODE`. `create_booking` takes the HTTP write branch (its own daily cap, `MCP_ABUSE_CREATE_BOOKING_DAILY_CAP`, default 20, and an `attendee.email` format check — #8071). `list_meetings` (#8843 D5) twins `GET /v1/schedule/meetings`: it takes no argument and returns the site's Live meetings — slug, visibility, position and whether each can be booked, plus a bookable one's calLink, title, length, description and price; `private` means unlisted, not secret
127
+ **Redirect (1):** `get_redirect` (#10202 D8) — module-gated (`module: "content-write"`) and licence-feature-tagged (`feature: "workspace"`, the content-write claim), gated on the deployment's `workspace` licence feature under `MCP_FEATURE_GATE_MODE`. It twins `GET /v1/redirects`: given an old site path (and an optional locale) it returns where the site permanently redirects it — `{ from, to, permanent: true }` — or the route's not-found body. Its handler rides `@working-theory/mcp-workspace`'s anonymous `tools` (`redirect.ts`), the pack the `workspace` and `multi-tenant` modules carry, and calls the module-level `apiClient` with no agent key
127
128
 
128
- ### Why the parity test counts 21 and a keyless server registers 19 (20 over stdio once Free is released)
129
+ ### Why the parity test counts 22 and a keyless server registers 20 (21 over stdio once Free is released)
129
130
 
130
- - **21 in the `TOOLS` array / parity test**: the `TOOLS` array in
131
- `packages/metadata/src/mcp-tool-catalog.ts` defines 21 anonymous tools; the
132
- parity test counts that full array. It also holds the thirty-one signed-in
133
- workspace tools (#3591), which no anonymous view returns — see § The
131
+ - **22 in the `TOOLS` array / parity test**: the `TOOLS` array in
132
+ `packages/metadata/src/mcp-tool-catalog.ts` defines 22 anonymous tools; the
133
+ parity test counts that full array. It also holds the thirty-three signed-in
134
+ workspace tools (#3591; the content-suggestion pair `draft_page_suggestions`
135
+ and `decide_page_suggestion` since #10218), which no anonymous view returns — see § The
134
136
  signed-in workspace surface below.
135
- - **19 registered on a KEYLESS deployment, 20 on a commerce-claimed one,
136
- while the Free level is held or over HTTP — 20 and 21 over stdio once it is
137
+ - **20 registered on a KEYLESS deployment, 21 on a commerce-claimed one,
138
+ while the Free level is held or over HTTP — 21 and 22 over stdio once it is
137
139
  released** (#5971, #9534, #9965 D13): `createMcpServer` (`apps/mcp/src/server.ts`) registers
138
140
  `getMcpToolRegistrations()` minus the topology seam's `UNSERVED_MCP_TOOLS`,
139
141
  minus `create_project` while create-ailk's `release-scope.json` holds
@@ -143,16 +145,16 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
143
145
  `addon`-tagged tool whose claim this deployment does not hold.
144
146
  `start_purchase` carries `addon: "commerce"`, so it is the one entry whose
145
147
  presence depends on the licence — one fewer without the claim.
146
- - **19 free anonymous tools while Free is held or over HTTP (20 over stdio once released)**: since #5853 retired the three content-write
148
+ - **20 anonymous tools while Free is held or over HTTP (21 over stdio once released)**: since #5853 retired the three content-write
147
149
  tools, every registered tool remains anonymous — call-time gating checks a
148
- licence claim, never an identity. The six carrying `tier: "pro"`
150
+ licence claim, never an identity. The seven carrying `tier: "pro"`
149
151
  (`get_flow`, `submit_flow_step`, `list_availability`, `get_event_meta`,
150
- `list_meetings`, `create_booking`) are gated at REGISTRATION **and at call time** (#7315):
152
+ `list_meetings`, `create_booking`, `get_redirect`) are gated at REGISTRATION **and at call time** (#7315):
151
153
  each needs its module, and each catalog row names the licence `feature:`
152
154
  that entitles it. `start_purchase` is gated at call time by both this
153
155
  server-wide gate and its own Gate 2 in `tools/purchase.ts`.
154
156
  `MCP_FEATURE_GATE_MODE` (`packages/metadata`) decides what an unheld
155
- feature does. In `"warn"` mode (this release) the six register on module
157
+ feature does. In `"warn"` mode (this release) the seven register on module
156
158
  presence alone, and the boot (`bootMcp()`) logs one warning (stderr) per tool
157
159
  served without its feature. In `"refuse"` mode (the next release) they
158
160
  also need the deployment's licence to hold the feature, and
@@ -283,7 +285,9 @@ Example (lead routing):
283
285
  - Don't add a tool without the matching `apps/api` endpoint.
284
286
  - Don't put business logic in tool handlers — they're adapters.
285
287
 
286
- Last updated: 2026-10-26 — #9965 (D12, D13): `create_project` is served over stdio only — the HTTP transport, the default, never registers it, and the web manifest always drops it — and `trustOneHop` trusts its hop only on Vercel — verified against `packages/mcp-server/src/{server,run,serverless-app}.ts` and `apps/web/app/.well-known/mcp.json/route.ts`.
288
+ Last updated: 2026-10-29 — #10202 (D8): `get_redirect` (`mcp-workspace`'s `redirect.ts`, the pack's first anonymous handler) twins `GET /v1/redirects` under `module: "content-write"` and `feature: "workspace"` — 22 in `TOOLS`, 20 registered keyless and 21 commerce-claimed while Free is held, seven `tier: "pro"` — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
289
+
290
+ Prior: 2026-10-26 — #9965 (D12, D13): `create_project` is served over stdio only — the HTTP transport, the default, never registers it, and the web manifest always drops it — and `trustOneHop` trusts its hop only on Vercel — verified against `packages/mcp-server/src/{server,run,serverless-app}.ts` and `apps/web/app/.well-known/mcp.json/route.ts`.
287
291
 
288
292
  Prior: 2026-10-24 — #9749: `get_slack_connection_status` (`mcp-workspace`'s `slack_connection.ts`) and `get_prospect_channel_settings` / `set_prospect_channel_settings` (`mcp-scheduling`'s `prospect_channel_settings.ts`, the first signed-in handlers outside `mcp-workspace`) twin the Slack status read and the prospect-channel settings pair — the signed-in workspace surface is thirty-one tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts`, `packages-paid/mcp-scheduling/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
289
293