@clubedge/create-clubedge-app 0.7.0 → 0.9.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,20 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.9.0
6
+
7
+ - Default to Starter `v0.8.0`, whose projects have a `clubedge` script: `pnpm clubedge add`, `remove`, and `upgrade`.
8
+ - `add` and `remove` ask for the module and option when they are not given, offering only options the project can use. Without a terminal, missing arguments remain an error.
9
+ - Messages suggest `pnpm clubedge ...` in projects with the `clubedge` script, and the outro after creating a project mentions it.
10
+ - Test the CLI on Windows in CI: its checks and tests, and generated projects with a module change.
11
+
12
+ ## 0.8.0
13
+
14
+ - Add `add <module> <option>` and `remove <module>` to change the modules of an existing project. The CLI renders the project's recorded Starter commit with the current and the new selection and merges the difference into the project. Files you never changed are updated, your edits are kept, and lines both sides changed get conflict markers. The local env file keeps your values: only generated values follow the new selection, and new variables are added. A Git repository without uncommitted changes is required unless `--force` is passed, so the result can be reviewed and undone.
15
+ - Add `upgrade [--ref <ref>]`, which moves an existing project to a newer Starter (this CLI's release by default) with the same merge as `add` and `remove`, and records the new revision. Modules the project predates start as `none` where the Starter offers it, and options the new Starter no longer offers are refused before anything changes.
16
+ - Record the commit of a Starter fetched with `--ref`, so projects created from a branch or tag can later be changed and upgraded.
17
+ - Fix creating a project directly under a Windows drive root, such as `D:\my-app`, which failed with EPERM.
18
+
5
19
  ## 0.7.0
6
20
 
7
21
  - Default to Starter `v0.7.0`, which adds `--infra local`: PostgreSQL through PGlite and a local folder for files, so `pnpm dev` runs with nothing installed.
package/README.md CHANGED
@@ -12,27 +12,27 @@ The CLI downloads a tested Starter release, names the generated project, creates
12
12
  Each CLI release is tested against one specific Starter release:
13
13
 
14
14
  ```text
15
- create-clubedge-app v0.7.0
15
+ create-clubedge-app v0.9.0
16
16
  |
17
17
  v
18
- Clubedge Starter v0.7.0
19
- commit 3b8114760b8151c2538b57c06620ff2b884f2d56
18
+ Clubedge Starter v0.8.0
19
+ commit 0b77577a3fe77617c84e250d8b220169b72a297e
20
20
  ```
21
21
 
22
22
  The current relationship is:
23
23
 
24
24
  | CLI release | Default Starter release | Meaning |
25
25
  | --- | --- | --- |
26
- | `create-clubedge-app@0.7.0` | `clubedge-starter@v0.7.0` | The CLI's required, tested default |
26
+ | `create-clubedge-app@0.9.0` | `clubedge-starter@v0.8.0` | The CLI's required, tested default |
27
27
 
28
28
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
29
29
 
30
- The `v0.7.0` tag is expected to resolve to commit
31
- `3b8114760b8151c2538b57c06620ff2b884f2d56`. The CLI verifies this before
30
+ The `v0.8.0` tag is expected to resolve to commit
31
+ `0b77577a3fe77617c84e250d8b220169b72a297e`. The CLI verifies this before
32
32
  scaffolding with the default reference and fails rather than silently using a
33
33
  moved tag.
34
34
 
35
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.7.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.
35
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.8.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.
36
36
 
37
37
  ## Requirements
38
38
 
@@ -136,13 +136,36 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
136
136
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
137
137
 
138
138
  # Reproduce a specific Starter release or commit
139
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.7.0
139
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.8.0
140
140
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
141
141
 
142
142
  # Test local Starter changes before releasing them
143
143
  pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
144
144
  ```
145
145
 
146
+ ## Change an existing project
147
+
148
+ 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:
149
+
150
+ ```sh
151
+ pnpm clubedge add # choose a module and option from a list
152
+ pnpm dlx @clubedge/create-clubedge-app add cache redis # or switch: add auth better-auth
153
+ pnpm dlx @clubedge/create-clubedge-app remove storage
154
+ pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
155
+ pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
156
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.8.0 # or a specific tag, branch, or commit
157
+ ```
158
+
159
+ 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.
160
+
161
+ - Files you never changed are updated, added, or deleted.
162
+ - Files you changed keep your edits. Where the module change touches the same lines, the file gets conflict markers (`<<<<<<< yours` ... `>>>>>>> clubedge`) for you to resolve.
163
+ - Your own files are never touched.
164
+ - `apps/web/.env.local` keeps your values. Values still at their generated defaults follow the new selection, and new variables are added.
165
+ - `pnpm-lock.yaml` is replaced if you never changed your dependencies; otherwise `pnpm install` reconciles it.
166
+
167
+ 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.
168
+
146
169
  ## How it works
147
170
 
148
171
  Each CLI release bundles one Starter release. When the release is built, `scripts/bundle-template.mjs` clones the pinned Starter tag, verifies that it resolves to the pinned commit, and copies its tracked files into the package (`template/` and `template.lock.json`). Running the CLI then needs no GitHub access for the default Starter.
@@ -181,7 +204,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
181
204
  1. Make and validate changes in `clubedge-starter`.
182
205
  2. Run lint, typecheck, unit tests, browser tests, and production build.
183
206
  3. Update the Starter package versions.
184
- 4. Commit `main`, create a release tag such as `v0.7.0`, and publish the Starter release.
207
+ 4. Commit `main`, create a release tag such as `v0.8.0`, and publish the Starter release.
185
208
 
186
209
  ### CLI maintainers
187
210
 
package/dist/cli/args.js CHANGED
@@ -24,6 +24,7 @@ export function parseCliArgs(argv) {
24
24
  git: { type: "boolean", default: true },
25
25
  yes: { type: "boolean", short: "y", default: false },
26
26
  "dry-run": { type: "boolean", default: false },
27
+ force: { type: "boolean", default: false },
27
28
  help: { type: "boolean", short: "h", default: false },
28
29
  version: { type: "boolean", short: "v", default: false },
29
30
  },
@@ -34,8 +35,16 @@ export function parseCliArgs(argv) {
34
35
  throw new UsageError(`${message.split("\n")[0]} Run with --help to see available options.`);
35
36
  }
36
37
  const { values, positionals } = parsed;
37
- if (positionals.length > 1)
38
+ const command = parseCommand(positionals);
39
+ if (command.name === "create" && positionals.length > 1) {
38
40
  throw new UsageError("Only one project directory can be provided.");
41
+ }
42
+ if (command.name !== "create" && (values.framework !== undefined || MODULE_FLAGS.some((id) => values[id]))) {
43
+ throw new UsageError("--framework and module flags only apply when creating a project; use add or remove to change one module.");
44
+ }
45
+ if ((command.name === "add" || command.name === "remove") && values.ref !== undefined) {
46
+ throw new UsageError("--ref only applies when creating or upgrading a project.");
47
+ }
39
48
  if (values.framework !== undefined && !values.framework.trim()) {
40
49
  throw new UsageError("--framework requires a framework id, such as next or tanstack-start.");
41
50
  }
@@ -55,7 +64,8 @@ export function parseCliArgs(argv) {
55
64
  throw new UsageError("Use either --ref or --template-dir, not both.");
56
65
  }
57
66
  return {
58
- projectDirectory: positionals[0],
67
+ command,
68
+ projectDirectory: command.name === "create" ? positionals[0] : undefined,
59
69
  framework: values.framework?.trim(),
60
70
  modules,
61
71
  ref: values.ref,
@@ -64,7 +74,31 @@ export function parseCliArgs(argv) {
64
74
  git: values.git,
65
75
  yes: values.yes,
66
76
  dryRun: values["dry-run"],
77
+ force: values.force,
67
78
  help: values.help,
68
79
  version: values.version,
69
80
  };
70
81
  }
82
+ /**
83
+ * `add <module> <option>`, `remove <module>`, and `upgrade` change an existing project; anything
84
+ * else is the directory of a new project. Create a project literally named "add" with `./add`.
85
+ */
86
+ function parseCommand(positionals) {
87
+ const [name, module, option, ...extra] = positionals;
88
+ if (name === "add") {
89
+ if (extra.length)
90
+ throw new UsageError("Usage: add [module] [option], for example: add cache redis");
91
+ return { name, module, option };
92
+ }
93
+ if (name === "remove") {
94
+ if (option !== undefined)
95
+ throw new UsageError("Usage: remove [module], for example: remove storage");
96
+ return { name, module };
97
+ }
98
+ if (name === "upgrade") {
99
+ if (module !== undefined)
100
+ throw new UsageError("Usage: upgrade [--ref <tag>], for example: upgrade --ref v0.8.0");
101
+ return { name };
102
+ }
103
+ return { name: "create" };
104
+ }
package/dist/cli/ui.js CHANGED
@@ -61,6 +61,17 @@ This CLI release scaffolds Starter ${starterPin.starterRef} (commit ${starterPin
61
61
 
62
62
  Usage:
63
63
  pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
64
+ pnpm dlx @clubedge/create-clubedge-app add [module] [option] (inside a project)
65
+ pnpm dlx @clubedge/create-clubedge-app remove [module] (inside a project)
66
+ pnpm dlx @clubedge/create-clubedge-app upgrade [--ref <tag>] (inside a project)
67
+
68
+ Inside a project with the clubedge script, use pnpm clubedge add, remove, or
69
+ upgrade. Without a module or option, add and remove ask for them.
70
+
71
+ add and remove change one module of an existing project, and upgrade moves it
72
+ to a newer Starter (this CLI's release unless --ref is given). The change is
73
+ merged into your code; lines you changed that it also changes get conflict
74
+ markers. Commit first so you can review the result with git diff.
64
75
 
65
76
  Options:
66
77
  --framework <id> App framework: next (default) or tanstack-start
@@ -69,8 +80,10 @@ Options:
69
80
  --cache <id> Cache and rate limits: redis (default) or memory
70
81
  --infra <id> Local services: docker (default), supabase, local, or none
71
82
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
83
+ (upgrade: the Starter to upgrade to)
72
84
  --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
73
- --dry-run Show what would be created without writing anything
85
+ --dry-run Show what would be created or changed without writing anything
86
+ --force add/remove: change a project with uncommitted changes or without Git
74
87
  -y, --yes Accept defaults and never prompt (default directory: my-app)
75
88
  --no-install Skip dependency installation
76
89
  --no-git Skip Git repository initialization
@@ -84,6 +97,9 @@ Examples:
84
97
  pnpm dlx @clubedge/create-clubedge-app my-app --dry-run
85
98
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
86
99
  pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
100
+ pnpm dlx @clubedge/create-clubedge-app add auth better-auth
101
+ pnpm dlx @clubedge/create-clubedge-app remove storage --dry-run
102
+ pnpm dlx @clubedge/create-clubedge-app@latest upgrade
87
103
 
88
104
  Learn more:
89
105
  CLI ${cliUrl}
@@ -0,0 +1,97 @@
1
+ // The local env file holds the developer's secrets, so commands treat it line by line, the way
2
+ // the three-way merge treats files: values still at their generated defaults follow the new
3
+ // selection, and values the developer set are never changed or removed.
4
+ const LINE = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;
5
+ const COMMENT = /^\s*#/;
6
+ const keyOf = (line) => LINE.exec(line)?.[1];
7
+ const valueOf = (line) => LINE.exec(line)[2].replace(/\s+#.*$/, "").trim();
8
+ const find = (lines, key) => lines.findIndex((line) => keyOf(line) === key);
9
+ function values(lines) {
10
+ const map = new Map();
11
+ for (const line of lines) {
12
+ const key = keyOf(line);
13
+ if (key)
14
+ map.set(key, valueOf(line));
15
+ }
16
+ return map;
17
+ }
18
+ /** The comment lines directly above line `at`. */
19
+ function commentsAbove(lines, at) {
20
+ let start = at;
21
+ while (start > 0 && COMMENT.test(lines[start - 1]))
22
+ start--;
23
+ return lines.slice(start, at);
24
+ }
25
+ const sameLines = (a, b) => a.length === b.length && a.every((line, i) => line === b[i]);
26
+ /**
27
+ * Updates the local env file from the base example to the target example. Each variable's
28
+ * comment lines move with it unless the developer edited them, and a new variable is added after
29
+ * the variable it follows in the target's example, or at the end.
30
+ */
31
+ export function updateEnvFile(local, baseExample, targetExample) {
32
+ const base = baseExample.split(/\r?\n/);
33
+ const target = targetExample.split(/\r?\n/);
34
+ const baseValues = values(base);
35
+ const targetValues = values(target);
36
+ const lines = local.split(/\r?\n/);
37
+ const mine = values(lines);
38
+ const eol = local.includes("\r\n") ? "\r\n" : "\n";
39
+ const result = { text: local, added: [], updated: [], removed: [], customized: [], unused: [] };
40
+ /** Replaces the variable's line, and its comments if the developer kept the base's. */
41
+ function replace(key, replacement) {
42
+ const at = find(lines, key);
43
+ const comments = commentsAbove(lines, at);
44
+ const generated = sameLines(comments, commentsAbove(base, find(base, key)));
45
+ const start = generated ? at - comments.length : at;
46
+ lines.splice(start, at - start + 1, ...(generated ? replacement : replacement.slice(-1)));
47
+ }
48
+ for (const [key, before] of baseValues) {
49
+ if (!mine.has(key))
50
+ continue;
51
+ const after = targetValues.get(key);
52
+ if (after === before || mine.get(key) === after)
53
+ continue;
54
+ const generated = mine.get(key) === before;
55
+ if (after === undefined) {
56
+ (generated ? result.removed : result.unused).push(key);
57
+ if (generated)
58
+ replace(key, []);
59
+ }
60
+ else {
61
+ (generated ? result.updated : result.customized).push(key);
62
+ if (generated) {
63
+ const at = find(target, key);
64
+ replace(key, [...commentsAbove(target, at), target[at]]);
65
+ }
66
+ }
67
+ }
68
+ const appended = [];
69
+ target.forEach((line, at) => {
70
+ const key = keyOf(line);
71
+ if (!key || baseValues.has(key) || mine.has(key) || result.added.includes(key))
72
+ return;
73
+ result.added.push(key);
74
+ // The nearest variable above it in the target, and the blank or comment lines in between.
75
+ let anchor = at - 1;
76
+ while (anchor >= 0 && !keyOf(target[anchor]))
77
+ anchor--;
78
+ const position = anchor >= 0 ? find(lines, keyOf(target[anchor])) : -1;
79
+ if (position === -1)
80
+ appended.push(...commentsAbove(target, at), line);
81
+ else
82
+ lines.splice(position + 1, 0, ...target.slice(anchor + 1, at + 1));
83
+ });
84
+ let text = lines.join(eol);
85
+ // Removed variables can leave blank lines in a row, as removed template blocks do.
86
+ if (result.removed.length) {
87
+ text = text.replace(new RegExp(`(?:${eol}){3,}`, "g"), eol + eol);
88
+ if (local.endsWith(eol))
89
+ text = text.replace(new RegExp(`(?:${eol})+$`), eol);
90
+ }
91
+ if (appended.length) {
92
+ const separator = text === "" || text.endsWith(eol) ? "" : eol;
93
+ text += `${separator}${eol}${appended.join(eol)}${eol}`;
94
+ }
95
+ result.text = text;
96
+ return result;
97
+ }
@@ -1,7 +1,7 @@
1
1
  import { constants } from "node:fs";
2
2
  import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { dirname, join, parse } from "node:path";
4
- import { copyTemplateFiles, emptyDirectory } from "../utils/fs.js";
4
+ import { copyTemplateFiles, emptyDirectory, pathExists } from "../utils/fs.js";
5
5
  export class TargetError extends Error {
6
6
  name = "TargetError";
7
7
  }
@@ -30,7 +30,9 @@ export async function assertTargetIsUsable(directory) {
30
30
  export async function executePlan(plan, source) {
31
31
  const target = plan.targetDirectory;
32
32
  await assertTargetIsUsable(target);
33
- await mkdir(dirname(target), { recursive: true });
33
+ // Only create a missing parent: on Windows, mkdir on an existing drive root (D:\) fails.
34
+ if (!(await pathExists(dirname(target))))
35
+ await mkdir(dirname(target), { recursive: true });
34
36
  let created = false;
35
37
  try {
36
38
  await mkdir(target);
@@ -0,0 +1,96 @@
1
+ // Three-way merge between what the CLI generated (base), what it would generate now (target),
2
+ // and the project as it is (current). Changes from base to target are applied to the project
3
+ // without losing the project's own edits. Pure: callers read and write the files.
4
+ import { diff3Merge } from "node-diff3";
5
+ export const CONFLICT_LABELS = { yours: "yours", theirs: "clubedge" };
6
+ function isBinary(content) {
7
+ return content.subarray(0, 8000).includes(0);
8
+ }
9
+ function normalize(content) {
10
+ return content.toString("utf8").replace(/\r\n/g, "\n");
11
+ }
12
+ function usesCrlf(content) {
13
+ return content.includes("\r\n");
14
+ }
15
+ /** The text in the project's line endings: a project checked out with CRLF keeps CRLF. */
16
+ function withLineEndings(text, like) {
17
+ return Buffer.from(like && usesCrlf(like) ? text.replace(/\n/g, "\r\n") : text);
18
+ }
19
+ /** Equal content, ignoring CRLF versus LF line endings in text files. */
20
+ export function sameContent(a, b) {
21
+ if (isBinary(a) || isBinary(b))
22
+ return a.equals(b);
23
+ return normalize(a) === normalize(b);
24
+ }
25
+ /** Merges base -> target into current. Resolves null for binary files, which cannot be merged. */
26
+ export function mergeText(current, base, target) {
27
+ if (isBinary(current) || isBinary(base) || isBinary(target))
28
+ return null;
29
+ const split = (content) => normalize(content).split("\n");
30
+ const regions = diff3Merge(split(current), split(base), split(target), { excludeFalseConflicts: true });
31
+ const lines = [];
32
+ let conflict = false;
33
+ for (const region of regions) {
34
+ if (region.ok) {
35
+ lines.push(...region.ok);
36
+ continue;
37
+ }
38
+ if (!region.conflict)
39
+ continue;
40
+ conflict = true;
41
+ lines.push(`<<<<<<< ${CONFLICT_LABELS.yours}`, ...region.conflict.a, "=======", ...region.conflict.b, `>>>>>>> ${CONFLICT_LABELS.theirs}`);
42
+ }
43
+ return { text: lines.join("\n"), conflict };
44
+ }
45
+ function merged(path, current, base, target, binaryReason) {
46
+ const result = mergeText(current, base, target);
47
+ if (!result)
48
+ return { path, kind: "keep", reason: binaryReason };
49
+ const content = withLineEndings(result.text, current);
50
+ return { path, kind: result.conflict ? "conflict" : "merge", content };
51
+ }
52
+ /**
53
+ * Decides what happens to every file the base or the target contains. Files only the project
54
+ * has are never touched. Actions come back sorted by path.
55
+ */
56
+ export function planMerge(base, target, current) {
57
+ const paths = [...new Set([...base.keys(), ...target.keys()])].sort();
58
+ const actions = [];
59
+ for (const path of paths) {
60
+ const before = base.get(path);
61
+ const after = target.get(path);
62
+ const mine = current.get(path);
63
+ if (before && after && sameContent(before, after))
64
+ continue;
65
+ if (!before && after) {
66
+ if (!mine)
67
+ actions.push({ path, kind: "create", content: after });
68
+ else if (!sameContent(mine, after)) {
69
+ actions.push(merged(path, mine, Buffer.alloc(0), after, "Your file differs from the one the new selection adds."));
70
+ }
71
+ continue;
72
+ }
73
+ if (before && !after) {
74
+ if (!mine)
75
+ continue;
76
+ if (sameContent(mine, before))
77
+ actions.push({ path, kind: "delete" });
78
+ else
79
+ actions.push({ path, kind: "keep", reason: "You changed this file, and the new selection no longer includes it. Delete it if nothing uses it." });
80
+ continue;
81
+ }
82
+ if (before && after) {
83
+ if (!mine) {
84
+ actions.push({ path, kind: "keep", reason: "You deleted this file, and the new selection changes it. Restore it if you need the change." });
85
+ }
86
+ else if (sameContent(mine, before)) {
87
+ const content = isBinary(after) ? after : withLineEndings(normalize(after), mine);
88
+ actions.push({ path, kind: "update", content });
89
+ }
90
+ else if (!sameContent(mine, after)) {
91
+ actions.push(merged(path, mine, before, after, "You changed this file, and the new selection changes it too."));
92
+ }
93
+ }
94
+ }
95
+ return actions;
96
+ }
@@ -0,0 +1,68 @@
1
+ // Reads an existing generated project and renders projects in memory, so commands that change
2
+ // a project (add, remove, upgrade) can compare what the CLI generated with what it would now.
3
+ import { readFile } from "node:fs/promises";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { pathExists } from "../utils/fs.js";
6
+ import { displayNameFromPackageName } from "../utils/names.js";
7
+ import { createPlan } from "./plan.js";
8
+ export class ProjectError extends Error {
9
+ name = "ProjectError";
10
+ }
11
+ /** Finds the generated project that contains `directory` and reads its provenance. */
12
+ export async function readProject(directory) {
13
+ let current = resolve(directory);
14
+ while (true) {
15
+ const manifestPath = join(current, "package.json");
16
+ if (await pathExists(manifestPath)) {
17
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
18
+ const origin = manifest.clubedge;
19
+ if (origin?.starterRef) {
20
+ if (!origin.framework || !origin.modules) {
21
+ throw new ProjectError("This project was created before create-clubedge-app 0.5.0 and does not record its modules, so it cannot be changed automatically.");
22
+ }
23
+ return {
24
+ root: current,
25
+ identity: { packageName: manifest.name, displayName: displayNameFromPackageName(manifest.name) },
26
+ cliVersion: origin.cliVersion,
27
+ starterRepository: origin.starterRepository,
28
+ starterRef: origin.starterRef,
29
+ starterCommit: origin.starterCommit,
30
+ framework: origin.framework,
31
+ modules: origin.modules,
32
+ };
33
+ }
34
+ }
35
+ const parent = dirname(current);
36
+ if (parent === current) {
37
+ throw new ProjectError("No Clubedge project found here. Run this command inside a project created by create-clubedge-app.");
38
+ }
39
+ current = parent;
40
+ }
41
+ }
42
+ /** The files the CLI generates for this selection, without writing anything. */
43
+ export async function renderProject(input) {
44
+ const plan = createPlan({ ...input, targetDirectory: "", git: false, install: false });
45
+ const files = new Map();
46
+ for (const { from, to } of plan.files)
47
+ files.set(to, await readFile(join(input.source.root, from)));
48
+ for (const edit of plan.edits) {
49
+ const content = files.get(edit.path);
50
+ if (content)
51
+ files.set(edit.path, Buffer.from(edit.apply(content.toString("utf8"))));
52
+ }
53
+ return { files, envFile: plan.envFile };
54
+ }
55
+ /** Reads the project's copies of the given paths; missing files are left out. */
56
+ export async function readProjectFiles(root, paths) {
57
+ const files = new Map();
58
+ for (const path of paths) {
59
+ try {
60
+ files.set(path, await readFile(join(root, path)));
61
+ }
62
+ catch (error) {
63
+ if (error.code !== "ENOENT")
64
+ throw error;
65
+ }
66
+ }
67
+ return files;
68
+ }
@@ -63,6 +63,18 @@ export function pickTagCommit(output, tag) {
63
63
  .map(([id, ref]) => [ref, id]));
64
64
  return refs.get(`refs/tags/${tag}^{}`) ?? refs.get(`refs/tags/${tag}`);
65
65
  }
66
+ /** Resolves a tag, branch, or full commit SHA to a commit with `git ls-remote`. */
67
+ export async function resolveRefCommit(repository, ref) {
68
+ if (/^[0-9a-f]{40}$/i.test(ref))
69
+ return ref.toLowerCase();
70
+ const output = await runCapture("git", ["ls-remote", `https://github.com/${repository}.git`]);
71
+ return pickTagCommit(output, ref) ?? pickBranchCommit(output, ref);
72
+ }
73
+ /** Picks a branch's commit from `git ls-remote` output. */
74
+ export function pickBranchCommit(output, branch) {
75
+ const line = output.split("\n").find((entry) => entry.trim().split(/\s+/)[1] === `refs/heads/${branch}`);
76
+ return line?.trim().split(/\s+/)[0];
77
+ }
66
78
  export async function downloadGitHubTemplate(ref, { repository = starterPin.starterRepository, expectedCommit } = {}) {
67
79
  if (expectedCommit) {
68
80
  const commit = await resolveTagCommit(repository, ref);
@@ -82,7 +94,8 @@ export async function downloadGitHubTemplate(ref, { repository = starterPin.star
82
94
  files: files.map((file) => ({ from: file, to: file })),
83
95
  repository,
84
96
  ref,
85
- commit: expectedCommit,
97
+ // Recorded in the project's provenance so later add, remove, and upgrade find this revision.
98
+ commit: expectedCommit ?? (await resolveRefCommit(repository, ref).catch(() => undefined)),
86
99
  cleanup,
87
100
  };
88
101
  }
@@ -0,0 +1,84 @@
1
+ // Changes an existing project from one generated state (base) to another (target): different
2
+ // modules for add and remove, or a newer Starter for upgrade. The project's own edits survive
3
+ // through a three-way merge, see merge.ts.
4
+ import { mkdir, readdir, rm, rmdir, writeFile } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+ import { updateEnvFile } from "./env.js";
7
+ import { planMerge, sameContent } from "./merge.js";
8
+ import { readProjectFiles, renderProject } from "./project.js";
9
+ const LOCKFILE = "pnpm-lock.yaml";
10
+ export async function planUpdate(project, base, target) {
11
+ const render = (side) => renderProject({
12
+ source: side.source,
13
+ manifest: side.manifest,
14
+ identity: project.identity,
15
+ framework: project.framework,
16
+ modules: side.modules,
17
+ cliVersion: side.cliVersion,
18
+ });
19
+ const before = await render(base);
20
+ const after = await render(target);
21
+ // The lockfile and the local env file are handled on their own below.
22
+ const envFile = after.envFile ?? before.envFile;
23
+ const special = [LOCKFILE, ...(envFile ? [envFile.to] : [])];
24
+ const strip = (files) => new Map([...files].filter(([path]) => !special.includes(path)));
25
+ const baseFiles = strip(before.files);
26
+ const targetFiles = strip(after.files);
27
+ const current = await readProjectFiles(project.root, [
28
+ ...new Set([...baseFiles.keys(), ...targetFiles.keys(), ...special]),
29
+ ]);
30
+ const actions = planMerge(baseFiles, targetFiles, current);
31
+ let lockfile = { kind: "unchanged" };
32
+ const baseLock = before.files.get(LOCKFILE);
33
+ const targetLock = after.files.get(LOCKFILE);
34
+ const currentLock = current.get(LOCKFILE);
35
+ if (targetLock && !(baseLock && sameContent(baseLock, targetLock))) {
36
+ lockfile =
37
+ baseLock && currentLock && sameContent(currentLock, baseLock)
38
+ ? { kind: "replace", content: targetLock }
39
+ : { kind: "install" };
40
+ }
41
+ let env = null;
42
+ if (envFile) {
43
+ const text = (files) => files.get(envFile.from)?.toString("utf8") ?? "";
44
+ const baseExample = text(before.files);
45
+ const targetExample = text(after.files);
46
+ const local = current.get(envFile.to)?.toString("utf8");
47
+ const update = local === undefined ? null : updateEnvFile(local, baseExample, targetExample);
48
+ env = { path: envFile.to, update, changed: update !== null && update.text !== local };
49
+ }
50
+ return { project, actions, lockfile, env };
51
+ }
52
+ /** Removes empty folders left behind by deleted files, up to the project root. */
53
+ async function removeEmptyFolders(root, path) {
54
+ let folder = dirname(join(root, path));
55
+ while (folder.length > root.length) {
56
+ try {
57
+ if ((await readdir(folder)).length > 0)
58
+ return;
59
+ await rmdir(folder);
60
+ }
61
+ catch {
62
+ return;
63
+ }
64
+ folder = dirname(folder);
65
+ }
66
+ }
67
+ export async function applyUpdate(plan) {
68
+ const { root } = plan.project;
69
+ for (const action of plan.actions) {
70
+ const path = join(root, action.path);
71
+ if (action.kind === "delete") {
72
+ await rm(path, { force: true });
73
+ await removeEmptyFolders(root, action.path);
74
+ }
75
+ else if (action.kind !== "keep") {
76
+ await mkdir(dirname(path), { recursive: true });
77
+ await writeFile(path, action.content);
78
+ }
79
+ }
80
+ if (plan.lockfile.kind === "replace")
81
+ await writeFile(join(root, LOCKFILE), plan.lockfile.content);
82
+ if (plan.env?.update && plan.env.changed)
83
+ await writeFile(join(root, plan.env.path), plan.env.update.text);
84
+ }
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { cancel, confirm, intro, isCancel, log, outro, select, spinner, text } from "@clack/prompts";
2
- import { relative, resolve } from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join, relative, resolve } from "node:path";
3
4
  import process from "node:process";
4
5
  import { parseCliArgs, UsageError } from "./cli/args.js";
5
6
  import { dim, helpText, showBanner } from "./cli/ui.js";
@@ -8,6 +9,7 @@ import { readTemplateManifest } from "./core/manifest.js";
8
9
  import { createPlan, describePlan, selectFramework } from "./core/plan.js";
9
10
  import { resolveTemplateSource } from "./core/source.js";
10
11
  import { cliUrl, cliVersion, siteUrl } from "./package-info.js";
12
+ import { changeProject } from "./modify.js";
11
13
  import { initializeGit } from "./steps/git.js";
12
14
  import { detectLauncher, installDependencies, resolvePnpm } from "./steps/install.js";
13
15
  import { displayNameFromPackageName, packageNameFromDirectory } from "./utils/names.js";
@@ -182,8 +184,13 @@ async function createProject(args) {
182
184
  }
183
185
  }
