create-meith 0.30.0 → 0.31.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/bin.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { execFile } from "node:child_process";
5
- import { mkdir, readdir, writeFile } from "node:fs/promises";
6
- import { dirname, join, resolve } from "node:path";
7
- import { promisify } from "node:util";
4
+ import { execFile as execFile2 } from "node:child_process";
5
+ import { mkdir as mkdir2, readdir, writeFile as writeFile2 } from "node:fs/promises";
6
+ import { dirname as dirname2, join as join2, resolve } from "node:path";
7
+ import { promisify as promisify2 } from "node:util";
8
8
 
9
9
  // src/scaffold.ts
10
10
  var DEFAULT_REPOSITORY_URL = "https://github.com/meith-dev/meith";
@@ -125,6 +125,18 @@ var ENV_SMTP_MAIL_BLOCK = `# Mail. Leave these alone and the installer asks for
125
125
  # MAIL_SMTP_USERNAME=
126
126
  # MAIL_SMTP_PASSWORD=
127
127
  # MAIL_FROM=noreply@yourdomain.com`;
128
+ var ENV_BACKUP_BLOCK = `# The off-site backup destination: an S3-compatible bucket \`meith backup\`
129
+ # ships every bundle to, pruned there to the newest --keep (7 unless set).
130
+ # All four required together \u2014 a partial set fails the backup, never the board.
131
+ # BACKUP_S3_ENDPOINT is for anything S3-compatible (R2, MinIO, Spaces;
132
+ # BACKUP_S3_REGION=auto for R2); BACKUP_S3_PREFIX shares one bucket between
133
+ # boards. Use a bucket of its own, never the bucket the uploads live in.
134
+ # BACKUP_S3_BUCKET=
135
+ # BACKUP_S3_REGION=
136
+ # BACKUP_S3_ACCESS_KEY_ID=
137
+ # BACKUP_S3_SECRET_ACCESS_KEY=
138
+ # BACKUP_S3_ENDPOINT=
139
+ # BACKUP_S3_PREFIX=`;
128
140
  function selfHostEnvExample(name) {
129
141
  return `# ${name} \u2014 environment.
130
142
  #
@@ -155,6 +167,8 @@ APP_URL=
155
167
 
156
168
  ${ENV_SMTP_MAIL_BLOCK}
157
169
 
170
+ ${ENV_BACKUP_BLOCK}
171
+
158
172
  `;
159
173
  }
