@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.
- package/CHANGELOG.md +12 -0
- package/README.md +33 -11
- package/dist/cli/args.js +38 -2
- package/dist/cli/ui.js +15 -2
- package/dist/core/customize.js +5 -2
- package/dist/core/env.js +97 -0
- package/dist/core/execute.js +4 -2
- package/dist/core/lockfile-peers.js +174 -0
- package/dist/core/merge.js +96 -0
- package/dist/core/project.js +68 -0
- package/dist/core/source.js +14 -1
- package/dist/core/update.js +84 -0
- package/dist/index.js +14 -2
- package/dist/modify.js +263 -0
- package/dist/steps/install.js +2 -2
- package/package.json +5 -4
- package/template/.env.example +4 -0
- package/template/README.md +7 -0
- package/template/SETUP.md +11 -0
- package/template/_gitignore +2 -0
- package/template/apps/start/.env.example +4 -0
- package/template/apps/start/package.json +1 -1
- package/template/apps/start/scripts/check-services.ts +22 -0
- package/template/apps/start/src/server/storage.ts +13 -0
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/scripts/check-services.ts +22 -0
- package/template/apps/web/src/server/storage.ts +13 -0
- package/template/clubedge.template.json +19 -1
- package/template/package.json +1 -1
- package/template/packages/auth/package.json +1 -1
- package/template/packages/auth-better-auth/package.json +1 -1
- package/template/packages/auth-supabase/package.json +1 -1
- package/template/packages/cache/package.json +1 -1
- package/template/packages/cache-redis/package.json +1 -1
- package/template/packages/core/package.json +1 -1
- package/template/packages/db/package.json +1 -1
- package/template/packages/local-db/package.json +24 -0
- package/template/packages/local-db/scripts/serve.ts +66 -0
- package/template/packages/local-db/src/server.test.ts +119 -0
- package/template/packages/local-db/src/server.ts +153 -0
- package/template/packages/local-db/tsconfig.json +4 -0
- package/template/packages/storage/package.json +3 -2
- package/template/packages/storage/src/file-system.test.ts +110 -0
- package/template/packages/storage/src/file-system.ts +124 -0
- package/template/packages/storage-s3/package.json +1 -1
- package/template/packages/storage-supabase/package.json +1 -1
- package/template/packages/ui/package.json +1 -1
- package/template/pnpm-lock.yaml +31 -9
- 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.
|
|
15
|
+
create-clubedge-app v0.8.0
|
|
16
16
|
|
|
|
17
17
|
v
|
|
18
|
-
Clubedge Starter v0.
|
|
19
|
-
commit
|
|
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.
|
|
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.
|
|
31
|
-
`
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
package/dist/core/customize.js
CHANGED
|
@@ -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";
|
package/dist/core/env.js
ADDED
|
@@ -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
|
+
}
|
package/dist/core/execute.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|