@clubedge/create-clubedge-app 0.39.0 → 0.41.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 (148) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +17 -11
  3. package/dist/cli/args.js +5 -0
  4. package/dist/cli/ui.js +2 -0
  5. package/dist/core/customize.js +8 -3
  6. package/dist/core/health.js +17 -0
  7. package/dist/core/merge.js +5 -0
  8. package/dist/core/project.js +46 -4
  9. package/dist/core/update.js +18 -6
  10. package/dist/index.js +10 -5
  11. package/dist/inspect.js +17 -2
  12. package/dist/mcp-server.js +5 -3
  13. package/dist/modify.js +51 -11
  14. package/dist/steps/install.js +30 -7
  15. package/dist/utils/process.js +36 -2
  16. package/package.json +3 -3
  17. package/template/README.md +1 -2
  18. package/template/SETUP.md +46 -11
  19. package/template/apps/api/package.json +1 -1
  20. package/template/apps/api-fastapi/.env.example +100 -0
  21. package/template/apps/api-fastapi/Dockerfile +15 -4
  22. package/template/apps/api-fastapi/clubedge.stack.json +125 -1
  23. package/template/apps/api-fastapi/package.json +2 -2
  24. package/template/apps/api-fastapi/pyproject.toml +17 -1
  25. package/template/apps/api-fastapi/src/clubedge_api/account.py +37 -0
  26. package/template/apps/api-fastapi/src/clubedge_api/app.py +113 -18
  27. package/template/apps/api-fastapi/src/clubedge_api/auth.py +18 -1
  28. package/template/apps/api-fastapi/src/clubedge_api/background_jobs.py +115 -0
  29. package/template/apps/api-fastapi/src/clubedge_api/cache.py +178 -0
  30. package/template/apps/api-fastapi/src/clubedge_api/check_env.py +39 -7
  31. package/template/apps/api-fastapi/src/clubedge_api/check_services.py +251 -12
  32. package/template/apps/api-fastapi/src/clubedge_api/contract.py +27 -0
  33. package/template/apps/api-fastapi/src/clubedge_api/contract_check.py +57 -0
  34. package/template/apps/api-fastapi/src/clubedge_api/db.py +32 -0
  35. package/template/apps/api-fastapi/src/clubedge_api/email.py +34 -0
  36. package/template/apps/api-fastapi/src/clubedge_api/email_provider.py +89 -0
  37. package/template/apps/api-fastapi/src/clubedge_api/email_resend.py +55 -0
  38. package/template/apps/api-fastapi/src/clubedge_api/email_smtp.py +83 -0
  39. package/template/apps/api-fastapi/src/clubedge_api/http.py +91 -0
  40. package/template/apps/api-fastapi/src/clubedge_api/job_queue.py +314 -0
  41. package/template/apps/api-fastapi/src/clubedge_api/jobs.py +41 -0
  42. package/template/apps/api-fastapi/src/clubedge_api/jobs_bullmq.py +305 -0
  43. package/template/apps/api-fastapi/src/clubedge_api/jobs_inngest.py +365 -0
  44. package/template/apps/api-fastapi/src/clubedge_api/main.py +25 -0
  45. package/template/apps/api-fastapi/src/clubedge_api/memory_cache.py +54 -0
  46. package/template/apps/api-fastapi/src/clubedge_api/observability.py +50 -0
  47. package/template/apps/api-fastapi/src/clubedge_api/payments.py +333 -0
  48. package/template/apps/api-fastapi/src/clubedge_api/payments_stripe.py +225 -0
  49. package/template/apps/api-fastapi/src/clubedge_api/plans.py +24 -0
  50. package/template/apps/api-fastapi/src/clubedge_api/production_env.py +140 -0
  51. package/template/apps/api-fastapi/src/clubedge_api/rate_limit.py +131 -0
  52. package/template/apps/api-fastapi/src/clubedge_api/routes/__init__.py +1 -0
  53. package/template/apps/api-fastapi/src/clubedge_api/routes/account.py +176 -0
  54. package/template/apps/api-fastapi/src/clubedge_api/routes/billing.py +173 -0
  55. package/template/apps/api-fastapi/src/clubedge_api/routes/client_errors.py +111 -0
  56. package/template/apps/api-fastapi/src/clubedge_api/routes/inngest.py +22 -0
  57. package/template/apps/api-fastapi/src/clubedge_api/routes/jobs.py +112 -0
  58. package/template/apps/api-fastapi/src/clubedge_api/routes/jobs_run.py +32 -0
  59. package/template/apps/api-fastapi/src/clubedge_api/server.py +22 -1
  60. package/template/apps/api-fastapi/src/clubedge_api/settings.py +6 -0
  61. package/template/apps/api-fastapi/src/clubedge_api/storage.py +47 -0
  62. package/template/apps/api-fastapi/src/clubedge_api/storage_local.py +102 -0
  63. package/template/apps/api-fastapi/src/clubedge_api/storage_provider.py +29 -0
  64. package/template/apps/api-fastapi/src/clubedge_api/storage_s3.py +144 -0
  65. package/template/apps/api-fastapi/src/clubedge_api/storage_supabase.py +126 -0
  66. package/template/apps/api-fastapi/src/clubedge_api/telemetry.py +85 -0
  67. package/template/apps/api-fastapi/src/clubedge_api/telemetry_otel.py +137 -0
  68. package/template/apps/api-fastapi/src/clubedge_api/telemetry_sentry.py +99 -0
  69. package/template/apps/api-fastapi/src/clubedge_api/tracing.py +38 -0
  70. package/template/apps/api-fastapi/src/clubedge_api/users.py +53 -0
  71. package/template/apps/api-fastapi/src/clubedge_api/variants/auth_appwrite.py +58 -1
  72. package/template/apps/api-fastapi/src/clubedge_api/variants/auth_better_auth.py +67 -1
  73. package/template/apps/api-fastapi/src/clubedge_api/variants/auth_firebase.py +75 -8
  74. package/template/apps/api-fastapi/src/clubedge_api/variants/auth_supabase.py +55 -1
  75. package/template/apps/api-fastapi/src/clubedge_api/variants/background_jobs_inngest.py +125 -0
  76. package/template/apps/api-fastapi/src/clubedge_api/variants/cache_memory.py +22 -0
  77. package/template/apps/api-fastapi/src/clubedge_api/variants/email_resend.py +35 -0
  78. package/template/apps/api-fastapi/src/clubedge_api/variants/observability_sentry.py +90 -0
  79. package/template/apps/api-fastapi/src/clubedge_api/variants/storage_supabase.py +37 -0
  80. package/template/apps/api-fastapi/tests/__init__.py +0 -0
  81. package/template/apps/api-fastapi/tests/fake_payments.py +172 -0
  82. package/template/apps/api-fastapi/tests/fakes.py +80 -0
  83. package/template/apps/api-fastapi/tests/test_account.py +275 -0
  84. package/template/apps/api-fastapi/tests/test_app.py +12 -19
  85. package/template/apps/api-fastapi/tests/test_background_jobs.py +67 -0
  86. package/template/apps/api-fastapi/tests/test_billing.py +205 -0
  87. package/template/apps/api-fastapi/tests/test_cache.py +134 -0
  88. package/template/apps/api-fastapi/tests/test_check_services.py +82 -0
  89. package/template/apps/api-fastapi/tests/test_client_errors.py +175 -0
  90. package/template/apps/api-fastapi/tests/test_contract_check.py +41 -0
  91. package/template/apps/api-fastapi/tests/test_email.py +82 -0
  92. package/template/apps/api-fastapi/tests/test_email_resend.py +87 -0
  93. package/template/apps/api-fastapi/tests/test_email_smtp.py +128 -0
  94. package/template/apps/api-fastapi/tests/test_http.py +84 -0
  95. package/template/apps/api-fastapi/tests/test_inngest.py +39 -0
  96. package/template/apps/api-fastapi/tests/test_job_queue.py +156 -0
  97. package/template/apps/api-fastapi/tests/test_jobs.py +95 -0
  98. package/template/apps/api-fastapi/tests/test_jobs_bullmq.py +283 -0
  99. package/template/apps/api-fastapi/tests/test_jobs_inngest.py +245 -0
  100. package/template/apps/api-fastapi/tests/test_jobs_run.py +44 -0
  101. package/template/apps/api-fastapi/tests/test_memory_cache.py +42 -0
  102. package/template/apps/api-fastapi/tests/test_observability.py +57 -0
  103. package/template/apps/api-fastapi/tests/test_payments.py +131 -0
  104. package/template/apps/api-fastapi/tests/test_payments_stripe.py +262 -0
  105. package/template/apps/api-fastapi/tests/test_production_env.py +95 -0
  106. package/template/apps/api-fastapi/tests/test_rate_limit.py +103 -0
  107. package/template/apps/api-fastapi/tests/test_storage_local.py +82 -0
  108. package/template/apps/api-fastapi/tests/test_storage_s3.py +196 -0
  109. package/template/apps/api-fastapi/tests/test_storage_supabase.py +95 -0
  110. package/template/apps/api-fastapi/tests/test_telemetry_otel.py +119 -0
  111. package/template/apps/api-fastapi/tests/test_telemetry_sentry.py +70 -0
  112. package/template/apps/api-fastapi/tests/test_users.py +48 -0
  113. package/template/apps/api-fastapi/uv.lock +581 -1
  114. package/template/apps/start/package.json +1 -1
  115. package/template/apps/vite/package.json +1 -1
  116. package/template/apps/web/package.json +1 -1
  117. package/template/clubedge.template.json +1 -0
  118. package/template/e2e/telemetry-fake.ts +78 -9
  119. package/template/package.json +1 -1
  120. package/template/packages/auth/package.json +1 -1
  121. package/template/packages/auth-appwrite/package.json +1 -1
  122. package/template/packages/auth-better-auth/package.json +1 -1
  123. package/template/packages/auth-firebase/package.json +1 -1
  124. package/template/packages/auth-supabase/package.json +1 -1
  125. package/template/packages/cache/package.json +1 -1
  126. package/template/packages/cache-redis/package.json +1 -1
  127. package/template/packages/contract/openapi.yaml +2 -2
  128. package/template/packages/contract/package.json +1 -1
  129. package/template/packages/core/package.json +1 -1
  130. package/template/packages/db/package.json +1 -1
  131. package/template/packages/email/package.json +1 -1
  132. package/template/packages/email-resend/package.json +1 -1
  133. package/template/packages/email-smtp/package.json +1 -1
  134. package/template/packages/jobs/package.json +1 -1
  135. package/template/packages/jobs-bullmq/package.json +1 -1
  136. package/template/packages/jobs-inngest/package.json +1 -1
  137. package/template/packages/jobs-pg-boss/package.json +1 -1
  138. package/template/packages/local-db/package.json +1 -1
  139. package/template/packages/observability/package.json +1 -1
  140. package/template/packages/observability-otel/package.json +1 -1
  141. package/template/packages/observability-sentry/package.json +1 -1
  142. package/template/packages/payments/package.json +1 -1
  143. package/template/packages/payments-stripe/package.json +1 -1
  144. package/template/packages/storage/package.json +1 -1
  145. package/template/packages/storage-s3/package.json +1 -1
  146. package/template/packages/storage-supabase/package.json +1 -1
  147. package/template/packages/ui/package.json +1 -1
  148. package/template.lock.json +77 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.41.0
