@clubedge/create-clubedge-app 0.6.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +33 -11
  3. package/dist/cli/args.js +38 -2
  4. package/dist/cli/ui.js +15 -2
  5. package/dist/core/customize.js +5 -2
  6. package/dist/core/env.js +97 -0
  7. package/dist/core/execute.js +4 -2
  8. package/dist/core/lockfile-peers.js +174 -0
  9. package/dist/core/merge.js +96 -0
  10. package/dist/core/project.js +68 -0
  11. package/dist/core/source.js +14 -1
  12. package/dist/core/update.js +84 -0
  13. package/dist/index.js +14 -2
  14. package/dist/modify.js +263 -0
  15. package/dist/steps/install.js +2 -2
  16. package/package.json +5 -4
  17. package/template/.env.example +4 -0
  18. package/template/README.md +7 -0
  19. package/template/SETUP.md +11 -0
  20. package/template/_gitignore +2 -0
  21. package/template/apps/start/.env.example +4 -0
  22. package/template/apps/start/package.json +1 -1
  23. package/template/apps/start/scripts/check-services.ts +22 -0
  24. package/template/apps/start/src/server/storage.ts +13 -0
  25. package/template/apps/web/package.json +1 -1
  26. package/template/apps/web/scripts/check-services.ts +22 -0
  27. package/template/apps/web/src/server/storage.ts +13 -0
  28. package/template/clubedge.template.json +19 -1
  29. package/template/package.json +1 -1
  30. package/template/packages/auth/package.json +1 -1
  31. package/template/packages/auth-better-auth/package.json +1 -1
  32. package/template/packages/auth-supabase/package.json +1 -1
  33. package/template/packages/cache/package.json +1 -1
  34. package/template/packages/cache-redis/package.json +1 -1
  35. package/template/packages/core/package.json +1 -1
  36. package/template/packages/db/package.json +1 -1
  37. package/template/packages/local-db/package.json +24 -0
  38. package/template/packages/local-db/scripts/serve.ts +66 -0
  39. package/template/packages/local-db/src/server.test.ts +119 -0
  40. package/template/packages/local-db/src/server.ts +153 -0
  41. package/template/packages/local-db/tsconfig.json +4 -0
  42. package/template/packages/storage/package.json +3 -2
  43. package/template/packages/storage/src/file-system.test.ts +110 -0
  44. package/template/packages/storage/src/file-system.ts +124 -0
  45. package/template/packages/storage-s3/package.json +1 -1
  46. package/template/packages/storage-supabase/package.json +1 -1
  47. package/template/packages/ui/package.json +1 -1
  48. package/template/pnpm-lock.yaml +31 -9
  49. package/template.lock.json +9 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
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
+
12
+ ## 0.7.0
13
+
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.
15
+ - Remove optional peer links to packages that only left-out modules installed, so generated projects no longer install them. For example, a project without `--infra local` no longer installs PGlite through drizzle-orm, and a project without Better Auth no longer installs its optional peers.
16
+
5
17
  ## 0.6.0
6
18
 
7
19
  - Default to Starter `v0.6.0`, which adds the local infrastructure module and `pnpm check:services`.
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.6.0
15
+ create-clubedge-app v0.8.0
16
16
  |
17
17
  v
18
- Clubedge Starter v0.6.0
19
- commit bf5a5e801254816455cbb837902283b788ab7595
18
+ Clubedge Starter v0.7.0
19
+ commit 3b8114760b8151c2538b57c06620ff2b884f2d56
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.6.0` | `clubedge-starter@v0.6.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
 
30
- The `v0.6.0` tag is expected to resolve to commit
31
- `bf5a5e801254816455cbb837902283b788ab7595`. The CLI verifies this before
30
+ The `v0.7.0` tag is expected to resolve to commit
31
+ `3b8114760b8151c2538b57c06620ff2b884f2d56`. 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.6.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.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.
36
36
 
37
37
  ## Requirements
38
38
 
@@ -92,7 +92,7 @@ Authentication, file storage, and caching are modules. Each has a default, and a
92
92
  | `--auth` | `supabase`, `better-auth`, `none` | `supabase` |
93
93
  | `--storage` | `s3`, `supabase` (needs Supabase Auth), `none` | `s3` |
94
94
  | `--cache` | `redis` (falls back to memory without `REDIS_URL`), `memory` | `redis` |
95
- | `--infra` | `docker` (Docker Compose), `supabase` (Supabase CLI), `none` | `docker` |
95
+ | `--infra` | `docker` (Docker Compose), `supabase` (Supabase CLI), `local` (no Docker: PGlite and a local folder), `none` | `docker` |
96
96
 
