@clubedge/create-clubedge-app 0.46.1 → 0.47.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 (145) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +29 -11
  3. package/dist/core/execute.js +16 -2
  4. package/dist/index.js +11 -3
  5. package/dist/mcp-progress.js +57 -0
  6. package/dist/mcp-server.js +7 -3
  7. package/dist/utils/names.js +11 -0
  8. package/dist/utils/open.js +22 -1
  9. package/dist/utils/process.js +90 -6
  10. package/package.json +5 -5
  11. package/skills/clubedge-starter/SKILL.md +1 -0
  12. package/template/.dockerignore +6 -0
  13. package/template/.github/workflows/ci.yml +2 -8
  14. package/template/CONTRIBUTING.md +1 -1
  15. package/template/README.md +23 -17
  16. package/template/SETUP.md +147 -4
  17. package/template/_gitignore +7 -0
  18. package/template/apps/api/.env.example +3 -0
  19. package/template/apps/api/cloudflare.json +5 -0
  20. package/template/apps/api/clubedge.stack.json +6 -2
  21. package/template/apps/api/package.json +1 -1
  22. package/template/apps/api/src/app.ts +9 -2
  23. package/template/apps/api/src/cors.test.ts +79 -0
  24. package/template/apps/api/src/env/production.test.ts +21 -0
  25. package/template/apps/api/src/env/production.ts +11 -0
  26. package/template/apps/api/src/env.ts +21 -0
  27. package/template/apps/api/src/server/index.ts +8 -0
  28. package/template/apps/api/src/server/storage.test.ts +30 -0
  29. package/template/apps/api/src/server/storage.ts +9 -2
  30. package/template/apps/api/src/server/variants/storage.supabase.ts +9 -2
  31. package/template/apps/api-fastapi/.env.example +3 -0
  32. package/template/apps/api-fastapi/cloudflare.json +5 -0
  33. package/template/apps/api-fastapi/clubedge.stack.json +10 -2
  34. package/template/apps/api-fastapi/package.json +1 -1
  35. package/template/apps/api-fastapi/pyproject.toml +1 -1
  36. package/template/apps/api-fastapi/src/clubedge_api/app.py +6 -2
  37. package/template/apps/api-fastapi/src/clubedge_api/production_env.py +17 -0
  38. package/template/apps/api-fastapi/src/clubedge_api/server.py +1 -0
  39. package/template/apps/api-fastapi/src/clubedge_api/settings.py +42 -0
  40. package/template/apps/api-fastapi/src/clubedge_api/storage.py +3 -2
  41. package/template/apps/api-fastapi/src/clubedge_api/storage_local.py +11 -5
  42. package/template/apps/api-fastapi/src/clubedge_api/storage_provider.py +16 -1
  43. package/template/apps/api-fastapi/src/clubedge_api/storage_range.py +45 -0
  44. package/template/apps/api-fastapi/src/clubedge_api/storage_s3.py +4 -3
  45. package/template/apps/api-fastapi/src/clubedge_api/storage_supabase.py +8 -3
  46. package/template/apps/api-fastapi/src/clubedge_api/variants/storage_supabase.py +3 -2
  47. package/template/apps/api-fastapi/tests/test_cors.py +75 -0
  48. package/template/apps/api-fastapi/tests/test_production_env.py +13 -0
  49. package/template/apps/api-fastapi/tests/test_storage_local.py +19 -0
  50. package/template/apps/api-fastapi/tests/test_storage_range.py +54 -0
  51. package/template/apps/api-fastapi/tests/test_storage_s3.py +19 -0
  52. package/template/apps/api-fastapi/tests/test_storage_supabase.py +17 -0
  53. package/template/apps/api-fastapi/uv.lock +1 -1
  54. package/template/apps/capacitor/package.json +1 -1
  55. package/template/apps/expo/clubedge.stack.json +0 -1
  56. package/template/apps/expo/package.json +14 -14
  57. package/template/apps/expo/src/components/update-gate.tsx +10 -2
  58. package/template/apps/start/clubedge.stack.json +2 -1
  59. package/template/apps/start/package.json +1 -1
  60. package/template/apps/start/src/server/storage.test.ts +31 -0
  61. package/template/apps/start/src/server/storage.ts +9 -2
  62. package/template/apps/start/src/server/variants/storage.supabase.ts +9 -2
  63. package/template/apps/vite/clubedge.stack.json +4 -1
  64. package/template/apps/vite/package.json +1 -1
  65. package/template/apps/vite/public/_headers +7 -0
  66. package/template/apps/web/package.json +1 -1
  67. package/template/apps/web/src/server/storage.ts +9 -2
  68. package/template/apps/web/src/server/variants/storage.supabase.ts +9 -2
  69. package/template/clubedge/modules/deploy.json +10 -0
  70. package/template/clubedge.template.json +15 -1
  71. package/template/deploy/cloudflare/package.json +17 -0
  72. package/template/deploy/cloudflare/scripts/config.mjs +41 -0
  73. package/template/deploy/cloudflare/scripts/config.test.ts +43 -0
  74. package/template/deploy/cloudflare/scripts/wrangler.mjs +63 -0
  75. package/template/deploy/cloudflare/src/index.ts +128 -0
  76. package/template/deploy/cloudflare/src/keep-awake.test.ts +26 -0
  77. package/template/deploy/cloudflare/src/keep-awake.ts +17 -0
  78. package/template/deploy/cloudflare/src/routing.test.ts +52 -0
  79. package/template/deploy/cloudflare/src/routing.ts +50 -0
  80. package/template/deploy/cloudflare/src/website.ts +33 -0
  81. package/template/deploy/cloudflare/tsconfig.json +9 -0
  82. package/template/deploy/cloudflare/wrangler.jsonc +49 -0
  83. package/template/deploy/cloudflare/wrangler.website.jsonc +24 -0
  84. package/template/e2e/auth-flow.spec.ts +10 -2
  85. package/template/e2e/auth.spec.ts +3 -0
  86. package/template/e2e/email-verification.spec.ts +9 -1
  87. package/template/e2e/firebase-auth-flow.spec.ts +2 -1
  88. package/template/e2e/jobs.spec.ts +2 -1
  89. package/template/e2e/observability.spec.ts +2 -0
  90. package/template/e2e/password-reset.spec.ts +15 -1
  91. package/template/e2e/payments.spec.ts +6 -2
  92. package/template/e2e/settings.spec.ts +9 -1
  93. package/template/e2e/unique.ts +13 -0
  94. package/template/package.json +3 -7
  95. package/template/packages/auth/package.json +1 -1
  96. package/template/packages/auth-appwrite/package.json +1 -1
  97. package/template/packages/auth-better-auth/package.json +1 -1
  98. package/template/packages/auth-firebase/package.json +1 -1
  99. package/template/packages/auth-supabase/package.json +1 -1
  100. package/template/packages/auth-supabase/src/bearer.test.ts +90 -0
  101. package/template/packages/auth-supabase/src/index.test.ts +82 -2
  102. package/template/packages/auth-supabase/src/index.ts +45 -12
  103. package/template/packages/cache/package.json +1 -1
  104. package/template/packages/cache-redis/package.json +1 -1
  105. package/template/packages/contract/package.json +1 -1
  106. package/template/packages/core/package.json +1 -1
  107. package/template/packages/db/package.json +1 -1
  108. package/template/packages/email/package.json +1 -1
  109. package/template/packages/email-resend/package.json +1 -1
  110. package/template/packages/email-smtp/package.json +1 -1
  111. package/template/packages/email-smtp/src/index.test.ts +45 -1
  112. package/template/packages/email-smtp/src/index.ts +26 -2
  113. package/template/packages/jobs/package.json +1 -1
  114. package/template/packages/jobs-bullmq/package.json +1 -1
  115. package/template/packages/jobs-inngest/package.json +1 -1
  116. package/template/packages/jobs-pg-boss/package.json +1 -1
  117. package/template/packages/local-db/package.json +1 -1
  118. package/template/packages/mobile/package.json +1 -1
  119. package/template/packages/observability/package.json +1 -1
  120. package/template/packages/observability-otel/package.json +1 -1
  121. package/template/packages/observability-sentry/package.json +1 -1
  122. package/template/packages/payments/package.json +1 -1
  123. package/template/packages/payments-stripe/package.json +1 -1
  124. package/template/packages/push/package.json +1 -1
  125. package/template/packages/push-expo/package.json +1 -1
  126. package/template/packages/push-native/package.json +1 -1
  127. package/template/packages/storage/package.json +1 -1
  128. package/template/packages/storage/src/file-system.test.ts +18 -0
  129. package/template/packages/storage/src/file-system.ts +5 -2
  130. package/template/packages/storage/src/index.ts +8 -1
  131. package/template/packages/storage/src/range.test.ts +47 -0
  132. package/template/packages/storage/src/range.ts +28 -0
  133. package/template/packages/storage/src/types.ts +13 -1
  134. package/template/packages/storage-s3/package.json +1 -1
  135. package/template/packages/storage-s3/src/index.test.ts +21 -0
  136. package/template/packages/storage-s3/src/index.ts +9 -2
  137. package/template/packages/storage-supabase/package.json +2 -2
  138. package/template/packages/storage-supabase/src/index.test.ts +75 -0
  139. package/template/packages/storage-supabase/src/index.ts +6 -2
  140. package/template/packages/ui/package.json +1 -1
  141. package/template/playwright.config.ts +9 -1
  142. package/template/pnpm-lock.yaml +701 -659
  143. package/template/pnpm-workspace.yaml +1 -0
  144. package/template/vitest.config.ts +6 -1
  145. package/template.lock.json +29 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.47.0
