@clubedge/create-clubedge-app 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.15.0
6
+
7
+ - Add `status`: a project's Starter release, framework, modules, and Git state, whether a newer Starter release exists (from npm, still working offline), and the command to run next.
8
+ - Add `doctor`: checks Node.js and pnpm against the project's versions, installed dependencies, the lockfile, and the env file against the example, then runs `pnpm check:services` (`--no-services` skips it). It exits with an error when a check fails.
9
+ - `add` and `remove` take several modules (`add email resend payments stripe`, `remove storage payments`) and apply them as one change, checking requirements after every change.
10
+ - After a change with conflicts, list each file with its number of conflict blocks.
11
+ - `upgrade` lists the Starter releases it crosses, with the sections of their release notes.
12
+ - Fix a finished command exiting with an error when Windows briefly locked the downloaded Starter's temporary folder.
13
+ - CI: test generated projects on macOS too; the release workflow waits up to 15 minutes for npm.
14
+
5
15
  ## 0.14.1
6
16
 
7
17
  - Default to Starter `v0.13.1`. In projects with background jobs or observability, a configuration error such as a missing `DATABASE_URL` no longer stops the server at startup; pages that need no database keep working, and the error is reported once.
package/README.md CHANGED
@@ -95,8 +95,8 @@ Interactive runs ask whether to customize these after the framework question.
95
95
  ```text
96
96
  Usage:
97
97
  pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
98
- pnpm dlx @clubedge/create-clubedge-app add [module] [option] (inside a project)
99
- pnpm dlx @clubedge/create-clubedge-app remove [module] (inside a project)
98
+ pnpm dlx @clubedge/create-clubedge-app add [module option]... (inside a project)
99
+ pnpm dlx @clubedge/create-clubedge-app remove [module]... (inside a project)
100
100
  pnpm dlx @clubedge/create-clubedge-app upgrade [--ref <tag>] (inside a project)
101
101
 
102
102
  Options:
@@ -136,12 +136,14 @@ pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
136
136
 
137
137
  ## Change an existing project
138
138
 
139
- Inside a project created by the CLI, `add` and `remove` change one module at a time, and `upgrade` moves the project to a newer Starter release. Projects from Starter v0.8.0 on have a `clubedge` script, so `pnpm clubedge add cache redis` works too. Without a module or option, `add` and `remove` ask for them:
139
+ Inside a project created by the CLI, `add` and `remove` change modules, and `upgrade` moves the project to a newer Starter release. Projects from Starter v0.8.0 on have a `clubedge` script, so `pnpm clubedge add cache redis` works too. Without a module or option, `add` and `remove` ask for them:
140
140
 
141
141
  ```sh
142
142
  pnpm clubedge add # choose a module and option from a list
143
143
  pnpm dlx @clubedge/create-clubedge-app add cache redis # or switch: add auth better-auth
144
144
  pnpm dlx @clubedge/create-clubedge-app remove storage
145
+ pnpm clubedge add email resend payments stripe # several modules as one change
146
+ pnpm clubedge remove storage payments
145
147
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
146
148
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
147
149
  pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.13.1 # or a specific tag, branch, or commit
@@ -155,14 +157,27 @@ The CLI renders the project twice, from the Starter commit and modules recorded
155
157
  - `apps/web/.env.local` keeps your values. Values still at their generated defaults follow the new selection, and new variables are added.
156
158
  - `pnpm-lock.yaml` is replaced if you never changed your dependencies; otherwise `pnpm install` reconciles it.
157
159
 
160
+ Several modules in one command are applied as one change, and their requirements are checked together, so `add auth supabase storage supabase` works even though Supabase Storage needs Supabase Auth. When lines conflict, the CLI lists each file with its number of conflict blocks. Before an upgrade, it lists the Starter releases it crosses with the sections of their release notes, such as `v0.13.0 Observability, Tests, Also`.
161
+
158
162
  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.
159
163
 
164
+ ## Check a project
165
+
166
+ Two read-only commands help you keep a project healthy:
167
+
168
+ ```sh
169
+ pnpm clubedge status # Starter release, modules, Git state, and whether a newer Starter exists
170
+ pnpm clubedge doctor # Node.js, pnpm, dependencies, lockfile, env file, and pnpm check:services
171
+ ```
172
+
173
+ `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 `apps/web/.env.local` has every variable in `.env.example`, 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.
174
+
160
175
  ## Version relationship
161
176
 
162
177
  Each CLI release is tested against one specific Starter release:
163
178
 
164
179
  ```text
165
- create-clubedge-app v0.14.1
180
+ create-clubedge-app v0.15.0
166
181
  |
167
182
  v
168
183
  Clubedge Starter v0.13.1
@@ -173,7 +188,7 @@ The current relationship is:
173
188
 
174
189
  | CLI release | Default Starter release | Meaning |
175
190
  | --- | --- | --- |
176
- | `create-clubedge-app@0.14.1` | `clubedge-starter@v0.13.1` | The CLI's required, tested default |
191
+ | `create-clubedge-app@0.15.0` | `clubedge-starter@v0.13.1` | The CLI's required, tested default |
177
192
 
178
193
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
179
194
 
@@ -208,6 +223,7 @@ src/
208
223
  | --- | --- |