184
186
  const directory = relative(process.cwd(), targetDirectory) || ".";
187
+ const scripts = JSON.parse(await readFile(join(targetDirectory, "package.json"), "utf8")).scripts ?? {};
188
+ const later = scripts.clubedge
189
+ ? `Change modules later with \`pnpm clubedge add\` or \`pnpm clubedge remove\`, and get Starter updates with \`pnpm clubedge upgrade\`.\n\n`
190
+ : "";
185
191
  outro(`Next steps:\n cd ${directory}\n ${pnpmCommand} dev\n\n` +
186
192
  `See SETUP.md for database and provider configuration.\n\n` +
193
+ later +
187
194
  `${dim("Docs")} ${cliUrl}\n${dim("Clubedge")} ${siteUrl}`);
188
195
  }
189
196
  export async function main(argv = process.argv.slice(2)) {
@@ -199,8 +206,19 @@ export async function main(argv = process.argv.slice(2)) {
199
206
  return;
200
207
  }
201
208
  assertSupportedNode();
202
- intro("Create a project with Clubedge Starter");
203
- await createProject(args);
209
+ if (args.command.name === "create") {
210
+ intro("Create a project with Clubedge Starter");
211
+ await createProject(args);
212
+ }
213
+ else {
214
+ const intros = {
215
+ add: "Add a module to this project",
216
+ remove: "Remove a module from this project",
217
+ upgrade: "Upgrade this project to a newer Starter",
218
+ };
219
+ intro(intros[args.command.name]);
220
+ await changeProject(args);
221
+ }
204
222
  }