6
+
7
+ - **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:
8
+ - deploying React + Vite and its API to Cloudflare (`--deploy cloudflare`), which has not yet run on an account with Workers Paid;
9
+ - downloads by byte range from storage, which browsers ask for to play audio and video;
10
+ - Supabase sessions that the Hono API verifies itself;
11
+ - fixes to the APIs' CORS and to SMTP sends that never answer;
12
+ - 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:
14
+ - A program (`.exe`, `.com`) now starts directly, with no shell, so `git`, `node`, `docker`, and `uv` no longer go through cmd.exe.
15
+ - 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.
18
+ - **`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
+ - **Sponsors** (#181, #182): the README has a Sponsors section. [Neon](https://neon.com) supports Clubedge Starter through its Open Source Program.
20
+ - CI runs generated projects' browser checks in Chromium, by name (#180), since the Starter now has three browser projects.
21
+ - `@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
+
5
23
  ## 0.46.1
6
24
 
7
25
  - **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
@@ -48,6 +48,8 @@ cwd = "D:/path/to/workspace"
48
48
 
49
49
  The MCP server is included in the same package and uses the CLI's bundled Starter and compatibility rules. Its create tool only writes beneath the configured working directory and refuses a non-empty destination.
50
50
 
51
+ With `install_dependencies`, `create_project` answers once `pnpm install` and each app's own install, such as `uv sync` for a FastAPI API, have finished, which can take minutes. A client that sends a progress token with the call (`_meta.progressToken`) gets a `notifications/progress` message when each install starts, every 5 seconds while it runs, and when it ends or fails, such as `pnpm install: running for 40s`. Clients that reset their request timeout on progress, as the MCP TypeScript SDK does with `resetTimeoutOnProgress`, then wait for as long as the installs take. `progress` counts the notifications sent and there is no `total`, since how far along an install is isn't known. Without a token nothing is sent, and the result is the same either way.
52
+
51
53
  ### Agent skill
