@decocms/start 4.4.0 → 4.5.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.
@@ -0,0 +1,35 @@
1
+ name: lockfile-check
2
+
3
+ # PR-time guardrail: re-runs the same install our release workflow
4
+ # runs on main (`bun install --frozen-lockfile`). Fails the PR if
5
+ # bun.lock would have rejected the install — closes the loop so drift
6
+ # can never reach main.
7
+ #
8
+ # Pairs with:
9
+ # - "packageManager": "bun@1.3.5" in package.json
10
+ # - .gitignore bans on package-lock.json / yarn.lock / pnpm-lock.yaml
11
+ # - the deco-post-cleanup audit's lockfile-* rules
12
+ #
13
+ # Adjust `bun-version` here in lockstep with the package.json
14
+ # `packageManager` field.
15
+
16
+ on:
17
+ pull_request:
18
+ paths:
19
+ - "package.json"
20
+ - "bun.lock"
21
+ - ".github/workflows/lockfile-check.yml"
22
+
23
+ permissions:
24
+ contents: read
25
+
26
+ jobs:
27
+ frozen-install:
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - uses: actions/checkout@v4
31
+ - uses: oven-sh/setup-bun@v2
32
+ with:
33
+ bun-version: 1.3.5
34
+ - name: bun install --frozen-lockfile
35
+ run: bun install --frozen-lockfile
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -187,21 +187,21 @@ export function report(ctx: MigrationContext): void {
187
187
  lines.push("## Next Steps");
188
188
  lines.push("");
189
189
  lines.push("```bash");
190
- lines.push("# 1. Install dependencies");
191
- lines.push("npm install");
190
+ lines.push("# 1. Install dependencies (bun is the canonical PM)");
191
+ lines.push("bun install");
192
192
  lines.push("");
193
193
  lines.push("# 2. Generate CMS blocks and schema");
194
- lines.push("npm run generate:blocks");
195
- lines.push("npm run generate:schema");
194
+ lines.push("bun run generate:blocks");
195
+ lines.push("bun run generate:schema");
196
196
  lines.push("");
197
197
  lines.push("# 3. Generate routes");
198
- lines.push("npx tsr generate");
198
+ lines.push("bunx tsr generate");
199
199
  lines.push("");
200
200
  lines.push("# 4. Type check");
201
- lines.push("npx tsc --noEmit");
201
+ lines.push("bunx tsc --noEmit");
202
202
  lines.push("");
203
203
  lines.push("# 5. Find unused code");
204
- lines.push("npm run knip");
204
+ lines.push("bun run knip");
205
205
  lines.push("");
206
206
  lines.push("# 6. Run dev server");
207
207
  lines.push("npm run dev");
@@ -2,7 +2,8 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { MigrationContext } from "./types";
4
4
  import { log, logPhase } from "./types";
5
- import { generatePackageJson } from "./templates/package-json";
5
+ import { CANONICAL_BUN_VERSION, generatePackageJson } from "./templates/package-json";
6
+ import { generateLockfileCheckYml } from "./templates/lockfile-check-yml";
6
7
  import { generateTsconfig } from "./templates/tsconfig";
7
8
  import { generateViteConfig } from "./templates/vite-config";
8
9
  import { generateKnipConfig } from "./templates/knip-config";
@@ -72,6 +73,16 @@ export function scaffold(ctx: MigrationContext): void {
72
73
  tabWidth: 2,
73
74
  }, null, 2) + "\n");
74
75
 
76
+ // PR-time lockfile guardrail. Lives in the site repo (per-site, not
77
+ // a centralised reusable workflow) because per D6.3 we are not
78
+ // scaffolding caller stubs into storefronts. Bun version is pinned
79
+ // in lockstep with `package.json` via CANONICAL_BUN_VERSION.
80
+ writeFile(
81
+ ctx,
82
+ ".github/workflows/lockfile-check.yml",
83
+ generateLockfileCheckYml(CANONICAL_BUN_VERSION),
84
+ );
85
+
75
86
  // Server entry files (server.ts, worker-entry.ts, router.tsx, runtime.ts, context.ts)
76
87
  writeMultiFile(ctx, generateServerEntry(ctx));
77
88
 
@@ -238,6 +249,11 @@ vite.config.timestamp_*
238
249
  # IDE
239
250
  .vscode/
240
251
  .idea/
