@clubedge/create-clubedge-app 0.7.0 → 0.8.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,13 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.8.0
6
+
7
+ - 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.
8
+ - 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.
9
+ - Record the commit of a Starter fetched with `--ref`, so projects created from a branch or tag can later be changed and upgraded.
10
+ - Fix creating a project directly under a Windows drive root, such as `D:\my-app`, which failed with EPERM.
11
+
5
12
  ## 0.7.0
6
13
 
7
14
  - 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,7 +12,7 @@ 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.8.0
16
16
  |
17
17
  v
18
18
  Clubedge Starter v0.7.0
@@ -23,7 +23,7 @@ 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.8.0` | `clubedge-starter@v0.7.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
 
@@ -143,6 +143,28 @@ pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
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:
149
+
150
+ ```sh
151
+ pnpm dlx @clubedge/create-clubedge-app add cache redis # or switch: add auth better-auth
152
+ pnpm dlx @clubedge/create-clubedge-app remove storage
153
+ pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
154
+ pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
155
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.8.0 # or a specific tag, branch, or commit
156
+ ```
157
+
158
+ 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.
159
+
160
+ - Files you never changed are updated, added, or deleted.
161
+ - 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.
162
+ - Your own files are never touched.
163
+ - `apps/web/.env.local` keeps your values. Values still at their generated defaults follow the new selection, and new variables are added.
164
+ - `pnpm-lock.yaml` is replaced if you never changed your dependencies; otherwise `pnpm install` reconciles it.
165
+
166
+ 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.
167
+
146
168
  ## How it works
147
169
 
148
170
  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.
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,33 @@ 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 (!module || !option || extra.length) {
90
+ throw new UsageError("Usage: add <module> <option>, for example: add cache redis");
91
+ }
92
+ return { name, module, option };
93
+ }
94
+ if (name === "remove") {
95
+ if (!module || option !== undefined) {
96
+ throw new UsageError("Usage: remove <module>, for example: remove storage");
97
+ }
98
+ return { name, module };
99
+ }
100
+ if (name === "upgrade") {
101
+ if (module !== undefined)
102
+ throw new UsageError("Usage: upgrade [--ref <tag>], for example: upgrade --ref v0.8.0");
103
+ return { name };
104
+ }
105
+ return { name: "create" };
106
+ }
package/dist/cli/ui.js CHANGED
@@ -61,6 +61,14 @@ 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
+ add and remove change one module of an existing project, and upgrade moves it
69
+ to a newer Starter (this CLI's release unless --ref is given). The change is
70
+ merged into your code; lines you changed that it also changes get conflict
71
+ markers. Commit first so you can review the result with git diff.
64
72
 
65
73
  Options:
66
74
  --framework <id> App framework: next (default) or tanstack-start
@@ -69,8 +77,10 @@ Options:
69
77
  --cache <id> Cache and rate limits: redis (default) or memory
70
78
  --infra <id> Local services: docker (default), supabase, local, or none
71
79
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
80
+ (upgrade: the Starter to upgrade to)
72
81
  --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
73
- --dry-run Show what would be created without writing anything
82
+ --dry-run Show what would be created or changed without writing anything
83
+ --force add/remove: change a project with uncommitted changes or without Git
74
84
  -y, --yes Accept defaults and never prompt (default directory: my-app)
75
85
  --no-install Skip dependency installation
76
86
  --no-git Skip Git repository initialization
@@ -84,6 +94,9 @@ Examples:
84
94
  pnpm dlx @clubedge/create-clubedge-app my-app --dry-run
85
95
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
86
96
  pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
97
+ pnpm dlx @clubedge/create-clubedge-app add auth better-auth
98
+ pnpm dlx @clubedge/create-clubedge-app remove storage --dry-run
99
+ pnpm dlx @clubedge/create-clubedge-app@latest upgrade
87
100
 
88
101
  Learn more:
89
102
  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
@@ -8,6 +8,7 @@ import { readTemplateManifest } from "./core/manifest.js";
8
8
  import { createPlan, describePlan, selectFramework } from "./core/plan.js";
9
9
  import { resolveTemplateSource } from "./core/source.js";
10
10
  import { cliUrl, cliVersion, siteUrl } from "./package-info.js";
11
+ import { changeProject } from "./modify.js";
11
12
  import { initializeGit } from "./steps/git.js";
12
13
  import { detectLauncher, installDependencies, resolvePnpm } from "./steps/install.js";
13
14
  import { displayNameFromPackageName, packageNameFromDirectory } from "./utils/names.js";
@@ -199,8 +200,19 @@ export async function main(argv = process.argv.slice(2)) {
199
200
  return;
200
201
  }
201
202
  assertSupportedNode();
202
- intro("Create a project with Clubedge Starter");
203
- await createProject(args);
203
+ if (args.command.name === "create") {
204
+ intro("Create a project with Clubedge Starter");
205
+ await createProject(args);
206
+ }
207
+ else {
208
+ const intros = {
209
+ add: "Add a module to this project",
210
+ remove: "Remove a module from this project",
211
+ upgrade: "Upgrade this project to a newer Starter",
212
+ };
213
+ intro(intros[args.command.name]);
214
+ await changeProject(args);
215
+ }
204
216
  }