52
54
 
53
55
  The npm package includes the Agent Skill at [`skills/clubedge-starter/SKILL.md`](skills/clubedge-starter/SKILL.md). Download and extract the package in PowerShell:
@@ -230,7 +232,7 @@ Options:
230
232
  -v, --version Show the CLI version
231
233
  ```
232
234
 
233
- The target directory must be empty or not exist. The CLI never deletes or overwrites existing files, and if scaffolding fails it removes whatever it wrote, so a failed run leaves nothing behind.
235
+ The target directory must be empty or not exist, and its name can't be one Windows reserves for a device, such as `CON`, `NUL`, or `COM1`, even on macOS or Linux; the same goes for any folder the CLI creates above it. The CLI never deletes or overwrites existing files, and if scaffolding fails it removes whatever it wrote, so a failed run leaves nothing behind.
234
236
 
235
237
  ```sh
236
238
  # Preview the files and edits without writing anything
@@ -243,7 +245,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
243
245
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
244
246
 
245
247
  # Reproduce a specific Starter release or commit
246
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.30.1
248
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.31.0
247
249
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
248
250
 
249
251
  # Test local Starter changes before releasing them
@@ -264,7 +266,7 @@ pnpm clubedge add mobile expo # the native app; ad
264
266
  pnpm clubedge remove mobile
265
267
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
266
268
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
267
- pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.30.1 # or a specific tag, branch, or commit
269
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.31.0 # or a specific tag, branch, or commit
268
270
  ```
269
271
 
270
272
  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.
@@ -413,27 +415,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
413
415
  Each CLI release is tested against one specific Starter release:
414
416
 
415
417
  ```text
416
- create-clubedge-app v0.46.1
418
+ create-clubedge-app v0.47.0
417
419
  |
418
420
  v
