@clubedge/create-clubedge-app 0.47.0 → 0.48.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 (63) hide show
  1. package/CHANGELOG.md +36 -3
  2. package/README.md +18 -16
  3. package/dist/cli/ui.js +4 -2
  4. package/dist/core/customize.js +6 -3
  5. package/dist/core/deploy.js +11 -5
  6. package/dist/core/execute.js +26 -10
  7. package/dist/core/health.js +26 -2
  8. package/dist/core/lockfile-peers.js +15 -2
  9. package/dist/core/merge.js +47 -4
  10. package/dist/core/package-json-merge.js +48 -10
  11. package/dist/core/update.js +23 -3
  12. package/dist/inspect.js +13 -18
  13. package/dist/modify.js +96 -25
  14. package/dist/utils/fs.js +15 -10
  15. package/package.json +3 -3
  16. package/template/apps/api/package.json +1 -1
  17. package/template/apps/api-fastapi/package.json +1 -1
  18. package/template/apps/api-fastapi/pyproject.toml +1 -1
  19. package/template/apps/api-fastapi/uv.lock +1 -1
  20. package/template/apps/capacitor/package.json +1 -1
  21. package/template/apps/expo/package.json +16 -16
  22. package/template/apps/start/package.json +1 -1
  23. package/template/apps/vite/package.json +1 -1
  24. package/template/apps/web/package.json +1 -1
  25. package/template/clubedge.template.json +1 -1
  26. package/template/deploy/cloudflare/package.json +1 -1
  27. package/template/e2e/tsconfig.json +7 -0
  28. package/template/package.json +3 -2
  29. package/template/packages/auth/package.json +1 -1
  30. package/template/packages/auth-appwrite/package.json +1 -1
  31. package/template/packages/auth-better-auth/package.json +1 -1
  32. package/template/packages/auth-firebase/package.json +1 -1
  33. package/template/packages/auth-supabase/package.json +1 -1
  34. package/template/packages/cache/package.json +1 -1
  35. package/template/packages/cache-redis/package.json +2 -2
  36. package/template/packages/cache-redis/src/index.test.ts +8 -0
  37. package/template/packages/cache-redis/src/index.ts +16 -8
  38. package/template/packages/contract/package.json +1 -1
  39. package/template/packages/core/package.json +1 -1
  40. package/template/packages/db/package.json +1 -1
  41. package/template/packages/email/package.json +1 -1
  42. package/template/packages/email-resend/package.json +1 -1
  43. package/template/packages/email-smtp/package.json +1 -1
  44. package/template/packages/jobs/package.json +1 -1
  45. package/template/packages/jobs-bullmq/package.json +1 -1
  46. package/template/packages/jobs-inngest/package.json +1 -1
  47. package/template/packages/jobs-pg-boss/package.json +1 -1
  48. package/template/packages/local-db/package.json +1 -1
  49. package/template/packages/mobile/package.json +1 -1
  50. package/template/packages/observability/package.json +1 -1
  51. package/template/packages/observability-otel/package.json +1 -1
  52. package/template/packages/observability-sentry/package.json +1 -1
  53. package/template/packages/payments/package.json +1 -1
  54. package/template/packages/payments-stripe/package.json +1 -1
  55. package/template/packages/push/package.json +1 -1
  56. package/template/packages/push-expo/package.json +1 -1
  57. package/template/packages/push-native/package.json +1 -1
  58. package/template/packages/storage/package.json +1 -1
  59. package/template/packages/storage-s3/package.json +1 -1
  60. package/template/packages/storage-supabase/package.json +1 -1
  61. package/template/packages/ui/package.json +1 -1
  62. package/template/pnpm-lock.yaml +544 -571
  63. package/template.lock.json +3 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.48.0