252
+
253
+ # Lockfiles — bun is canonical, prevent accidental drift
254
+ package-lock.json
255
+ yarn.lock
256
+ pnpm-lock.yaml
241
257
  `;
242
258
  }
243
259
 
@@ -17,6 +17,7 @@ const REQUIRED_FILES = [
17
17
  // Workers Builds (D6.3) -- configured in the CF dashboard, not via
18
18
  // GitHub workflow files in the site repo.
19
19
  ".github/workflows/regen-blocks.yml",
20
+ ".github/workflows/lockfile-check.yml",
20
21
  "knip.config.ts",
21
22
  ".prettierrc",
22
23
  "src/server.ts",
@@ -1239,6 +1239,345 @@ const ruleHtmxResidue: Rule = {
1239
1239
  },
1240
1240
  };
1241
1241
 
1242
+ /* ------------------------------------------------------------------ */
1243
+ /* Rule 9 — `lockfile-multiple` — multiple lockfiles tracked */
1244
+ /* ------------------------------------------------------------------ */
1245
+
1246
+ /**
1247
+ * Per the fleet-wide bun-canonical decision, every storefront commits
1248
+ * exactly one lockfile: `bun.lock`. This rule fires when any of the
1249
+ * non-bun lockfiles co-exist with bun.lock — that's the exact pattern
1250
+ * that broke Cloudflare Workers Builds with `lockfile had changes, but
1251
+ * lockfile is frozen` (the dual-lockfile drift).
1252
+ *
1253
+ * The `--fix` deletes the offending non-bun lockfiles. Adding the
1254
+ * `.gitignore` bans is a separate concern handled by `packageManager-missing`
1255
+ * (which also nudges the site to add the bans), so this rule stays
1256
+ * focused.
1257
+ */
1258
+ const NON_BUN_LOCKFILES = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
1259
+
1260
+ const ruleLockfileMultiple: Rule = {
1261
+ id: "lockfile-multiple",
1262
+ title: "Multiple lockfiles tracked alongside bun.lock",
1263
+ run({ siteDir, fs }: RuleContext): Finding[] {
1264
+ const bunLock = `${siteDir}/bun.lock`;
1265
+ if (!fs.exists(bunLock)) return [];
1266
+ const findings: Finding[] = [];
1267
+ for (const name of NON_BUN_LOCKFILES) {
1268
+ const abs = `${siteDir}/${name}`;
1269
+ if (!fs.exists(abs)) continue;
1270
+ findings.push({
1271
+ rule: "lockfile-multiple",
1272
+ severity: "warning",
1273
+ file: name,
1274
+ message: `${name} co-exists with bun.lock — Cloudflare Workers Builds picks bun and the other will silently drift`,
1275
+ fix: `rm ${name} (bun.lock is the canonical lockfile)`,
1276
+ meta: { lockfile: name },
1277
+ });
1278
+ }
1279
+ return findings;
1280
+ },
1281
+ applyFix({ siteDir }, findings, writer): FixAction[] {
1282
+ const actions: FixAction[] = [];
1283
+ for (const f of findings) {
1284
+ writer.deleteFile(`${siteDir}/${f.file}`);
1285
+ actions.push({
1286
+ file: f.file,
1287
+ kind: "delete",
1288
+ detail: `deleted (bun.lock is canonical)`,
1289
+ });
1290
+ }
1291
+ return actions;
1292
+ },
1293
+ };
1294
+
1295
+ /* ------------------------------------------------------------------ */
1296
+ /* Rule 10 — `lockfile-missing` — package.json without bun.lock */
1297
+ /* ------------------------------------------------------------------ */
1298
+
1299
+ /**
1300
+ * A storefront with `package.json` but no committed lockfile cannot
1301
+ * run `bun install --frozen-lockfile` in CI — Cloudflare Workers
1302
+ * Builds either falls back to a non-reproducible install or fails
1303
+ * outright depending on the build image. Detect-only because the
1304
+ * fix (`bun install`) requires network access and a working bun
1305
+ * toolchain that the audit shouldn't shell out to from inside its
1306
+ * own runner.
1307
+ */
1308
+ const ruleLockfileMissing: Rule = {
1309
+ id: "lockfile-missing",
1310
+ title: "Lockfile missing",
1311
+ run({ siteDir, fs }: RuleContext): Finding[] {
1312
+ const pkg = `${siteDir}/package.json`;
1313
+ if (!fs.exists(pkg)) return [];
1314
+ const bunLock = `${siteDir}/bun.lock`;
1315
+ if (fs.exists(bunLock)) return [];
1316
+ // If a non-bun lockfile is present, `lockfile-multiple` doesn't
1317
+ // apply but the site is still mid-migration to bun. We flag the
1318
+ // missing bun.lock here so the operator runs `bun install` to
1319
+ // produce the canonical one.
1320
+ return [
1321
+ {
1322
+ rule: "lockfile-missing",
1323
+ severity: "warning",
1324
+ file: "bun.lock",
1325
+ message: `No bun.lock committed — frozen installs cannot run on CF Workers Builds`,
1326
+ fix: `Run \`bun install\` and commit the resulting bun.lock`,
1327
+ meta: {},
1328
+ },
1329
+ ];
1330
+ },
1331
+ };
1332
+
1333
+ /* ------------------------------------------------------------------ */
1334
+ /* Rule 11 — `lockfile-drift` — bun.lock out of sync with package.json */
1335
+ /* ------------------------------------------------------------------ */
1336
+
1337
+ /**
1338
+ * Detects the head-on case behind the `lockfile had changes, but
1339
+ * lockfile is frozen` Workers Builds error: a direct dependency in
1340
+ * `package.json` has no version in `bun.lock` that satisfies the
1341
+ * declared range.
1342
+ *
1343
+ * Coverage is intentionally pragmatic — we recognise the four most
1344
+ * common range shapes (`^a.b.c`, `~a.b.c`, plain `a.b.c`, and the
1345
+ * sentinels `*` / `latest` / `next` / git/github specs which we skip).
1346
+ * Everything else falls back to "present in lockfile?", treating
1347
+ * unknown ranges as satisfied as long as the name appears at all.
1348
+ * The goal is high signal on the storefront fleet's actual failure
1349
+ * mode; full npm-semver fidelity is out of scope for this rule.
1350
+ *
1351
+ * Detect-only: regenerating bun.lock requires running `bun install`,
1352
+ * which the audit script doesn't do (would need network + bun in
1353
+ * PATH). Operators run it manually and re-run the audit.
1354
+ */
1355
+ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-[\w.+-]+)?$/;
1356
+
1357
+ function parseSemver(v: string): [number, number, number] | null {
1358
+ const m = SEMVER_RE.exec(v.trim());
1359
+ if (!m) return null;
1360
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
1361
+ }
1362
+
1363
+ function gte(a: [number, number, number], b: [number, number, number]): boolean {
1364
+ if (a[0] !== b[0]) return a[0] > b[0];
1365
+ if (a[1] !== b[1]) return a[1] > b[1];
1366
+ return a[2] >= b[2];
1367
+ }
1368
+
1369
+ function lt(a: [number, number, number], b: [number, number, number]): boolean {
1370
+ if (a[0] !== b[0]) return a[0] < b[0];
1371
+ if (a[1] !== b[1]) return a[1] < b[1];
1372
+ return a[2] < b[2];
1373
+ }
1374
+
1375
+ /**
1376
+ * Minimal `range satisfies version` check. Returns:
1377
+ * - `true` when the range is satisfied,
1378
+ * - `false` when it is definitely violated,
1379
+ * - `null` when we don't recognise the range shape and the caller
1380
+ * should fall back to a presence check.
1381
+ */
1382
+ function satisfiesRange(version: string, range: string): boolean | null {
1383
+ const trimmed = range.trim();
1384
+ // Sentinels and non-numeric specs we don't try to evaluate.
1385
+ if (
1386
+ trimmed === "*" ||
1387
+ trimmed === "latest" ||
1388
+ trimmed === "next" ||
1389
+ trimmed.startsWith("workspace:") ||
1390
+ trimmed.startsWith("file:") ||
1391
+ trimmed.startsWith("link:") ||
1392
+ trimmed.startsWith("git+") ||
1393
+ trimmed.startsWith("github:") ||
1394
+ trimmed.includes("://")
1395
+ ) {
1396
+ return null;
1397
+ }
1398
+ const ver = parseSemver(version);
1399
+ if (!ver) return null;
1400
+ // Caret: ^a.b.c → >=a.b.c <(a+1).0.0 when a > 0
1401
+ // ^0.b.c → >=0.b.c <0.(b+1).0 when a == 0 && b > 0
1402
+ // ^0.0.c → exactly 0.0.c
1403
+ if (trimmed.startsWith("^")) {
1404
+ const base = parseSemver(trimmed.slice(1));
1405
+ if (!base) return null;
1406
+ if (!gte(ver, base)) return false;
1407
+ let upper: [number, number, number];
1408
+ if (base[0] > 0) upper = [base[0] + 1, 0, 0];
1409
+ else if (base[1] > 0) upper = [0, base[1] + 1, 0];
1410
+ else upper = [0, 0, base[2] + 1];
1411
+ return lt(ver, upper);
1412
+ }
1413
+ // Tilde: ~a.b.c → >=a.b.c <a.(b+1).0
1414
+ if (trimmed.startsWith("~")) {
1415
+ const base = parseSemver(trimmed.slice(1));
1416
+ if (!base) return null;
1417
+ return gte(ver, base) && lt(ver, [base[0], base[1] + 1, 0]);
1418
+ }
1419
+ // Plain pin: a.b.c → exact match.
1420
+ const exact = parseSemver(trimmed);
1421
+ if (exact) return exact[0] === ver[0] && exact[1] === ver[1] && exact[2] === ver[2];
1422
+ return null;
1423
+ }
1424
+
1425
+ /**
1426
+ * Pull every `"<name>@<version>"` token out of bun.lock for a given
1427
+ * package name. Bun's lockfile format embeds the version inside the
1428
+ * package descriptor array, e.g.:
1429
+ *
1430
+ * "@decocms/start": ["@decocms/start@2.1.1", ...]
1431
+ *
1432
+ * We scan all such occurrences (a single package may appear multiple
1433
+ * times if the dep tree pulled different versions).
1434
+ */
1435
+ function lockfileVersionsOf(lockfile: string, name: string): string[] {
1436
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1437
+ const re = new RegExp(`['"]${escaped}@([^'"\\s]+)['"]`, "g");
1438
+ const seen = new Set<string>();
1439
+ for (const m of lockfile.matchAll(re)) {
1440
+ seen.add(m[1]);
1441
+ }
1442
+ return [...seen];
1443
+ }
1444
+
1445
+ const ruleLockfileDrift: Rule = {
1446
+ id: "lockfile-drift",
1447
+ title: "bun.lock drifted vs package.json direct dependencies",
1448
+ run({ siteDir, fs }: RuleContext): Finding[] {
1449
+ const pkgPath = `${siteDir}/package.json`;
1450
+ const lockPath = `${siteDir}/bun.lock`;
1451
+ if (!fs.exists(pkgPath) || !fs.exists(lockPath)) return [];
1452
+ let parsed: unknown;
1453
+ try {
1454
+ parsed = JSON.parse(fs.readText(pkgPath));
1455
+ } catch {
1456
+ return [];
1457
+ }
1458
+ if (typeof parsed !== "object" || parsed === null) return [];
1459
+ const pkg = parsed as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
1460
+ const lockText = fs.readText(lockPath);
1461
+ const drifted: { name: string; range: string; locked: string[] }[] = [];
1462
+ const buckets: Record<string, string>[] = [pkg.dependencies ?? {}, pkg.devDependencies ?? {}];
1463
+ for (const bucket of buckets) {
1464
+ for (const [name, range] of Object.entries(bucket)) {
1465
+ const versions = lockfileVersionsOf(lockText, name);
1466
+ if (versions.length === 0) {
1467
+ drifted.push({ name, range, locked: [] });
1468
+ continue;
1469
+ }
1470
+ // If at least one locked version satisfies the range, we're fine.
1471
+ // For unknown range shapes (return null), treat presence as
1472
+ // satisfaction — pragmatic, see rule docstring.
1473
+ let satisfied = false;
1474
+ for (const v of versions) {
1475
+ const result = satisfiesRange(v, range);
1476
+ if (result === true || result === null) {
1477
+ satisfied = true;
1478
+ break;
1479
+ }
1480
+ }
1481
+ if (!satisfied) drifted.push({ name, range, locked: versions });
1482
+ }
1483
+ }
1484
+ if (drifted.length === 0) return [];
1485
+ return [
1486
+ {
1487
+ rule: "lockfile-drift",
1488
+ severity: "warning",
1489
+ file: "bun.lock",
1490
+ message: `${drifted.length} direct dep(s) not satisfied by bun.lock — frozen install will fail`,
1491
+ fix: `Run \`bun install\` to refresh bun.lock, then commit. Drift: ${drifted
1492
+ .slice(0, 5)
1493
+ .map((d) => `${d.name} ${d.range} (locked: ${d.locked.length > 0 ? d.locked.join(", ") : "none"})`)
1494
+ .join("; ")}${drifted.length > 5 ? `; +${drifted.length - 5} more` : ""}`,
1495
+ meta: { drifted },
1496
+ },
1497
+ ];
1498
+ },
1499
+ };
1500
+
1501
+ /* ------------------------------------------------------------------ */
1502
+ /* Rule 12 — `package-manager-missing` — no packageManager field */
1503
+ /* ------------------------------------------------------------------ */
1504
+
1505
+ /**
1506
+ * Without a `packageManager` field in package.json, neither developers
1507
+ * nor CI are forced to agree on a PM. Anyone running `npm install`
1508
+ * or `yarn` produces an alternate lockfile that risks drifting from
1509
+ * `bun.lock` and breaking Workers Builds. The fleet-wide canonical
1510
+ * value is `bun@<CANONICAL_BUN_VERSION>`; bumping that constant here
1511
+ * propagates to all `--fix` runs.
1512
+ */
1513
+ const CANONICAL_PACKAGE_MANAGER = "bun@1.3.5";
1514
+
1515
+ const rulePackageManagerMissing: Rule = {
1516
+ id: "package-manager-missing",
1517
+ title: "Missing packageManager field in package.json",
1518
+ run({ siteDir, fs }: RuleContext): Finding[] {
1519
+ const pkgPath = `${siteDir}/package.json`;
1520
+ if (!fs.exists(pkgPath)) return [];
1521
+ let parsed: unknown;
1522
+ try {
1523
+ parsed = JSON.parse(fs.readText(pkgPath));
1524
+ } catch {
1525
+ return [];
1526
+ }
1527
+ if (typeof parsed !== "object" || parsed === null) return [];
1528
+ const pkg = parsed as { packageManager?: string };
1529
+ if (typeof pkg.packageManager === "string" && pkg.packageManager.length > 0) {
1530
+ return [];
1531
+ }
1532
+ return [
1533
+ {
1534
+ rule: "package-manager-missing",
1535
+ severity: "info",
1536
+ file: "package.json",
1537
+ message: `Missing "packageManager" field — contributors and CF Workers Builds may pick different PMs`,
1538
+ fix: `Set "packageManager": "${CANONICAL_PACKAGE_MANAGER}" in package.json`,
1539
+ meta: { canonical: CANONICAL_PACKAGE_MANAGER },
1540
+ },
1541
+ ];
1542
+ },
1543
+ applyFix({ siteDir, fs }, findings, writer): FixAction[] {
1544
+ if (findings.length === 0) return [];
1545
+ const pkgPath = `${siteDir}/package.json`;
1546
+ if (!fs.exists(pkgPath)) return [];
1547
+ const content = fs.readText(pkgPath);
1548
+ let parsed: Record<string, unknown>;
1549
+ try {
1550
+ parsed = JSON.parse(content) as Record<string, unknown>;
1551
+ } catch {
1552
+ return [];
1553
+ }
1554
+ if (typeof parsed.packageManager === "string" && parsed.packageManager.length > 0) {
1555
+ return [];
1556
+ }
1557
+ // Insert `packageManager` directly after `license` to match the
1558
+ // convention used across the storefront fleet. Falls back to the
1559
+ // end of the object when `license` is absent.
1560
+ const ordered: Record<string, unknown> = {};
1561
+ let inserted = false;
1562
+ for (const [k, v] of Object.entries(parsed)) {
1563
+ ordered[k] = v;
1564
+ if (!inserted && k === "license") {
1565
+ ordered.packageManager = CANONICAL_PACKAGE_MANAGER;
1566
+ inserted = true;
1567
+ }
1568
+ }
1569
+ if (!inserted) ordered.packageManager = CANONICAL_PACKAGE_MANAGER;
1570
+ writer.writeText(pkgPath, `${JSON.stringify(ordered, null, 2)}\n`);
1571
+ return [
1572
+ {
1573
+ file: "package.json",
1574
+ kind: "edit",
1575
+ detail: `set packageManager: "${CANONICAL_PACKAGE_MANAGER}"`,
1576
+ },
1577
+ ];
1578
+ },
1579
+ };
1580
+
1242
1581
  export const ALL_RULES: Rule[] = [
1243
1582
  ruleDeadLibShims,
1244
1583
  ruleObsoleteVitePlugins,
@@ -1249,12 +1588,18 @@ export const ALL_RULES: Rule[] = [
1249
1588
  ruleFrameworkTodos,
1250
1589
  ruleLocalFrameworkDuplicate,
1251
1590
  ruleHtmxResidue,
1591
+ ruleLockfileMultiple,
1592
+ ruleLockfileMissing,
1593
+ ruleLockfileDrift,
1594
+ rulePackageManagerMissing,
1252
1595
  ];
1253
1596
 
1254
1597
  /** Exported for direct unit tests. */
1255
1598
  export const _internals = {
1256
1599
  extractExports,
1257
1600
  symbolUsedOutsideLib,
1601
+ satisfiesRange,
1602
+ lockfileVersionsOf,
1258
1603
  rules: {
1259
1604
  ruleDeadLibShims,
1260
1605
  ruleObsoleteVitePlugins,
@@ -1265,5 +1610,9 @@ export const _internals = {
1265
1610
  ruleLocalWidgetsTypes,
1266
1611
  ruleFrameworkTodos,
1267
1612
  ruleLocalFrameworkDuplicate,
1613
+ ruleLockfileMultiple,
1614
+ ruleLockfileMissing,
1615
+ ruleLockfileDrift,
1616
+ rulePackageManagerMissing,
1268
1617
  },
1269
1618
  };
@@ -668,7 +668,9 @@ describe("runAudit — totals", () => {
668
668
  "dead-runtime-shim",
669
669
  "local-framework-duplicate",
670
670
  "local-widgets-types",
671
+ "lockfile-multiple",
671
672
  "obsolete-vite-plugins",
673
+ "package-manager-missing",
672
674
  "vtex-shim-regression",
673
675
  ].sort(),
674
676
  );
@@ -1450,3 +1452,217 @@ describe("rule: local-framework-duplicate", () => {
1450
1452
  expect(r.supportsAutoFix).toBe(true);
1451
1453
  });
1452
1454
  });