97
97
  ```sh
98
98
  # Only the monorepo, UI, database, and tooling
@@ -110,7 +110,7 @@ Usage: pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
110
110
  --auth <id> Authentication: supabase (default), better-auth, or none
111
111
  --storage <id> File storage: s3 (default), supabase, or none
112
112
  --cache <id> Cache and rate limits: redis (default) or memory
113
- --infra <id> Local services: docker (default), supabase, or none
113
+ --infra <id> Local services: docker (default), supabase, local, or none
114
114
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
115
115
  --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
116
116
  --dry-run Show what would be created without writing anything
@@ -136,13 +136,35 @@ 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.6.0
139
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.7.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:
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.
@@ -181,7 +203,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
181
203
  1. Make and validate changes in `clubedge-starter`.
182
204
  2. Run lint, typecheck, unit tests, browser tests, and production build.
183
205
  3. Update the Starter package versions.
184
- 4. Commit `main`, create a release tag such as `v0.6.0`, and publish the Starter release.
206
+ 4. Commit `main`, create a release tag such as `v0.7.0`, and publish the Starter release.
185
207
 
186
208
  ### CLI maintainers
187
209
 
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,16 +61,26 @@ 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
67
75
  --auth <id> Authentication: supabase (default), better-auth, or none
68
76
  --storage <id> File storage: s3 (default), supabase, or none
69
77
  --cache <id> Cache and rate limits: redis (default) or memory
70
- --infra <id> Local services: docker (default), supabase, or none
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}
@@ -1,5 +1,6 @@
1
1
  // Pure text transforms applied to template files. Keeping them free of I/O makes each one easy
2
2
  // to test and lets --dry-run describe them without touching the disk.
3
+ import { pruneOptionalPeers } from "./lockfile-peers.js";
3
4
  const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
4
5
  export function customizeRootManifest(source, { packageName }, provenance, dockerImage, scripts = { set: {}, remove: [] }) {
5
6
  const manifest = JSON.parse(source);
@@ -75,7 +76,9 @@ export function customizeAppManifest(source, appPackage) {
75
76
  /**
76
77
  * Keeps pnpm-lock.yaml in step with the selected framework so `pnpm install --frozen-lockfile`
77
78
  * works. Importers are keyed by workspace path at two-space indentation; package entries that
78
- * are no longer used are harmless, and pnpm prunes them on the next lockfile update.
79
+ * are no longer used are harmless, and pnpm prunes them on the next lockfile update. Optional
80
+ * peer links to packages that only left-out importers installed are removed, so those packages
81
+ * are not installed either.
79
82
  */
80
83
  export function customizeLockfile(source, { remove, rename, removeDependencies = [] }) {
81
84
  const output = [];
@@ -135,7 +138,7 @@ export function customizeLockfile(source, { remove, rename, removeDependencies =
135
138
  skippingDependency = false;
136
139
  output.push(line);
137
140
  }
138
- return output.join("\n");
141
+ return pruneOptionalPeers(output.join("\n"));
139
142
  }
140
143
  export class ConditionalError extends Error {
141
144
  name = "ConditionalError";
@@ -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,174 @@
1
+ // pnpm links a package's optional peer dependency whenever that peer is installed anywhere in the
2
+ // workspace, and records the link in lockfile keys: drizzle-orm@0.45.4(@electric-sql/pglite@0.5.8).
3
+ // When a left-out workspace package was the only reason the peer was installed, the link would
4
+ // keep installing it, so these helpers remove such links from the lockfile.
5
+ function unquote(text) {
6
+ return text.replace(/^(['"])(.*)\1$/, "$2");
7
+ }
8
+ /** "drizzle-orm@0.45.4(pglite@0.5.8)" -> "drizzle-orm@0.45.4"; "0.45.4(x@1)" -> "0.45.4". */
9
+ function withoutSuffix(text) {
10
+ const start = text.indexOf("(");
11
+ return start === -1 ? text : text.slice(0, start);
12
+ }
13
+ function parse(source) {
14
+ const roots = [];
15
+ const optionalPeers = new Map();
16
+ const snapshots = new Map();
17
+ let section = "";
18
+ let key = "";
19
+ let group = "";
20
+ let entry = "";
21
+ for (const line of source.split("\n")) {
22
+ if (/^\S/.test(line)) {
23
+ section = line.replace(/:.*$/, "");
24
+ continue;
25
+ }
26
+ const keyMatch = /^ {2}(\S.*?):(?: \{\})?$/.exec(line);
27
+ if (keyMatch) {
28
+ key = unquote(keyMatch[1]);
29
+ group = entry = "";
30
+ if (section === "snapshots")
31
+ snapshots.set(key, snapshots.get(key) ?? []);
32
+ continue;
33
+ }
34
+ const groupMatch = /^ {4}(\S+):$/.exec(line);
35
+ if (groupMatch) {
36
+ group = groupMatch[1];
37
+ entry = "";
38
+ continue;
39
+ }
40
+ const entryMatch = /^ {6}(\S.*?):(?: (.*))?$/.exec(line);
41
+ if (entryMatch) {
42
+ entry = unquote(entryMatch[1]);
43
+ const value = entryMatch[2] ? unquote(entryMatch[2]) : "";
44
+ if (section === "snapshots" && (group === "dependencies" || group === "optionalDependencies")) {
45
+ snapshots.get(key).push({ name: entry, ref: value });
46
+ }
47
+ continue;
48
+ }
49
+ const fieldMatch = /^ {8}(\S+): (.*)$/.exec(line);
50
+ if (!fieldMatch)
51
+ continue;
52
+ const [, field, value] = fieldMatch;
53
+ if (section === "importers" && field === "version")
54
+ roots.push(`${entry}@${unquote(value)}`);
55
+ if (section === "packages" && group === "peerDependenciesMeta" && field === "optional" && value === "true") {
56
+ const peers = optionalPeers.get(key) ?? new Set();
57
+ peers.add(entry);
58
+ optionalPeers.set(key, peers);
59
+ }
60
+ }
61
+ return { roots, optionalPeers, snapshots };
62
+ }
63
+ /**
64
+ * Finds optional peers that only optional-peer links keep installed: packages that no importer
65
+ * reaches through regular or required dependencies. Returns them as "name@version".
66
+ */
67
+ export function findUnneededOptionalPeers(source) {
68
+ const { roots, optionalPeers, snapshots } = parse(source);
69
+ const reached = new Set();
70
+ const peerLinks = new Set();
71
+ const queue = roots.filter((key) => snapshots.has(key));
72
+ while (queue.length) {
73
+ const key = queue.pop();
74
+ if (reached.has(key))
75
+ continue;
76
+ reached.add(key);
77
+ const peers = optionalPeers.get(withoutSuffix(key));
78
+ for (const { name, ref } of snapshots.get(key) ?? []) {
79
+ if (peers?.has(name)) {
80
+ peerLinks.add(`${name}@${withoutSuffix(ref)}`);
81
+ continue;
82
+ }
83
+ const target = `${name}@${ref}`;
84
+ if (snapshots.has(target))
85
+ queue.push(target);
86
+ }
87
+ }
88
+ const reachedPackages = new Set([...reached].map(withoutSuffix));
89
+ return [...peerLinks].filter((peer) => !reachedPackages.has(peer)).sort();
90
+ }
91
+ /** Removes every "(peer)" or "(peer(...))" segment from lockfile keys and versions. */
92
+ function removeSuffixSegments(text, peer) {
93
+ const marker = `(${peer}`;
94
+ let result = "";
95
+ let index = 0;
96
+ for (let found = text.indexOf(marker); found !== -1; found = text.indexOf(marker, index)) {
97
+ const next = text[found + marker.length];
98
+ if (next !== ")" && next !== "(") {
99
+ // A different package whose name merely starts with this one.
100
+ result += text.slice(index, found + marker.length);
101
+ index = found + marker.length;
102
+ continue;
103
+ }
104
+ let depth = 0;
105
+ let end = found;
106
+ for (; end < text.length; end++) {
107
+ if (text[end] === "(")
108
+ depth++;
109
+ else if (text[end] === ")" && --depth === 0)
110
+ break;
111
+ }
112
+ result += text.slice(index, found);
113
+ index = end + 1;
114
+ }
115
+ return result + text.slice(index);
116
+ }
117
+ /**
118
+ * Drops optional-peer links to packages nothing else installs (see findUnneededOptionalPeers),
119
+ * so `pnpm install --frozen-lockfile` no longer installs them. Snapshot entries that become
120
+ * identical are merged.
121
+ */
122
+ export function pruneOptionalPeers(source) {
123
+ const peers = findUnneededOptionalPeers(source);
124
+ if (!peers.length)
125
+ return source;
126
+ let text = source;
127
+ for (const peer of peers)
128
+ text = removeSuffixSegments(text, peer);
129
+ const output = [];
130
+ const seenKeys = new Set();
131
+ let section = "";
132
+ let skippingEntry = false;
133
+ let pendingGroup = null;
134
+ for (const line of text.split("\n")) {
135
+ if (/^\S/.test(line)) {
136
+ section = line.replace(/:.*$/, "");
137
+ skippingEntry = false;
138
+ pendingGroup = null;
139
+ output.push(line);
140
+ continue;
141
+ }
142
+ if (section !== "snapshots") {
143
+ output.push(line);
144
+ continue;
145
+ }
146
+ const keyMatch = /^ {2}(\S.*?):(?: \{\})?$/.exec(line);
147
+ if (keyMatch) {
148
+ pendingGroup = null;
149
+ // Removing a link can make two snapshot keys identical; keep the first entry.
150
+ skippingEntry = seenKeys.has(keyMatch[1]);
151
+ seenKeys.add(keyMatch[1]);
152
+ if (!skippingEntry)
153
+ output.push(line);
154
+ continue;
155
+ }
156
+ if (skippingEntry)
157
+ continue;
158
+ if (/^ {4}\S+:$/.test(line)) {
159
+ // Held until the group gets an entry, so a group whose only entry was removed disappears.
160
+ pendingGroup = line;
161
+ continue;
162
+ }
163
+ const dependency = /^ {6}(\S.*?): (.*)$/.exec(line);
164
+ if (dependency && peers.includes(`${unquote(dependency[1])}@${withoutSuffix(unquote(dependency[2]))}`)) {
165
+ continue;
166
+ }
167
+ if (pendingGroup !== null && line.trim() !== "") {
168
+ output.push(pendingGroup);
169
+ pendingGroup = null;
170
+ }
171
+ output.push(line);
172
+ }
173
+ return output.join("\n");
174
+ }
@@ -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
+ }