6
+
7
+ - **Starter v0.31.1** by default (#202). Its template schema is still 6. The [Starter's release notes](https://github.com/Clubedge/clubedge-starter/releases/tag/v0.31.1) cover:
8
+ - Expo SDK 58's latest patch versions, without which the native app's CI job fails in projects generated from v0.31.0;
9
+ - version 6 of the `redis` client for the Redis cache, still speaking RESP2;
10
+ - `pnpm typecheck` checking the browser tests too.
11
+ - **`add`, `remove`, and `upgrade` refuse to change a project that still has conflict markers from an earlier change** (#198; issue #195). Merging into those files could leave old and new markers interleaved, or carry them forward with exit code 0. The CLI now lists each file with its number of conflict blocks and asks to resolve them first. `--force` changes the project anyway.
12
+ - **Conflict blocks hold only the lines that differ** (#199; issue #196), as Git writes them. Lines your version and the Starter's share inside a conflict stay outside the markers, unless what separates two blocks is three lines or fewer, or lines without a letter or digit, such as closing braces and blank lines: those blocks stay one. In two apps built alongside the Starter, 44% and 39% of the lines inside markers were shared.
13
+ - **A dependency both sides raised merges to the higher version** (#193; issue #191). When the project and the Starter both raised a dependency's `^` or `~` range and both ranges keep the same major (the same minor for `~` and for `^0.x`), the merge keeps the higher range, where it left a conflict in `package.json` that pnpm can't read. Other changes to the same dependency, such as exact versions or a new major, still conflict. This stopped 4 of 30 test upgrades.
14
+ - **`add`, `remove`, `upgrade`, and `status` report what happened, and what to run next** (#194; issue #192):
15
+ - `status` has a Conflicts row with the files left with conflict markers, and its next step is to resolve them.
16
+ - A change ends by saying to resolve any conflicts first, then to review the diff and run `pnpm typecheck` and the tests: files that merged cleanly can still break the code.
17
+ - `--dry-run` says how many conflicts the change would leave, in how many files, and exits 2 with `--detailed-exit-code`, as the change itself would.
18
+ - When GitHub can't list the Starter releases an upgrade crosses, such as past its rate limit, the upgrade says so and links the releases page, instead of leaving the list out.
19
+ - A module new since the project's Starter release names that release, where it named the target.
20
+ - A README generated without its "Generated from" section, as React + Vite projects from older releases are, is no longer called replaced.
21
+ - A file whose merge changes nothing is left out of the change.
22
+ - **"Updated N files" counts every file the change writes** (#200; issue #197), the lockfile and env files included.
23
+ - **A failed scaffold keeps the files that were already in the directory** (#165 and issue #153, both by @ahmed-husssain). Rolling back a failed run in an existing directory emptied it; it now removes only the files the run wrote, and the folders that leaves empty.
24
+ - **A lockfile with CRLF line endings is customized like one with LF** (#189; issue #186, reported by @ahmed-husssain). With CRLF, the apps and packages a project leaves out stayed in its lockfile, and no optional peer was pruned. The Starter checks the lockfile out with LF, so this concerned copies made outside Git, such as one given to `--template-dir`.
25
+ - **`deploy` keeps the platform's direct database URL when the database doesn't change** (#188; issue #187, reported by @ahmed-husssain). A deploy with no env file, after one that supplied a pooled Neon URL, set `DATABASE_URL_DIRECT` to nothing, and migrations and the jobs worker fell back to the pooled URL.
26
+ - **An empty variable with an inline comment is empty** (#190, by @ahmed-husssain). `DATABASE_URL= # leave blank to use platform Postgres` read as the comment, so `deploy` took it for a hosted database, created none on the platform, and the app failed at startup. A `#` inside a value, as in `PASSWORD=secret#123`, stays part of it.
27
+ - `pnpm release` drafts name who wrote each pull request and list first-time contributors (#185).
28
+
29
+ Thanks to everyone who contributed to this release: @ahmed-husssain and @yassine-ahmed.
30
+
5
31
  ## 0.47.0
6
32
 
7
33
  - **Starter v0.31.0** by default (#183). Its template schema is still 6. The [Starter's release notes](https://github.com/Clubedge/clubedge-starter/releases/tag/v0.31.0) cover:
@@ -10,16 +36,23 @@ All notable changes to `create-clubedge-app` are documented here.
10
36
  - Supabase sessions that the Hono API verifies itself;
11
37
  - fixes to the APIs' CORS and to SMTP sends that never answer;
12
38
  - browser tests in Chromium, Firefox, and WebKit.
13
- - **On Windows, an argument can no longer run a second command or expand a variable** (#176). The CLI ran commands through cmd.exe as one line, where `%VAR%` is expanded inside quotes, a line break ends the command, and a quote inside an argument can leave `&` and `|` outside the quotes. No argument from outside reached these commands, so this closes the gap before one does:
39
+ - **On Windows, an argument can no longer run a second command or expand a variable** (#176; issue #156, reported by @ahmed-husssain). The CLI ran commands through cmd.exe as one line, where `%VAR%` is expanded inside quotes, a line break ends the command, and a quote inside an argument can leave `&` and `|` outside the quotes. No argument from outside reached these commands, so this closes the gap before one does:
14
40
  - A program (`.exe`, `.com`) now starts directly, with no shell, so `git`, `node`, `docker`, and `uv` no longer go through cmd.exe.
15
41
  - A batch file (`.cmd`, `.bat`), such as the npm shims for `pnpm` and `npx`, still needs cmd.exe. Each argument is quoted the way Windows programs split their command line, then escaped for both of cmd.exe's readings. An argument with a line break is refused.
16
- - **Windows device names are refused as project directories** (#169, #171): `CON`, `PRN`, `AUX`, `NUL`, `COM1` to `COM9`, and `LPT1` to `LPT9`, with or without an extension, which on Windows could create a folder that can't be deleted. The CLI checks the project's own name and each parent folder it would create, in the prompt and in the MCP server's `create_project` too. Folders that already exist are left alone.
17
- - **The browser opens web addresses only** (#175). Commands that open a page, such as `deploy`, `neon`, and `try`, hand the system's opener only an `http://` or `https://` address with a host, and open nothing otherwise.
42
+ - **Windows device names are refused as project directories** (#169, by @biggdawg320, and #171; issue #155, reported by @ahmed-husssain): `CON`, `PRN`, `AUX`, `NUL`, `COM1` to `COM9`, and `LPT1` to `LPT9`, with or without an extension, which on Windows could create a folder that can't be deleted. The CLI checks the project's own name and each parent folder it would create, in the prompt and in the MCP server's `create_project` too. Folders that already exist are left alone.
43
+ - **The browser opens web addresses only** (#175, by @jdotc1; issue #157, reported by @ahmed-husssain). Commands that open a page, such as `deploy`, `neon`, and `try`, hand the system's opener only an `http://` or `https://` address with a host, and open nothing otherwise.
18
44
  - **`create_project` reports progress over MCP** (#178) while it installs dependencies. A client that sends a progress token gets a notification when each install starts, every 5 seconds while it runs, and when it ends or fails, so a client that resets its timeout on progress keeps waiting. A client that sends none gets the same result as before.
19
45
  - **Sponsors** (#181, #182): the README has a Sponsors section. [Neon](https://neon.com) supports Clubedge Starter through its Open Source Program.
20
46
  - CI runs generated projects' browser checks in Chromium, by name (#180), since the Starter now has three browser projects.
21
47
  - `@modelcontextprotocol/server` 2.4.0 or newer (#173), vitest 5.1.0 (#174), and astral-sh/setup-uv 10.3.3 in CI (#172).
22
48
 
49
+ ### New contributors
50
+
51
+ - @biggdawg320 made their first contribution in #169.
52
+ - @jdotc1 made their first contribution in #175.
53
+
54
+ Thanks to everyone who contributed to this release: @ahmed-husssain, @biggdawg320, @jdotc1, and @yassine-ahmed.
55
+
23
56
  ## 0.46.1
24
57
 
25
58
  - **Starter v0.30.1** by default (#167). Its template schema is still 6. The [Starter's release notes](https://github.com/Clubedge/clubedge-starter/releases/tag/v0.30.1) cover:
package/README.md CHANGED
@@ -224,7 +224,8 @@ Options:
224
224
  (upgrade: the Starter to upgrade to)
225
225
  --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
226
226
  --dry-run Show what would be created or changed without writing anything
227
- --force add/remove: change a project with uncommitted changes or without Git
227
+ --force add/remove/upgrade: change a project with uncommitted changes,
228
+ unresolved conflicts, or without Git
228
229
  -y, --yes Accept defaults and never prompt (default directory: my-app)
229
230
  --no-install Skip dependency installation
230
231
  --no-git Skip Git repository initialization
@@ -245,7 +246,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
245
246
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
246
247
 
247
248
  # Reproduce a specific Starter release or commit
248
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.31.0
249
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.31.1
249
250
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
250
251
 
251
252
  # Test local Starter changes before releasing them
@@ -266,7 +267,7 @@ pnpm clubedge add mobile expo # the native app; ad
266
267
  pnpm clubedge remove mobile
267
268
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
268
269
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
269
- pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.31.0 # or a specific tag, branch, or commit
270
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.31.1 # or a specific tag, branch, or commit
270
271
  ```
271
272
 
272
273
  The CLI renders the project twice, from the Starter commit and modules recorded in your `package.json` and from the new state, and merges the difference into your project. The new state has other modules for `add` and `remove`, and the newer Starter with the same modules for `upgrade`. Modules a newer Starter introduces start as `none` where possible, so an upgrade never adds a service on its own.
@@ -289,11 +290,11 @@ Since CLI 0.46.0, `add` and `remove` also take the apps that run on phones rathe
289
290
  - An app implements the modules it uses, such as the native app's sign-in. When the project's option is one it lacks, switch it in the same command: `add mobile expo auth better-auth`.
290
291
  - Removing an app deletes the Starter's files and leaves what was never the Starter's, such as its env file, its `node_modules`, and the shell's `android/` and `ios/` projects. The CLI lists them, to delete once you no longer need them.
291
292
 
292
- The project must be a Git repository without uncommitted changes, so you can review the result with `git diff` and undo it. Pass `--force` to skip this check. Add `--no-install` to skip `pnpm install`. Afterward, run `pnpm db:migrate` and `pnpm check:services` if the change added tables or services.
293
+ The project must be a Git repository without uncommitted changes, so you can review the result with `git diff` and undo it, and without conflict markers left by an earlier change, which the new change would merge into: the CLI names the files to resolve first. Pass `--force` to skip these checks. Add `--no-install` to skip `pnpm install`. Afterward, run `pnpm typecheck` and your tests, since files that merge without conflicts can still break the code, and `pnpm db:migrate` and `pnpm check:services` if the change added tables or services.
293
294
 
294
295
  pnpm cannot read a `package.json` with conflict markers, so a change that leaves conflicts in one skips `pnpm install` and says so: resolve them, then run `pnpm install`. Until then, `pnpm clubedge` does not start either, and the CLI's own commands (`pnpm dlx @clubedge/create-clubedge-app status`, for example) stop with a message that names the file and the command that compares it with the Starter's version in `.clubedge/incoming/`. When the install fails for another reason, the CLI still lists what is left to resolve, then exits with an error.
295
296
 
296
- A change that leaves conflicts exits with 0, like a clean one. For scripts that need to tell them apart, add `--detailed-exit-code`: the CLI then exits with 2 when the change was written with conflicts to resolve, 0 when it was clean or there was nothing to change, and 1 when it failed.
297
+ A change that leaves conflicts exits with 0, like a clean one. For scripts that need to tell them apart, add `--detailed-exit-code`: the CLI then exits with 2 when the change was written with conflicts to resolve, 0 when it was clean or there was nothing to change, and 1 when it failed. With `--dry-run`, it counts the conflicts the change would leave, and exits with 2 when there are any, so a script can check before changing anything.
297
298
 
298
299
  ## A hosted database with Neon
299
300
 
@@ -323,11 +324,11 @@ Claiming keeps the data but resets the password: copy the pooled and direct conn
323
324
  Two read-only commands help you keep a project healthy:
324
325
 
325
326
  ```sh
326
- pnpm clubedge status # Starter release, modules, Git state, and whether a newer Starter exists
327
+ pnpm clubedge status # Starter release, modules, Git state, conflicts left, and whether a newer Starter exists
327
328
  pnpm clubedge doctor # Node.js, pnpm, dependencies, lockfile, env file, and pnpm check:services
328
329
  ```
329
330
 
330
- `status` ends with the command to run next, such as `pnpm clubedge upgrade` when a newer Starter release is available; it asks npm for the newest CLI and still works offline. `doctor` checks that Node.js and pnpm meet the project's versions, that dependencies are installed, that `pnpm-lock.yaml` matches every `package.json`, and that each app's local env file has every variable in its example (`apps/web/.env.local` and `.env.example`, and `apps/api/.env.local` and `apps/api/.env.example` with a separate API), then runs `pnpm check:services` (skip it with `--no-services`). It exits with an error when a check fails, so it also works in CI.
331
+ `status` ends with the command to run next, such as `pnpm clubedge upgrade` when a newer Starter release is available; it asks npm for the newest CLI and still works offline. It lists the files an earlier change left with conflict markers, and a `.clubedge/incoming/` left behind, and then says to resolve the conflicts first. `doctor` checks that Node.js and pnpm meet the project's versions, that dependencies are installed, that `pnpm-lock.yaml` matches every `package.json`, and that each app's local env file has every variable in its example (`apps/web/.env.local` and `.env.example`, and `apps/api/.env.local` and `apps/api/.env.example` with a separate API), then runs `pnpm check:services` (skip it with `--no-services`). It exits with an error when a check fails, so it also works in CI.
331
332
 
332
333
  ## Try it on a Railway VM (experimental)
333
334
 
@@ -415,27 +416,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
415
416
  Each CLI release is tested against one specific Starter release:
416
417
 
417
418
  ```text
418
- create-clubedge-app v0.47.0
419
+ create-clubedge-app v0.48.0
419
420
  |
420
421
  v
421
- Clubedge Starter v0.31.0
422
- commit e6c47d7b8ec153443ee35abab7fc61d37f55e36a
422
+ Clubedge Starter v0.31.1
423
+ commit 2fa0adfd67ef3511f577d11fdca4109cc8554759
423
424
  ```
424
425
 
425
426
  The current relationship is:
426
427
 
427
428
  | CLI release | Default Starter release | Meaning |
428
429
  | --- | --- | --- |
429
- | `create-clubedge-app@0.47.0` | `clubedge-starter@v0.31.0` | The CLI's required, tested default |
430
+ | `create-clubedge-app@0.48.0` | `clubedge-starter@v0.31.1` | The CLI's required, tested default |
430
431
 
431
432
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
432
433
 
433
- The `v0.31.0` tag is expected to resolve to commit
434
- `e6c47d7b8ec153443ee35abab7fc61d37f55e36a`. The CLI verifies this before
434
+ The `v0.31.1` tag is expected to resolve to commit
435
+ `2fa0adfd67ef3511f577d11fdca4109cc8554759`. The CLI verifies this before
435
436
  scaffolding with the default reference and fails rather than silently using a
436
437
  moved tag.
437
438
 
438
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.31.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
439
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.31.1`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
439
440
 
440
441
  Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
441
442
 
@@ -490,12 +491,12 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
490
491
  1. Make and validate changes in `clubedge-starter`.
491
492
  2. Run lint, typecheck, unit tests, browser tests, and production build.
492
493
  3. Update the Starter package versions.
493
- 4. Commit `main`, create a release tag such as `v0.31.0`, and publish the Starter release.
494
+ 4. Commit `main`, create a release tag such as `v0.31.1`, and publish the Starter release.
494
495
 
495
496
  ### CLI maintainers
496
497
 
497
498
  1. When the Starter publishes a release, the [Starter pin](.github/workflows/starter-pin.yml) workflow opens a pull request that moves `clubedge.starterRef` and `clubedge.starterCommit` in [`package.json`](package.json), and the docs that name them, to the new tag (daily, or run it by hand; locally: `pnpm release pin vX.Y.Z`). Its CI generates clean projects from that exact tag and runs install, lint, typecheck, unit tests, browser tests, production build, and runtime smoke checks. Merge it once they pass.
498
- 2. `pnpm release patch|minor|major` (from an up-to-date `main`; add `--dry-run` to preview) opens the release pull request: it bumps `version`, updates the version relationship here and in CONTRIBUTING, adds the release table rows for a minor or major release, and drafts the changelog entry from the pull requests merged since the last tag. Edit the draft in that pull request: it becomes the GitHub Release page.
499
+ 2. `pnpm release patch|minor|major` (from an up-to-date `main`; add `--dry-run` to preview) opens the release pull request: it bumps `version`, updates the version relationship here and in CONTRIBUTING, adds the release table rows for a minor or major release, and drafts the changelog entry from the pull requests merged since the last tag. The draft names who wrote each pull request that a maintainer or a bot didn't, lists the first-time contributors, and thanks everyone by name. Edit the draft in that pull request, keeping those credits and adding who reported the issues it closes: it becomes the GitHub Release page.
499
500
  3. After it is merged, `pnpm release tag` tags `main`. The Release workflow checks and publishes it to npm, creates the GitHub Release page from the changelog entry, and smoke-tests the published package.
500
501
 
501
502
  The pin workflow needs **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests**, or a `STARTER_PIN_TOKEN` secret (a fine-grained token with contents and pull requests write), which also lets its pull request start CI by itself.
@@ -548,6 +549,7 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
548
549
  | 0.45.0 | v0.29.0 | Starter v0.29.0: an update policy for installed apps, a new design for the native app, and clean splash screens; `package.json` merged by key |
549
550
  | 0.46.0 | v0.30.0 | Add and remove the mobile apps in an existing project; Starter v0.30.0: store releases, push notifications, and over-the-air updates; module options' `dependencies` |
550
551
  | 0.47.0 | v0.31.0 | Starter v0.31.0: deploying to Cloudflare (`--deploy cloudflare`), downloads by byte range, and browser tests in three engines; on Windows, arguments that can't start a second command and device names refused as project directories; install progress over MCP |
552
+ | 0.48.0 | v0.31.1 | Starter v0.31.1: Expo SDK 58's latest patch versions; `add`, `remove`, and `upgrade` refuse to merge into unresolved conflicts, conflict blocks hold only the lines that differ, a dependency both sides raised merges to the higher version, and reports say what to run next |
551
553
 
552
554
  ## Sponsors
553
555
 
package/dist/cli/ui.js CHANGED
@@ -131,9 +131,11 @@ ${selectionHelp(manifest)}
131
131
  (upgrade: the Starter to upgrade to)
132
132
  --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
133
133
  --dry-run Show what would be created or changed without writing anything
134
- --force add/remove: change a project with uncommitted changes or without Git
134
+ --force add/remove/upgrade: change a project with uncommitted changes,
135
+ unresolved conflicts, or without Git
135
136
  --detailed-exit-code add/remove/upgrade: exit with 2 when the change leaves conflicts to
136
- resolve (0: changed cleanly or nothing to change, 1: failed)
137
+ resolve, or would with --dry-run (0: changed cleanly or nothing to
138
+ change, 1: failed)
137
139
  --no-services doctor: skip pnpm check:services
138
140
  --no-open try, deploy (Vercel, Netlify, Render): do not open the URL in the browser
139
141
  --claim try: open the page that claims the VM, to share the preview
@@ -1,6 +1,6 @@
1
1
  // Pure text transforms applied to template files. Keeping them free of I/O makes each one easy
2
2
  // to test and lets --dry-run describe them without touching the disk.
3
- import { pruneOptionalPeers } from "./lockfile-peers.js";
3
+ import { keepLineEndings, pruneOptionalPeers } from "./lockfile-peers.js";
4
4
  import { FRAMEWORK_SLOT } from "./manifest-fields.js";
5
5
  const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
6
6
  /**
@@ -124,9 +124,12 @@ export function customizeAppManifest(source, appPackage, version) {
124
124
  * works. Importers are keyed by workspace path at two-space indentation; package entries that
125
125
  * are no longer used are harmless, and pnpm prunes them on the next lockfile update. Optional
126
126
  * peer links to packages that only left-out importers installed are removed, so those packages
127
- * are not installed either.
127
+ * are not installed either. The lockfile keeps its line endings.
128
128
  */
129
- export function customizeLockfile(source, { remove, rename = [], removeDependencies = [] }) {
129
+ export function customizeLockfile(source, changes) {
130
+ return keepLineEndings(source, (text) => customizeImporters(text, changes));
131
+ }
132
+ function customizeImporters(source, { remove, rename = [], removeDependencies = [] }) {
130
133
  const output = [];
131
134
  let inImporters = false;
132
135
  let skippingImporter = false;
@@ -98,7 +98,7 @@ export function parseEnvFile(text) {
98
98
  const raw = match[2].trim();
99
99
  // A quoted value keeps everything inside the quotes, and may still have a comment after them.
100
100
  const quoted = /^(["'])(.*?)\1\s*(?:#.*)?$/.exec(raw);
101
- values[match[1]] = quoted ? quoted[2] : raw.replace(/\s+#.*$/, "");
101
+ values[match[1]] = quoted ? quoted[2] : raw.replace(/(?:^|\s+)#.*$/, "");
102
102
  }
103
103
  return values;
104
104
  }
@@ -133,10 +133,16 @@ export function planVariables(input) {
133
133
  const direct = directDatabaseUrl(external.DATABASE_URL, envFile.DATABASE_URL_DIRECT);
134
134
  if (direct)
135
135
  set.DATABASE_URL_DIRECT = direct;
136
- // A direct URL left from an earlier database would send migrations there: point it at the
137
- // database the app now uses, or blank it (blank counts as unset).
138
- else if (existing.DATABASE_URL_DIRECT)
139
- set.DATABASE_URL_DIRECT = set.DATABASE_URL ?? "";
136
+ // A direct URL left from an earlier database would send migrations there. When this deploy
137
+ // decides the database (it sets DATABASE_URL, or the platform attaches its own), or no
138
+ // DATABASE_URL is left for the direct URL to belong to, point it at the database the app now
139
+ // uses, or blank it (blank counts as unset). A deploy that keeps the database the platform
140
+ // already has, such as one with no env file, leaves its direct URL as it is.
141
+ else if (existing.DATABASE_URL_DIRECT) {
142
+ const decided = set.DATABASE_URL !== undefined || attached.includes("DATABASE_URL");
143
+ if (decided || !existing.DATABASE_URL)
144
+ set.DATABASE_URL_DIRECT = set.DATABASE_URL ?? "";
145
+ }
140
146
  // Optional: without a direct URL, migrations and the jobs worker use DATABASE_URL, and the
141
147
  // client-address settings have safe defaults.
142
148
  const optional = ["DATABASE_URL_DIRECT", ...clientAddressKeys];
@@ -1,7 +1,6 @@
1
- import { constants } from "node:fs";
2
- import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
3
- import { basename, dirname, join, parse } from "node:path";
4
- import { copyTemplateFiles, emptyDirectory, pathExists } from "../utils/fs.js";
1
+ import { mkdir, readdir, readFile, rm, rmdir, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join, parse, relative, resolve } from "node:path";
3
+ import { copyTemplateFiles, pathExists } from "../utils/fs.js";
5
4
  import { isReservedDeviceName } from "../utils/names.js";
6
5
  export class TargetError extends Error {
7
6
  name = "TargetError";
@@ -37,9 +36,26 @@ export async function assertTargetIsUsable(directory) {
37
36
  throw error;
38
37
  }
39
38
  }
39
+ /** Removes files created during scaffolding, then removes any empty directories left behind. */
40
+ export async function cleanupWrittenFiles(target, files, folders = []) {
41
+ const dirs = new Set();
42
+ const targetPath = resolve(target);
43
+ for (const file of files)
44
+ await rm(file, { force: true }).catch(() => { });
45
+ const paths = [...files.map((file) => dirname(file)), ...folders.map((folder) => folder.replace(/^\\\\\?\\/, ""))];
46
+ for (const p of paths) {
47
+ for (let cur = resolve(p); cur !== targetPath && cur !== dirname(cur) && !relative(targetPath, cur).startsWith(".."); cur = dirname(cur)) {
48
+ dirs.add(cur);
49
+ }
50
+ }
51
+ for (const dir of [...dirs].sort((a, b) => b.length - a.length)) {
52
+ await rmdir(dir).catch(() => { });
53
+ }
54
+ }
40
55
  /**
41
56
  * Writes the project described by the plan. If any step fails, everything written so far is
42
- * removed: a directory the CLI created is deleted, and a pre-existing empty one is emptied.
57
+ * removed: a directory the CLI created is deleted, and for a pre-existing directory only files
58
+ * created during the run are removed, preserving any pre-existing contents.
43
59
  */
44
60
  export async function executePlan(plan, source) {
45
61
  const target = plan.targetDirectory;
@@ -56,21 +72,21 @@ export async function executePlan(plan, source) {
56
72
  if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST"))
57
73
  throw error;
58
74
  }
75
+ const written = [];
76
+ const folders = [];
59
77
  try {
60
- await copyTemplateFiles(source.root, target, plan.files);
78
+ await copyTemplateFiles(source.root, target, plan.files, written, folders);
61
79
  for (const edit of plan.edits) {
62
80
  const path = join(target, edit.path);
63
81
  await writeFile(path, edit.apply(await readFile(path, "utf8")));
64
82
  }
65
- for (const envFile of plan.envFiles) {
66
- await copyFile(join(target, envFile.from), join(target, envFile.to), constants.COPYFILE_EXCL);
67
- }
83
+ await copyTemplateFiles(target, target, plan.envFiles, written, folders);
68
84
  }
69
85
  catch (error) {
70
86
  if (created)
71
87
  await rm(target, { recursive: true, force: true });
72
88
  else
73
- await emptyDirectory(target);
89
+ await cleanupWrittenFiles(target, written, folders);
74
90
  throw error;
75
91
  }
76
92
  }
@@ -52,8 +52,14 @@ export function updateState(projectRef, latest) {
52
52
  export function latestCliCommand(cli) {
53
53
  return cli === "pnpm clubedge" ? cli : "pnpm dlx @clubedge/create-clubedge-app@latest";
54
54
  }
55
- /** What to do next, given the working tree and whether a newer Starter exists. */
56
- export function nextStep(git, update, cli) {
55
+ /**
56
+ * What to do next, given the working tree, whether a newer Starter exists, and the files an
57
+ * earlier change left with conflict markers, which come first.
58
+ */
59
+ export function nextStep(git, update, cli, conflicts = []) {
60
+ if (conflicts.length) {
61
+ return "Resolve the conflicts first: keep your lines, the Starter's, or a mix in each block, then delete the markers and .clubedge/incoming.";
62
+ }
57
63
  if (update.kind === "available") {
58
64
  const upgrade = `${latestCliCommand(cli)} upgrade`;
59
65
  return git.kind === "dirty"
@@ -210,6 +216,24 @@ export function checkDatabaseUrls(input) {
210
216
  }
211
217
  return { name, status: "ok", detail: "Neon, pooled for the app and direct for migrations and the jobs worker" };
212
218
  }
219
+ /**
220
+ * The conflicts line of `status`: the files an earlier change left with conflict markers, and
221
+ * the Starter's versions it left to compare with in .clubedge/incoming.
222
+ */
223
+ export function describeConflicts(conflicts, incomingLeft) {
224
+ if (!conflicts.length) {
225
+ return incomingLeft
226
+ ? "none, but .clubedge/incoming still holds the Starter's versions from the last change: delete it when you are done comparing"
227
+ : "none";
228
+ }
229
+ const total = conflicts.reduce((sum, c) => sum + c.blocks, 0);
230
+ const files = conflicts
231
+ .slice(0, 3)
232
+ .map((c) => `${c.path} (${c.blocks} ${c.blocks === 1 ? "block" : "blocks"})`)
233
+ .join(", ");
234
+ return (`${total} ${total === 1 ? "conflict" : "conflicts"} in ${conflicts.length} ${conflicts.length === 1 ? "file" : "files"}: ` +
235
+ `${files}${conflicts.length > 3 ? ", ..." : ""}${incomingLeft ? "; the Starter's versions are in .clubedge/incoming" : ""}`);
236
+ }
213
237
  export function checkConflicts(conflicts) {
214
238
  const name = "Conflicts";
215
239
  if (!conflicts.length)
@@ -2,6 +2,16 @@
2
2
  // workspace, and records the link in lockfile keys: drizzle-orm@0.45.4(@electric-sql/pglite@0.5.8).
3
3
  // When a left-out workspace package was the only reason the peer was installed, the link would
4
4
  // keep installing it, so these helpers remove such links from the lockfile.
5
+ /**
6
+ * Changes a lockfile's text with LF line endings and gives it back in the original's. The Starter's
7
+ * .gitattributes checks pnpm-lock.yaml out with LF everywhere, but a copy made outside Git, such as
8
+ * one given to --template-dir, can have CRLF, and every line would then end in a stray \r.
9
+ */
10
+ export function keepLineEndings(source, change) {
11
+ if (!source.includes("\r\n"))
12
+ return change(source);
13
+ return change(source.replace(/\r\n/g, "\n")).replace(/\n/g, "\r\n");
14
+ }
5
15
  function unquote(text) {
6
16
  return text.replace(/^(['"])(.*)\1$/, "$2");
7
17
  }
@@ -18,7 +28,7 @@ function parse(source) {
18
28
  let key = "";
19
29
  let group = "";
20
30
  let entry = "";
21
- for (const line of source.split("\n")) {
31
+ for (const line of source.split(/\r?\n/)) {
22
32
  if (/^\S/.test(line)) {
23
33
  section = line.replace(/:.*$/, "");
24
34
  continue;
@@ -117,9 +127,12 @@ function removeSuffixSegments(text, peer) {
117
127
  /**
118
128
  * Drops optional-peer links to packages nothing else installs (see findUnneededOptionalPeers),
119
129
  * so `pnpm install --frozen-lockfile` no longer installs them. Snapshot entries that become
120
- * identical are merged.
130
+ * identical are merged. The lockfile keeps its line endings.
121
131
  */
122
132
  export function pruneOptionalPeers(source) {
133
+ return keepLineEndings(source, pruneLines);
134
+ }
135
+ function pruneLines(source) {
123
136
  const peers = findUnneededOptionalPeers(source);
124
137
  if (!peers.length)
125
138
  return source;
@@ -50,13 +50,51 @@ function withMarkers(regions) {
50
50
  }
51
51
  return { text: lines.join("\n"), conflict };
52
52
  }
53
+ const hasLetterOrDigit = (lines) => lines.some((line) => /[\p{L}\p{N}]/u.test(line));
54
+ /**
55
+ * The regions with each conflict split where your lines and the Starter's are the same, so a
56
+ * block holds only what differs, as Git writes conflicts. Apps built alongside the Starter share
57
+ * many lines in their conflicts: 44% of those in Chess's and 39% in Chat App's.
58
+ */
59
+ function refine(regions) {
60
+ return regions.flatMap((region) => "ok" in region || !region.yours.length || !region.theirs.length
61
+ ? [region]
62
+ : diffComm(region.yours, region.theirs).map((part) => part.common ? { ok: part.common } : { yours: part.buffer1, theirs: part.buffer2 }));
63
+ }
64
+ /**
65
+ * Joins conflicts with few shared lines between them back into one block, as Git does: three
66
+ * lines or fewer, or lines without a letter or digit, such as closing braces and blank lines,
67
+ * read better inside one block than as the edges of two.
68
+ */
69
+ function joinClose(regions) {
70
+ const joined = [];
71
+ for (const region of regions) {
72
+ const last = joined.at(-1);
73
+ // Consecutive regions of one kind are one region.
74
+ if (last && "ok" in last && "ok" in region) {
75
+ joined[joined.length - 1] = { ok: [...last.ok, ...region.ok] };
76
+ continue;
77
+ }
78
+ if (last && !("ok" in last) && !("ok" in region)) {
79
+ joined[joined.length - 1] = { yours: [...last.yours, ...region.yours], theirs: [...last.theirs, ...region.theirs] };
80
+ continue;
81
+ }
82
+ const before = joined.at(-2);
83
+ if (!("ok" in region) && last && "ok" in last && before && !("ok" in before) && (last.ok.length <= 3 || !hasLetterOrDigit(last.ok))) {
84
+ joined.splice(-2, 2, { yours: [...before.yours, ...last.ok, ...region.yours], theirs: [...before.theirs, ...last.ok, ...region.theirs] });
85
+ continue;
86
+ }
87
+ joined.push(region);
88
+ }
89
+ return joined;
90
+ }
53
91
  /** Merges base -> target into current. Resolves null for binary files, which cannot be merged. */
54
92
  export function mergeText(current, base, target) {
55
93
  if (isBinary(current) || isBinary(base) || isBinary(target))
56
94
  return null;
57
95
  const split = (content) => normalize(content).split("\n");
58
96
  const regions = diff3Merge(split(current), split(base), split(target), { excludeFalseConflicts: true });
59
- return withMarkers(regions.flatMap((region) => region.ok ? [{ ok: region.ok }] : region.conflict ? [{ yours: region.conflict.a, theirs: region.conflict.b }] : []));
97
+ return withMarkers(joinClose(refine(regions.flatMap((region) => region.ok ? [{ ok: region.ok }] : region.conflict ? [{ yours: region.conflict.a, theirs: region.conflict.b }] : []))));
60
98
  }
61
99
  /**
62
100
  * Merges a package.json by key (see package-json-merge.ts), so edits to different keys never
@@ -69,6 +107,7 @@ export function mergePackageJsonText(current, base, target) {
69
107
  return null;
70
108
  return withMarkers(diffComm(merged.yours.split("\n"), merged.theirs.split("\n")).map((region) => region.common !== undefined ? { ok: region.common } : { yours: region.buffer1, theirs: region.buffer2 }));
71
109
  }
110
+ /** The merge of a file both sides changed; null when the project already has the Starter's change. */
72
111
  function merged(path, current, base, target, binaryReason) {
73
112
  const result = (/(^|\/)package\.json$/.test(path) ? mergePackageJsonText(current, base, target) : null) ?? mergeText(current, base, target);
74
113
  if (!result)
@@ -76,7 +115,7 @@ function merged(path, current, base, target, binaryReason) {
76
115
  const content = withLineEndings(result.text, current);
77
116
  if (result.conflict)
78
117
  return { path, kind: "conflict", content, incoming: withLineEndings(normalize(target), current) };
79
- return { path, kind: "merge", content };
118
+ return sameContent(content, current) ? null : { path, kind: "merge", content };
80
119
  }
81
120
  /**
82
121
  * Decides what happens to every file the base or the target contains. Files only the project
@@ -95,7 +134,9 @@ export function planMerge(base, target, current) {
95
134
  if (!mine)
96
135
  actions.push({ path, kind: "create", content: after });
97
136
  else if (!sameContent(mine, after)) {
98
- actions.push(merged(path, mine, Buffer.alloc(0), after, "Your file differs from the one the new selection adds."));
137
+ const action = merged(path, mine, Buffer.alloc(0), after, "Your file differs from the one the new selection adds.");
138
+ if (action)
139
+ actions.push(action);
99
140
  }
100
141
  continue;
101
142
  }
@@ -122,7 +163,9 @@ export function planMerge(base, target, current) {
122
163
  actions.push({ path, kind: "update", content });
123
164
  }
124
165
  else if (!sameContent(mine, after)) {
125
- actions.push(merged(path, mine, before, after, "You changed this file, and the new selection changes it too."));
166
+ const action = merged(path, mine, before, after, "You changed this file, and the new selection changes it too.");
167
+ if (action)
168
+ actions.push(action);
126
169
  }
127
170
  }
128
171
  }
@@ -1,8 +1,9 @@
1
1
  // package.json files merge by key instead of by line (#142), so edits to different keys never
2
2
  // conflict: a script the project adds after the Starter's, or an override next to one the
3
- // Starter adds. A key both sides changed to different values is a conflict. The merged file is
4
- // written twice, with your value for each such key and with the Starter's, and merge.ts puts
5
- // conflict markers around the lines where the two differ, which are that key's.
3
+ // Starter adds. A key both sides changed to different values is a conflict, except a dependency
4
+ // both raised within its range (see higherRange). The merged file is written twice, with your
5
+ // value for each conflict and with the Starter's, and merge.ts puts conflict markers around the
6
+ // lines where the two differ, which are that key's.
6
7
  /** A key both sides changed to different values; undefined is a side that removed it. */
7
8
  class Conflict {
8
9
  yours;
@@ -30,24 +31,61 @@ function same(a, b) {
30
31
  }
31
32
  return false;
32
33
  }
34
+ /** The objects whose values are version ranges: a package's dependencies, and pnpm's overrides. */
35
+ const RANGE_OBJECTS = [["dependencies"], ["devDependencies"], ["peerDependencies"], ["optionalDependencies"], ["pnpm", "overrides"]];
36
+ const holdsRanges = (path) => RANGE_OBJECTS.some((object) => object.length === path.length && object.every((key, i) => key === path[i]));
37
+ /** A ^ or ~ range on a full version, without a prerelease: ^1.7.21, ~58.0.24. */
38
+ const SIMPLE_RANGE = /^([\^~])(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
33
39
  /**
34
- * One key's value: whoever changed it, or a conflict when both did, differently. Objects both
35
- * changed merge key by key; arrays and scalars are single values. Undefined is a removed key.
40
+ * Of two ^ ranges, or two ~ ranges, the one with the higher minimum, when every version it
41
+ * allows the other allows too: the same major for ^ (the same minor for ^0.x, the same version
42
+ * for ^0.0.x), the same minor for ~. Then the higher one satisfies both sides, as `pnpm up` raising
43
+ * a project's version within its range does. Undefined for anything else, which stays a conflict:
44
+ * exact versions (Expo pins some), different operators or majors, prereleases, workspace:, npm:
45
+ * aliases and URLs.
36
46
  */
37
- function merge(base, yours, theirs) {
47
+ function higherRange(a, b) {
48
+ const first = SIMPLE_RANGE.exec(a);
49
+ const second = SIMPLE_RANGE.exec(b);
50
+ if (!first || !second || first[1] !== second[1])
51
+ return undefined;
52
+ const x = first.slice(2).map(Number);
53
+ const y = second.slice(2).map(Number);
54
+ // How many numbers a range keeps fixed: ~ the major and minor, ^ up to its first one that is not 0.
55
+ const fixed = (version) => (first[1] === "~" ? 2 : version.findIndex((n) => n !== 0) + 1 || 3);
56
+ const kept = fixed(x);
57
+ if (kept !== fixed(y) || x.slice(0, kept).some((n, i) => n !== y[i]))
58
+ return undefined;
59
+ for (const [i, n] of x.entries())
60
+ if (n !== y[i])
61
+ return n > (y[i] ?? 0) ? a : b;
62
+ return a;
63
+ }
64
+ /**
65
+ * One key's value, at `path` from the file's root: whoever changed it, or a conflict when both
66
+ * did, differently. Objects both changed merge key by key; arrays and scalars are single values.
67
+ * Undefined is a removed key. A dependency both raised within its range takes the higher range.
68
+ */
69
+ function merge(base, yours, theirs, path) {
38
70
  if (same(yours, theirs) || same(base, theirs))
39
71
  return yours;
40
72
  if (same(base, yours))
41
73
  return theirs;
42
74
  if (isObject(yours) && isObject(theirs))
43
- return mergeObjects(isObject(base) ? base : {}, yours, theirs);
75
+ return mergeObjects(isObject(base) ? base : {}, yours, theirs, path);
76
+ if (typeof yours === "string" && typeof theirs === "string" && holdsRanges(path.slice(0, -1))) {
77
+ const higher = higherRange(yours, theirs);
78
+ if (higher !== undefined)
79
+ return higher;
80
+ }
44
81
  return new Conflict(yours, theirs);
45
82
  }
46
83
  /**
47
84
  * Your keys in your order; a key only the Starter has goes after the key it follows in the
48
85
  * Starter's file, or first. At the root, `clubedge` (the provenance) is always the Starter's.
86
+ * `path` is the object's, from the file's root.
49
87
  */
50
- function mergeObjects(base, yours, theirs, root = false) {
88
+ function mergeObjects(base, yours, theirs, path = []) {
51
89
  const order = Object.keys(yours);
52
90
  let previous;
53
91
  for (const key of Object.keys(theirs)) {
@@ -57,7 +95,7 @@ function mergeObjects(base, yours, theirs, root = false) {
57
95
  }
58
96
  const result = {};
59
97
  for (const key of order) {
60
- const value = root && key === "clubedge" ? own(theirs, key) : merge(own(base, key), own(yours, key), own(theirs, key));
98
+ const value = path.length === 0 && key === "clubedge" ? own(theirs, key) : merge(own(base, key), own(yours, key), own(theirs, key), [...path, key]);
61
99
  if (value !== undefined)
62
100
  set(result, key, value);
63
101
  }
@@ -100,7 +138,7 @@ export function mergePackageJson(current, base, target) {
100
138
  const [yours, before, theirs] = parsed;
101
139
  if (!isObject(yours) || !isObject(before) || !isObject(theirs))
102
140
  return null;
103
- const merged = mergeObjects(before, yours, theirs, true);
141
+ const merged = mergeObjects(before, yours, theirs);
104
142
  const write = (side) => JSON.stringify(resolve(merged, side), null, indentation(current)) + (current.endsWith("\n") ? "\n" : "");
105
143
  return { yours: write("yours"), theirs: write("theirs") };
106
144
  }