205
223
  catch (error) {
206
224
  console.error(`\n${error instanceof Error ? error.message : String(error)}`);
package/dist/modify.js ADDED
@@ -0,0 +1,340 @@
1
+ // `add`, `remove`, and `upgrade` change an existing project. The project's recorded Starter
2
+ // commit and modules (base) and the new state (target: other modules, or a newer Starter) are
3
+ // rendered in memory, and the difference is merged into the project (see core/update.ts).
4
+ import { confirm, isCancel, log, outro, select, spinner } from "@clack/prompts";
5
+ import { readFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import process from "node:process";
8
+ import { UsageError } from "./cli/args.js";
9
+ import { dim } from "./cli/ui.js";
10
+ import { readTemplateManifest } from "./core/manifest.js";
11
+ import { selectModules } from "./core/plan.js";
12
+ import { readProject } from "./core/project.js";
13
+ import { downloadGitHubTemplate, readDirectoryTemplate, resolveTemplateSource, } from "./core/source.js";
14
+ import { applyUpdate, planUpdate } from "./core/update.js";
15
+ import { cliVersion, starterPin } from "./package-info.js";
16
+ import { installDependencies, resolvePnpm } from "./steps/install.js";
17
+ import { commandSucceeds, runCapture } from "./utils/process.js";
18
+ /** How to run the CLI in this project: its `clubedge` script when it has one. */
19
+ async function cliCommand(project) {
20
+ try {
21
+ const manifest = JSON.parse(await readFile(join(project.root, "package.json"), "utf8"));
22
+ if (manifest.scripts?.clubedge)
23
+ return "pnpm clubedge";
24
+ }
25
+ catch {
26
+ // Fall back to the full command below.
27
+ }
28
+ return "pnpm dlx @clubedge/create-clubedge-app";
29
+ }
30
+ /** The Starter revision the project was generated from. */
31
+ async function projectSource(project, templateDir) {
32
+ if (templateDir)
33
+ return readDirectoryTemplate(templateDir);
34
+ if (!project.starterCommit) {
35
+ throw new UsageError(`This project records no Starter commit (Starter "${project.starterRef}"), so the CLI cannot fetch the revision it came from. Pass --template-dir with that Starter checkout.`);
36
+ }
37
+ if (project.starterCommit === starterPin.starterCommit)
38
+ return resolveTemplateSource({});
39
+ return downloadGitHubTemplate(project.starterCommit, { repository: project.starterRepository });
40
+ }
41
+ /** The Starter to upgrade to: --template-dir, --ref, or the release this CLI is pinned to. */
42
+ async function upgradeSource(args) {
43
+ if (args.templateDir)
44
+ return readDirectoryTemplate(args.templateDir);
45
+ return resolveTemplateSource({ ref: args.ref });
46
+ }
47
+ /**
48
+ * Renders the project's own revision with the provenance it recorded, so the generated
49
+ * package.json and README match the project even when the revision was fetched by commit.
50
+ */
51
+ function asRecorded(source, project) {
52
+ return { ...source, ref: project.starterRef, commit: project.starterCommit };
53
+ }
54
+ /**
55
+ * Refuses to change a project whose changes Git cannot show and undo, unless --force: a Git
56
+ * repository must have no uncommitted changes, and a folder without Git needs confirmation.
57
+ */
58
+ async function assertSafeToChange(project, args, interactive) {
59
+ if (args.force)
60
+ return;
61
+ const isRepository = await commandSucceeds("git", ["-C", project.root, "rev-parse", "--is-inside-work-tree"]);
62
+ if (isRepository) {
63
+ const status = await runCapture("git", ["-C", project.root, "status", "--porcelain"]);
64
+ if (status) {
65
+ throw new UsageError("The project has uncommitted changes. Commit or stash them first, so you can review this change with `git diff` and undo it if needed, or pass --force.");
66
+ }
67
+ return;
68
+ }
69
+ if (!interactive) {
70
+ throw new UsageError("The project is not a Git repository, so this change could not be undone. Pass --force to change it anyway.");
71
+ }
72
+ const proceed = await confirm({
73
+ message: "The project is not a Git repository, so this change cannot be undone. Continue?",
74
+ initialValue: false,
75
+ });
76
+ if (isCancel(proceed) || !proceed)
77
+ throw new UsageError("Cancelled; nothing was changed.");
78
+ }
79
+ /** Whether an option's requirements hold for the project's other modules. */
80
+ function available(manifest, project, moduleId, optionId) {
81
+ const option = manifest.modules[moduleId].options[optionId];
82
+ return Object.entries(option.requires).every(([other, allowed]) => allowed.includes(project.modules[other] ?? ""));
83
+ }
84
+ /**
85
+ * Asks for whatever add or remove was not given: the module, then the option. Without a
86
+ * terminal, the missing arguments are an error. Resolves null when a prompt is cancelled.
87
+ */
88
+ 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 };
91
+ }
92
+ if (command.name === "remove" && command.module)
93
+ return { name: "remove", module: command.module };
94
+ if (!interactive) {
95
+ throw new UsageError(command.name === "add"
96
+ ? "Usage: add <module> <option>, for example: add cache redis. Run it in a terminal to choose from a list."
97
+ : "Usage: remove <module>, for example: remove storage. Run it in a terminal to choose from a list.");
98
+ }
99
+ let moduleId = command.module;
100
+ if (!moduleId) {
101
+ const choices = Object.entries(manifest.modules).filter(([id, module]) => command.name === "add" ? true : Boolean(module.options.none) && project.modules[id] !== "none");
102
+ if (!choices.length)
103
+ throw new UsageError("No module can be removed: every removable module is already none.");
104
+ const value = await select({
105
+ message: command.name === "add" ? "Which module do you want to add or switch?" : "Which module do you want to remove?",
106
+ options: choices.map(([id, module]) => ({
107
+ value: id,
108
+ label: module.name,
109
+ hint: `now: ${module.options[project.modules[id] ?? ""]?.name ?? project.modules[id] ?? "not set"}`,
110
+ })),
111
+ });
112
+ if (isCancel(value))
113
+ return null;
114
+ moduleId = value;
115
+ }
116
+ if (command.name === "remove")
117
+ return { name: "remove", module: moduleId };
118
+ const module = manifest.modules[moduleId];
119
+ if (!module) {
120
+ throw new UsageError(`Unknown module "${moduleId}". Choose one of: ${Object.keys(manifest.modules).join(", ")}.`);
121
+ }
122
+ const options = Object.keys(module.options).filter((id) => id !== project.modules[moduleId] && available(manifest, project, moduleId, id));
123
+ if (!options.length)
124
+ throw new UsageError(`${module.name} has no other option this project can use.`);
125
+ const option = await select({
126
+ message: `${module.name}: which option?`,
127
+ options: options.map((id) => ({
128
+ value: id,
129
+ label: module.options[id].name,
130
+ ...(id === module.default ? { hint: "default" } : {}),
131
+ })),
132
+ });
133
+ if (isCancel(option))
134
+ return null;
135
+ return { name: "add", module: moduleId, option };
136
+ }
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(", ")}.`);
148
+ }
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}`);
154
+ }
155
+ option = "none";
156
+ }
157
+ // Validates requirements between modules, such as Supabase Storage needing Supabase Auth.
158
+ return selectModules(manifest, { ...project.modules, [command.module]: option });
159
+ }
160
+ /**
161
+ * The project's selection in a newer Starter. Modules the project predates get their "none"
162
+ * option where there is one, so an upgrade never adds a service on its own.
163
+ */
164
+ export function upgradedModules(manifest, project, cli = "pnpm clubedge") {
165
+ const requested = {};
166
+ const introduced = [];
167
+ for (const [id, module] of Object.entries(manifest.modules)) {
168
+ const recorded = project.modules[id];
169
+ if (recorded === undefined) {
170
+ requested[id] = module.options.none ? "none" : module.default;
171
+ introduced.push(id);
172
+ }
173
+ else if (!module.options[recorded]) {
174
+ throw new UsageError(`The new Starter no longer offers ${module.name} "${recorded}". Switch to one of ${Object.keys(module.options).join(", ")} with ${cli} add ${id} <option> before upgrading.`);
175
+ }
176
+ else {
177
+ requested[id] = recorded;
178
+ }
179
+ }
180
+ const dropped = Object.keys(project.modules).filter((id) => !manifest.modules[id]);
181
+ return { modules: selectModules(manifest, requested), introduced, dropped };
182
+ }
183
+ const verbs = {
184
+ create: "add",
185
+ update: "update",
186
+ merge: "merge",
187
+ conflict: "conflict",
188
+ delete: "delete",
189
+ keep: "keep",
190
+ };
191
+ function describe(plan) {
192
+ const lines = plan.actions.map((action) => `${verbs[action.kind].padEnd(8)} ${action.path}`);
193
+ if (plan.lockfile.kind === "replace")
194
+ lines.push(`${"update".padEnd(8)} pnpm-lock.yaml`);
195
+ if (plan.lockfile.kind === "install")
196
+ lines.push(`${"install".padEnd(8)} pnpm-lock.yaml ${dim("(pnpm install reconciles your dependency changes)")}`);
197
+ const env = plan.env?.update;
198
+ if (plan.env && env && plan.env.changed) {
199
+ const parts = [
200
+ env.added.length ? `adds ${env.added.join(", ")}` : "",
201
+ env.updated.length ? `updates ${env.updated.join(", ")}` : "",
202
+ env.removed.length ? `removes ${env.removed.join(", ")}` : "",
203
+ ].filter(Boolean);
204
+ lines.push(`${"update".padEnd(8)} ${plan.env.path} ${dim(`(${parts.join("; ")})`)}`);
205
+ }
206
+ return lines.join("\n");
207
+ }
208
+ function report(plan) {
209
+ const conflicts = plan.actions.filter((action) => action.kind === "conflict");
210
+ const kept = plan.actions.filter((action) => action.kind === "keep");
211
+ if (conflicts.length) {
212
+ log.warn(`Resolve the conflict markers (<<<<<<< yours / >>>>>>> clubedge) in:\n${conflicts.map((action) => ` ${action.path}`).join("\n")}`);
213
+ }
214
+ if (kept.length) {
215
+ log.info(`Check these files by hand:\n${kept.map((action) => ` ${action.path}: ${action.reason}`).join("\n")}`);
216
+ }
217
+ if (plan.env && !plan.env.update) {
218
+ log.info(`${plan.env.path} does not exist yet; create it from the example file.`);
219
+ }
220
+ const env = plan.env?.update;
221
+ if (plan.env && env?.customized.length) {
222
+ log.info(`You set your own ${env.customized.join(", ")} in ${plan.env.path}; the example's value changed, so check whether yours still fits.`);
223
+ }
224
+ if (plan.env && env?.unused.length) {
225
+ log.info(`The project no longer uses ${env.unused.join(", ")}, which you set in ${plan.env.path}. Remove them when you no longer need them.`);
226
+ }
227
+ }
228
+ /** Works out what the command changes; resolves null when the project is already there. */
229
+ async function planChange(project, command, base, baseManifest, target, cli) {
230
+ const baseSide = { source: asRecorded(base, project), manifest: baseManifest, modules: project.modules, cliVersion: project.cliVersion };
231
+ if (command.name !== "upgrade") {
232
+ const modules = changedModules(baseManifest, project, command, cli);
233
+ const changed = Object.keys(modules).filter((id) => modules[id] !== project.modules[id]);
234
+ if (!changed.length)
235
+ return null;
236
+ return {
237
+ base: baseSide,
238
+ target: { ...baseSide, modules, cliVersion },
239
+ summary: changed.map((id) => `${baseManifest.modules[id].name}: ${project.modules[id]} -> ${modules[id]}`).join(", "),
240
+ notes: [],
241
+ };
242
+ }
243
+ if (!target)
244
+ throw new Error("upgrade needs a target Starter.");
245
+ if (!target.commit && target.kind === "github") {
246
+ throw new UsageError(`Could not resolve Starter "${target.ref}" to a commit. Use a tag, a branch, or a full commit SHA.`);
247
+ }
248
+ if (target.commit && target.commit === project.starterCommit)
249
+ return null;
250
+ const targetManifest = await readTemplateManifest(target.root);
251
+ const { modules, introduced, dropped } = upgradedModules(targetManifest, project, cli);
252
+ const notes = [
253
+ ...introduced.map((id) => {
254
+ const module = targetManifest.modules[id];
255
+ return `Starter ${target.ref} adds ${module.name}; the project uses "${modules[id]}". Choose another option with: ${cli} add ${id} <option>`;
256
+ }),
257
+ ...dropped.map((id) => `Starter ${target.ref} no longer has the ${id} module; its files are merged away like any other change.`),
258
+ ];
259
+ if (/^v\d/.test(target.ref)) {
260
+ notes.push(`Release notes: https://github.com/${target.repository}/releases/tag/${target.ref}`);
261
+ }
262
+ return {
263
+ base: baseSide,
264
+ target: { source: target, manifest: targetManifest, modules, cliVersion },
265
+ summary: `Starter ${project.starterRef} -> ${target.ref}`,
266
+ notes,
267
+ };
268
+ }
269
+ export async function changeProject(args) {
270
+ const command = args.command;
271
+ if (command.name === "create")
272
+ throw new Error("Not a project change.");
273
+ const interactive = Boolean(process.stdin.isTTY) && !args.yes;
274
+ const project = await readProject(process.cwd());
275
+ await assertSafeToChange(project, args, interactive);
276
+ const upgrading = command.name === "upgrade";
277
+ const progress = spinner();
278
+ progress.start(upgrading ? "Preparing both Starter revisions" : `Preparing Starter ${project.starterRef}`);
279
+ const sources = [];
280
+ let spinning = true;
281
+ try {
282
+ // For upgrade, --template-dir names the new Starter; the project's own comes from GitHub.
283
+ const base = await projectSource(project, upgrading ? undefined : args.templateDir);
284
+ sources.push(base);
285
+ const target = upgrading ? await upgradeSource(args) : null;
286
+ if (target)
287
+ sources.push(target);
288
+ const baseManifest = await readTemplateManifest(base.root);
289
+ progress.stop("Starter ready");
290
+ spinning = false;
291
+ const cli = await cliCommand(project);
292
+ const complete = command.name === "upgrade" ? command : await completeCommand(baseManifest, project, command, interactive);
293
+ if (!complete) {
294
+ outro("Cancelled; nothing was changed.");
295
+ return;
296
+ }
297
+ const change = await planChange(project, complete, base, baseManifest, target, cli);
298
+ if (!change) {
299
+ outro(upgrading ? `The project is already on Starter ${project.starterRef}.` : "The project already uses that selection; nothing to change.");
300
+ return;
301
+ }
302
+ const plan = await planUpdate(project, change.base, change.target);
303
+ log.message(`${change.summary}\n\n${describe(plan) || "No file changes."}`);
304
+ if (args.dryRun) {
305
+ for (const note of change.notes)
306
+ log.info(note);
307
+ outro("Dry run: nothing was changed. Run again without --dry-run to apply.");
308
+ return;
309
+ }
310
+ await applyUpdate(plan);
311
+ log.success(`Updated ${plan.actions.filter((action) => action.kind !== "keep").length} files`);
312
+ if (args.install) {
313
+ const pnpm = await resolvePnpm();
314
+ if (!pnpm)
315
+ log.warn("pnpm was not found. Run `pnpm install` to update dependencies.");
316
+ else {
317
+ await installDependencies(project.root, pnpm, plan.lockfile.kind === "install" ? ["--no-frozen-lockfile"] : []);
318
+ log.success("Dependencies updated");
319
+ }
320
+ }
321
+ else if (plan.lockfile.kind !== "unchanged") {
322
+ log.info("Run `pnpm install` to update dependencies.");
323
+ }
324
+ report(plan);
325
+ for (const note of change.notes)
326
+ log.info(note);
327
+ outro(upgrading
328
+ ? "Review the changes with `git diff`, then run `pnpm db:migrate` for new migrations and your tests."
329
+ : "Review the changes with `git diff`, then run `pnpm check:services` and `pnpm db:migrate` if the change added services or tables.");
330
+ }
331
+ catch (error) {
332
+ if (spinning)
333
+ progress.stop("Could not prepare the Starter");
334
+ throw error;
335
+ }
336
+ finally {
337
+ for (const source of sources)
338
+ await source.cleanup();
339
+ }
340
+ }
@@ -13,7 +13,7 @@ export function detectLauncher(userAgent = process.env.npm_config_user_agent) {
13
13
  const name = userAgent?.split("/")[0];
14
14
  return name && ["npm", "pnpm", "yarn", "bun"].includes(name) ? name : null;
15
15
  }