205
217
  catch (error) {
206
218
  console.error(`\n${error instanceof Error ? error.message : String(error)}`);
package/dist/modify.js ADDED
@@ -0,0 +1,263 @@
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, spinner } from "@clack/prompts";
5
+ import process from "node:process";
6
+ import { UsageError } from "./cli/args.js";
7
+ import { dim } from "./cli/ui.js";
8
+ import { readTemplateManifest } from "./core/manifest.js";
9
+ import { selectModules } from "./core/plan.js";
10
+ import { readProject } from "./core/project.js";
11
+ import { downloadGitHubTemplate, readDirectoryTemplate, resolveTemplateSource, } from "./core/source.js";
12
+ import { applyUpdate, planUpdate } from "./core/update.js";
13
+ import { cliVersion, starterPin } from "./package-info.js";
14
+ import { installDependencies, resolvePnpm } from "./steps/install.js";
15
+ import { commandSucceeds, runCapture } from "./utils/process.js";
16
+ /** The Starter revision the project was generated from. */
17
+ async function projectSource(project, templateDir) {
18
+ if (templateDir)
19
+ return readDirectoryTemplate(templateDir);
20
+ if (!project.starterCommit) {
21
+ 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.`);
22
+ }
23
+ if (project.starterCommit === starterPin.starterCommit)
24
+ return resolveTemplateSource({});
25
+ return downloadGitHubTemplate(project.starterCommit, { repository: project.starterRepository });
26
+ }
27
+ /** The Starter to upgrade to: --template-dir, --ref, or the release this CLI is pinned to. */
28
+ async function upgradeSource(args) {
29
+ if (args.templateDir)
30
+ return readDirectoryTemplate(args.templateDir);
31
+ return resolveTemplateSource({ ref: args.ref });
32
+ }
33
+ /**
34
+ * Renders the project's own revision with the provenance it recorded, so the generated
35
+ * package.json and README match the project even when the revision was fetched by commit.
36
+ */
37
+ function asRecorded(source, project) {
38
+ return { ...source, ref: project.starterRef, commit: project.starterCommit };
39
+ }
40
+ /**
41
+ * Refuses to change a project whose changes Git cannot show and undo, unless --force: a Git
42
+ * repository must have no uncommitted changes, and a folder without Git needs confirmation.
43
+ */
44
+ async function assertSafeToChange(project, args, interactive) {
45
+ if (args.force)
46
+ return;
47
+ const isRepository = await commandSucceeds("git", ["-C", project.root, "rev-parse", "--is-inside-work-tree"]);
48
+ if (isRepository) {
49
+ const status = await runCapture("git", ["-C", project.root, "status", "--porcelain"]);
50
+ if (status) {
51
+ 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.");
52
+ }
53
+ return;
54
+ }
55
+ if (!interactive) {
56
+ throw new UsageError("The project is not a Git repository, so this change could not be undone. Pass --force to change it anyway.");
57
+ }
58
+ const proceed = await confirm({
59
+ message: "The project is not a Git repository, so this change cannot be undone. Continue?",
60
+ initialValue: false,
61
+ });
62
+ if (isCancel(proceed) || !proceed)
63
+ throw new UsageError("Cancelled; nothing was changed.");
64
+ }
65
+ /** The selection after add or remove, validated against the Starter's manifest. */
66
+ function changedModules(manifest, project, command) {
67
+ const module = manifest.modules[command.module];
68
+ if (!module) {
69
+ throw new UsageError(`Unknown module "${command.module}". Choose one of: ${Object.keys(manifest.modules).join(", ")}.`);
70
+ }
71
+ let option;
72
+ if (command.name === "add") {
73
+ option = command.option;
74
+ if (!module.options[option]) {
75
+ throw new UsageError(`Unknown ${command.module} option "${option}". Choose one of: ${Object.keys(module.options).join(", ")}.`);
76
+ }
77
+ }
78
+ else {
79
+ if (!module.options.none) {
80
+ const others = Object.keys(module.options).filter((id) => id !== project.modules[command.module]);
81
+ throw new UsageError(`${module.name} cannot be removed. Switch it instead, for example: add ${command.module} ${others[0] ?? module.default}`);
82
+ }
83
+ option = "none";
84
+ }
85
+ // Validates requirements between modules, such as Supabase Storage needing Supabase Auth.
86
+ return selectModules(manifest, { ...project.modules, [command.module]: option });
87
+ }
88
+ /**
89
+ * The project's selection in a newer Starter. Modules the project predates get their "none"
90
+ * option where there is one, so an upgrade never adds a service on its own.
91
+ */
92
+ export function upgradedModules(manifest, project) {
93
+ const requested = {};
94
+ const introduced = [];
95
+ for (const [id, module] of Object.entries(manifest.modules)) {
96
+ const recorded = project.modules[id];
97
+ if (recorded === undefined) {
98
+ requested[id] = module.options.none ? "none" : module.default;
99
+ introduced.push(id);
100
+ }
101
+ else if (!module.options[recorded]) {
102
+ throw new UsageError(`The new Starter no longer offers ${module.name} "${recorded}". Choose one of: ${Object.keys(module.options).join(", ")}, with add ${id} <option>, before upgrading.`);
103
+ }
104
+ else {
105
+ requested[id] = recorded;
106
+ }
107
+ }
108
+ const dropped = Object.keys(project.modules).filter((id) => !manifest.modules[id]);
109
+ return { modules: selectModules(manifest, requested), introduced, dropped };
110
+ }
111
+ const verbs = {
112
+ create: "add",
113
+ update: "update",
114
+ merge: "merge",
115
+ conflict: "conflict",
116
+ delete: "delete",
117
+ keep: "keep",
118
+ };
119
+ function describe(plan) {
120
+ const lines = plan.actions.map((action) => `${verbs[action.kind].padEnd(8)} ${action.path}`);
121
+ if (plan.lockfile.kind === "replace")
122
+ lines.push(`${"update".padEnd(8)} pnpm-lock.yaml`);
123
+ if (plan.lockfile.kind === "install")
124
+ lines.push(`${"install".padEnd(8)} pnpm-lock.yaml ${dim("(pnpm install reconciles your dependency changes)")}`);
125
+ const env = plan.env?.update;
126
+ if (plan.env && env && plan.env.changed) {
127
+ const parts = [
128
+ env.added.length ? `adds ${env.added.join(", ")}` : "",
129
+ env.updated.length ? `updates ${env.updated.join(", ")}` : "",
130
+ env.removed.length ? `removes ${env.removed.join(", ")}` : "",
131
+ ].filter(Boolean);
132
+ lines.push(`${"update".padEnd(8)} ${plan.env.path} ${dim(`(${parts.join("; ")})`)}`);
133
+ }
134
+ return lines.join("\n");
135
+ }
136
+ function report(plan) {
137
+ const conflicts = plan.actions.filter((action) => action.kind === "conflict");
138
+ const kept = plan.actions.filter((action) => action.kind === "keep");
139
+ if (conflicts.length) {
140
+ log.warn(`Resolve the conflict markers (<<<<<<< yours / >>>>>>> clubedge) in:\n${conflicts.map((action) => ` ${action.path}`).join("\n")}`);
141
+ }
142
+ if (kept.length) {
143
+ log.info(`Check these files by hand:\n${kept.map((action) => ` ${action.path}: ${action.reason}`).join("\n")}`);
144
+ }
145
+ if (plan.env && !plan.env.update) {
146
+ log.info(`${plan.env.path} does not exist yet; create it from the example file.`);
147
+ }
148
+ const env = plan.env?.update;
149
+ if (plan.env && env?.customized.length) {
150
+ log.info(`You set your own ${env.customized.join(", ")} in ${plan.env.path}; the example's value changed, so check whether yours still fits.`);
151
+ }
152
+ if (plan.env && env?.unused.length) {
153
+ 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.`);
154
+ }
155
+ }
156
+ /** Works out what the command changes; resolves null when the project is already there. */
157
+ async function planChange(project, command, base, target) {
158
+ const baseManifest = await readTemplateManifest(base.root);
159
+ const baseSide = { source: asRecorded(base, project), manifest: baseManifest, modules: project.modules, cliVersion: project.cliVersion };
160
+ if (command.name !== "upgrade") {
161
+ const modules = changedModules(baseManifest, project, command);
162
+ const changed = Object.keys(modules).filter((id) => modules[id] !== project.modules[id]);
163
+ if (!changed.length)
164
+ return null;
165
+ return {
166
+ base: baseSide,
167
+ target: { ...baseSide, modules, cliVersion },
168
+ summary: changed.map((id) => `${baseManifest.modules[id].name}: ${project.modules[id]} -> ${modules[id]}`).join(", "),
169
+ notes: [],
170
+ };
171
+ }
172
+ if (!target)
173
+ throw new Error("upgrade needs a target Starter.");
174
+ if (!target.commit && target.kind === "github") {
175
+ throw new UsageError(`Could not resolve Starter "${target.ref}" to a commit. Use a tag, a branch, or a full commit SHA.`);
176
+ }
177
+ if (target.commit && target.commit === project.starterCommit)
178
+ return null;
179
+ const targetManifest = await readTemplateManifest(target.root);
180
+ const { modules, introduced, dropped } = upgradedModules(targetManifest, project);
181
+ const notes = [
182
+ ...introduced.map((id) => {
183
+ const module = targetManifest.modules[id];
184
+ return `Starter ${target.ref} adds ${module.name}; the project uses "${modules[id]}". Choose another option with: add ${id} <option>`;
185
+ }),
186
+ ...dropped.map((id) => `Starter ${target.ref} no longer has the ${id} module; its files are merged away like any other change.`),
187
+ ];
188
+ if (/^v\d/.test(target.ref)) {
189
+ notes.push(`Release notes: https://github.com/${target.repository}/releases/tag/${target.ref}`);
190
+ }
191
+ return {
192
+ base: baseSide,
193
+ target: { source: target, manifest: targetManifest, modules, cliVersion },
194
+ summary: `Starter ${project.starterRef} -> ${target.ref}`,
195
+ notes,
196
+ };
197
+ }
198
+ export async function changeProject(args) {
199
+ const command = args.command;
200
+ if (command.name === "create")
201
+ throw new Error("Not a project change.");
202
+ const interactive = Boolean(process.stdin.isTTY) && !args.yes;
203
+ const project = await readProject(process.cwd());
204
+ await assertSafeToChange(project, args, interactive);
205
+ const upgrading = command.name === "upgrade";
206
+ const progress = spinner();
207
+ progress.start(upgrading ? "Preparing both Starter revisions" : `Preparing Starter ${project.starterRef}`);
208
+ const sources = [];
209
+ let spinning = true;
210
+ try {
211
+ // For upgrade, --template-dir names the new Starter; the project's own comes from GitHub.
212
+ const base = await projectSource(project, upgrading ? undefined : args.templateDir);
213
+ sources.push(base);
214
+ const target = upgrading ? await upgradeSource(args) : null;
215
+ if (target)
216
+ sources.push(target);
217
+ const change = await planChange(project, command, base, target);
218
+ spinning = false;
219
+ if (!change) {
220
+ progress.stop("Starter ready");
221
+ outro(upgrading ? `The project is already on Starter ${project.starterRef}.` : "The project already uses that selection; nothing to change.");
222
+ return;
223
+ }
224
+ progress.stop("Starter ready");
225
+ const plan = await planUpdate(project, change.base, change.target);
226
+ log.message(`${change.summary}\n\n${describe(plan) || "No file changes."}`);
227
+ if (args.dryRun) {
228
+ for (const note of change.notes)
229
+ log.info(note);
230
+ outro("Dry run: nothing was changed. Run again without --dry-run to apply.");
231
+ return;
232
+ }
233
+ await applyUpdate(plan);
234
+ log.success(`Updated ${plan.actions.filter((action) => action.kind !== "keep").length} files`);
235
+ if (args.install) {
236
+ const pnpm = await resolvePnpm();
237
+ if (!pnpm)
238
+ log.warn("pnpm was not found. Run `pnpm install` to update dependencies.");
239
+ else {
240
+ await installDependencies(project.root, pnpm, plan.lockfile.kind === "install" ? ["--no-frozen-lockfile"] : []);
241
+ log.success("Dependencies updated");
242
+ }
243
+ }
244
+ else if (plan.lockfile.kind !== "unchanged") {
245
+ log.info("Run `pnpm install` to update dependencies.");
246
+ }
247
+ report(plan);
248
+ for (const note of change.notes)
249
+ log.info(note);
250
+ outro(upgrading
251
+ ? "Review the changes with `git diff`, then run `pnpm db:migrate` for new migrations and your tests."
252
+ : "Review the changes with `git diff`, then run `pnpm check:services` and `pnpm db:migrate` if the change added services or tables.");
253
+ }
254
+ catch (error) {
255
+ if (spinning)
256
+ progress.stop("Could not prepare the Starter");
257
+ throw error;
258
+ }
259
+ finally {
260
+ for (const source of sources)
261
+ await source.cleanup();
262
+ }
263
+ }
@@ -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.8.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",
@@ -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",