419
- Clubedge Starter v0.30.1
420
- commit 6d04531ffb2cd9f0e76b6e2b3b542f733b66aed6
421
+ Clubedge Starter v0.31.0
422
+ commit e6c47d7b8ec153443ee35abab7fc61d37f55e36a
421
423
  ```
422
424
 
423
425
  The current relationship is:
424
426
 
425
427
  | CLI release | Default Starter release | Meaning |
426
428
  | --- | --- | --- |
427
- | `create-clubedge-app@0.46.1` | `clubedge-starter@v0.30.1` | The CLI's required, tested default |
429
+ | `create-clubedge-app@0.47.0` | `clubedge-starter@v0.31.0` | The CLI's required, tested default |
428
430
 
429
431
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
430
432
 
431
- The `v0.30.1` tag is expected to resolve to commit
432
- `6d04531ffb2cd9f0e76b6e2b3b542f733b66aed6`. The CLI verifies this before
433
+ The `v0.31.0` tag is expected to resolve to commit
434
+ `e6c47d7b8ec153443ee35abab7fc61d37f55e36a`. The CLI verifies this before
433
435
  scaffolding with the default reference and fails rather than silently using a
434
436
  moved tag.
435
437
 
436
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.30.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.
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.
437
439
 
438
440
  Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
439
441
 
@@ -488,7 +490,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
488
490
  1. Make and validate changes in `clubedge-starter`.
489
491
  2. Run lint, typecheck, unit tests, browser tests, and production build.
490
492
  3. Update the Starter package versions.
491
- 4. Commit `main`, create a release tag such as `v0.30.1`, and publish the Starter release.
493
+ 4. Commit `main`, create a release tag such as `v0.31.0`, and publish the Starter release.
492
494
 
493
495
  ### CLI maintainers
494
496
 
@@ -545,6 +547,22 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
545
547
  | 0.44.0 | v0.28.0 | Starter v0.28.0: the native app with Expo (`--mobile expo`, template schema 6); projects without a website (`--framework none`), with template rules; audit exceptions per stack |
546
548
  | 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 |
547
549
  | 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
+ | 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 |
551
+
552
+ ## Sponsors
553
+
554
+ <!-- To add a sponsor: put its light and dark logos in .github/assets/sponsors/ and copy the entry below. -->
555
+
556
+ <p>
557
+ <a href="https://neon.com">
558
+ <picture>
559
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Clubedge/create-clubedge-app/main/.github/assets/sponsors/neon-dark.svg">
560
+ <img src="https://raw.githubusercontent.com/Clubedge/create-clubedge-app/main/.github/assets/sponsors/neon-light.svg" alt="Neon" height="36">
561
+ </picture>
562
+ </a>
563
+ </p>
564
+
565
+ [Neon](https://neon.com) supports Clubedge Starter through its Open Source Program.
548
566
 
549
567
  ## Community
550
568
 
@@ -1,15 +1,29 @@
1
1
  import { constants } from "node:fs";
2
2
  import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
3
- import { dirname, join, parse } from "node:path";
3
+ import { basename, dirname, join, parse } from "node:path";
4
4
  import { copyTemplateFiles, emptyDirectory, pathExists } from "../utils/fs.js";
5
+ import { isReservedDeviceName } from "../utils/names.js";
5
6
  export class TargetError extends Error {
6
7
  name = "TargetError";
7
8
  }
8
- /** Refuses filesystem roots and non-empty directories. Never deletes or overwrites anything. */
9
+ /** Refuses filesystem roots, reserved Windows device names, and non-empty directories. Never deletes or overwrites anything. */
9
10
  export async function assertTargetIsUsable(directory) {
10
11
  if (parse(directory).root === directory) {
11
12
  throw new TargetError("Choose a project subdirectory rather than a filesystem root.");
12
13
  }
14
+ // Check the project's own name and each missing parent that executePlan would create. A parent
15
+ // that already exists is left alone, such as a home folder named con on macOS.
16
+ let folder = directory;
17
+ while (true) {
18
+ const name = basename(folder);
19
+ if (isReservedDeviceName(name)) {
20
+ throw new TargetError(`"${name}" is a reserved device name on Windows. Choose another directory name.`);
21
+ }
22
+ const parent = dirname(folder);
23
+ if (parent === folder || (await pathExists(parent)))
24
+ break;
25
+ folder = parent;
26
+ }
13
27
  try {
14
28
  const entries = await readdir(directory);
15
29
  if (entries.length > 0)
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { cancel, confirm, intro, isCancel, log, outro, select, spinner, text } from "@clack/prompts";
2
2
  import { readFile } from "node:fs/promises";
3
- import { join, relative, resolve } from "node:path";
3
+ import { basename, join, relative, resolve } from "node:path";
4
4
  import process from "node:process";
5
5
  import { parseCliArgs, UsageError } from "./cli/args.js";
6
6
  import { dim, helpText, showBanner } from "./cli/ui.js";
@@ -17,7 +17,7 @@ import { describeNewDatabase, neonCommand, provisionNeonDatabase } from "./neon.
17
17
  import { initializeGit } from "./steps/git.js";
18
18
  import { detectLauncher, detectPnpm, installDependencies } from "./steps/install.js";
19
19
  import { commandSucceeds, run } from "./utils/process.js";
20
- import { displayNameFromPackageName, packageNameFromDirectory } from "./utils/names.js";
20
+ import { displayNameFromPackageName, isReservedDeviceName, packageNameFromDirectory } from "./utils/names.js";
21
21
  const minimumNode = [22, 12];
22
22
  export function assertSupportedNode(version = process.versions.node) {
23
23
  const [major = 0, minor = 0] = version.split(".").map(Number);
@@ -29,7 +29,15 @@ async function askProjectDirectory() {
29
29
  const value = await text({
30
30
  message: "Where should the new project be created?",
31
31
  placeholder: "my-app",
32
- validate: (input) => (input?.trim() ? undefined : "Enter a project directory name."),
32
+ validate: (input) => {
33
+ const trimmed = input?.trim();
34
+ if (!trimmed)
35
+ return "Enter a project directory name.";
36
+ if (isReservedDeviceName(basename(trimmed))) {
37
+ return `"${basename(trimmed)}" is a reserved device name on Windows. Choose another directory name.`;
38
+ }
39
+ return undefined;
40
+ },
33
41
  });
34
42
  if (isCancel(value)) {
35
43
  cancel("Operation cancelled.");
@@ -0,0 +1,57 @@
1
+ /**
2
+ * How often a running step reports progress. Clients that reset their request timeout on
3
+ * progress wait tens of seconds, so a few seconds between notifications keeps the call alive.
4
+ */
5
+ const intervalMs = 5_000;
6
+ /** A duration for a message: "45s", and "2m 5s" from a minute on. */
7
+ function formatElapsed(milliseconds) {
8
+ const seconds = Math.round(milliseconds / 1000);
9
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
10
+ }
11
+ /**
12
+ * Progress notifications for the long steps of a tool call, such as `pnpm install`, which can
13
+ * outlast a client's request timeout. Only a client that sent a progress token with its request
14
+ * (`_meta.progressToken`) is notified; without one the steps run and nothing is sent.
15
+ */
16
+ export function stepProgress(request) {
17
+ const token = request._meta?.progressToken;
18
+ // The protocol wants each value above the one before. How far along an install is isn't known,
19
+ // so the value counts the notifications sent, and no total is given.
20
+ let sent = 0;
21
+ const notify = async (message) => {
22
+ // A client that cancelled the call no longer knows the token.
23
+ if (token === undefined || request.signal.aborted)
24
+ return;
25
+ sent += 1;
26
+ try {
27
+ await request.notify({ method: "notifications/progress", params: { progressToken: token, progress: sent, message } });
28
+ }
29
+ catch {
30
+ // The client left or its transport closed. The step is not affected.
31
+ }
32
+ };
33
+ return {
34
+ async step(name, run) {
35
+ if (token === undefined)
36
+ return run();
37
+ const startedAt = Date.now();
38
+ const elapsed = () => formatElapsed(Date.now() - startedAt);
39
+ // Notifications are not awaited: a client that reads slowly must not hold the step up.
40
+ void notify(`${name}: started`);
41
+ const timer = setInterval(() => void notify(`${name}: running for ${elapsed()}`), intervalMs);
42
+ try {
43
+ const result = await run();
44
+ void notify(`${name}: finished in ${elapsed()}`);
45
+ return result;
46
+ }
47
+ catch (error) {
48
+ void notify(`${name}: failed after ${elapsed()}`);
49
+ throw error;
50
+ }
51
+ finally {
52
+ // Cleared however the step ends, so the timer never keeps the server running.
53
+ clearInterval(timer);
54
+ }
55
+ },
56
+ };
57
+ }
@@ -9,6 +9,7 @@ import { isOptionalSlot, readTemplateManifest } from "./core/manifest.js";
9
9
  import { neonSetupStep } from "./core/neon.js";
10
10
  import { createPlan, describePlan } from "./core/plan.js";
11
11
  import { resolveTemplateSource } from "./core/source.js";
12
+ import { stepProgress } from "./mcp-progress.js";
12
13
  import { cliVersion, cliUrl, siteUrl } from "./package-info.js";
13
14
  import { initializeGit } from "./steps/git.js";
14
15
  import { detectPnpm, installDependencies } from "./steps/install.js";
@@ -202,7 +203,7 @@ function createServer() {
202
203
  initialize_git: z.boolean().default(false).describe("Initialize a Git repository after generating files."),
203
204
  install_dependencies: z.boolean().default(false).describe("Run pnpm install and declared secondary toolchain installs after generating files."),
204
205
  }),
205
- }, async ({ directory, initialize_git, install_dependencies, ...options }) => withTemplate(async (source) => {
206
+ }, async ({ directory, initialize_git, install_dependencies, ...options }, ctx) => withTemplate(async (source) => {
206
207
  const target = await workspaceDestination(directory);
207
208
  const plan = await planProject(source, options, target, { git: initialize_git, install: install_dependencies });
208
209
  await executePlan(plan, source);
@@ -227,8 +228,11 @@ function createServer() {
227
228
  : "Project created, but pnpm was not found. Install pnpm and run it in the project directory.");
228
229
  }
229
230
  else {
231
+ // Installs can outlast a client's request timeout. A client that sent a progress token
232
+ // hears from each one while it runs, and can keep waiting.
233
+ const progress = stepProgress(ctx.mcpReq);
230
234
  try {
231
- await installDependencies(target, pnpm, [], mcpStdio);
235
+ await progress.step([...pnpm, "install"].join(" "), () => installDependencies(target, pnpm, [], mcpStdio));
232
236
  for (const { directory: appDirectory, app, toolchain } of plan.toolchains) {
233
237
  const [check, ...checkArgs] = toolchain.check;
234
238
  if (!(await commandSucceeds(check, checkArgs))) {
@@ -237,7 +241,7 @@ function createServer() {
237
241
  }
238
242
  const [command, ...args] = toolchain.install;
239
243
  try {
240
- await run(command, args, resolve(target, appDirectory), mcpStdio);
244
+ await progress.step(`${toolchain.install.join(" ")} in ${appDirectory}`, () => run(command, args, resolve(target, appDirectory), mcpStdio));
241
245
  }
242
246
  catch (error) {
243
247
  warnings.push(`${app} dependencies did not install: ${error instanceof Error ? error.message : String(error)}`);
@@ -1,4 +1,15 @@
1
1
  import { basename } from "node:path";
2
+ // Windows reserves these names (and the CONIN$/CONOUT$ console handles) for legacy devices,
3
+ // in any case and regardless of extension, even though the Win32 namespace can bypass the
4
+ // check and leave an undeletable directory behind. COM/LPT also reserve the ¹²³ superscript
5
+ // digits Windows treats as equivalent to 1, 2, 3.
6
+ const RESERVED_DEVICE_NAMES = /^(CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[0-9¹²³]|LPT[0-9¹²³])(\..*)?$/i;
7
+ export function isReservedDeviceName(name) {
8
+ const dotIndex = name.indexOf(".");
9
+ const base = dotIndex === -1 ? name : name.slice(0, dotIndex);
10
+ const extension = dotIndex === -1 ? "" : name.slice(dotIndex);
11
+ return RESERVED_DEVICE_NAMES.test(base.replace(/ +$/, "") + extension);
12
+ }
2
13
  export function packageNameFromDirectory(directory) {
3
14
  return (basename(directory)
4
15
  .toLowerCase()
@@ -1,7 +1,21 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import process from "node:process";
3
+ /** Only web URLs may reach an OS opener (which can otherwise launch local programs). */
4
+ export function assertWebUrl(url) {
5
+ // Reject URL-parser fixups (backslashes, missing //, or surrounding whitespace)
6
+ // before passing the original value to a platform-specific command.
7
+ if (!/^https?:\/\//.test(url) || /[\\\s]/.test(url)) {
8
+ throw new Error("Refusing to open a non-web URL");
9
+ }
10
+ const parsed = new URL(url);
11
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || !parsed.hostname) {
12
+ throw new Error("Refusing to open a non-web URL");
13
+ }
14
+ return parsed;
15
+ }
3
16
  /** The command that opens a URL in the default browser on this platform. */
4
17
  export function openCommand(url, platform = process.platform) {
18
+ assertWebUrl(url);
5
19
  if (platform === "win32")
6
20
  return ["rundll32", ["url.dll,FileProtocolHandler", url]];
7
21
  if (platform === "darwin")
@@ -10,7 +24,14 @@ export function openCommand(url, platform = process.platform) {
10
24
  }
11
25
  /** Opens a URL in the default browser. Best effort: returns false when nothing could open it. */
12
26
  export async function openUrl(url) {
13
- const [command, args] = openCommand(url);
27
+ let command;
28
+ let args;
29
+ try {
30
+ [command, args] = openCommand(url);
31
+ }
32
+ catch {
33
+ return false;
34
+ }
14
35
  return await new Promise((resolve) => {
15
36
  try {
16
37
  const child = spawn(command, args, { detached: true, stdio: "ignore" });
@@ -1,21 +1,105 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { statSync } from "node:fs";
2
3
  import { access } from "node:fs/promises";
3
4
  import { delimiter, isAbsolute, join, resolve } from "node:path";
4
5
  import process from "node:process";
6
+ import { fileURLToPath } from "node:url";
5
7
  // Package manager shims on Windows are .cmd files, which need a shell to start.
6
8
  const useShell = process.platform === "win32";
7
- /** An argument as one word for cmd.exe: plain words as they are, anything else in double quotes. */
8
- export function quoteForCmd(argument) {
9
- return /^[\w@+=:,./\\-]+$/.test(argument) ? argument : `"${argument.replaceAll('"', '""')}"`;
9
+ /** An environment variable by name, whatever its case: Windows keeps Path as it was written. */
10
+ function envValue(env, name) {
11
+ const key = Object.keys(env).find((candidate) => candidate.toUpperCase() === name);
12
+ return key === undefined ? undefined : env[key];
13
+ }
14
+ function isFile(path) {
15
+ try {
16
+ return statSync(path).isFile();
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ /**
23
+ * The file cmd.exe runs for a command: the path as given when it names a folder, else the first
24
+ * match in the current folder and then PATH's folders, each with PATHEXT's extensions in order
25
+ * unless the command already ends with one. Null when there is none.
26
+ */
27
+ export function resolveForCmd(command, env = process.env, cwd = process.cwd()) {
28
+ const extensions = (envValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
29
+ const named = extensions.some((extension) => command.toLowerCase().endsWith(extension.toLowerCase()));
30
+ const candidates = (base) => (named ? [base] : extensions.map((extension) => base + extension));
31
+ if (/[\\/:]/.test(command))
32
+ return candidates(resolve(cwd, command)).find(isFile) ?? null;
33
+ // A folder in PATH may be written in quotes.
34
+ const path = (envValue(env, "PATH") ?? "").split(delimiter).map((folder) => folder.replace(/^"(.*)"$/, "$1"));
35
+ const folders = [cwd, ...path.filter(Boolean)];
36
+ for (const folder of folders) {
37
+ const found = candidates(join(isAbsolute(folder) ? folder : resolve(cwd, folder), command)).find(isFile);
38
+ if (found)
39
+ return found;
40
+ }
41
+ return null;
42
+ }
43
+ /** The characters cmd.exe gives a meaning to. A caret before one makes cmd.exe read it as text. */
44
+ const cmdSpecial = /[()\][%!^"`<>&|;, *?]/g;
45
+ const caret = (text) => text.replace(cmdSpecial, "^$&");
46
+ /**
47
+ * An argument as one word of a command line that cmd.exe runs, so the program receives exactly
48
+ * the argument: plain words as they are, and anything else quoted the way Windows programs split
49
+ * their command line (CommandLineToArgvW: backslashes before a quote doubled, the quote escaped),
50
+ * then with a caret before each character cmd.exe would act on. That covers quotes, so cmd.exe
51
+ * never reads the argument as its own quoted text, and `%`, which breaks up `%VAR%`. A batch file
52
+ * such as an npm shim passes its arguments on in a line cmd.exe reads again, so for one the carets
53
+ * are added twice (`passes`). The method is https://qntm.org/cmd, as cross-spawn applies it.
54
+ *
55
+ * A line break ends the command for cmd.exe whatever surrounds it, so it's refused.
56
+ */
57
+ export function quoteForCmd(argument, passes = 1) {
58
+ if (/[\r\n]/.test(argument)) {
59
+ throw new Error(`An argument with a line break can't be passed to a command through cmd.exe: ${JSON.stringify(argument)}`);
60
+ }
61
+ if (/^[\w@+=:,./\\-]+$/.test(argument))
62
+ return argument;
63
+ let quoted = '"';
64
+ let backslashes = 0;
65
+ for (const character of argument) {
66
+ if (character === "\\") {
67
+ backslashes++;
68
+ continue;
69
+ }
70
+ quoted += character === '"' ? "\\".repeat(backslashes * 2 + 1) + '"' : "\\".repeat(backslashes) + character;
71
+ backslashes = 0;
72
+ }
73
+ quoted += "\\".repeat(backslashes * 2) + '"';
74
+ for (let pass = 0; pass < passes; pass++)
75
+ quoted = caret(quoted);
76
+ return quoted;
77
+ }
78
+ /**
79
+ * The command line for cmd.exe: the command with a caret before each special character (a path
80
+ * with spaces stays one word), then its arguments, escaped twice for a batch file.
81
+ */
82
+ export function cmdLine(command, args, batch) {
83
+ if (/[\r\n]/.test(command))
84
+ throw new Error(`A command with a line break can't run through cmd.exe: ${JSON.stringify(command)}`);
85
+ return [caret(command), ...args.map((argument) => quoteForCmd(argument, batch ? 2 : 1))].join(" ");
10
86
  }
11
87
  /**
12
- * Starts a command, through the shell on Windows. There it passes one quoted command line, not an
13
- * argument list: Node warns (DEP0190) when arguments are only concatenated for a shell.
88
+ * Starts a command. On Windows, with `shell`, it finds the file cmd.exe would run:
89
+ * - a program (.exe, .com) starts directly, with no shell, so nothing reads its arguments but it;
90
+ * - a batch file (.cmd, .bat), such as an npm shim, can only start through cmd.exe, and gets one
91
+ * command line with every argument escaped for it (Node warns, DEP0190, when arguments are only
92
+ * concatenated for a shell);
93
+ * - anything else goes to cmd.exe too, which runs it or reports that it's missing.
14
94
  */
15
95
  export function spawnCommand(command, args, options, shell = useShell) {
16
96
  if (!shell)
17
97
  return spawn(command, args, options);
18
- return spawn([command, ...args].map(quoteForCmd).join(" "), { ...options, shell: true });
98
+ const cwd = options.cwd === undefined ? process.cwd() : typeof options.cwd === "string" ? options.cwd : fileURLToPath(options.cwd);
99
+ const file = resolveForCmd(command, options.env ?? process.env, cwd);
100
+ if (file && /\.(exe|com)$/i.test(file))
101
+ return spawn(file, args, options);
102
+ return spawn(cmdLine(file ?? command, args, file !== null && /\.(cmd|bat)$/i.test(file)), { ...options, shell: true });
19
103
  }
20
104
  /** Runs a command with inherited output. Rejects on a non-zero exit. */
21
105
  export async function run(command, args, cwd, stdio = "inherit") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.46.1",
3
+ "version": "0.47.0",
4
4
  "description": "Create React apps from Clubedge Starter with a CLI or local MCP server.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.9.0",
@@ -24,8 +24,8 @@
24
24
  },
25
25
  "clubedge": {
26
26
  "starterRepository": "Clubedge/clubedge-starter",
27
- "starterRef": "v0.30.1",
28
- "starterCommit": "6d04531ffb2cd9f0e76b6e2b3b542f733b66aed6"
27
+ "starterRef": "v0.31.0",
28
+ "starterCommit": "e6c47d7b8ec153443ee35abab7fc61d37f55e36a"
29
29
  },
30
30
  "license": "Apache-2.0",
31
31
  "repository": {
@@ -57,7 +57,7 @@
57
57
  "release": "node scripts/release.mjs"
58
58
  },
59
59
  "dependencies": {
60
- "@modelcontextprotocol/server": "^2.0.0",
60
+ "@modelcontextprotocol/server": "^2.4.0",
61
61
  "@clack/prompts": "^1.8.1",
62
62
  "giget": "^3.3.1",
63
63
  "node-diff3": "^3.2.1",
@@ -68,6 +68,6 @@
68
68
  "@types/node": "^26.6.5",
69
69
  "@types/ssh2": "^1.15.6",
70
70
  "typescript": "^5.9.3",
71
- "vitest": "^5.0.5"
71
+ "vitest": "^5.1.0"
72
72
  }
73
73
  }
@@ -16,6 +16,7 @@ Create and manage Clubedge Starter projects through the supported CLI or MCP too
16
16
  - If the `clubedge-starter` MCP server is available, call `get_starter_capabilities` first. Treat its response as authoritative for framework/API choices, module options, defaults, and requirements.
17
17
  - Before scaffolding through MCP, call `plan_project` with the destination `directory` and requested `stacks` and `modules`. It validates compatibility and previews the files and setup actions without writing to disk. Resolve invalid combinations and any open choices before creating the project. Use `validate_project_config` when only a compatibility check is needed and no destination plan is useful.
18
18
  - Use `create_project` to scaffold through MCP. Give `directory` as a relative path beneath the MCP server's working directory. Set `initialize_git` and `install_dependencies` to `true` only when appropriate for the user's request; both default to `false`.
19
+ - With `install_dependencies`, `create_project` answers once `pnpm install` and any toolchain install, such as `uv sync`, have finished, which can take minutes. The server sends progress notifications while they run, so a client that asks for them keeps waiting. If the call times out anyway, the project is already on disk and the server may still be installing: do not create it again. Check the directory, and run `pnpm install` there if the dependencies are missing.
19
20
  - If MCP is unavailable, use the CLI. Prefer the installed project-local executable when present; otherwise use `pnpm dlx @clubedge/create-clubedge-app <directory> --yes` and provide the requested options as CLI flags.
20
21
  - Use the CLI's `add`, `remove`, or `upgrade` commands for changes to an existing generated project. Do not reproduce those changes by manually copying Starter files.
21
22
 
@@ -9,3 +9,9 @@ playwright-report
9
9
  .output
10
10
  .nitro
11
11
  .tanstack
12
+ # clubedge:if deploy=cloudflare
13
+ # Wrangler's working files and local secrets (deploy/cloudflare), which change between builds.
14
+ deploy/cloudflare/.wrangler
15
+ deploy/cloudflare/.dev.vars*
16
+ deploy/cloudflare/*.named.jsonc
17
+ # clubedge:end
@@ -88,13 +88,6 @@ jobs:
88
88
  done
89
89
  curl --fail --silent --show-error --output /dev/null http://127.0.0.1:3001/api/health
90
90
  # clubedge:end
91
- # clubedge:if mobile!=none
92
- # package.json's pnpm.auditConfig skips two high advisories in Expo's development CLI
93
- # (@expo/cli), which builds and serves the native app but is not part of it: node-forge's,
94
- # which has no fixed release, and zod 3's, fixed only in zod 4. pnpm skips them wherever they
95
- # appear, so remove them once Expo's CLI no longer needs them. Last checked on 2026-10-26,
96
- # with @expo/cli 58.1.15.
97
- # clubedge:end
98
91
  - run: pnpm audit --audit-level high
99
92
 
100
93
  # clubedge:if framework!=none
@@ -108,7 +101,8 @@ jobs:
108
101
  node-version: 22
109
102
  cache: pnpm
110
103
  - run: pnpm install --frozen-lockfile
111
- - run: pnpm exec playwright install --with-deps chromium
104
+ # Each engine visitors use (playwright.config.ts): Chrome's, Firefox's, and Safari's WebKit.
105
+ - run: pnpm exec playwright install --with-deps chromium firefox webkit
112
106
  - run: pnpm test:e2e
113
107
  # clubedge:end
114
108
 
@@ -21,7 +21,7 @@ pnpm format:check
21
21
  pnpm build
22
22
  ```
23
23
 
24
- Run `pnpm test:e2e` for changes that affect browser behavior. It requires a locally installed Playwright browser. CI runs the full configured verification workflow for pull requests.
24
+ Run `pnpm test:e2e` for changes that affect browser behavior. It runs in Chromium, Firefox, and WebKit, and requires them installed locally (`pnpm exec playwright install chromium firefox webkit`). CI runs the full configured verification workflow for pull requests.
25
25
 
26
26
  ## Contribution workflow
27
27