1455
+
1456
+ describe("rule: lockfile-multiple", () => {
1457
+ it("flags package-lock.json and yarn.lock when bun.lock exists", () => {
1458
+ const fs = makeFs({
1459
+ "/site/package.json": "{}",
1460
+ "/site/bun.lock": "{}",
1461
+ "/site/package-lock.json": "{}",
1462
+ "/site/yarn.lock": "",
1463
+ });
1464
+ const report = runAudit(SITE, fs);
1465
+ const r = report.rules.find((r) => r.rule === "lockfile-multiple")!;
1466
+ expect(r.findings.map((f) => f.file).sort()).toEqual(["package-lock.json", "yarn.lock"]);
1467
+ });
1468
+
1469
+ it("does not flag when only bun.lock is present", () => {
1470
+ const fs = makeFs({
1471
+ "/site/package.json": "{}",
1472
+ "/site/bun.lock": "{}",
1473
+ });
1474
+ const report = runAudit(SITE, fs);
1475
+ const r = report.rules.find((r) => r.rule === "lockfile-multiple")!;
1476
+ expect(r.findings).toEqual([]);
1477
+ });
1478
+
1479
+ it("does not fire when bun.lock is absent (lockfile-missing handles that case)", () => {
1480
+ const fs = makeFs({
1481
+ "/site/package.json": "{}",
1482
+ "/site/package-lock.json": "{}",
1483
+ });
1484
+ const report = runAudit(SITE, fs);
1485
+ const r = report.rules.find((r) => r.rule === "lockfile-multiple")!;
1486
+ expect(r.findings).toEqual([]);
1487
+ });
1488
+
1489
+ it("--fix deletes the offending lockfiles", () => {
1490
+ const { fs, writer, store } = makeMutableFs({
1491
+ "/site/package.json": "{}",
1492
+ "/site/bun.lock": "{}",
1493
+ "/site/package-lock.json": "{}",
1494
+ "/site/yarn.lock": "",
1495
+ });
1496
+ runAudit(SITE, fs, { writer });
1497
+ expect(store["/site/package-lock.json"]).toBeUndefined();
1498
+ expect(store["/site/yarn.lock"]).toBeUndefined();
1499
+ expect(store["/site/bun.lock"]).toBeDefined();
1500
+ });
1501
+ });
1502
+
1503
+ describe("rule: lockfile-missing", () => {
1504
+ it("fires when package.json exists without bun.lock", () => {
1505
+ const fs = makeFs({ "/site/package.json": "{}" });
1506
+ const report = runAudit(SITE, fs);
1507
+ const r = report.rules.find((r) => r.rule === "lockfile-missing")!;
1508
+ expect(r.findings).toHaveLength(1);
1509
+ expect(r.findings[0].file).toBe("bun.lock");
1510
+ expect(r.findings[0].fix).toContain("bun install");
1511
+ });
1512
+
1513
+ it("does not fire when bun.lock exists", () => {
1514
+ const fs = makeFs({ "/site/package.json": "{}", "/site/bun.lock": "{}" });
1515
+ const report = runAudit(SITE, fs);
1516
+ const r = report.rules.find((r) => r.rule === "lockfile-missing")!;
1517
+ expect(r.findings).toEqual([]);
1518
+ });
1519
+
1520
+ it("does not fire on a directory without package.json", () => {
1521
+ const fs = makeFs({});
1522
+ const report = runAudit(SITE, fs);
1523
+ const r = report.rules.find((r) => r.rule === "lockfile-missing")!;
1524
+ expect(r.findings).toEqual([]);
1525
+ });
1526
+ });
1527
+
1528
+ describe("rule: lockfile-drift", () => {
1529
+ it("flags a caret range whose locked version is below the floor", () => {
1530
+ const pkg = JSON.stringify({
1531
+ dependencies: { "@decocms/apps": "^1.11.0" },
1532
+ });
1533
+ const lock = `"@decocms/apps@1.6.2"\n`;
1534
+ const fs = makeFs({
1535
+ "/site/package.json": pkg,
1536
+ "/site/bun.lock": lock,
1537
+ });
1538
+ const report = runAudit(SITE, fs);
1539
+ const r = report.rules.find((r) => r.rule === "lockfile-drift")!;
1540
+ expect(r.findings).toHaveLength(1);
1541
+ expect(r.findings[0].fix).toContain("@decocms/apps");
1542
+ });
1543
+
1544
+ it("does not flag when locked version satisfies the caret range", () => {
1545
+ const pkg = JSON.stringify({
1546
+ dependencies: { "@decocms/apps": "^1.11.0" },
1547
+ });
1548
+ const lock = `"@decocms/apps@1.13.2"\n`;
1549
+ const fs = makeFs({
1550
+ "/site/package.json": pkg,
1551
+ "/site/bun.lock": lock,
1552
+ });
1553
+ const report = runAudit(SITE, fs);
1554
+ const r = report.rules.find((r) => r.rule === "lockfile-drift")!;
1555
+ expect(r.findings).toEqual([]);
1556
+ });
1557
+
1558
+ it("flags a dep that's missing from bun.lock entirely", () => {
1559
+ const pkg = JSON.stringify({
1560
+ dependencies: { "missing-pkg": "^1.0.0" },
1561
+ });
1562
+ const fs = makeFs({
1563
+ "/site/package.json": pkg,
1564
+ "/site/bun.lock": `"other-pkg@1.0.0"\n`,
1565
+ });
1566
+ const report = runAudit(SITE, fs);
1567
+ const r = report.rules.find((r) => r.rule === "lockfile-drift")!;
1568
+ expect(r.findings).toHaveLength(1);
1569
+ });
1570
+
1571
+ it("treats * / latest / git specs as satisfied by mere presence", () => {
1572
+ const pkg = JSON.stringify({
1573
+ dependencies: {
1574
+ "loose-dep": "*",
1575
+ "latest-dep": "latest",
1576
+ },
1577
+ });
1578
+ const lock = `"loose-dep@9.9.9"\n"latest-dep@0.0.1"\n`;
1579
+ const fs = makeFs({
1580
+ "/site/package.json": pkg,
1581
+ "/site/bun.lock": lock,
1582
+ });
1583
+ const report = runAudit(SITE, fs);
1584
+ const r = report.rules.find((r) => r.rule === "lockfile-drift")!;
1585
+ expect(r.findings).toEqual([]);
1586
+ });
1587
+
1588
+ it("considers devDependencies too", () => {
1589
+ const pkg = JSON.stringify({
1590
+ devDependencies: { typescript: "~5.9.0" },
1591
+ });
1592
+ const lock = `"typescript@5.8.0"\n`;
1593
+ const fs = makeFs({
1594
+ "/site/package.json": pkg,
1595
+ "/site/bun.lock": lock,
1596
+ });
1597
+ const report = runAudit(SITE, fs);
1598
+ const r = report.rules.find((r) => r.rule === "lockfile-drift")!;
1599
+ expect(r.findings).toHaveLength(1);
1600
+ });
1601
+
1602
+ it("internals: satisfiesRange handles caret, tilde, exact, and sentinels", () => {
1603
+ const { satisfiesRange } = _internals;
1604
+ expect(satisfiesRange("1.13.2", "^1.11.0")).toBe(true);
1605
+ expect(satisfiesRange("1.6.2", "^1.11.0")).toBe(false);
1606
+ expect(satisfiesRange("2.0.0", "^1.11.0")).toBe(false);
1607
+ expect(satisfiesRange("0.5.3", "^0.5.0")).toBe(true);
1608
+ expect(satisfiesRange("0.6.0", "^0.5.0")).toBe(false);
1609
+ expect(satisfiesRange("5.9.5", "~5.9.0")).toBe(true);
1610
+ expect(satisfiesRange("5.10.0", "~5.9.0")).toBe(false);
1611
+ expect(satisfiesRange("1.2.3", "1.2.3")).toBe(true);
1612
+ expect(satisfiesRange("1.2.4", "1.2.3")).toBe(false);
1613
+ expect(satisfiesRange("1.2.3", "*")).toBeNull();
1614
+ expect(satisfiesRange("1.2.3", "latest")).toBeNull();
1615
+ expect(satisfiesRange("1.2.3", "git+https://...")).toBeNull();
1616
+ });
1617
+ });
1618
+
1619
+ describe("rule: package-manager-missing", () => {
1620
+ it("fires when packageManager is absent", () => {
1621
+ const fs = makeFs({
1622
+ "/site/package.json": JSON.stringify({ name: "x", license: "MIT" }),
1623
+ });
1624
+ const report = runAudit(SITE, fs);
1625
+ const r = report.rules.find((r) => r.rule === "package-manager-missing")!;
1626
+ expect(r.findings).toHaveLength(1);
1627
+ expect(r.findings[0].fix).toContain("bun@");
1628
+ });
1629
+
1630
+ it("does not fire when packageManager is set", () => {
1631
+ const fs = makeFs({
1632
+ "/site/package.json": JSON.stringify({
1633
+ name: "x",
1634
+ packageManager: "bun@1.3.5",
1635
+ }),
1636
+ });
1637
+ const report = runAudit(SITE, fs);
1638
+ const r = report.rules.find((r) => r.rule === "package-manager-missing")!;
1639
+ expect(r.findings).toEqual([]);
1640
+ });
1641
+
1642
+ it("--fix writes packageManager directly after license, preserving other keys", () => {
1643
+ const initial = JSON.stringify({
1644
+ name: "x",
1645
+ version: "1.0.0",
1646
+ license: "MIT",
1647
+ dependencies: { foo: "^1.0.0" },
1648
+ });
1649
+ const { fs, writer, store } = makeMutableFs({
1650
+ "/site/package.json": initial,
1651
+ });
1652
+ runAudit(SITE, fs, { writer });
1653
+ const updated = JSON.parse(store["/site/package.json"]);
1654
+ expect(updated.packageManager).toMatch(/^bun@/);
1655
+ const keys = Object.keys(updated);
1656
+ expect(keys.indexOf("packageManager")).toBe(keys.indexOf("license") + 1);
1657
+ expect(updated.dependencies).toEqual({ foo: "^1.0.0" });
1658
+ });
1659
+
1660
+ it("--fix appends packageManager when license is absent", () => {
1661
+ const { fs, writer, store } = makeMutableFs({
1662
+ "/site/package.json": JSON.stringify({ name: "x" }),
1663
+ });
1664
+ runAudit(SITE, fs, { writer });
1665
+ const updated = JSON.parse(store["/site/package.json"]);
1666
+ expect(updated.packageManager).toMatch(/^bun@/);
1667
+ });
1668
+ });
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { generateLockfileCheckYml } from "./lockfile-check-yml";
3
+
4
+ describe("generateLockfileCheckYml", () => {
5
+ it("emits a valid GitHub Actions workflow with the bun version pinned", () => {
6
+ const yaml = generateLockfileCheckYml("1.3.5");
7
+ expect(yaml).toContain("name: lockfile-check");
8
+ expect(yaml).toContain("on:");
9
+ expect(yaml).toContain("pull_request:");
10
+ expect(yaml).toContain("bun-version: 1.3.5");
11
+ expect(yaml).toContain("bun install --frozen-lockfile");
12
+ });
13
+
14
+ it("strips an accidental `bun@` prefix from the version input", () => {
15
+ const yaml = generateLockfileCheckYml("bun@1.3.5");
16
+ expect(yaml).toContain("bun-version: 1.3.5");
17
+ expect(yaml).not.toMatch(/bun-version:\s*bun@/);
18
+ });
19
+
20
+ it("scopes the trigger to package.json + bun.lock + the workflow itself", () => {
21
+ const yaml = generateLockfileCheckYml("1.3.5");
22
+ expect(yaml).toContain('"package.json"');
23
+ expect(yaml).toContain('"bun.lock"');
24
+ expect(yaml).toContain('".github/workflows/lockfile-check.yml"');
25
+ });
26
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Generates `.github/workflows/lockfile-check.yml`, the PR-time
3
+ * guardrail that fails any pull request which would have failed
4
+ * Cloudflare Workers Builds with `lockfile had changes, but lockfile
5
+ * is frozen`. This catches drift before it reaches main, instead of
6
+ * only after deploy attempts.
7
+ *
8
+ * Lives in the site repo (per-site, not centralised) because per
9
+ * D6.3 we are NOT scaffolding caller stubs that pull in
10
+ * `decocms/deco-start@vN` reusable workflows. The check is small
11
+ * enough that copy-paste-per-site is the right tradeoff.
12
+ *
13
+ * Bun version pinning matches the `packageManager` field in the
14
+ * scaffolded `package.json` (see `templates/package-json.ts`'s
15
+ * `CANONICAL_BUN_VERSION`). Kept in lockstep manually for now;
16
+ * future iterations may consolidate both into a shared constant.
17
+ */
18
+ export function generateLockfileCheckYml(bunVersion: string): string {
19
+ const v = stripBunPrefix(bunVersion);
20
+ return `name: lockfile-check
21
+
22
+ # PR-time guardrail: re-runs the same install Cloudflare Workers Builds
23
+ # runs on deploy (\`bun install --frozen-lockfile\`). Fails the PR if
24
+ # bun.lock would have rejected the install — closes the loop so drift
25
+ # can never reach main, only to be caught by Workers Builds at deploy
26
+ # time.
27
+ #
28
+ # Pairs with:
29
+ # - "packageManager": "bun@${v}" in package.json
30
+ # - .gitignore bans on package-lock.json / yarn.lock / pnpm-lock.yaml
31
+ # - the deco-post-cleanup audit's lockfile-* rules
32
+ #
33
+ # Adjust \`bun-version\` here in lockstep with the package.json
34
+ # \`packageManager\` field.
35
+
36
+ on:
37
+ pull_request:
38
+ paths:
39
+ - "package.json"
40
+ - "bun.lock"
41
+ - ".github/workflows/lockfile-check.yml"
42
+
43
+ permissions:
44
+ contents: read
45
+
46
+ jobs:
47
+ frozen-install:
48
+ runs-on: ubuntu-latest
49
+ steps:
50
+ - uses: actions/checkout@v4
51
+ - uses: oven-sh/setup-bun@v2
52
+ with:
53
+ bun-version: ${v}
54
+ - name: bun install --frozen-lockfile
55
+ run: bun install --frozen-lockfile
56
+ `;
57
+ }
58
+
59
+ /**
60
+ * Strip an accidental `bun@` prefix from the version string so the
61
+ * YAML's `bun-version` input (which expects a bare semver) doesn't
62
+ * receive `bun@1.3.5`.
63
+ */
64
+ function stripBunPrefix(input: string): string {
65
+ return input.replace(/^bun@/, "");
66
+ }
@@ -1,6 +1,17 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import type { MigrationContext } from "../types";
3
3
 
4
+ /**
5
+ * Fleet-wide canonical bun version. Bumped here propagates to all newly
6
+ * migrated sites via the `packageManager` field AND the
7
+ * `lockfile-check.yml` workflow's `bun-version` input. See
8
+ * MIGRATION_TOOLING_PLAN.md for the bun-canonical decision.
9
+ *
10
+ * Exported because the lockfile-check workflow template reads it
11
+ * directly to keep both files in lockstep.
12
+ */
13
+ export const CANONICAL_BUN_VERSION = "1.3.5";
14
+
4
15
  /**
5
16
  * Get the latest published version of an npm package.
6
17
  * Falls back to the provided default if the lookup fails.
@@ -120,7 +131,7 @@ export function generatePackageJson(ctx: MigrationContext): string {
120
131
  "format:check": 'prettier --check "src/**/*.{ts,tsx}"',
121
132
  knip: "knip",
122
133
  clean:
123
- "rm -rf node_modules .cache dist .wrangler/state node_modules/.vite && npm install",
134
+ "rm -rf node_modules .cache dist .wrangler/state node_modules/.vite && bun install",
124
135
  "tailwind:lint":
125
136
  "tsx scripts/tailwind-lint.ts",
126
137
  "tailwind:fix":
@@ -128,6 +139,7 @@ export function generatePackageJson(ctx: MigrationContext): string {
128
139
  },
129
140
  author: "deco.cx",
130
141
  license: "MIT",
142
+ packageManager: `bun@${CANONICAL_BUN_VERSION}`,
131
143
  dependencies: {
132
144
  "@decocms/apps": `^${appsVersion}`,
133
145
  "@decocms/start": `^${startVersion}`,
@@ -78,9 +78,11 @@ function showHelp() {
78
78
  --fix Auto-apply mechanical fixes for the safe rules
79
79
  (dead-lib-shims, dead-runtime-shim, local-widgets-types,
80
80
  vtex-shim-regression swap subset, obsolete-vite-plugins,
81
- local-framework-duplicate auto-fixable subset).
82
- Other rules — including htmx-residue and the warn-only
83
- entries of local-framework-duplicate stay detect-only.
81
+ local-framework-duplicate auto-fixable subset,
82
+ lockfile-multiple, package-manager-missing).
83
+ Other rulesincluding htmx-residue, lockfile-missing,
84
+ lockfile-drift, and the warn-only entries of
85
+ local-framework-duplicate — stay detect-only.
84
86
  --json Emit machine-readable JSON instead of pretty text
85
87
  --strict Exit code 2 if any warning-severity findings exist
86
88
  --help, -h Show this help
@@ -246,11 +246,16 @@ function bootstrap(ctx: { sourceDir: string }) {
246
246
  return true;
247
247
  };
248
248
 
249
- // Detect package manager
250
- const pm = process.env.npm_execpath?.includes("bun") ? "bun" : "npm";
249
+ // bun is the fleet-wide canonical package manager for decocms storefronts.
250
+ // We hardcode it here (instead of sniffing process.env.npm_execpath) so a
251
+ // freshly-migrated site always commits a bun.lock and never accidentally
252
+ // ships a package-lock.json that drifts vs bun.lock under CF Workers Builds.
253
+ // See MIGRATION_TOOLING_PLAN.md and the package-json template for the
254
+ // matching `packageManager` field that pins the version.
255
+ const pm = "bun";
251
256
  if (!run(`${pm} install`, "Install dependencies", true)) return;
252
- run("npx tsx node_modules/@decocms/start/scripts/generate-blocks.ts", "Generate CMS blocks");
253
- run("npx tsr generate", "Generate TanStack routes");
257
+ run("bunx tsx node_modules/@decocms/start/scripts/generate-blocks.ts", "Generate CMS blocks");
258
+ run("bunx tsr generate", "Generate TanStack routes");
254
259
 
255
260
  if (failures > 0) {
256
261
  console.log(