209
224
  | Configure a generated project | Its `SETUP.md`, also in the [Starter repository](https://github.com/Clubedge/clubedge-starter/blob/main/SETUP.md) |
210
225
  | Change modules or upgrade a project | [Change an existing project](#change-an-existing-project) |
226
+ | See where a project stands, or why it does not run | [Check a project](#check-a-project) |
211
227
  | Reproduce an exact Starter version | [Version relationship](#version-relationship) |
212
228
  | See what changed in each release | [CHANGELOG.md](CHANGELOG.md) |
213
229
  | See what is planned next | [ROADMAP.md](ROADMAP.md) |
@@ -260,7 +276,8 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
260
276
  | 0.12.0 | v0.11.0 | Payments |
261
277
  | 0.13.0 | v0.12.0 | Background jobs |
262
278
  | 0.14.0 | v0.13.0 | Observability |
263
- | next | | `status` and `doctor` commands, several modules in one change, macOS CI |
279
+ | 0.15.0 | v0.13.1 | `status` and `doctor`, several modules in one change, conflict and upgrade summaries, macOS CI |
280
+ | next | | Show the Starter's version of a conflicted file next to yours |
264
281
 
265
282
  ## Community
266
283
 
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
+ services: { type: "boolean", default: true },
15
16
  help: { type: "boolean", short: "h", default: false },
16
17
  version: { type: "boolean", short: "v", default: false },
17
18
  };
@@ -78,9 +79,9 @@ export function parseCliArgs(argv) {
78
79
  throw new UsageError("Only one project directory can be provided.");
79
80
  }
80
81
  if (command.name !== "create" && (values.framework !== undefined || Object.keys(modules).length)) {
81
- throw new UsageError("--framework and module flags only apply when creating a project; use add or remove to change one module.");
82
+ throw new UsageError("--framework and module flags only apply when creating a project; use add or remove to change modules.");
82
83
  }
83
- if ((command.name === "add" || command.name === "remove") && values.ref !== undefined) {
84
+ if (command.name !== "create" && command.name !== "upgrade" && values.ref !== undefined) {
84
85
  throw new UsageError("--ref only applies when creating or upgrading a project.");
85
86
  }
86
87
  if (values.framework !== undefined && !values.framework.trim()) {
@@ -104,25 +105,42 @@ export function parseCliArgs(argv) {
104
105
  yes: values.yes,
105
106
  dryRun: values["dry-run"],
106
107
  force: values.force,
108
+ services: values.services,
107
109
  help: values.help,
108
110
  version: values.version,
109
111
  };
110
112
  }
113
+ function assertDistinct(modules) {
114
+ const repeated = modules.find((module, index) => modules.indexOf(module) !== index);
115
+ if (repeated)
116
+ throw new UsageError(`The module "${repeated}" is named more than once.`);
117
+ }
111
118
  /**
112
- * `add <module> <option>`, `remove <module>`, and `upgrade` change an existing project; anything
113
- * else is the directory of a new project. Create a project literally named "add" with `./add`.
119
+ * `add <module> <option>...`, `remove <module>...`, and `upgrade` change an existing project, and `status`
120
+ * and `doctor` inspect one; anything else is the directory of a new project. Create a project
121
+ * literally named "add" with `./add`.
114
122
  */
115
123
  function parseCommand(positionals) {
116
- const [name, module, option, ...extra] = positionals;
124
+ const [name, ...rest] = positionals;
125
+ const module = rest[0];
117
126
  if (name === "add") {
118
- if (extra.length)
119
- throw new UsageError("Usage: add [module] [option], for example: add cache redis");
120
- return { name, module, option };
127
+ if (rest.length > 1 && rest.length % 2 === 1) {
128
+ throw new UsageError("Usage: add [module option]..., for example: add cache redis, or add email resend payments stripe");
129
+ }
130
+ const changes = rest.length === 1
131
+ ? [{ module: rest[0] }]
132
+ : Array.from({ length: rest.length / 2 }, (_, i) => ({ module: rest[2 * i], option: rest[2 * i + 1] }));
133
+ assertDistinct(changes.map((change) => change.module));
134
+ return { name, changes };
121
135
  }
122
136
  if (name === "remove") {
123
- if (option !== undefined)
124
- throw new UsageError("Usage: remove [module], for example: remove storage");
125
- return { name, module };
137
+ assertDistinct(rest);
138
+ return { name, modules: rest };
139
+ }
140
+ if (name === "status" || name === "doctor") {
141
+ if (module !== undefined)
142
+ throw new UsageError(`Usage: ${name}, run inside a project.`);
143
+ return { name };
126
144
  }
127
145
  if (name === "upgrade") {
128
146
  if (module !== undefined)
package/dist/cli/ui.js CHANGED
@@ -86,17 +86,24 @@ This CLI release scaffolds Starter ${starterPin.starterRef} (commit ${starterPin
86
86
 
87
87
  Usage:
88
88
  pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
89
- pnpm dlx @clubedge/create-clubedge-app add [module] [option] (inside a project)
90
- pnpm dlx @clubedge/create-clubedge-app remove [module] (inside a project)
89
+ pnpm dlx @clubedge/create-clubedge-app add [module option]... (inside a project)
90
+ pnpm dlx @clubedge/create-clubedge-app remove [module]... (inside a project)
91
91
  pnpm dlx @clubedge/create-clubedge-app upgrade [--ref <tag>] (inside a project)
92
+ pnpm dlx @clubedge/create-clubedge-app status (inside a project)
93
+ pnpm dlx @clubedge/create-clubedge-app doctor (inside a project)
92
94
 
93
- Inside a project with the clubedge script, use pnpm clubedge add, remove, or
94
- upgrade. Without a module or option, add and remove ask for them.
95
+ Inside a project with the clubedge script, use pnpm clubedge add, remove,
96
+ upgrade, status, or doctor. Without a module or option, add and remove ask for them.
95
97
 
96
- add and remove change one module of an existing project, and upgrade moves it
97
- to a newer Starter (this CLI's release unless --ref is given). The change is
98
- merged into your code; lines you changed that it also changes get conflict
99
- markers. Commit first so you can review the result with git diff.
98
+ add and remove change modules of an existing project, several at once as one
99
+ change, and upgrade moves it to a newer Starter (this CLI's release unless --ref
100
+ is given) and lists the Starter releases it crosses. The change is merged into
101
+ your code; lines you changed that it also changes get conflict markers. Commit
102
+ first so you can review the result with git diff.
103
+
104
+ status shows the project's Starter release, modules, and Git state, and whether a
105
+ newer Starter is available. doctor checks Node.js, pnpm, the dependencies, the
106
+ lockfile, and the env file, then runs pnpm check:services; it changes nothing.
100
107
 
101
108
  Options:
102
109
  ${selectionHelp(manifest)}
@@ -105,6 +112,7 @@ ${selectionHelp(manifest)}
105
112
  --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
106
113
  --dry-run Show what would be created or changed without writing anything
107
114
  --force add/remove: change a project with uncommitted changes or without Git
115
+ --no-services doctor: skip pnpm check:services
108
116
  -y, --yes Accept defaults and never prompt (default directory: my-app)
109
117
  --no-install Skip dependency installation
110
118
  --no-git Skip Git repository initialization
@@ -119,8 +127,11 @@ Examples:
119
127
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
120
128
  pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
121
129
  pnpm dlx @clubedge/create-clubedge-app add auth better-auth
130
+ pnpm dlx @clubedge/create-clubedge-app add email resend payments stripe
122
131
  pnpm dlx @clubedge/create-clubedge-app remove storage --dry-run
123
132
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade
133
+ pnpm clubedge status
134
+ pnpm clubedge doctor --no-services
124
135
 
125
136
  Learn more:
126
137
  CLI ${cliUrl}
package/dist/core/env.js CHANGED
@@ -4,6 +4,10 @@
4
4
  const LINE = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;
5
5
  const COMMENT = /^\s*#/;
6
6
  const keyOf = (line) => LINE.exec(line)?.[1];
7
+ /** The variable names an env file sets, in order. */
8
+ export function envKeys(text) {
9
+ return text.split(/\r?\n/).flatMap((line) => keyOf(line) ?? []);
10
+ }
7
11
  const valueOf = (line) => LINE.exec(line)[2].replace(/\s+#.*$/, "").trim();
8
12
  const find = (lines, key) => lines.findIndex((line) => keyOf(line) === key);
9
13
  function values(lines) {
@@ -0,0 +1,161 @@
1
+ // Pure checks behind `status` and `doctor`: they take what the commands read from the project,
2
+ // the system, and npm, and return what to show, so each can be tested without a real project.
3
+ import { envKeys } from "./env.js";
4
+ /** Compares dotted versions such as "v0.13.1" and "0.12.0"; null when either is not one. */
5
+ export function compareVersions(a, b) {
6
+ const parse = (value) => {
7
+ const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(value.trim());
8
+ return match ? match.slice(1).map(Number) : null;
9
+ };
10
+ const left = parse(a);
11
+ const right = parse(b);
12
+ if (!left || !right)
13
+ return null;
14
+ for (let i = 0; i < 3; i++) {
15
+ if (left[i] !== right[i])
16
+ return left[i] - right[i];
17
+ }
18
+ return 0;
19
+ }
20
+ const REGISTRY_URL = "https://registry.npmjs.org/@clubedge%2fcreate-clubedge-app/latest";
21
+ /**
22
+ * The newest published CLI and the Starter release it is pinned to, from npm. Resolves null when
23
+ * npm cannot be reached in time, so status still works offline.
24
+ */
25
+ export async function fetchLatestRelease(fetcher = fetch, timeoutMs = 4_000) {
26
+ try {
27
+ const response = await fetcher(REGISTRY_URL, {
28
+ headers: { accept: "application/json" },
29
+ signal: AbortSignal.timeout(timeoutMs),
30
+ });
31
+ if (!response.ok)
32
+ return null;
33
+ const latest = (await response.json());
34
+ if (typeof latest.version !== "string" || typeof latest.clubedge?.starterRef !== "string")
35
+ return null;
36
+ return { cliVersion: latest.version, starterRef: latest.clubedge.starterRef };
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ export function updateState(projectRef, latest) {
43
+ if (!latest)
44
+ return { kind: "unknown" };
45
+ const difference = compareVersions(latest.starterRef, projectRef);
46
+ if (difference === null)
47
+ return { kind: "unknown" };
48
+ return difference > 0 ? { kind: "available", ...latest } : { kind: "current" };
49
+ }
50
+ /** The command that runs the newest CLI in this project. */
51
+ export function latestCliCommand(cli) {
52
+ return cli === "pnpm clubedge" ? cli : "pnpm dlx @clubedge/create-clubedge-app@latest";
53
+ }
54
+ /** What to do next, given the working tree and whether a newer Starter exists. */
55
+ export function nextStep(git, update, cli) {
56
+ if (update.kind === "available") {
57
+ const upgrade = `${latestCliCommand(cli)} upgrade`;
58
+ return git.kind === "dirty"
59
+ ? `Commit or stash your changes, then run \`${upgrade}\` to move to Starter ${update.starterRef}.`
60
+ : `Run \`${upgrade}\` to move to Starter ${update.starterRef}.`;
61
+ }
62
+ if (update.kind === "current") {
63
+ return `The project is on the newest Starter release. Run \`${cli} doctor\` to check its setup.`;
64
+ }
65
+ return `Could not check for a newer Starter release. Run \`${cli} doctor\` to check the project's setup.`;
66
+ }
67
+ /** The lowest version a range such as ">=22.12.0" or "^22.12.0" accepts. */
68
+ function minimumOf(range) {
69
+ const match = /(\d+\.\d+\.\d+)/.exec(range ?? "");
70
+ return match ? match[1] : null;
71
+ }
72
+ export function checkNode(actual, engines) {
73
+ const minimum = minimumOf(engines) ?? "22.12.0";
74
+ const difference = compareVersions(actual, minimum);
75
+ return difference !== null && difference >= 0
76
+ ? { name: "Node.js", status: "ok", detail: `${actual} (needs ${minimum} or newer)` }
77
+ : {
78
+ name: "Node.js",
79
+ status: "fail",
80
+ detail: `${actual}, but the project needs ${minimum} or newer`,
81
+ fix: `Install Node.js ${minimum} or newer, such as the current LTS release.`,
82
+ };
83
+ }
84
+ /** Compares pnpm with the version in package.json's packageManager, such as "pnpm@10.9.0". */
85
+ export function checkPnpm(actual, packageManager) {
86
+ const pinned = /^pnpm@(\d+\.\d+\.\d+)/.exec(packageManager ?? "")?.[1];
87
+ if (!actual) {
88
+ return {
89
+ name: "pnpm",
90
+ status: "fail",
91
+ detail: "not found",
92
+ fix: "Run `corepack enable`, which installs the pnpm version the project pins.",
93
+ };
94
+ }
95
+ if (!pinned)
96
+ return { name: "pnpm", status: "ok", detail: actual };
97
+ const difference = compareVersions(actual, pinned);
98
+ return difference !== null && difference >= 0
99
+ ? { name: "pnpm", status: "ok", detail: `${actual} (project pins ${pinned})` }
100
+ : {
101
+ name: "pnpm",
102
+ status: "warn",
103
+ detail: `${actual}, older than the ${pinned} the project pins`,
104
+ fix: "Run `corepack enable` so pnpm follows the project's packageManager field.",
105
+ };
106
+ }
107
+ /**
108
+ * Compares the local env file with the example: variables the example has and the local file
109
+ * lacks, and variables only the local file sets.
110
+ */
111
+ export function checkEnvFile(example, local, paths) {
112
+ const name = "Environment file";
113
+ if (local === null) {
114
+ return {
115
+ name,
116
+ status: "fail",
117
+ detail: `${paths.local} does not exist`,
118
+ fix: `Copy ${paths.example} to ${paths.local} and fill in the values.`,
119
+ };
120
+ }
121
+ if (example === null)
122
+ return { name, status: "ok", detail: `${paths.local} exists (no ${paths.example} to compare)` };
123
+ const exampleKeys = new Set(envKeys(example));
124
+ const localKeys = new Set(envKeys(local));
125
+ const missing = [...exampleKeys].filter((key) => !localKeys.has(key));
126
+ const unused = [...localKeys].filter((key) => !exampleKeys.has(key));
127
+ if (!missing.length && !unused.length) {
128
+ return { name, status: "ok", detail: `${paths.local} has every variable in ${paths.example}` };
129
+ }
130
+ const parts = [
131
+ missing.length ? `missing ${missing.join(", ")}` : "",
132
+ unused.length ? `not in ${paths.example}: ${unused.join(", ")}` : "",
133
+ ].filter(Boolean);
134
+ return {
135
+ name,
136
+ status: "warn",
137
+ detail: `${paths.local}: ${parts.join("; ")}`,
138
+ fix: missing.length
139
+ ? `Add the missing variables from ${paths.example}; empty values are fine for services you do not use yet.`
140
+ : "Remove variables the project no longer uses, unless your own code reads them.",
141
+ };
142
+ }
143
+ /** Reads the result of `pnpm install --frozen-lockfile --lockfile-only`. */
144
+ export function checkLockfile(error) {
145
+ const name = "Lockfile";
146
+ if (error === null)
147
+ return { name, status: "ok", detail: "pnpm-lock.yaml matches every package.json" };
148
+ if (error.includes("ERR_PNPM_OUTDATED_LOCKFILE")) {
149
+ return {
150
+ name,
151
+ status: "fail",
152
+ detail: "pnpm-lock.yaml is out of date with a package.json",
153
+ fix: "Run `pnpm install` to update it, then commit pnpm-lock.yaml.",
154
+ };
155
+ }
156
+ return {
157
+ name,
158
+ status: "warn",
159
+ detail: `could not check: ${error.split("\n").find((line) => line.trim()) ?? error}`,
160
+ };
161
+ }
@@ -3,6 +3,14 @@
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
+ /** How many conflict blocks a merged file has. */
7
+ export function countConflicts(content) {
8
+ const marker = `<<<<<<< ${CONFLICT_LABELS.yours}`;
9
+ return content
10
+ .toString("utf8")
11
+ .split(/\r?\n/)
12
+ .filter((line) => line === marker).length;
13
+ }
6
14
  function isBinary(content) {
7
15
  return content.subarray(0, 8000).includes(0);
8
16
  }
@@ -0,0 +1,59 @@
1
+ // The Starter releases an upgrade crosses, from GitHub, so `upgrade` can say what changed before
2
+ // the developer reviews `git diff`.
3
+ import { compareVersions } from "./health.js";
4
+ /**
5
+ * The repository's published releases, newest first. Resolves null when GitHub cannot be reached
6
+ * in time or refuses, so an upgrade never depends on it. GITHUB_TOKEN raises the rate limit.
7
+ */
8
+ export async function fetchReleases(repository, fetcher = fetch, timeoutMs = 5_000) {
9
+ try {
10
+ const token = process.env.GITHUB_TOKEN;
11
+ const response = await fetcher(`https://api.github.com/repos/${repository}/releases?per_page=100`, {
12
+ headers: {
13
+ accept: "application/vnd.github+json",
14
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
15
+ },
16
+ signal: AbortSignal.timeout(timeoutMs),
17
+ });
18
+ if (!response.ok)
19
+ return null;
20
+ const releases = (await response.json());
21
+ if (!Array.isArray(releases))
22
+ return null;
23
+ return releases
24
+ .filter((release) => typeof release.tag_name === "string" && !release.draft && !release.prerelease)
25
+ .map((release) => ({
26
+ tag: release.tag_name,
27
+ name: typeof release.name === "string" && release.name ? release.name : release.tag_name,
28
+ body: typeof release.body === "string" ? release.body : "",
29
+ }));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ /** Releases after `from`, up to and including `to`, oldest first. */
36
+ export function releasesBetween(releases, from, to) {
37
+ return releases
38
+ .filter((release) => {
39
+ const afterFrom = compareVersions(release.tag, from);
40
+ const upToTo = compareVersions(release.tag, to);
41
+ return afterFrom !== null && upToTo !== null && afterFrom > 0 && upToTo <= 0;
42
+ })
43
+ .sort((a, b) => compareVersions(a.tag, b.tag));
44
+ }
45
+ /** The second-level headings of release notes, such as "Background jobs" and "Tests". */
46
+ export function releaseHeadings(body) {
47
+ return body
48
+ .split(/\r?\n/)
49
+ .flatMap((line) => /^##\s+(.+?)\s*#*\s*$/.exec(line)?.[1] ?? []);
50
+ }
51
+ /** One line per release: its tag and what its notes cover. */
52
+ export function describeReleases(releases) {
53
+ return releases
54
+ .map((release) => {
55
+ const headings = releaseHeadings(release.body);
56
+ return `${release.tag} ${headings.length ? headings.join(", ") : release.name}`;
57
+ })
58
+ .join("\n");
59
+ }
@@ -83,7 +83,9 @@ export async function downloadGitHubTemplate(ref, { repository = starterPin.star
83
83
  }
84
84
  }
85
85
  const temporary = await mkdtemp(join(tmpdir(), "create-clubedge-app-"));
86
- const cleanup = () => rm(temporary, { recursive: true, force: true });
86
+ // Windows can briefly lock a folder that was just read (antivirus, indexing). Retry, and never
87
+ // fail a command that already did its work over a leftover folder in the system temp directory.
88
+ const cleanup = () => rm(temporary, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }).catch(() => undefined);
87
89
  try {
88
90
  const root = join(temporary, "starter");
89
91
  await downloadTemplate(`gh:${repository}#${ref}`, { dir: root, force: true });
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { readTemplateManifest } from "./core/manifest.js";
9
9
  import { createPlan, describePlan, selectFramework } from "./core/plan.js";
10
10
  import { readBundledTemplate, resolveTemplateSource } from "./core/source.js";
11
11
  import { cliUrl, cliVersion, siteUrl, starterPin } from "./package-info.js";
12
+ import { runDoctor, showStatus } from "./inspect.js";
12
13
  import { changeProject } from "./modify.js";
13
14
  import { initializeGit } from "./steps/git.js";
14
15
  import { detectLauncher, installDependencies, resolvePnpm } from "./steps/install.js";
@@ -225,6 +226,14 @@ export async function main(argv = process.argv.slice(2)) {
225
226
  intro("Create a project with Clubedge Starter");
226
227
  await createProject(args);
227
228
  }
229
+ else if (args.command.name === "status") {
230
+ intro("Project status");
231
+ await showStatus(args);
232
+ }
233
+ else if (args.command.name === "doctor") {
234
+ intro("Check this project's setup");
235
+ await runDoctor(args);
236
+ }
228
237
  else {
229
238
  const intros = {
230
239
  add: "Add a module to this project",
@@ -0,0 +1,125 @@
1
+ // `status` and `doctor` look at an existing project without changing it: status reports where
2
+ // it stands and what to run next, and doctor checks that it can run.
3
+ import { log, outro } from "@clack/prompts";
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import process from "node:process";
7
+ import { dim } from "./cli/ui.js";
8
+ import { checkEnvFile, checkLockfile, checkNode, checkPnpm, fetchLatestRelease, nextStep, updateState, } from "./core/health.js";
9
+ import { readProject } from "./core/project.js";
10
+ import { cliCommand } from "./modify.js";
11
+ import { resolvePnpm } from "./steps/install.js";
12
+ import { pathExists } from "./utils/fs.js";
13
+ import { commandSucceeds, run, runCapture } from "./utils/process.js";
14
+ /** Where generated projects keep their env files. */
15
+ const ENV_PATHS = { example: ".env.example", local: "apps/web/.env.local" };
16
+ async function readText(path) {
17
+ try {
18
+ return await readFile(path, "utf8");
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export async function gitState(root) {
25
+ if (!(await commandSucceeds("git", ["-C", root, "rev-parse", "--is-inside-work-tree"])))
26
+ return { kind: "none" };
27
+ const status = await runCapture("git", ["-C", root, "status", "--porcelain"]);
28
+ const files = status.split("\n").filter(Boolean).length;
29
+ return files ? { kind: "dirty", files } : { kind: "clean" };
30
+ }
31
+ const describeGit = (git) => git.kind === "none"
32
+ ? "not a Git repository"
33
+ : git.kind === "clean"
34
+ ? "clean"
35
+ : `${git.files} uncommitted ${git.files === 1 ? "change" : "changes"}`;
36
+ export async function showStatus(_args) {
37
+ const project = await readProject(process.cwd());
38
+ const [git, latest, cli] = await Promise.all([gitState(project.root), fetchLatestRelease(), cliCommand(project)]);
39
+ const update = updateState(project.starterRef, latest);
40
+ const commit = project.starterCommit ? ` ${dim(`(commit ${project.starterCommit.slice(0, 7)})`)}` : "";
41
+ const modules = Object.entries(project.modules)
42
+ .map(([id, option]) => `${id}: ${option}`)
43
+ .join(", ");
44
+ const updates = update.kind === "available"
45
+ ? `Starter ${update.starterRef} is available (create-clubedge-app ${update.cliVersion})`
46
+ : update.kind === "current"
47
+ ? "on the newest Starter release"
48
+ : "could not check";
49
+ const rows = [
50
+ ["Project", project.identity.packageName],
51
+ ["Starter", `${project.starterRef}${commit}, created with create-clubedge-app ${project.cliVersion}`],
52
+ ["Framework", project.framework],
53
+ ["Modules", modules],
54
+ ["Git", describeGit(git)],
55
+ ["Updates", updates],
56
+ ];
57
+ log.message(rows.map(([label, value]) => `${label.padEnd(10)} ${value}`).join("\n"));
58
+ outro(nextStep(git, update, cli));
59
+ }
60
+ /** Runs `pnpm install --frozen-lockfile --lockfile-only` offline; resolves its error or null. */
61
+ async function lockfileError(root, pnpm) {
62
+ const [command, ...prefix] = pnpm;
63
+ try {
64
+ await runCapture(command, [...prefix, "install", "--frozen-lockfile", "--lockfile-only", "--offline"], root);
65
+ return null;
66
+ }
67
+ catch (error) {
68
+ return error instanceof Error ? error.message : String(error);
69
+ }
70
+ }
71
+ function print(result) {
72
+ const line = `${result.name}: ${result.detail}${result.fix ? `\n${dim(result.fix)}` : ""}`;
73
+ if (result.status === "ok")
74
+ log.success(line);
75
+ else if (result.status === "warn")
76
+ log.warn(line);
77
+ else
78
+ log.error(line);
79
+ }
80
+ export async function runDoctor(args) {
81
+ const project = await readProject(process.cwd());
82
+ const root = project.root;
83
+ const manifest = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
84
+ const results = [];
85
+ const check = (result) => {
86
+ results.push(result);
87
+ print(result);
88
+ };
89
+ check(checkNode(process.versions.node, manifest.engines?.node));
90
+ const pnpm = await resolvePnpm();
91
+ const pnpmVersion = pnpm ? await runCapture(pnpm[0], [...pnpm.slice(1), "--version"]).catch(() => null) : null;
92
+ check(checkPnpm(pnpmVersion, manifest.packageManager));
93
+ const installed = await pathExists(join(root, "node_modules", ".modules.yaml"));
94
+ check(installed
95
+ ? { name: "Dependencies", status: "ok", detail: "installed" }
96
+ : { name: "Dependencies", status: "fail", detail: "not installed", fix: "Run `pnpm install`." });
97
+ if (pnpm)
98
+ check(checkLockfile(await lockfileError(root, pnpm)));
99
+ check(checkEnvFile(await readText(join(root, ENV_PATHS.example)), await readText(join(root, ENV_PATHS.local)), ENV_PATHS));
100
+ // Service checks need the dependencies, and print their own results.
101
+ if (args.services && manifest.scripts?.["check:services"] && pnpm && installed) {
102
+ log.step("Checking the configured services with `pnpm check:services`");
103
+ try {
104
+ await run(pnpm[0], [...pnpm.slice(1), "check:services"], root);
105
+ results.push({ name: "Services", status: "ok", detail: "every configured service answered" });
106
+ }
107
+ catch {
108
+ check({
109
+ name: "Services",
110
+ status: "fail",
111
+ detail: "a configured service did not answer",
112
+ fix: "See the output above; SETUP.md explains each service.",
113
+ });
114
+ }
115
+ }
116
+ const failed = results.filter((result) => result.status === "fail").length;
117
+ const warned = results.filter((result) => result.status === "warn").length;
118
+ if (failed)
119
+ process.exitCode = 1;
120
+ outro(failed
121
+ ? `${failed} ${failed === 1 ? "check" : "checks"} failed${warned ? ` and ${warned} need attention` : ""}.`
122
+ : warned
123
+ ? `No failures; ${warned} ${warned === 1 ? "check needs" : "checks need"} attention.`
124
+ : "Every check passed.");
125
+ }
package/dist/modify.js CHANGED
@@ -8,6 +8,9 @@ import process from "node:process";
8
8
  import { UsageError } from "./cli/args.js";
9
9
  import { dim } from "./cli/ui.js";
10
10
  import { readTemplateManifest } from "./core/manifest.js";
11
+ import { compareVersions } from "./core/health.js";
12
+ import { countConflicts } from "./core/merge.js";
13
+ import { describeReleases, fetchReleases, releasesBetween } from "./core/releases.js";
11
14
  import { selectModules } from "./core/plan.js";
12
15
  import { readProject } from "./core/project.js";
13
16
  import { downloadGitHubTemplate, readDirectoryTemplate, resolveTemplateSource, } from "./core/source.js";
@@ -16,7 +19,7 @@ import { cliVersion, starterPin } from "./package-info.js";
16
19
  import { installDependencies, resolvePnpm } from "./steps/install.js";
17
20
  import { commandSucceeds, runCapture } from "./utils/process.js";
18
21
  /** How to run the CLI in this project: its `clubedge` script when it has one. */
19
- async function cliCommand(project) {
22
+ export async function cliCommand(project) {
20
23
  try {
21
24
  const manifest = JSON.parse(await readFile(join(project.root, "package.json"), "utf8"));
22
25
  if (manifest.scripts?.clubedge)
@@ -86,17 +89,17 @@ function available(manifest, project, moduleId, optionId) {
86
89
  * terminal, the missing arguments are an error. Resolves null when a prompt is cancelled.
87
90
  */
88
91
  export async function completeCommand(manifest, project, command, interactive) {
89
- if (command.name === "add" && command.module && command.option) {
90
- return { name: "add", module: command.module, option: command.option };
92
+ if (command.name === "add" && command.changes.length && command.changes.every((change) => change.option)) {
93
+ return { name: "add", changes: command.changes.map(({ module, option }) => ({ module, option: option })) };
91
94
  }
92
- if (command.name === "remove" && command.module)
93
- return { name: "remove", module: command.module };
95
+ if (command.name === "remove" && command.modules.length)
96
+ return { name: "remove", modules: command.modules };
94
97
  if (!interactive) {
95
98
  throw new UsageError(command.name === "add"
96
99
  ? "Usage: add <module> <option>, for example: add cache redis. Run it in a terminal to choose from a list."
97
100
  : "Usage: remove <module>, for example: remove storage. Run it in a terminal to choose from a list.");
98
101
  }
99
- let moduleId = command.module;
102
+ let moduleId = command.name === "add" ? command.changes[0]?.module : undefined;
100
103
  if (!moduleId) {
101
104
  const choices = Object.entries(manifest.modules).filter(([id, module]) => command.name === "add" ? true : Boolean(module.options.none) && project.modules[id] !== "none");
102
105
  if (!choices.length)
@@ -114,7 +117,7 @@ export async function completeCommand(manifest, project, command, interactive) {
114
117
  moduleId = value;
115
118
  }
116
119
  if (command.name === "remove")
117
- return { name: "remove", module: moduleId };
120
+ return { name: "remove", modules: [moduleId] };
118
121
  const module = manifest.modules[moduleId];
119
122
  if (!module) {
120
123
  throw new UsageError(`Unknown module "${moduleId}". Choose one of: ${Object.keys(manifest.modules).join(", ")}.`);
@@ -132,30 +135,32 @@ export async function completeCommand(manifest, project, command, interactive) {
132
135
  });
133
136
  if (isCancel(option))
134
137
  return null;
135
- return { name: "add", module: moduleId, option };
138
+ return { name: "add", changes: [{ module: moduleId, option }] };
136
139
  }
137
- /** The selection after add or remove, validated against the Starter's manifest. */
138
- function changedModules(manifest, project, command, cli) {
139
- const module = manifest.modules[command.module];
140
- if (!module) {
141
- throw new UsageError(`Unknown module "${command.module}". Choose one of: ${Object.keys(manifest.modules).join(", ")}.`);
142
- }
143
- let option;
144
- if (command.name === "add") {
145
- option = command.option;
146
- if (!module.options[option]) {
147
- throw new UsageError(`Unknown ${command.module} option "${option}". Choose one of: ${Object.keys(module.options).join(", ")}.`);
140
+ /**
141
+ * The selection after add or remove, validated against the Starter's manifest. Every change
142
+ * applies before requirements are checked, so changes that only work together, such as Supabase
143
+ * Auth with Supabase Storage, can be made in one command.
144
+ */
145
+ export function changedModules(manifest, project, command, cli) {
146
+ const changes = command.name === "add" ? command.changes : command.modules.map((module) => ({ module, option: "none" }));
147
+ const requested = { ...project.modules };
148
+ for (const { module: id, option } of changes) {
149
+ const module = manifest.modules[id];
150
+ if (!module) {
151
+ throw new UsageError(`Unknown module "${id}". Choose one of: ${Object.keys(manifest.modules).join(", ")}.`);
148
152
  }
149
- }
150
- else {
151
- if (!module.options.none) {
152
- const others = Object.keys(module.options).filter((id) => id !== project.modules[command.module]);
153
- throw new UsageError(`${module.name} cannot be removed. Switch it instead, for example: ${cli} add ${command.module} ${others[0] ?? module.default}`);
153
+ if (command.name === "remove" && !module.options.none) {
154
+ const others = Object.keys(module.options).filter((other) => other !== project.modules[id]);
155
+ throw new UsageError(`${module.name} cannot be removed. Switch it instead, for example: ${cli} add ${id} ${others[0] ?? module.default}`);
156
+ }
157
+ if (!module.options[option]) {
158
+ throw new UsageError(`Unknown ${id} option "${option}". Choose one of: ${Object.keys(module.options).join(", ")}.`);
154
159
  }
155
- option = "none";
160
+ requested[id] = option;
156
161
  }
157
162
  // Validates requirements between modules, such as Supabase Storage needing Supabase Auth.
158
- return selectModules(manifest, { ...project.modules, [command.module]: option });
163
+ return selectModules(manifest, requested);
159
164
  }
160
165
  /**
161
166
  * The project's selection in a newer Starter. Modules the project predates get their "none"
@@ -209,7 +214,13 @@ function report(plan) {
209
214
  const conflicts = plan.actions.filter((action) => action.kind === "conflict");
210
215
  const kept = plan.actions.filter((action) => action.kind === "keep");
211
216
  if (conflicts.length) {
212
- log.warn(`Resolve the conflict markers (<<<<<<< yours / >>>>>>> clubedge) in:\n${conflicts.map((action) => ` ${action.path}`).join("\n")}`);
217
+ const blocks = conflicts.map((action) => countConflicts(action.content));
218
+ const total = blocks.reduce((sum, count) => sum + count, 0);
219
+ log.warn(`Resolve ${total} ${total === 1 ? "conflict" : "conflicts"} in ${conflicts.length} ${conflicts.length === 1 ? "file" : "files"}. ` +
220
+ "Each is marked <<<<<<< yours ... ======= ... >>>>>>> clubedge: keep your lines, the Starter's, or a mix, then delete the markers.\n" +
221
+ conflicts
222
+ .map((action, i) => ` ${action.path} ${dim(`(${blocks[i]} ${blocks[i] === 1 ? "block" : "blocks"})`)}`)
223
+ .join("\n"));
213
224
  }
214
225
  if (kept.length) {
215
226
  log.info(`Check these files by hand:\n${kept.map((action) => ` ${action.path}: ${action.reason}`).join("\n")}`);
@@ -259,16 +270,24 @@ async function planChange(project, command, base, baseManifest, target, cli) {
259
270
  if (/^v\d/.test(target.ref)) {
260
271
  notes.push(`Release notes: https://github.com/${target.repository}/releases/tag/${target.ref}`);
261
272
  }
273
+ let releases;
274
+ if (compareVersions(project.starterRef, target.ref) !== null) {
275
+ const published = await fetchReleases(target.repository);
276
+ const crossed = published ? releasesBetween(published, project.starterRef, target.ref) : [];
277
+ if (crossed.length)
278
+ releases = describeReleases(crossed);
279
+ }
262
280
  return {
263
281
  base: baseSide,
264
282
  target: { source: target, manifest: targetManifest, modules, cliVersion },
265
283
  summary: `Starter ${project.starterRef} -> ${target.ref}`,
266
284
  notes,
285
+ releases,
267
286
  };
268
287
  }
269
288
  export async function changeProject(args) {
270
289
  const command = args.command;
271
- if (command.name === "create")
290
+ if (command.name !== "add" && command.name !== "remove" && command.name !== "upgrade")
272
291
  throw new Error("Not a project change.");
273
292
  const interactive = Boolean(process.stdin.isTTY) && !args.yes;
274
293
  const project = await readProject(process.cwd());
@@ -300,6 +319,8 @@ export async function changeProject(args) {
300
319
  return;
301
320
  }
302
321
  const plan = await planUpdate(project, change.base, change.target);
322
+ if (change.releases)
323
+ log.info(`Starter releases in this upgrade:\n${change.releases}`);
303
324
  log.message(`${change.summary}\n\n${describe(plan) || "No file changes."}`);
304
325
  if (args.dryRun) {
305
326
  for (const note of change.notes)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Create a Next.js or TanStack Start application from the Clubedge Starter reference implementation.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.9.0",