16
- export async function installDependencies(directory, pnpm) {
16
+ export async function installDependencies(directory, pnpm, args = []) {
17
17
  const [command, ...prefix] = pnpm;
18
- await run(command, [...prefix, "install"], directory);
18
+ await run(command, [...prefix, "install", ...args], directory);
19
19
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",
@@ -22,8 +22,8 @@
22
22
  },
23
23
  "clubedge": {
24
24
  "starterRepository": "Clubedge/clubedge-starter",
25
- "starterRef": "v0.7.0",
26
- "starterCommit": "3b8114760b8151c2538b57c06620ff2b884f2d56"
25
+ "starterRef": "v0.8.0",
26
+ "starterCommit": "0b77577a3fe77617c84e250d8b220169b72a297e"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "repository": {
@@ -52,7 +52,8 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@clack/prompts": "^1.8.1",
55
- "giget": "^2.0.0"
55
+ "giget": "^2.0.0",
56
+ "node-diff3": "^3.2.1"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@types/node": "^22.20.5",
@@ -169,6 +169,14 @@ Local services:
169
169
  - `pnpm infra:up`: run only the local database, for example for `pnpm check:services` or browser tests. <!-- clubedge:only infra=local -->
170
170
  - `pnpm infra:reset`: delete the local database and files in `.data/` (stop `pnpm dev` first). <!-- clubedge:only infra=local -->
171
171
 
172
+ Change the project later:
173
+
174
+ - `pnpm clubedge add <module> <option>`: add a module or switch its option, for example `pnpm clubedge add auth better-auth` or `pnpm clubedge add infra local`. Run `pnpm clubedge add` without arguments to choose from a list.
175
+ - `pnpm clubedge remove <module>`: remove a module, for example `pnpm clubedge remove storage`.
176
+ - `pnpm clubedge upgrade`: move the project to the newest Starter release.
177
+
178
+ These commands merge the change into your code instead of overwriting it: files you never touched are updated, your edits are kept, and lines you both changed get conflict markers. Commit first, because they need a clean Git tree so that `git diff` shows the result. Add `--dry-run` to preview a change.
179
+
172
180
  <!-- clubedge:if starter-repository -->
173
181
 
174
182
  ## Working on this repository
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/start",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/web",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "scripts": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clubedge-starter-v1",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "packageManager": "pnpm@10.9.0",
@@ -29,6 +29,7 @@
29
29
  "docker:build": "docker build -t clubedge-starter .",
30
30
  "docker:start": "docker run --rm -p 3000:3000 --env-file apps/web/.env.local clubedge-starter",
31
31
  "check:services": "pnpm --filter @clubedge/web check:services",
32
+ "clubedge": "pnpm dlx @clubedge/create-clubedge-app@latest",
32
33
  "infra:up": "docker compose -f docker-compose.dev.yml up -d --wait",
33
34
  "infra:down": "docker compose -f docker-compose.dev.yml down",
34
35
  "infra:reset": "docker compose -f docker-compose.dev.yml down --volumes",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth-better-auth",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Better Auth adapter for @clubedge/auth, storing users and sessions in your own PostgreSQL.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth-supabase",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Supabase Auth adapter for @clubedge/auth.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/cache",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/cache-redis",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Redis rate limiter and cache for @clubedge/cache.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/core",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/db",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/local-db",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "PostgreSQL for development without Docker: PGlite served over the PostgreSQL protocol.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/storage",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/storage-s3",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "S3-compatible adapter for @clubedge/storage.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/storage-supabase",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Supabase Storage adapter for @clubedge/storage.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/ui",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "repository": "Clubedge/clubedge-starter",
3
- "ref": "v0.7.0",
4
- "commit": "3b8114760b8151c2538b57c06620ff2b884f2d56",
3
+ "ref": "v0.8.0",
4
+ "commit": "0b77577a3fe77617c84e250d8b220169b72a297e",
5
5
  "files": [
6
6
  ".dockerignore",
7
7
  ".env.example",