6
+
7
+ - **A `package.json` with conflict markers no longer breaks the next commands** (#122, by @NishilRathod; issue #111):
8
+ - After `add`, `remove`, or `upgrade` leaves markers in a `package.json`, `pnpm install` is skipped with the reason: pnpm cannot read the file, so resolve it, then run `pnpm install`. The conflict report always prints, even when the install fails.
9
+ - pnpm is found from outside the project, so a broken `package.json` is no longer reported as "pnpm was not found", and an installed pnpm that fails says why.
10
+ - `status`, `doctor`, `add`, `remove`, `upgrade`, `deploy`, `neon`, and `try` name the unreadable `package.json` instead of printing a JSON parse error, and point to `git diff --no-index` against the Starter's version in `.clubedge/incoming`.
11
+ - `--detailed-exit-code` for `add`, `remove`, and `upgrade`: exit 2 when the change left conflicts to resolve, 0 when it was clean. Without it, conflicts still exit 0, as existing projects' upgrade workflow expects. The next Starter release's workflow uses it to open the upgrade pull request even when `package.json` conflicts.
12
+ - **`doctor` fails on unresolved conflict markers** in the project's files, listing them with their block counts (#123, by @ahmed-husssain; issue #109).
13
+ - **Projects own their version and README** (#125, by @ahmed-husssain; issue #124):
14
+ - New projects start at version `0.1.0`, and `add`, `remove`, and `upgrade` keep the project's own version, so the root `package.json` no longer conflicts on it with every Starter release.
15
+ - A README the project replaced (without the generated `## Generated from` section) is kept as it is; the Starter's new README goes to `.clubedge/incoming/README.md` to compare.
16
+ - CI covers these cases end to end on Linux and Windows (#128).
17
+ - Still on Starter `v0.26.0`.
18
+
19
+ ## 0.40.0
20
+
21
+ - **Every module on React + Vite with FastAPI**: default to Starter `v0.26.0` (#126). `--framework vite --api fastapi` now offers the module options Hono got in 0.39.0, each written in Python:
22
+ - `--storage s3` (or a local folder with `--infra local`) and `--storage supabase`.
23
+ - `--cache redis`, for the rate limits and cache.
24
+ - `--email smtp` and `--email resend`.
25
+ - `--observability otel` and `--observability sentry`, with browser error reporting.
26
+ - `--payments stripe`, on the same tables as the Hono API.
27
+ - `--jobs bullmq` and `--jobs inngest`. `--jobs pg-boss` is still refused for FastAPI: pg-boss has no Python version.
28
+ - The account settings page in full: display name, password change, and account deletion.
29
+ - New Vite + FastAPI projects get the same defaults as the other stacks: S3 storage, Redis cache, and SMTP email.
30
+ - Each module has its own uv dependency group: every project keeps the same `uv.lock`, and its production image installs only the selected modules' libraries.
31
+ - Rendered Python files keep the two blank lines PEP 8 and `ruff format` want between top-level definitions when a conditional block is removed (#121). Generating FastAPI projects from Starter `v0.26.0` needs this release.
32
+ - The README says the decoupled stacks with Hono support every module (#120).
33
+
5
34
  ## 0.39.0
6
35
 
7
36
  - **Every module on React + Vite with Hono**: default to Starter `v0.25.0` (#118). `--framework vite --api hono` now offers every module option the full-stack apps have, each running in the API with the matching UI in the frontend:
package/README.md CHANGED
@@ -141,7 +141,7 @@ They meet at one HTTP contract, `packages/contract/openapi.yaml`: the frontend c
141
141
 
142
142
  With `--auth supabase`, the Vite app owns the browser session and attaches its access token to contract requests. Hono or FastAPI verifies that token with Supabase Auth and returns the contract's shared user shape. Configure `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_KEY`, `SUPABASE_URL`, and `SUPABASE_PUBLISHABLE_KEY` in the generated env examples; no Supabase service-role key goes to the browser.
143
143
 
144
- The decoupled stacks support Supabase Auth, Better Auth, Firebase, Appwrite, or `none`, plus memory cache, any local infrastructure (the API's `apps/api/.env.local` holds `DATABASE_URL`, since the API owns the database), and Docker or Railway deployment. With Docker, `pnpm clubedge deploy` writes a `compose.yaml` that builds the frontend and API as separate services; copy the variables of `.env.example` and `apps/api/.env.example` to `.env.production`, fill in their values, and run `docker compose --env-file .env.production up --build -d`. The frontend's `VITE_*` values are build arguments, compiled into its bundle; with Better Auth, its Node server also gets the auth variables (`DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, and the email settings). With Railway, deploy provisions a public frontend and a private API, wires the frontend proxy to the API, and runs migrations before releasing the API. See [docs/stack-packs.md](docs/stack-packs.md) for how stacks and deployment facts are described.
144
+ With the Hono API, the decoupled stacks support every module option the full-stack frameworks do (since Starter v0.26.0), each running in the API with the matching UI in the frontend; with FastAPI, they support Supabase Auth, Better Auth, Firebase, Appwrite, or `none`, plus memory cache. Both work with any local infrastructure (the API's `apps/api/.env.local` holds `DATABASE_URL`, since the API owns the database), and Docker or Railway deployment. With Docker, `pnpm clubedge deploy` writes a `compose.yaml` that builds the frontend and API as separate services; copy the variables of `.env.example` and `apps/api/.env.example` to `.env.production`, fill in their values, and run `docker compose --env-file .env.production up --build -d`. The frontend's `VITE_*` values are build arguments, compiled into its bundle; with Better Auth, its Node server also gets the auth variables (`DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, and the email settings). With Railway, deploy provisions a public frontend and a private API, wires the frontend proxy to the API, and runs migrations before releasing the API. See [docs/stack-packs.md](docs/stack-packs.md) for how stacks and deployment facts are described.
145
145
 
146
146
  ## Choosing modules
147
147
 
@@ -207,7 +207,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
207
207
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
208
208
 
209
209
  # Reproduce a specific Starter release or commit
210
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.25.0
210
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.26.0
211
211
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
212
212
 
213
213
  # Test local Starter changes before releasing them
@@ -226,7 +226,7 @@ pnpm clubedge add email resend payments stripe # several modules as
226
226
  pnpm clubedge remove storage payments
227
227
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
228
228
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
229
- pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.25.0 # or a specific tag, branch, or commit
229
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.26.0 # or a specific tag, branch, or commit
230
230
  ```
231
231
 
232
232
  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.
@@ -241,6 +241,10 @@ Several modules in one command are applied as one change, and their requirements
241
241
 
242
242
  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.
243
243
 
244
+ 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.
245
+
246
+ 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.
247
+
244
248
  ## A hosted database with Neon
245
249
 
246
250
  `--infra neon` gives the project a [Neon](https://neon.com) PostgreSQL database with nothing to install and no account:
@@ -337,27 +341,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
337
341
  Each CLI release is tested against one specific Starter release:
338
342
 
339
343
  ```text
340
- create-clubedge-app v0.39.0
344
+ create-clubedge-app v0.41.0
341
345
  |
342
346
  v
343
- Clubedge Starter v0.25.0
344
- commit 17f4e82dca42884e7bfc3c66991110d00c7428a8
347
+ Clubedge Starter v0.26.0
348
+ commit 7c01a0295bea53adc72bda5b843a478ddd04f892
345
349
  ```
346
350
 
347
351
  The current relationship is:
348
352
 
349
353
  | CLI release | Default Starter release | Meaning |
350
354
  | --- | --- | --- |
351
- | `create-clubedge-app@0.39.0` | `clubedge-starter@v0.25.0` | The CLI's required, tested default |
355
+ | `create-clubedge-app@0.41.0` | `clubedge-starter@v0.26.0` | The CLI's required, tested default |
352
356
 
353
357
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
354
358
 
355
- The `v0.25.0` tag is expected to resolve to commit
356
- `17f4e82dca42884e7bfc3c66991110d00c7428a8`. The CLI verifies this before
359
+ The `v0.26.0` tag is expected to resolve to commit
360
+ `7c01a0295bea53adc72bda5b843a478ddd04f892`. The CLI verifies this before
357
361
  scaffolding with the default reference and fails rather than silently using a
358
362
  moved tag.
359
363
 
360
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.25.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.
364
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.26.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.
361
365
 
362
366
  Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
363
367
 
@@ -412,7 +416,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
412
416
  1. Make and validate changes in `clubedge-starter`.
413
417
  2. Run lint, typecheck, unit tests, browser tests, and production build.
414
418
  3. Update the Starter package versions.
415
- 4. Commit `main`, create a release tag such as `v0.25.0`, and publish the Starter release.
419
+ 4. Commit `main`, create a release tag such as `v0.26.0`, and publish the Starter release.
416
420
 
417
421
  ### CLI maintainers
418
422
 
@@ -462,6 +466,8 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
462
466
  | 0.37.0 | v0.24.1 | An MCP server (capabilities, validate, plan, create) and an agent skill for coding agents |
463
467
  | 0.38.0 | v0.24.2 | Starter v0.24.2: Next.js security update (critical RCE advisories); database URLs, Neon, and deploy variables for a frontend and its API |
464
468
  | 0.39.0 | v0.25.0 | Starter v0.25.0: every module on React + Vite with Hono, and account settings on React + Vite |
469
+ | 0.40.0 | v0.26.0 | Starter v0.26.0: every module on React + Vite with FastAPI (pg-boss excepted) |
470
+ | 0.41.0 | v0.26.0 | A conflicted package.json no longer breaks later commands (`--detailed-exit-code`); doctor finds conflict markers; projects own their version and README |
465
471
 
466
472
  ## Community
467
473
 
package/dist/cli/args.js CHANGED
@@ -12,6 +12,7 @@ const OPTIONS = {
12
12
  yes: { type: "boolean", short: "y", default: false },
13
13
  "dry-run": { type: "boolean", default: false },
14
14
  force: { type: "boolean", default: false },
15
+ "detailed-exit-code": { type: "boolean", default: false },
15
16
  services: { type: "boolean", default: true },
16
17
  open: { type: "boolean", default: true },
17
18
  claim: { type: "boolean", default: false },
@@ -98,6 +99,9 @@ export function parseCliArgs(argv) {
98
99
  if (command.name !== "try" && (values.claim || values.status)) {
99
100
  throw new UsageError("--claim and --status only apply to try.");
100
101
  }
102
+ if (command.name !== "add" && command.name !== "remove" && command.name !== "upgrade" && values["detailed-exit-code"]) {
103
+ throw new UsageError("--detailed-exit-code only applies to add, remove, and upgrade.");
104
+ }
101
105
  if (command.name === "try" && values["template-dir"] !== undefined) {
102
106
  throw new UsageError("--template-dir does not apply to try: the VM downloads the Starter itself.");
103
107
  }
@@ -122,6 +126,7 @@ export function parseCliArgs(argv) {
122
126
  yes: values.yes,
123
127
  dryRun: values["dry-run"],
124
128
  force: values.force,
129
+ detailedExitCode: values["detailed-exit-code"],
125
130
  services: values.services,
126
131
  open: values.open,
127
132
  claim: values.claim,
package/dist/cli/ui.js CHANGED
@@ -130,6 +130,8 @@ ${selectionHelp(manifest)}
130
130
  --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
131
131
  --dry-run Show what would be created or changed without writing anything
132
132
  --force add/remove: change a project with uncommitted changes or without Git
133
+ --detailed-exit-code add/remove/upgrade: exit with 2 when the change leaves conflicts to
134
+ resolve (0: changed cleanly or nothing to change, 1: failed)
133
135
  --no-services doctor: skip pnpm check:services
134
136
  --no-open try, deploy (Vercel, Netlify, Render): do not open the URL in the browser
135
137
  --claim try: open the page that claims the VM, to share the preview
@@ -21,9 +21,10 @@ function recordStacks(stacks) {
21
21
  ...(Object.keys(apps).length ? { apps } : {}),
22
22
  };
23
23
  }
24
- export function customizeRootManifest(source, { packageName }, provenance, dockerImage, scripts = { set: {}, remove: [] }) {
24
+ export function customizeRootManifest(source, { packageName, version = "0.1.0" }, provenance, dockerImage, scripts = { set: {}, remove: [] }) {
25
25
  const manifest = JSON.parse(source);
26
26
  manifest.name = packageName;
27
+ manifest.version = version;
27
28
  if (manifest.scripts && typeof manifest.scripts === "object") {
28
29
  for (const name of scripts.remove)
29
30
  delete manifest.scripts[name];
@@ -229,8 +230,12 @@ export function applyConditionals(source, selection, path = "file") {
229
230
  if (!removed)
230
231
  return text;
231
232
  // A removed block can leave two blank lines in a row, or blank lines at the start or end of
232
- // the file; formatted sources never have them.
233
- const collapsed = text.replace(/\n{3,}/g, "\n\n").replace(/^\n+/, "");
233
+ // the file; formatted sources never have them. Python is the exception: two blank lines come
234
+ // before a top-level definition (PEP 8, which ruff format enforces), one inside indented code.
235
+ const squeezed = path.endsWith(".py")
236
+ ? text.replace(/\n{4,}/g, "\n\n\n").replace(/\n{3,}(?=[ \t])/g, "\n\n")
237
+ : text.replace(/\n{3,}/g, "\n\n");
238
+ const collapsed = squeezed.replace(/^\n+/, "");
234
239
  return source.endsWith("\n") ? collapsed.replace(/\n+$/, "\n") : collapsed;
235
240
  }
236
241
  /** Removes dependencies on workspace packages that were left out of the project. */
@@ -210,3 +210,20 @@ export function checkDatabaseUrls(input) {
210
210
  }
211
211
  return { name, status: "ok", detail: "Neon, pooled for the app and direct for migrations and the jobs worker" };
212
212
  }
213
+ export function checkConflicts(conflicts) {
214
+ const name = "Conflicts";
215
+ if (!conflicts.length)
216
+ return { name, status: "ok", detail: "no unresolved conflict markers" };
217
+ const total = conflicts.reduce((sum, c) => sum + c.blocks, 0);
218
+ const sample = conflicts
219
+ .slice(0, 5)
220
+ .map((c) => `\n • ${c.path} (${c.blocks} ${c.blocks === 1 ? "block" : "blocks"})`)
221
+ .join("");
222
+ const more = conflicts.length > 5 ? `\n ... and ${conflicts.length - 5} more files` : "";
223
+ return {
224
+ name,
225
+ status: "fail",
226
+ detail: `${conflicts.length} ${conflicts.length === 1 ? "file has" : "files have"} unresolved conflict markers (${total} total):${sample}${more}`,
227
+ fix: 'Resolve the "<<<<<<< yours" markers in these files, then delete .clubedge/incoming.',
228
+ };
229
+ }
@@ -3,6 +3,11 @@
3
3
  // without losing the project's own edits. Pure: callers read and write the files.
4
4
  import { diff3Merge } from "node-diff3";
5
5
  export const CONFLICT_LABELS = { yours: "yours", theirs: "clubedge" };
6
+ /**
7
+ * Where the Starter's versions of files left to resolve are saved, next to the project's own,
8
+ * for comparing by hand. The folder ignores itself, so it never shows in git status or a commit.
9
+ */
10
+ export const INCOMING_DIR = ".clubedge/incoming";
6
11
  /** How many conflict blocks a merged file has. */
7
12
  export function countConflicts(content) {
8
13
  const marker = `<<<<<<< ${CONFLICT_LABELS.yours}`;
@@ -1,21 +1,59 @@
1
1
  // Reads an existing generated project and renders projects in memory, so commands that change
2
2
  // a project (add, remove, upgrade) can compare what the CLI generated with what it would now.
3
3
  import { readFile } from "node:fs/promises";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { dirname, join, relative, resolve, sep } from "node:path";
5
5
  import { pathExists } from "../utils/fs.js";
6
6
  import { displayNameFromPackageName } from "../utils/names.js";
7
7
  import { recordedStacks } from "./customize.js";
8
+ import { countConflicts, INCOMING_DIR } from "./merge.js";
8
9
  import { createPlan } from "./plan.js";
9
10
  export class ProjectError extends Error {
10
11
  name = "ProjectError";
11
12
  }
13
+ /** The Starter's version of a file, saved by the last change in the `.clubedge` of a folder above it. */
14
+ async function findIncoming(file) {
15
+ let folder = dirname(file);
16
+ while (true) {
17
+ const incoming = join(folder, INCOMING_DIR, relative(folder, file));
18
+ if (await pathExists(incoming))
19
+ return incoming;
20
+ const parent = dirname(folder);
21
+ if (parent === folder)
22
+ return null;
23
+ folder = parent;
24
+ }
25
+ }
26
+ /**
27
+ * Why a package.json does not parse, naming it as seen from `directory`. Most often it is a
28
+ * conflict left by add, remove, or upgrade, which pnpm cannot read either.
29
+ */
30
+ async function unreadableManifest(path, content, directory, error) {
31
+ const shown = (file) => relative(directory, file).split(sep).join("/");
32
+ const conflicts = countConflicts(content);
33
+ if (!conflicts) {
34
+ return new ProjectError(`${shown(path)} is not valid JSON: ${error instanceof Error ? error.message : String(error)}. Fix it, then run this again.`);
35
+ }
36
+ const incoming = await findIncoming(path);
37
+ return new ProjectError(`${shown(path)} has ${conflicts} unresolved ${conflicts === 1 ? "conflict" : "conflicts"} from an earlier add, remove, or upgrade, ` +
38
+ "marked <<<<<<< yours ... ======= ... >>>>>>> clubedge. While it has the markers, pnpm cannot read it either, so `pnpm install` and `pnpm clubedge` fail too. " +
39
+ "Keep your lines, the Starter's, or a mix, delete the markers, then run this again." +
40
+ (incoming ? `\nThe Starter's version, to compare with yours:\n git diff --no-index ${shown(path)} ${shown(incoming)}` : ""));
41
+ }
12
42
  /** Finds the generated project that contains `directory` and reads its provenance. */
13
43
  export async function readProject(directory) {
14
- let current = resolve(directory);
44
+ const start = resolve(directory);
45
+ let current = start;
15
46
  while (true) {
16
47
  const manifestPath = join(current, "package.json");
17
48
  if (await pathExists(manifestPath)) {
18
- const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
49
+ const content = await readFile(manifestPath);
50
+ let manifest;
51
+ try {
52
+ manifest = JSON.parse(content.toString("utf8"));
53
+ }
54
+ catch (error) {
55
+ throw await unreadableManifest(manifestPath, content, start, error);
56
+ }
19
57
  const origin = manifest.clubedge;
20
58
  if (origin?.starterRef) {
21
59
  // Projects record the framework only when the Starter offered a choice, but always their modules.
@@ -24,7 +62,11 @@ export async function readProject(directory) {
24
62
  }
25
63
  return {
26
64
  root: current,
27
- identity: { packageName: manifest.name, displayName: displayNameFromPackageName(manifest.name) },
65
+ identity: {
66
+ packageName: manifest.name,
67
+ displayName: displayNameFromPackageName(manifest.name),
68
+ version: typeof manifest.version === "string" ? manifest.version : "0.1.0",
69
+ },
28
70
  cliVersion: origin.cliVersion,
29
71
  starterRepository: origin.starterRepository,
30
72
  starterRef: origin.starterRef,
@@ -4,14 +4,10 @@
4
4
  import { mkdir, readdir, rm, rmdir, writeFile } from "node:fs/promises";
5
5
  import { dirname, join } from "node:path";
6
6
  import { updateEnvFile } from "./env.js";
7
- import { planMerge, sameContent } from "./merge.js";
7
+ import { INCOMING_DIR, planMerge, sameContent } from "./merge.js";
8
8
  import { readProjectFiles, renderProject } from "./project.js";
9
+ export { INCOMING_DIR };
9
10
  const LOCKFILE = "pnpm-lock.yaml";
10
- /**
11
- * Where the Starter's versions of files left to resolve are saved, next to the project's own,
12
- * for comparing by hand. The folder ignores itself, so it never shows in git status or a commit.
13
- */
14
- export const INCOMING_DIR = ".clubedge/incoming";
15
11
  export async function planUpdate(project, base, target) {
16
12
  const render = (side) => renderProject({
17
13
  source: side.source,
@@ -32,7 +28,23 @@ export async function planUpdate(project, base, target) {
32
28
  const current = await readProjectFiles(project.root, [
33
29
  ...new Set([...baseFiles.keys(), ...targetFiles.keys(), ...special]),
34
30
  ]);
31
+ const currentReadme = current.get("README.md");
32
+ const customReadme = Boolean(currentReadme && !currentReadme.toString("utf8").includes("## Generated from"));
33
+ const targetReadme = targetFiles.get("README.md");
34
+ if (customReadme) {
35
+ baseFiles.delete("README.md");
36
+ targetFiles.delete("README.md");
37
+ }
35
38
  const actions = planMerge(baseFiles, targetFiles, current);
39
+ if (customReadme && targetReadme && !sameContent(currentReadme, targetReadme)) {
40
+ actions.push({
41
+ path: "README.md",
42
+ kind: "keep",
43
+ reason: "You replaced the generated README with your own.",
44
+ incoming: targetReadme,
45
+ });
46
+ actions.sort((a, b) => a.path.localeCompare(b.path));
47
+ }
36
48
  let lockfile = { kind: "unchanged" };
37
49
  const baseLock = before.files.get(LOCKFILE);
38
50
  const targetLock = after.files.get(LOCKFILE);
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ import { deployProject } from "./deploy.js";
15
15
  import { tryOnRailway } from "./try.js";
16
16
  import { describeNewDatabase, neonCommand, provisionNeonDatabase } from "./neon.js";
17
17
  import { initializeGit } from "./steps/git.js";
18
- import { detectLauncher, installDependencies, resolvePnpm } from "./steps/install.js";
18
+ import { detectLauncher, detectPnpm, installDependencies } from "./steps/install.js";
19
19
  import { commandSucceeds, run } from "./utils/process.js";
20
20
  import { displayNameFromPackageName, packageNameFromDirectory } from "./utils/names.js";
21
21
  const minimumNode = [22, 12];
@@ -148,7 +148,7 @@ async function createProject(args) {
148
148
  const targetDirectory = resolve(process.cwd(), requested);
149
149
  await assertTargetIsUsable(targetDirectory);
150
150
  const packageName = packageNameFromDirectory(targetDirectory);
151
- const identity = { packageName, displayName: displayNameFromPackageName(packageName) };
151
+ const identity = { packageName, displayName: displayNameFromPackageName(packageName), version: "0.1.0" };
152
152
  const progress = spinner();
153
153
  progress.start("Preparing the Starter");
154
154
  const source = await resolveTemplateSource({ ref: args.ref, templateDir: args.templateDir }).catch((error) => {
@@ -237,9 +237,11 @@ async function createProject(args) {
237
237
  if (launcher && launcher !== "pnpm") {
238
238
  log.info(`This project is a pnpm workspace, so dependencies are installed with pnpm (not ${launcher}).`);
239
239
  }
240
- const pnpm = await resolvePnpm();
240
+ const { pnpm, problem } = await detectPnpm();
241
241
  if (!pnpm) {
242
- log.warn("pnpm was not found. Enable it with `corepack enable`, then run `pnpm install` in the project.");
242
+ log.warn(problem
243
+ ? `pnpm is installed but did not run, so dependencies were not installed. ${problem} Run \`pnpm install\` in the project once it works.`
244
+ : "pnpm was not found. Enable it with `corepack enable`, then run `pnpm install` in the project.");
243
245
  }
244
246
  else {
245
247
  pnpmCommand = pnpm.join(" ");
@@ -321,7 +323,10 @@ export async function main(argv = process.argv.slice(2)) {
321
323
  upgrade: "Upgrade this project to a newer Starter",
322
324
  };
323
325
  intro(intros[args.command.name]);
324
- await changeProject(args);
326
+ // Conflicts exit with 0 unless asked: the Starter's upgrade workflow in existing projects
327
+ // runs the newest CLI under `set -o pipefail`, and would stop before opening its pull request.
328
+ if ((await changeProject(args)) === "conflicts" && args.detailedExitCode)
329
+ process.exitCode = 2;
325
330
  }
326
331
  }
327
332
  catch (error) {
package/dist/inspect.js CHANGED
@@ -5,14 +5,15 @@ import { readFile } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
6
  import process from "node:process";
7
7
  import { dim } from "./cli/ui.js";
8
- import { checkDatabaseUrls, checkEnvFile, checkLockfile, checkNode, checkPnpm, fetchLatestRelease, nextStep, updateState, } from "./core/health.js";
8
+ import { checkConflicts, checkDatabaseUrls, checkEnvFile, checkLockfile, checkNode, checkPnpm, fetchLatestRelease, nextStep, updateState, } from "./core/health.js";
9
+ import { countConflicts } from "./core/merge.js";
9
10
  import { parseEnvFile } from "./core/deploy.js";
10
11
  import { neonDatabaseState } from "./core/neon.js";
11
12
  import { databaseEnvFile, projectEnvFiles, readProject } from "./core/project.js";
12
13
  import { savedDatabaseForUrl } from "./neon.js";
13
14
  import { cliCommand } from "./modify.js";
14
15
  import { resolvePnpm } from "./steps/install.js";
15
- import { pathExists } from "./utils/fs.js";
16
+ import { listTemplateFiles, pathExists } from "./utils/fs.js";
16
17
  import { commandSucceeds, run, runCapture } from "./utils/process.js";
17
18
  async function readText(path) {
18
19
  try {
@@ -78,6 +79,19 @@ function print(result) {
78
79
  else
79
80
  log.error(line);
80
81
  }
82
+ export async function findConflictedFiles(root) {
83
+ const files = await listTemplateFiles(root);
84
+ const conflicts = [];
85
+ for (const file of files) {
86
+ const content = await readFile(join(root, file)).catch(() => null);
87
+ if (!content)
88
+ continue;
89
+ const blocks = countConflicts(content);
90
+ if (blocks > 0)
91
+ conflicts.push({ path: file, blocks });
92
+ }
93
+ return conflicts;
94
+ }
81
95
  export async function runDoctor(args) {
82
96
  const project = await readProject(process.cwd());
83
97
  const root = project.root;
@@ -91,6 +105,7 @@ export async function runDoctor(args) {
91
105
  const pnpm = await resolvePnpm();
92
106
  const pnpmVersion = pnpm ? await runCapture(pnpm[0], [...pnpm.slice(1), "--version"]).catch(() => null) : null;
93
107
  check(checkPnpm(pnpmVersion, manifest.packageManager));
108
+ check(checkConflicts(await findConflictedFiles(root)));
94
109
  const installed = await pathExists(join(root, "node_modules", ".modules.yaml"));
95
110
  check(installed
96
111
  ? { name: "Dependencies", status: "ok", detail: "installed" }
@@ -11,7 +11,7 @@ import { createPlan, describePlan } from "./core/plan.js";
11
11
  import { resolveTemplateSource } from "./core/source.js";
12
12
  import { cliVersion, cliUrl, siteUrl } from "./package-info.js";
13
13
  import { initializeGit } from "./steps/git.js";
14
- import { installDependencies, resolvePnpm } from "./steps/install.js";
14
+ import { detectPnpm, installDependencies } from "./steps/install.js";
15
15
  import { commandSucceeds, run } from "./utils/process.js";
16
16
  import { displayNameFromPackageName, packageNameFromDirectory } from "./utils/names.js";
17
17
  // Keep subprocess output off the MCP JSON-RPC stdout stream, and don't let children read MCP input.
@@ -217,9 +217,11 @@ function createServer() {
217
217
  }
218
218
  }
219
219
  if (install_dependencies) {
220
- const pnpm = await resolvePnpm();
220
+ const { pnpm, problem } = await detectPnpm();
221
221
  if (!pnpm) {
222
- warnings.push("Project created, but pnpm was not found. Install pnpm and run it in the project directory.");
222
+ warnings.push(problem
223
+ ? `Project created, but pnpm is installed and did not run. ${problem}`
224
+ : "Project created, but pnpm was not found. Install pnpm and run it in the project directory.");
223
225
  }
224
226
  else {
225
227
  try {
package/dist/modify.js CHANGED
@@ -18,7 +18,8 @@ import { applyUpdate, INCOMING_DIR, planUpdate } from "./core/update.js";
18
18
  import { cliVersion, starterPin } from "./package-info.js";
19
19
  import { parseEnvFile } from "./core/deploy.js";
20
20
  import { describeNewDatabase, projectDatabaseEnv, provisionNeonDatabase } from "./neon.js";
21
- import { installDependencies, resolvePnpm } from "./steps/install.js";
21
+ import { detectPnpm, installDependencies } from "./steps/install.js";
22
+ import { listTemplateFiles } from "./utils/fs.js";
22
23
  import { commandSucceeds, runCapture } from "./utils/process.js";
23
24
  /** How to run the CLI in this project: its `clubedge` script when it has one. */
24
25
  export async function cliCommand(project) {
@@ -230,8 +231,27 @@ function describe(plan) {
230
231
  }
231
232
  return lines.join("\n");
232
233
  }
234
+ const conflictsOf = (plan) => plan.actions.filter((action) => action.kind === "conflict");
235
+ /**
236
+ * The project's package.json files with conflict markers: this change's, and any an earlier change
237
+ * left that this one does not touch, which its plan does not list.
238
+ */
239
+ async function conflictedManifests(root) {
240
+ const manifests = (await listTemplateFiles(root)).filter((path) => /(^|\/)package\.json$/.test(path));
241
+ const conflicted = await Promise.all(manifests.map(async (path) => (countConflicts(await readFile(join(root, path))) ? path : null)));
242
+ return conflicted.filter((path) => path !== null);
243
+ }
244
+ /**
245
+ * Why pnpm cannot run yet: it reads every package.json in the workspace and stops at one with
246
+ * conflict markers, so `pnpm install` and `pnpm clubedge` fail until they are resolved.
247
+ */
248
+ function conflictedManifestWarning(paths, install) {
249
+ const one = paths.length === 1;
250
+ return (`${install ? "Skipped `pnpm install`: " : ""}${paths.join(", ")} ${one ? "has" : "have"} conflict markers, which pnpm cannot read, ` +
251
+ `so pnpm commands, \`pnpm clubedge\` among them, fail until ${one ? "it is" : "they are"} resolved. Then run \`pnpm install\`.`);
252
+ }
233
253
  function report(plan) {
234
- const conflicts = plan.actions.filter((action) => action.kind === "conflict");
254
+ const conflicts = conflictsOf(plan);
235
255
  const kept = plan.actions.filter((action) => action.kind === "keep");
236
256
  const incoming = (path) => `${INCOMING_DIR}/${path}`;
237
257
  if (conflicts.length) {
@@ -366,12 +386,12 @@ export async function changeProject(args) {
366
386
  const complete = command.name === "upgrade" ? command : await completeCommand(baseManifest, project, command, interactive);
367
387
  if (!complete) {
368
388
  outro("Cancelled; nothing was changed.");
369
- return;
389
+ return "unchanged";
370
390
  }
371
391
  const change = await planChange(project, complete, base, baseManifest, target, cli);
372
392
  if (!change) {
373
393
  outro(upgrading ? `The project is already on Starter ${project.starterRef}.` : "The project already uses that selection; nothing to change.");
374
- return;
394
+ return "unchanged";
375
395
  }
376
396
  const plan = await planUpdate(project, change.base, change.target);
377
397
  if (change.releases)
@@ -381,17 +401,32 @@ export async function changeProject(args) {
381
401
  for (const note of change.notes)
382
402
  log.info(note);
383
403
  outro("Dry run: nothing was changed. Run again without --dry-run to apply.");
384
- return;
404
+ return "unchanged";
385
405
  }
386
406
  await applyUpdate(plan);
387
407
  log.success(`Updated ${plan.actions.filter((action) => action.kind !== "keep").length} files`);
388
- if (args.install) {
389
- const pnpm = await resolvePnpm();
390
- if (!pnpm)
391
- log.warn("pnpm was not found. Run `pnpm install` to update dependencies.");
408
+ // The files are written now, so what is left to resolve is reported even when the install fails.
409
+ let installError;
410
+ const manifests = await conflictedManifests(project.root);
411
+ if (manifests.length) {
412
+ log.warn(conflictedManifestWarning(manifests, args.install));
413
+ }
414
+ else if (args.install) {
415
+ const { pnpm, problem } = await detectPnpm();
416
+ if (pnpm) {
417
+ try {
418
+ await installDependencies(project.root, pnpm, plan.lockfile.kind === "install" ? ["--no-frozen-lockfile"] : []);
419
+ log.success("Dependencies updated");
420
+ }
421
+ catch (error) {
422
+ installError = error;
423
+ }
424
+ }
425
+ else if (problem) {
426
+ log.warn(`pnpm is installed but did not run, so dependencies were not updated. ${problem} Run \`pnpm install\` once it works.`);
427
+ }
392
428
  else {
393
- await installDependencies(project.root, pnpm, plan.lockfile.kind === "install" ? ["--no-frozen-lockfile"] : []);
394
- log.success("Dependencies updated");
429
+ log.warn("pnpm was not found. Run `pnpm install` to update dependencies.");
395
430
  }
396
431
  }
397
432
  else if (plan.lockfile.kind !== "unchanged") {
@@ -400,11 +435,16 @@ export async function changeProject(args) {
400
435
  report(plan);
401
436
  for (const note of change.notes)
402
437
  log.info(note);
438
+ if (installError) {
439
+ const reason = installError instanceof Error ? installError.message : String(installError);
440
+ throw new Error(`The files were updated, but \`pnpm install\` did not complete: ${reason}${reason.endsWith(".") ? "" : "."} Fix that, then run \`pnpm install\`.`);
441
+ }
403
442
  if (change.target.modules.infra === "neon" && project.modules.infra !== "neon")
404
443
  await addNeonDatabase(project, args, cli);
405
444
  outro(upgrading
406
445
  ? "Review the changes with `git diff`, then run `pnpm db:migrate` for new migrations and your tests."
407
446
  : "Review the changes with `git diff`, then run `pnpm check:services` and `pnpm db:migrate` if the change added services or tables.");
447
+ return conflictsOf(plan).length ? "conflicts" : "changed";
408
448
  }
409
449
  catch (error) {
410
450
  if (spinning)
@@ -1,12 +1,35 @@
1
+ import { tmpdir } from "node:os";
1
2
  import process from "node:process";
2
- import { commandSucceeds, run } from "../utils/process.js";
3
- /** The generated workspace relies on pnpm features (workspace:* and pnpm-workspace.yaml). */
3
+ import { onPath, run, runCapture, withAbsolutePath } from "../utils/process.js";
4
+ /**
5
+ * Finds pnpm, which the generated workspace relies on (workspace:* and pnpm-workspace.yaml), or
6
+ * corepack, which runs the pnpm version the project pins. Both are checked outside the project:
7
+ * they read the package.json of the folder they start in and fail when it does not parse, such as
8
+ * while it has conflict markers, and that is not pnpm missing.
9
+ */
10
+ export async function detectPnpm() {
11
+ let problem;
12
+ for (const pnpm of [["pnpm"], ["corepack", "pnpm"]]) {
13
+ const command = pnpm[0];
14
+ try {
15
+ // `corepack --version`, not `corepack pnpm --version`: outside a project, corepack would
16
+ // download its own default pnpm just to answer. PATH's relative folders still mean the
17
+ // folder the CLI runs in, where onPath and the install look.
18
+ await runCapture(command, ["--version"], tmpdir(), withAbsolutePath());
19
+ return { pnpm };
20
+ }
21
+ catch (error) {
22
+ if (!problem && (await onPath(command))) {
23
+ const reason = error instanceof Error ? error.message : String(error);
24
+ problem = `\`${command} --version\` failed: ${reason}${reason.endsWith(".") ? "" : "."}`;
25
+ }
26
+ }
27
+ }
28
+ return { pnpm: null, problem };
29
+ }
30
+ /** The command that runs pnpm, or null; detectPnpm also says why an installed one did not run. */
4
31
  export async function resolvePnpm() {
5
- if (await commandSucceeds("pnpm", ["--version"]))
6
- return ["pnpm"];
7
- if (await commandSucceeds("corepack", ["pnpm", "--version"]))
8
- return ["corepack", "pnpm"];
9
- return null;
32
+ return (await detectPnpm()).pnpm;
10
33
  }
11
34
  /** The package manager that launched the CLI, from npm_config_user_agent (e.g. "npm/10.8.2 ..."). */
12
35
  export function detectLauncher(userAgent = process.env.npm_config_user_agent) {