160
174
  function vercelEnvExample(name) {
@@ -323,7 +337,6 @@ function envExample(name, target) {
323
337
  }
324
338
  var SELF_HOST_DEPLOY_KIT = [
325
339
  ".dockerignore",
326
- ".github/dependabot.yml",
327
340
  ".github/workflows/build.yml",
328
341
  "Dockerfile",
329
342
  "Dockerfile.prebuilt",
@@ -508,9 +521,10 @@ export function installedPluginDefinitions() {
508
521
  files.set(
509
522
  ".github/dependabot.yml",
510
523
  `# Keeps this board's GitHub Actions current: one weekly pull request that
511
- # bumps the actions pinned in .github/workflows. Upgrading Meith itself is
512
- # separate and deliberate \u2014 see README.md, "Upgrading" \u2014 because the board's
513
- # \`next\` pin has to move together with \`@meith/web\` rather than on its own.
524
+ # bumps the actions pinned in .github/workflows. Meith itself is updated
525
+ # separately, by .github/workflows/update.yml \u2014 see README.md, "Upgrading" \u2014
526
+ # because the board's \`next\` pin has to move together with \`@meith/web\`
527
+ # rather than on its own.
514
528
  version: 2
515
529
  updates:
516
530
  - package-ecosystem: github-actions
@@ -520,6 +534,82 @@ updates:
520
534
  groups:
521
535
  actions:
522
536
  patterns: ['*']
537
+ `
538
+ );
539
+ files.set(
540
+ ".github/workflows/update.yml",
541
+ `# Opens a pull request whenever a new Meith release is out \u2014 see README.md,
542
+ # "Upgrading". Once a week, and on the Run workflow button, it runs the same
543
+ # updater you can run by hand, \`npx create-meith@latest update\`: every
544
+ # @meith/* pin and next moved together in package.json, and the deploy files
545
+ # the scaffold owns rewritten to the new release's shape. A file you have
546
+ # edited yourself is never rewritten; the run's log names any it left alone.
547
+ #
548
+ # One-time setup: under Settings \u2192 Actions \u2192 General, enable "Allow GitHub
549
+ # Actions to create and approve pull requests" \u2014 without it the last step
550
+ # fails, saying GitHub Actions is not permitted to create pull requests.
551
+ #
552
+ # Merging is still an upgrade, not a formality: read the release notes the
553
+ # pull request links, take a backup first, and once the new version serves,
554
+ # run \`meith upgrade\` against it. Migrations are forward-only \u2014 the backup
555
+ # is the rollback.
556
+ name: Meith update
557
+
558
+ on:
559
+ schedule:
560
+ - cron: '30 4 * * 1'
561
+ workflow_dispatch:
562
+
563
+ permissions:
564
+ contents: write
565
+ pull-requests: write
566
+
567
+ jobs:
568
+ update:
569
+ name: Update Meith
570
+ runs-on: ubuntu-latest
571
+ steps:
572
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
573
+
574
+ - name: Run the updater
575
+ run: npx --yes create-meith@latest update
576
+
577
+ - name: Open a pull request
578
+ env:
579
+ GH_TOKEN: \${{ github.token }}
580
+ run: |
581
+ if [ -z "$(git status --porcelain)" ]; then
582
+ echo "Already up to date."
583
+ exit 0
584
+ fi
585
+ VERSION=$(node -p "require('./package.json').dependencies['@meith/web']")
586
+ BODY="$RUNNER_TEMP/meith-update-body.md"
587
+ {
588
+ echo "Updates this board to Meith $VERSION \u2014 every @meith/* package and"
589
+ echo "next moved together, and the scaffold-owned deploy files rewritten"
590
+ echo "to this release's shape. Files with local edits were left alone;"
591
+ echo "this workflow run's log names any it skipped."
592
+ echo
593
+ echo "Before merging and deploying:"
594
+ echo
595
+ echo "1. Read the release notes \u2014 their Migrations line says what this"
596
+ echo " release does to the schema:"
597
+ echo " https://github.com/meith-dev/meith/releases/tag/v$VERSION"
598
+ echo "2. Take a backup. Migrations are forward-only; the backup is the"
599
+ echo " rollback."
600
+ echo
601
+ echo "After the new version deploys, run 'meith upgrade' once against the"
602
+ echo "board \u2014 the admin panel shows a notice until it has run. See"
603
+ echo "README.md, \\"Upgrading\\"."
604
+ } > "$BODY"
605
+ git config user.name "github-actions[bot]"
606
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
607
+ git checkout -B meith-update
608
+ git add -A
609
+ git commit -m "Update Meith to $VERSION"
610
+ git push -f origin meith-update
611
+ gh pr create --title "Update Meith to $VERSION" --body-file "$BODY" \\
612
+ || gh pr edit meith-update --title "Update Meith to $VERSION" --body-file "$BODY"
523
613
  `
524
614
  );
525
615
  files.set(
@@ -934,8 +1024,24 @@ services:
934
1024
  - MAIL_SMTP_USERNAME=\${MAIL_SMTP_USERNAME:-}
935
1025
  - MAIL_SMTP_PASSWORD=\${MAIL_SMTP_PASSWORD:-}
936
1026
  - MAIL_FROM=\${MAIL_FROM:-}
1027
+ # The off-site backup destination, unset until the four required values
1028
+ # are filled in on the Coolify resource. Coolify only hands a compose
1029
+ # service the variables the file names, so a Scheduled Task running
1030
+ # \`meith backup\` in this container would never see them without these
1031
+ # lines \u2014 see the meith repository's
1032
+ # docs/getting-started/deployment/coolify.md, "Set up backups".
1033
+ - BACKUP_S3_BUCKET=\${BACKUP_S3_BUCKET:-}
1034
+ - BACKUP_S3_REGION=\${BACKUP_S3_REGION:-}
1035
+ - BACKUP_S3_ACCESS_KEY_ID=\${BACKUP_S3_ACCESS_KEY_ID:-}
1036
+ - BACKUP_S3_SECRET_ACCESS_KEY=\${BACKUP_S3_SECRET_ACCESS_KEY:-}
1037
+ - BACKUP_S3_ENDPOINT=\${BACKUP_S3_ENDPOINT:-}
1038
+ - BACKUP_S3_PREFIX=\${BACKUP_S3_PREFIX:-}
937
1039
  volumes:
938
1040
  - uploads:/app/.uploads
1041
+ # Where \`meith backup --dir /backups\` writes its ring of bundles. A
1042
+ # named volume rather than the container filesystem, so the ring survives
1043
+ # every redeploy.
1044
+ - backups:/backups
939
1045
  depends_on:
940
1046
  postgres:
941
1047
  condition: service_healthy
@@ -971,6 +1077,7 @@ services:
971
1077
  volumes:
972
1078
  pgdata:
973
1079
  uploads:
1080
+ backups:
974
1081
  `
975
1082
  );
976
1083
  files.set(
@@ -1066,8 +1173,24 @@ services:
1066
1173
  - MAIL_SMTP_USERNAME=\${MAIL_SMTP_USERNAME:-}
1067
1174
  - MAIL_SMTP_PASSWORD=\${MAIL_SMTP_PASSWORD:-}
1068
1175
  - MAIL_FROM=\${MAIL_FROM:-}
1176
+ # The off-site backup destination, unset until the four required values
1177
+ # are filled in on the Coolify resource. Coolify only hands a compose
1178
+ # service the variables the file names, so a Scheduled Task running
1179
+ # \`meith backup\` in this container would never see them without these
1180
+ # lines \u2014 see the meith repository's
1181
+ # docs/getting-started/deployment/coolify.md, "Set up backups".
1182
+ - BACKUP_S3_BUCKET=\${BACKUP_S3_BUCKET:-}
1183
+ - BACKUP_S3_REGION=\${BACKUP_S3_REGION:-}
1184
+ - BACKUP_S3_ACCESS_KEY_ID=\${BACKUP_S3_ACCESS_KEY_ID:-}
1185
+ - BACKUP_S3_SECRET_ACCESS_KEY=\${BACKUP_S3_SECRET_ACCESS_KEY:-}
1186
+ - BACKUP_S3_ENDPOINT=\${BACKUP_S3_ENDPOINT:-}
1187
+ - BACKUP_S3_PREFIX=\${BACKUP_S3_PREFIX:-}
1069
1188
  volumes:
1070
1189
  - uploads:/app/.uploads
1190
+ # Where \`meith backup --dir /backups\` writes its ring of bundles. A
1191
+ # named volume rather than the container filesystem, so the ring survives
1192
+ # every redeploy.
1193
+ - backups:/backups
1071
1194
  depends_on:
1072
1195
  postgres:
1073
1196
  condition: service_healthy
@@ -1103,6 +1226,7 @@ services:
1103
1226
  volumes:
1104
1227
  pgdata:
1105
1228
  uploads:
1229
+ backups:
1106
1230
  `
1107
1231
  );
1108
1232
  files.set(
@@ -1288,27 +1412,52 @@ for the full guide.
1288
1412
 
1289
1413
  ## Upgrading
1290
1414
 
1415
+ \`.github/workflows/update.yml\` does this for you: once a week \u2014 and
1416
+ whenever you press **Run workflow** on the Actions tab \u2014 it checks for a new
1417
+ Meith release and opens a pull request that moves every \`@meith/*\` package
1418
+ and \`next\` together, and rewrites the deploy files this scaffold owns
1419
+ (\`Dockerfile\`, the compose files, the workflows) to the new release's
1420
+ shape. A file you have edited yourself is never rewritten; the run's log
1421
+ names any it left for you. One-time setup: under
1422
+ **Settings \u2192 Actions \u2192 General**, enable **Allow GitHub Actions to create
1423
+ and approve pull requests**, or the workflow cannot open one.
1424
+
1425
+ Merging that pull request is still an upgrade, not a formality: read the
1426
+ release notes it links, take a backup first, and press **Redeploy** in
1427
+ Coolify after the merge \u2014 pushing alone does not rebuild. Once the new
1428
+ version serves, run \`npm run meith -- upgrade\` against it for the plugin
1429
+ migrations.
1430
+
1431
+ The same update, by hand and without waiting for the schedule:
1432
+
1433
+ \`\`\`sh
1434
+ npx create-meith@latest update
1435
+ git commit -am "Update Meith"
1436
+ git push
1437
+ \`\`\`
1438
+
1439
+ Under the hood, the version move is these two commands, plus the deploy-file
1440
+ rewrite neither of them can do:
1441
+
1291
1442
  \`\`\`sh
1292
1443
  npm install --save-exact @meith/web@latest @meith/cli@latest @meith/theme-default@latest
1293
1444
  npm install --save-exact next@$(node -p "require('./node_modules/@meith/web/package.json').dependencies.next")
1294
- git commit -am "Upgrade Meith and the Next.js version it builds with"
1295
- git push
1296
1445
  \`\`\`
1297
1446
 
1298
1447
  The second command is not optional. This board pins \`next\` itself, and
1299
- nothing bumps it for you: upgrading only the \`@meith/*\` packages leaves the
1300
- board's own pin on the old Next while \`@meith/web\` depends on the new one,
1301
- which npm resolves by installing both \u2014 the build then runs on one version
1302
- while everything reading \`package.json\` sees the other. Reading the version
1303
- out of the freshly installed \`@meith/web\` is what keeps the two the same
1304
- without anybody having to know the number.
1305
-
1306
- This upgrade is deliberate and manual for that reason: \`next\` and
1307
- \`@meith/web\` move together or not at all. What *is* kept current for you is
1308
- this repository's own GitHub Actions \u2014 \`.github/dependabot.yml\` opens a
1309
- weekly pull request bumping the actions pinned in
1310
- \`.github/workflows/build.yml\`, which is a safe, independent update the two
1311
- commands above never touch.
1448
+ the npm commands alone never bump it: upgrading only the \`@meith/*\` packages
1449
+ leaves the board's own pin on the old Next while \`@meith/web\` depends on the
1450
+ new one, which npm resolves by installing both \u2014 the build then runs on one
1451
+ version while everything reading \`package.json\` sees the other. Reading the
1452
+ version out of the freshly installed \`@meith/web\` is what keeps the two the
1453
+ same without anybody having to know the number.
1454
+
1455
+ \`next\` and \`@meith/web\` move together or not at all, which is why one
1456
+ updater owns the whole move and no dependency bot bumps either on its own.
1457
+ What Dependabot *does* keep current is this repository's own GitHub Actions \u2014
1458
+ \`.github/dependabot.yml\` opens a weekly pull request bumping the actions
1459
+ pinned under \`.github/workflows\`, a safe, independent update the updater
1460
+ leaves to it.
1312
1461
 
1313
1462
  On the quick-start path there is no version to keep in sync by hand:
1314
1463
  \`Dockerfile\` runs \`npm install\` straight from this \`package.json\` on every
@@ -1542,20 +1691,38 @@ each retry into another attempt against whatever it is failing against.
1542
1691
 
1543
1692
  ## Upgrading
1544
1693
 
1694
+ \`.github/workflows/update.yml\` does this for you: once a week \u2014 and
1695
+ whenever you press **Run workflow** on the Actions tab \u2014 it checks for a new
1696
+ Meith release and opens a pull request that moves every \`@meith/*\` package
1697
+ and \`next\` together. A file you have edited yourself is never rewritten; the
1698
+ run's log names any it left for you. One-time setup: under
1699
+ **Settings \u2192 Actions \u2192 General**, enable **Allow GitHub Actions to create and
1700
+ approve pull requests**, or the workflow cannot open one. Read the release
1701
+ notes the pull request links and take a backup before merging; Vercel
1702
+ rebuilds on the merge, and the build command applies the new migrations
1703
+ before it builds.
1704
+
1705
+ The same update, by hand and without waiting for the schedule:
1706
+
1707
+ \`\`\`sh
1708
+ npx create-meith@latest update
1709
+ git commit -am "Update Meith"
1710
+ git push
1711
+ \`\`\`
1712
+
1713
+ Under the hood, the version move is these two commands:
1714
+
1545
1715
  \`\`\`sh
1546
1716
  npm install --save-exact @meith/web@latest @meith/cli@latest @meith/theme-default@latest
1547
1717
  npm install --save-exact next@$(node -p "require('./node_modules/@meith/web/package.json').dependencies.next")
1548
- git commit -am "Upgrade Meith and the Next.js version it builds with"
1549
- git push
1550
1718
  \`\`\`
1551
1719
 
1552
- Vercel rebuilds on the push, and the build command applies the new migrations
1553
- before it builds. \`--save-exact\` matters and \`.npmrc\` already sets it for
1554
- everything else installed here.
1720
+ \`--save-exact\` matters and \`.npmrc\` already sets it for everything else
1721
+ installed here.
1555
1722
 
1556
1723
  The second command is not optional. This board pins \`next\` itself \u2014 Vercel
1557
- reads that pin to pick its Next.js builder \u2014 and nothing bumps it for you.
1558
- Upgrading only the \`@meith/*\` packages leaves two versions of Next
1724
+ reads that pin to pick its Next.js builder \u2014 and the npm commands alone never
1725
+ bump it. Upgrading only the \`@meith/*\` packages leaves two versions of Next
1559
1726
  installed, the board built with one and the platform configured for the
1560
1727
  other. Reading the version out of the freshly installed \`@meith/web\` keeps
1561
1728
  them the same without anybody having to know the number.
@@ -2098,8 +2265,369 @@ function extensionNextSteps(name) {
2098
2265
  return [`cd ${name}`, "npm install", "npm test"];
2099
2266
  }
2100
2267
 
2101
- // src/cli.ts
2268
+ // src/update.ts
2269
+ import { execFile } from "node:child_process";
2270
+ import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2271
+ import { dirname, join } from "node:path";
2272
+ import { promisify } from "node:util";
2273
+ import { gunzipSync } from "node:zlib";
2102
2274
  var execFileAsync = promisify(execFile);
2275
+ var TEMPLATE_BOARD_NAME = "meith-board";
2276
+ var TEMPLATE_REPOSITORIES = {
2277
+ "self-host": "meith-dev/template",
2278
+ vercel: "meith-dev/vercel-template"
2279
+ };
2280
+ var RELEASE_NOTES_URL = "https://github.com/meith-dev/meith/releases/tag";
2281
+ function templateTarballUrl(target, version) {
2282
+ return `https://codeload.github.com/${TEMPLATE_REPOSITORIES[target]}/tar.gz/refs/tags/v${version}`;
2283
+ }
2284
+ function parseExactVersion(value) {
2285
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
2286
+ if (match === null) return null;
2287
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
2288
+ }
2289
+ function compareExactVersions(left, right) {
2290
+ const a = parseExactVersion(left);
2291
+ const b = parseExactVersion(right);
2292
+ if (a === null || b === null) return 0;
2293
+ for (let part = 0; part < 3; part++) {
2294
+ if (a[part] !== b[part])
2295
+ return a[part] - b[part];
2296
+ }
2297
+ return 0;
2298
+ }
2299
+ function substituteBoardName(content, name) {
2300
+ if (name === TEMPLATE_BOARD_NAME) return content;
2301
+ return content.split(TEMPLATE_BOARD_NAME).join(name);
2302
+ }
2303
+ function normalizeActionPins(content) {
2304
+ return content.split("\n").map((line) => /^\s*(- )?uses:\s/.test(line) ? line.replace(/@\S+(\s*#.*)?$/, "@pin") : line).join("\n");
2305
+ }
2306
+ function comparable(path, content) {
2307
+ return path.startsWith(".github/workflows/") ? normalizeActionPins(content) : content;
2308
+ }
2309
+ function tarText(block, start, length) {
2310
+ const slice = block.subarray(start, start + length);
2311
+ const end = slice.indexOf(0);
2312
+ return new TextDecoder().decode(end === -1 ? slice : slice.subarray(0, end));
2313
+ }
2314
+ function unpackTemplateTarball(data, name) {
2315
+ const tar = gunzipSync(data);
2316
+ const files = /* @__PURE__ */ new Map();
2317
+ for (let at = 0; at + 512 <= tar.length; ) {
2318
+ const header = tar.subarray(at, at + 512);
2319
+ at += 512;
2320
+ if (header.every((byte) => byte === 0)) break;
2321
+ const size = Number.parseInt(tarText(header, 124, 12).trim() || "0", 8);
2322
+ const type = header[156] ?? 0;
2323
+ const prefix = tarText(header, 345, 155);
2324
+ const path = prefix === "" ? tarText(header, 0, 100) : `${prefix}/${tarText(header, 0, 100)}`;
2325
+ if (type === 48 || type === 0) {
2326
+ const relative = path.split("/").slice(1).join("/");
2327
+ if (relative !== "") {
2328
+ const content = new TextDecoder().decode(tar.subarray(at, at + size));
2329
+ files.set(relative, substituteBoardName(content, name));
2330
+ }
2331
+ }
2332
+ at += Math.ceil(size / 512) * 512;
2333
+ }
2334
+ return files;
2335
+ }
2336
+ async function fetchPreviousTree(target, version, name) {
2337
+ try {
2338
+ const response = await fetch(templateTarballUrl(target, version), {
2339
+ redirect: "follow",
2340
+ signal: AbortSignal.timeout(3e4)
2341
+ });
2342
+ if (!response.ok) return null;
2343
+ return unpackTemplateTarball(new Uint8Array(await response.arrayBuffer()), name);
2344
+ } catch {
2345
+ return null;
2346
+ }
2347
+ }
2348
+ function mergeDependencies(current, next, newVersion) {
2349
+ const merged = {};
2350
+ for (const [name, range] of Object.entries(current)) {
2351
+ merged[name] = next[name] ?? (name.startsWith("@meith/") ? newVersion : range);
2352
+ }
2353
+ for (const [name, range] of Object.entries(next)) {
2354
+ if (!(name in merged)) merged[name] = range;
2355
+ }
2356
+ return merged;
2357
+ }
2358
+ function mergeScripts(current, previous, next, degraded) {
2359
+ const merged = { ...current ?? {} };
2360
+ for (const [key, value] of Object.entries(next)) {
2361
+ const held = current?.[key];
2362
+ if (held === void 0) {
2363
+ if (degraded || previous?.[key] === void 0) merged[key] = value;
2364
+ continue;
2365
+ }
2366
+ if (held === value || held === previous?.[key]) merged[key] = value;
2367
+ }
2368
+ return merged;
2369
+ }
2370
+ function mergeManifest(currentText, previousText, nextText, newVersion) {
2371
+ const current = JSON.parse(currentText);
2372
+ const previous = previousText === null ? null : JSON.parse(previousText);
2373
+ const next = JSON.parse(nextText);
2374
+ const merged = { ...current };
2375
+ merged.dependencies = mergeDependencies(
2376
+ current.dependencies ?? {},
2377
+ next.dependencies ?? {},
2378
+ newVersion
2379
+ );
2380
+ if (current.devDependencies !== void 0) {
2381
+ merged.devDependencies = mergeDependencies(current.devDependencies, {}, newVersion);
2382
+ }
2383
+ if (next.scripts !== void 0) {
2384
+ merged.scripts = mergeScripts(
2385
+ current.scripts,
2386
+ previous?.scripts,
2387
+ next.scripts,
2388
+ previous === null
2389
+ );
2390
+ }
2391
+ for (const field of ["type", "engines"]) {
2392
+ if (!(field in next)) continue;
2393
+ const untouched = previous !== null && JSON.stringify(current[field]) === JSON.stringify(previous[field]);
2394
+ if (!(field in current) || untouched) merged[field] = next[field];
2395
+ }
2396
+ return `${JSON.stringify(merged, null, 2)}
2397
+ `;
2398
+ }
2399
+ function planUpdate({ current, previous, next, newVersion }) {
2400
+ const writes = /* @__PURE__ */ new Map();
2401
+ const deletes = [];
2402
+ const created = [];
2403
+ const updated = [];
2404
+ const skipped = [];
2405
+ const review = [];
2406
+ const currentManifest = current.get("package.json");
2407
+ const nextManifest = next.get("package.json");
2408
+ if (currentManifest !== void 0 && nextManifest !== void 0) {
2409
+ const merged = mergeManifest(
2410
+ currentManifest,
2411
+ previous?.get("package.json") ?? null,
2412
+ nextManifest,
2413
+ newVersion
2414
+ );
2415
+ if (merged !== currentManifest) {
2416
+ writes.set("package.json", merged);
2417
+ updated.push("package.json");
2418
+ }
2419
+ }
2420
+ if (previous === null) {
2421
+ for (const [path, content] of next) {
2422
+ if (path === "package.json") continue;
2423
+ if (current.get(path) !== content) review.push(path);
2424
+ }
2425
+ return { writes, deletes, created, updated, skipped, review: review.sort() };
2426
+ }
2427
+ const paths = /* @__PURE__ */ new Set([...next.keys(), ...previous.keys()]);
2428
+ paths.delete("package.json");
2429
+ for (const path of [...paths].sort()) {
2430
+ const held = current.get(path);
2431
+ const before = previous.get(path);
2432
+ const after = next.get(path);
2433
+ if (after !== void 0) {
2434
+ if (held === void 0) {
2435
+ if (before === void 0) {
2436
+ writes.set(path, after);
2437
+ created.push(path);
2438
+ }
2439
+ continue;
2440
+ }
2441
+ if (held === after) continue;
2442
+ if (before !== void 0 && comparable(path, held) === comparable(path, before)) {
2443
+ writes.set(path, after);
2444
+ updated.push(path);
2445
+ } else {
2446
+ skipped.push(path);
2447
+ }
2448
+ continue;
2449
+ }
2450
+ if (held === void 0 || before === void 0) continue;
2451
+ if (comparable(path, held) === comparable(path, before)) deletes.push(path);
2452
+ else skipped.push(path);
2453
+ }
2454
+ return { writes, deletes, created, updated, skipped: skipped.sort(), review };
2455
+ }
2456
+ async function exists(path) {
2457
+ try {
2458
+ await access(path);
2459
+ return true;
2460
+ } catch {
2461
+ return false;
2462
+ }
2463
+ }
2464
+ async function readIfPresent(path) {
2465
+ try {
2466
+ return await readFile(path, "utf8");
2467
+ } catch {
2468
+ return null;
2469
+ }
2470
+ }
2471
+ async function refreshNpmLockfile(cwd) {
2472
+ try {
2473
+ await execFileAsync(
2474
+ "npm",
2475
+ ["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"],
2476
+ { cwd }
2477
+ );
2478
+ return null;
2479
+ } catch {
2480
+ return "package-lock.json could not be refreshed \u2014 run `npm install` here to bring it up to date.";
2481
+ }
2482
+ }
2483
+ async function runUpdate(newVersion, options = {}) {
2484
+ const cwd = options.cwd ?? process.cwd();
2485
+ const manifestText = await readIfPresent(join(cwd, "package.json"));
2486
+ if (manifestText === null) {
2487
+ return {
2488
+ code: 1,
2489
+ lines: [
2490
+ "create-meith update: no package.json here.",
2491
+ "Run this inside the board directory \u2014 the one `npx create-meith` scaffolded,",
2492
+ "or the clone of your board repository."
2493
+ ]
2494
+ };
2495
+ }
2496
+ let manifest;
2497
+ try {
2498
+ manifest = JSON.parse(manifestText);
2499
+ } catch {
2500
+ return { code: 1, lines: ["create-meith update: package.json here is not valid JSON."] };
2501
+ }
2502
+ const currentVersion = manifest.dependencies?.["@meith/web"];
2503
+ if (currentVersion === void 0) {
2504
+ return {
2505
+ code: 1,
2506
+ lines: [
2507
+ "create-meith update: this package.json does not depend on @meith/web,",
2508
+ "so this does not look like a Meith board."
2509
+ ]
2510
+ };
2511
+ }
2512
+ const parsedCurrent = parseExactVersion(currentVersion);
2513
+ if (parsedCurrent === null) {
2514
+ return {
2515
+ code: 1,
2516
+ lines: [
2517
+ `create-meith update: @meith/web is pinned to '${currentVersion}', not an exact X.Y.Z version.`,
2518
+ "Pin it first \u2014 `npm install --save-exact @meith/web@<version>` \u2014 so the updater",
2519
+ "can tell which release this board is on."
2520
+ ]
2521
+ };
2522
+ }
2523
+ const parsedNew = parseExactVersion(newVersion);
2524
+ if (parsedNew === null) {
2525
+ return {
2526
+ code: 1,
2527
+ lines: [`create-meith update: cannot update to '${newVersion}' \u2014 not an exact version.`]
2528
+ };
2529
+ }
2530
+ if (compareExactVersions(currentVersion, newVersion) === 0) {
2531
+ return { code: 0, lines: [`Already at ${newVersion} \u2014 nothing to update.`] };
2532
+ }
2533
+ if (compareExactVersions(currentVersion, newVersion) > 0) {
2534
+ return {
2535
+ code: 1,
2536
+ lines: [
2537
+ `create-meith update: this board is on ${currentVersion}, newer than ${newVersion}.`,
2538
+ "Downgrades are refused \u2014 migrations are forward-only. To update, run the",
2539
+ "newest updater: `npx create-meith@latest update`."
2540
+ ]
2541
+ };
2542
+ }
2543
+ if (parsedNew[0] - parsedCurrent[0] > 2) {
2544
+ const stage = parsedCurrent[0] + 2;
2545
+ return {
2546
+ code: 1,
2547
+ lines: [
2548
+ `create-meith update: ${currentVersion} to ${newVersion} jumps more than two majors,`,
2549
+ "which is further than upgrades are tested to span. Update in stages instead \u2014",
2550
+ `\`npx create-meith@${stage} update\` first, deploy and run \`meith upgrade\`,`,
2551
+ "then come back to `npx create-meith@latest update`."
2552
+ ]
2553
+ };
2554
+ }
2555
+ const name = typeof manifest.name === "string" && manifest.name !== "" ? manifest.name : TEMPLATE_BOARD_NAME;
2556
+ const target = await exists(join(cwd, "vercel.json")) ? "vercel" : "self-host";
2557
+ const next = scaffold({
2558
+ name,
2559
+ version: newVersion,
2560
+ repositoryUrl: DEFAULT_REPOSITORY_URL,
2561
+ target
2562
+ });
2563
+ const previous = await (options.loadPrevious ?? fetchPreviousTree)(target, currentVersion, name);
2564
+ const paths = /* @__PURE__ */ new Set(["package.json", ...next.keys(), ...previous?.keys() ?? []]);
2565
+ const current = /* @__PURE__ */ new Map();
2566
+ for (const path of paths) {
2567
+ const content = await readIfPresent(join(cwd, path));
2568
+ if (content !== null) current.set(path, content);
2569
+ }
2570
+ const plan = planUpdate({ current, previous, next, newVersion });
2571
+ for (const [path, content] of plan.writes) {
2572
+ const absolute = join(cwd, path);
2573
+ await mkdir(dirname(absolute), { recursive: true });
2574
+ await writeFile(absolute, content, "utf8");
2575
+ }
2576
+ for (const path of plan.deletes) {
2577
+ await rm(join(cwd, path), { force: true });
2578
+ }
2579
+ const lockfileWarning = plan.writes.size > 0 && await exists(join(cwd, "package-lock.json")) ? await (options.refreshLockfile ?? refreshNpmLockfile)(cwd) : null;
2580
+ const templateUrl = `https://github.com/${TEMPLATE_REPOSITORIES[target]}`;
2581
+ const nextManifest = JSON.parse(next.get("package.json") ?? "{}");
2582
+ const nextPin = nextManifest.dependencies?.next;
2583
+ const lines = [];
2584
+ const changed = plan.writes.size + plan.deletes.length;
2585
+ if (changed === 0) {
2586
+ lines.push(`Nothing to write \u2014 every file already matches ${newVersion}.`);
2587
+ } else {
2588
+ lines.push(
2589
+ `Updated ${name} from ${currentVersion} to ${newVersion} \u2014 ${changed} file${changed === 1 ? "" : "s"} changed.`,
2590
+ ""
2591
+ );
2592
+ }
2593
+ for (const path of plan.updated) {
2594
+ lines.push(
2595
+ path === "package.json" ? ` updated package.json \u2014 every @meith/* pin to ${newVersion}${nextPin === void 0 ? "" : `, next to ${nextPin}`}` : ` updated ${path}`
2596
+ );
2597
+ }
2598
+ for (const path of plan.created) lines.push(` added ${path}`);
2599
+ for (const path of plan.deletes) lines.push(` removed ${path} \u2014 this release no longer ships it`);
2600
+ for (const path of plan.skipped) {
2601
+ lines.push(
2602
+ ` kept ${path} \u2014 it differs from the ${currentVersion} scaffold; compare it with ${templateUrl} at v${newVersion}`
2603
+ );
2604
+ }
2605
+ if (previous === null) {
2606
+ lines.push(
2607
+ "",
2608
+ `Could not read the v${currentVersion} template from ${templateUrl}, so only`,
2609
+ "package.json was updated. Review these files against that repository at",
2610
+ `v${newVersion} \u2014 this release may have changed them:`,
2611
+ ...plan.review.map((path) => ` ${path}`)
2612
+ );
2613
+ }
2614
+ if (lockfileWarning !== null) lines.push("", lockfileWarning);
2615
+ if (changed > 0) {
2616
+ lines.push(
2617
+ "",
2618
+ "Next:",
2619
+ " 1. Review the diff, commit, and push.",
2620
+ " 2. Take a backup, then deploy. The release notes name the migrations:",
2621
+ ` ${RELEASE_NOTES_URL}/v${newVersion}`,
2622
+ " 3. Once the new version serves, run `meith upgrade` against the board \u2014",
2623
+ " the admin panel shows a notice until it has run."
2624
+ );
2625
+ }
2626
+ return { code: 0, lines };
2627
+ }
2628
+
2629
+ // src/cli.ts
2630
+ var execFileAsync2 = promisify2(execFile2);
2103
2631
  async function isSafeTarget(target) {
2104
2632
  try {
2105
2633
  return (await readdir(target)).length === 0;
@@ -2109,14 +2637,14 @@ async function isSafeTarget(target) {
2109
2637
  }
2110
2638
  async function initGit(target) {
2111
2639
  try {
2112
- await execFileAsync("git", ["init", "-q", "-b", "main"], { cwd: target });
2113
- await execFileAsync("git", ["add", "-A"], { cwd: target });
2640
+ await execFileAsync2("git", ["init", "-q", "-b", "main"], { cwd: target });
2641
+ await execFileAsync2("git", ["add", "-A"], { cwd: target });
2114
2642
  return true;
2115
2643
  } catch {
2116
2644
  return false;
2117
2645
  }
2118
2646
  }
2119
- async function run(argv, version) {
2647
+ async function run(argv, version, updateOptions = {}) {
2120
2648
  const positional = [];
2121
2649
  for (let i = 0; i < argv.length; i++) {
2122
2650
  const arg = argv[i];
@@ -2131,21 +2659,30 @@ async function run(argv, version) {
2131
2659
  return {
2132
2660
  code: 0,
2133
2661
  lines: [
2134
- "create-meith \u2014 scaffold a forum project, a plugin or a theme.",
2662
+ "create-meith \u2014 scaffold a forum project, a plugin or a theme, or update one.",
2135
2663
  "",
2136
2664
  " npx create-meith <name> [--repo <url>] [--no-git]",
2137
2665
  " npx create-meith --plugin <name> [--repo <url>] [--no-git]",
2138
2666
  " npx create-meith --theme <name> [--repo <url>] [--no-git]",
2667
+ " npx create-meith@latest update",
2139
2668
  "",
2140
2669
  "The first form writes a deployable board into ./<name>, then tells you",
2141
2670
  "what to run. --plugin and --theme write an extension workspace instead:",
2142
2671
  "source and a passing test copied from the meith repository\u2019s worked",
2143
2672
  "examples, plus a README and a marketplace listing.json.",
2144
2673
  "",
2674
+ "`update`, run inside a board, moves it to this release: every @meith/*",
2675
+ "pin and next together in package.json, and the deploy files the scaffold",
2676
+ "owns rewritten to the new shape \u2014 files you have edited are left alone",
2677
+ "and named. `update` is a reserved word, so a board cannot take it as a name.",
2678
+ "",
2145
2679
  "--no-git skips initializing a git repository in the new directory."
2146
2680
  ]
2147
2681
  };
2148
2682
  }
2683
+ if (name === "update") {
2684
+ return await runUpdate(version, updateOptions);
2685
+ }
2149
2686
  const wantsPlugin = argv.includes("--plugin");
2150
2687
  const wantsTheme = argv.includes("--theme");
2151
2688
  if (wantsPlugin && wantsTheme) {
@@ -2178,9 +2715,9 @@ async function run(argv, version) {
2178
2715
  repositoryUrl: repoIndex === -1 ? void 0 : repositoryUrl
2179
2716
  });
2180
2717
  for (const [relative, contents] of files) {
2181
- const path = join(target, relative);
2182
- await mkdir(dirname(path), { recursive: true });
2183
- await writeFile(path, contents, "utf8");
2718
+ const path = join2(target, relative);
2719
+ await mkdir2(dirname2(path), { recursive: true });
2720
+ await writeFile2(path, contents, "utf8");
2184
2721
  }
2185
2722
  const gitReady = argv.includes("--no-git") ? false : await initGit(target);
2186
2723
  return {
@@ -2219,7 +2756,7 @@ async function run(argv, version) {
2219
2756
  }
2220
2757
 
2221
2758
  // src/bin.ts
2222
- var result = await run(process.argv.slice(2), "0.30.0");
2759
+ var result = await run(process.argv.slice(2), "0.31.0");
2223
2760
  for (const line of result.lines) {
2224
2761
  if (result.code === 0) console.log(line);
2225
2762
  else console.error(line);