@clubedge/create-clubedge-app 0.40.0 → 0.41.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 +14 -0
- package/README.md +7 -2
- package/dist/cli/args.js +5 -0
- package/dist/cli/ui.js +2 -0
- package/dist/core/customize.js +2 -1
- package/dist/core/health.js +17 -0
- package/dist/core/merge.js +5 -0
- package/dist/core/project.js +46 -4
- package/dist/core/update.js +18 -6
- package/dist/index.js +10 -5
- package/dist/inspect.js +17 -2
- package/dist/mcp-server.js +5 -3
- package/dist/modify.js +51 -11
- package/dist/steps/install.js +30 -7
- package/dist/utils/process.js +36 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-clubedge-app` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.41.0
|
|
6
|
+
|
|
7
|
+
- **A `package.json` with conflict markers no longer breaks the next commands** (#122, by @NishilRathod; issue #111):
|
|
8
|
+
- After `add`, `remove`, or `upgrade` leaves markers in a `package.json`, `pnpm install` is skipped with the reason: pnpm cannot read the file, so resolve it, then run `pnpm install`. The conflict report always prints, even when the install fails.
|
|
9
|
+
- pnpm is found from outside the project, so a broken `package.json` is no longer reported as "pnpm was not found", and an installed pnpm that fails says why.
|
|
10
|
+
- `status`, `doctor`, `add`, `remove`, `upgrade`, `deploy`, `neon`, and `try` name the unreadable `package.json` instead of printing a JSON parse error, and point to `git diff --no-index` against the Starter's version in `.clubedge/incoming`.
|
|
11
|
+
- `--detailed-exit-code` for `add`, `remove`, and `upgrade`: exit 2 when the change left conflicts to resolve, 0 when it was clean. Without it, conflicts still exit 0, as existing projects' upgrade workflow expects. The next Starter release's workflow uses it to open the upgrade pull request even when `package.json` conflicts.
|
|
12
|
+
- **`doctor` fails on unresolved conflict markers** in the project's files, listing them with their block counts (#123, by @ahmed-husssain; issue #109).
|
|
13
|
+
- **Projects own their version and README** (#125, by @ahmed-husssain; issue #124):
|
|
14
|
+
- New projects start at version `0.1.0`, and `add`, `remove`, and `upgrade` keep the project's own version, so the root `package.json` no longer conflicts on it with every Starter release.
|
|
15
|
+
- A README the project replaced (without the generated `## Generated from` section) is kept as it is; the Starter's new README goes to `.clubedge/incoming/README.md` to compare.
|
|
16
|
+
- CI covers these cases end to end on Linux and Windows (#128).
|
|
17
|
+
- Still on Starter `v0.26.0`.
|
|
18
|
+
|
|
5
19
|
## 0.40.0
|
|
6
20
|
|
|
7
21
|
- **Every module on React + Vite with FastAPI**: default to Starter `v0.26.0` (#126). `--framework vite --api fastapi` now offers the module options Hono got in 0.39.0, each written in Python:
|
package/README.md
CHANGED
|
@@ -241,6 +241,10 @@ Several modules in one command are applied as one change, and their requirements
|
|
|
241
241
|
|
|
242
242
|
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.
|
|
243
243
|
|
|
244
|
+
pnpm cannot read a `package.json` with conflict markers, so a change that leaves conflicts in one skips `pnpm install` and says so: resolve them, then run `pnpm install`. Until then, `pnpm clubedge` does not start either, and the CLI's own commands (`pnpm dlx @clubedge/create-clubedge-app status`, for example) stop with a message that names the file and the command that compares it with the Starter's version in `.clubedge/incoming/`. When the install fails for another reason, the CLI still lists what is left to resolve, then exits with an error.
|
|
245
|
+
|
|
246
|
+
A change that leaves conflicts exits with 0, like a clean one. For scripts that need to tell them apart, add `--detailed-exit-code`: the CLI then exits with 2 when the change was written with conflicts to resolve, 0 when it was clean or there was nothing to change, and 1 when it failed.
|
|
247
|
+
|
|
244
248
|
## A hosted database with Neon
|
|
245
249
|
|
|
246
250
|
`--infra neon` gives the project a [Neon](https://neon.com) PostgreSQL database with nothing to install and no account:
|
|
@@ -337,7 +341,7 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
|
|
|
337
341
|
Each CLI release is tested against one specific Starter release:
|
|
338
342
|
|
|
339
343
|
```text
|
|
340
|
-
create-clubedge-app v0.
|
|
344
|
+
create-clubedge-app v0.41.0
|
|
341
345
|
|
|
|
342
346
|
v
|
|
343
347
|
Clubedge Starter v0.26.0
|
|
@@ -348,7 +352,7 @@ The current relationship is:
|
|
|
348
352
|
|
|
349
353
|
| CLI release | Default Starter release | Meaning |
|
|
350
354
|
| --- | --- | --- |
|
|
351
|
-
| `create-clubedge-app@0.
|
|
355
|
+
| `create-clubedge-app@0.41.0` | `clubedge-starter@v0.26.0` | The CLI's required, tested default |
|
|
352
356
|
|
|
353
357
|
This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
|
|
354
358
|
|
|
@@ -463,6 +467,7 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
|
|
|
463
467
|
| 0.38.0 | v0.24.2 | Starter v0.24.2: Next.js security update (critical RCE advisories); database URLs, Neon, and deploy variables for a frontend and its API |
|
|
464
468
|
| 0.39.0 | v0.25.0 | Starter v0.25.0: every module on React + Vite with Hono, and account settings on React + Vite |
|
|
465
469
|
| 0.40.0 | v0.26.0 | Starter v0.26.0: every module on React + Vite with FastAPI (pg-boss excepted) |
|
|
470
|
+
| 0.41.0 | v0.26.0 | A conflicted package.json no longer breaks later commands (`--detailed-exit-code`); doctor finds conflict markers; projects own their version and README |
|
|
466
471
|
|
|
467
472
|
## Community
|
|
468
473
|
|
package/dist/cli/args.js
CHANGED
|
@@ -12,6 +12,7 @@ const OPTIONS = {
|
|
|
12
12
|
yes: { type: "boolean", short: "y", default: false },
|
|
13
13
|
"dry-run": { type: "boolean", default: false },
|
|
14
14
|
force: { type: "boolean", default: false },
|
|
15
|
+
"detailed-exit-code": { type: "boolean", default: false },
|
|
15
16
|
services: { type: "boolean", default: true },
|
|
16
17
|
open: { type: "boolean", default: true },
|
|
17
18
|
claim: { type: "boolean", default: false },
|
|
@@ -98,6 +99,9 @@ export function parseCliArgs(argv) {
|
|
|
98
99
|
if (command.name !== "try" && (values.claim || values.status)) {
|
|
99
100
|
throw new UsageError("--claim and --status only apply to try.");
|
|
100
101
|
}
|
|
102
|
+
if (command.name !== "add" && command.name !== "remove" && command.name !== "upgrade" && values["detailed-exit-code"]) {
|
|
103
|
+
throw new UsageError("--detailed-exit-code only applies to add, remove, and upgrade.");
|
|
104
|
+
}
|
|
101
105
|
if (command.name === "try" && values["template-dir"] !== undefined) {
|
|
102
106
|
throw new UsageError("--template-dir does not apply to try: the VM downloads the Starter itself.");
|
|
103
107
|
}
|
|
@@ -122,6 +126,7 @@ export function parseCliArgs(argv) {
|
|
|
122
126
|
yes: values.yes,
|
|
123
127
|
dryRun: values["dry-run"],
|
|
124
128
|
force: values.force,
|
|
129
|
+
detailedExitCode: values["detailed-exit-code"],
|
|
125
130
|
services: values.services,
|
|
126
131
|
open: values.open,
|
|
127
132
|
claim: values.claim,
|
package/dist/cli/ui.js
CHANGED
|
@@ -130,6 +130,8 @@ ${selectionHelp(manifest)}
|
|
|
130
130
|
--template-dir <path> Scaffold from a local Starter checkout (for Starter development)
|
|
131
131
|
--dry-run Show what would be created or changed without writing anything
|
|
132
132
|
--force add/remove: change a project with uncommitted changes or without Git
|
|
133
|
+
--detailed-exit-code add/remove/upgrade: exit with 2 when the change leaves conflicts to
|
|
134
|
+
resolve (0: changed cleanly or nothing to change, 1: failed)
|
|
133
135
|
--no-services doctor: skip pnpm check:services
|
|
134
136
|
--no-open try, deploy (Vercel, Netlify, Render): do not open the URL in the browser
|
|
135
137
|
--claim try: open the page that claims the VM, to share the preview
|
package/dist/core/customize.js
CHANGED
|
@@ -21,9 +21,10 @@ function recordStacks(stacks) {
|
|
|
21
21
|
...(Object.keys(apps).length ? { apps } : {}),
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
|
-
export function customizeRootManifest(source, { packageName }, provenance, dockerImage, scripts = { set: {}, remove: [] }) {
|
|
24
|
+
export function customizeRootManifest(source, { packageName, version = "0.1.0" }, provenance, dockerImage, scripts = { set: {}, remove: [] }) {
|
|
25
25
|
const manifest = JSON.parse(source);
|
|
26
26
|
manifest.name = packageName;
|
|
27
|
+
manifest.version = version;
|
|
27
28
|
if (manifest.scripts && typeof manifest.scripts === "object") {
|
|
28
29
|
for (const name of scripts.remove)
|
|
29
30
|
delete manifest.scripts[name];
|
package/dist/core/health.js
CHANGED
|
@@ -210,3 +210,20 @@ export function checkDatabaseUrls(input) {
|
|
|
210
210
|
}
|
|
211
211
|
return { name, status: "ok", detail: "Neon, pooled for the app and direct for migrations and the jobs worker" };
|
|
212
212
|
}
|
|
213
|
+
export function checkConflicts(conflicts) {
|
|
214
|
+
const name = "Conflicts";
|
|
215
|
+
if (!conflicts.length)
|
|
216
|
+
return { name, status: "ok", detail: "no unresolved conflict markers" };
|
|
217
|
+
const total = conflicts.reduce((sum, c) => sum + c.blocks, 0);
|
|
218
|
+
const sample = conflicts
|
|
219
|
+
.slice(0, 5)
|
|
220
|
+
.map((c) => `\n • ${c.path} (${c.blocks} ${c.blocks === 1 ? "block" : "blocks"})`)
|
|
221
|
+
.join("");
|
|
222
|
+
const more = conflicts.length > 5 ? `\n ... and ${conflicts.length - 5} more files` : "";
|
|
223
|
+
return {
|
|
224
|
+
name,
|
|
225
|
+
status: "fail",
|
|
226
|
+
detail: `${conflicts.length} ${conflicts.length === 1 ? "file has" : "files have"} unresolved conflict markers (${total} total):${sample}${more}`,
|
|
227
|
+
fix: 'Resolve the "<<<<<<< yours" markers in these files, then delete .clubedge/incoming.',
|
|
228
|
+
};
|
|
229
|
+
}
|
package/dist/core/merge.js
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
// without losing the project's own edits. Pure: callers read and write the files.
|
|
4
4
|
import { diff3Merge } from "node-diff3";
|
|
5
5
|
export const CONFLICT_LABELS = { yours: "yours", theirs: "clubedge" };
|
|
6
|
+
/**
|
|
7
|
+
* Where the Starter's versions of files left to resolve are saved, next to the project's own,
|
|
8
|
+
* for comparing by hand. The folder ignores itself, so it never shows in git status or a commit.
|
|
9
|
+
*/
|
|
10
|
+
export const INCOMING_DIR = ".clubedge/incoming";
|
|
6
11
|
/** How many conflict blocks a merged file has. */
|
|
7
12
|
export function countConflicts(content) {
|
|
8
13
|
const marker = `<<<<<<< ${CONFLICT_LABELS.yours}`;
|
package/dist/core/project.js
CHANGED
|
@@ -1,21 +1,59 @@
|
|
|
1
1
|
// Reads an existing generated project and renders projects in memory, so commands that change
|
|
2
2
|
// a project (add, remove, upgrade) can compare what the CLI generated with what it would now.
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
|
-
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { pathExists } from "../utils/fs.js";
|
|
6
6
|
import { displayNameFromPackageName } from "../utils/names.js";
|
|
7
7
|
import { recordedStacks } from "./customize.js";
|
|
8
|
+
import { countConflicts, INCOMING_DIR } from "./merge.js";
|
|
8
9
|
import { createPlan } from "./plan.js";
|
|
9
10
|
export class ProjectError extends Error {
|
|
10
11
|
name = "ProjectError";
|
|
11
12
|
}
|
|
13
|
+
/** The Starter's version of a file, saved by the last change in the `.clubedge` of a folder above it. */
|
|
14
|
+
async function findIncoming(file) {
|
|
15
|
+
let folder = dirname(file);
|
|
16
|
+
while (true) {
|
|
17
|
+
const incoming = join(folder, INCOMING_DIR, relative(folder, file));
|
|
18
|
+
if (await pathExists(incoming))
|
|
19
|
+
return incoming;
|
|
20
|
+
const parent = dirname(folder);
|
|
21
|
+
if (parent === folder)
|
|
22
|
+
return null;
|
|
23
|
+
folder = parent;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Why a package.json does not parse, naming it as seen from `directory`. Most often it is a
|
|
28
|
+
* conflict left by add, remove, or upgrade, which pnpm cannot read either.
|
|
29
|
+
*/
|
|
30
|
+
async function unreadableManifest(path, content, directory, error) {
|
|
31
|
+
const shown = (file) => relative(directory, file).split(sep).join("/");
|
|
32
|
+
const conflicts = countConflicts(content);
|
|
33
|
+
if (!conflicts) {
|
|
34
|
+
return new ProjectError(`${shown(path)} is not valid JSON: ${error instanceof Error ? error.message : String(error)}. Fix it, then run this again.`);
|
|
35
|
+
}
|
|
36
|
+
const incoming = await findIncoming(path);
|
|
37
|
+
return new ProjectError(`${shown(path)} has ${conflicts} unresolved ${conflicts === 1 ? "conflict" : "conflicts"} from an earlier add, remove, or upgrade, ` +
|
|
38
|
+
"marked <<<<<<< yours ... ======= ... >>>>>>> clubedge. While it has the markers, pnpm cannot read it either, so `pnpm install` and `pnpm clubedge` fail too. " +
|
|
39
|
+
"Keep your lines, the Starter's, or a mix, delete the markers, then run this again." +
|
|
40
|
+
(incoming ? `\nThe Starter's version, to compare with yours:\n git diff --no-index ${shown(path)} ${shown(incoming)}` : ""));
|
|
41
|
+
}
|
|
12
42
|
/** Finds the generated project that contains `directory` and reads its provenance. */
|
|
13
43
|
export async function readProject(directory) {
|
|
14
|
-
|
|
44
|
+
const start = resolve(directory);
|
|
45
|
+
let current = start;
|
|
15
46
|
while (true) {
|
|
16
47
|
const manifestPath = join(current, "package.json");
|
|
17
48
|
if (await pathExists(manifestPath)) {
|
|
18
|
-
const
|
|
49
|
+
const content = await readFile(manifestPath);
|
|
50
|
+
let manifest;
|
|
51
|
+
try {
|
|
52
|
+
manifest = JSON.parse(content.toString("utf8"));
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
throw await unreadableManifest(manifestPath, content, start, error);
|
|
56
|
+
}
|
|
19
57
|
const origin = manifest.clubedge;
|
|
20
58
|
if (origin?.starterRef) {
|
|
21
59
|
// Projects record the framework only when the Starter offered a choice, but always their modules.
|
|
@@ -24,7 +62,11 @@ export async function readProject(directory) {
|
|
|
24
62
|
}
|
|
25
63
|
return {
|
|
26
64
|
root: current,
|
|
27
|
-
identity: {
|
|
65
|
+
identity: {
|
|
66
|
+
packageName: manifest.name,
|
|
67
|
+
displayName: displayNameFromPackageName(manifest.name),
|
|
68
|
+
version: typeof manifest.version === "string" ? manifest.version : "0.1.0",
|
|
69
|
+
},
|
|
28
70
|
cliVersion: origin.cliVersion,
|
|
29
71
|
starterRepository: origin.starterRepository,
|
|
30
72
|
starterRef: origin.starterRef,
|
package/dist/core/update.js
CHANGED
|
@@ -4,14 +4,10 @@
|
|
|
4
4
|
import { mkdir, readdir, rm, rmdir, writeFile } from "node:fs/promises";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { updateEnvFile } from "./env.js";
|
|
7
|
-
import { planMerge, sameContent } from "./merge.js";
|
|
7
|
+
import { INCOMING_DIR, planMerge, sameContent } from "./merge.js";
|
|
8
8
|
import { readProjectFiles, renderProject } from "./project.js";
|
|
9
|
+
export { INCOMING_DIR };
|
|
9
10
|
const LOCKFILE = "pnpm-lock.yaml";
|
|
10
|
-
/**
|
|
11
|
-
* Where the Starter's versions of files left to resolve are saved, next to the project's own,
|
|
12
|
-
* for comparing by hand. The folder ignores itself, so it never shows in git status or a commit.
|
|
13
|
-
*/
|
|
14
|
-
export const INCOMING_DIR = ".clubedge/incoming";
|
|
15
11
|
export async function planUpdate(project, base, target) {
|
|
16
12
|
const render = (side) => renderProject({
|
|
17
13
|
source: side.source,
|
|
@@ -32,7 +28,23 @@ export async function planUpdate(project, base, target) {
|
|
|
32
28
|
const current = await readProjectFiles(project.root, [
|
|
33
29
|
...new Set([...baseFiles.keys(), ...targetFiles.keys(), ...special]),
|
|
34
30
|
]);
|
|
31
|
+
const currentReadme = current.get("README.md");
|
|
32
|
+
const customReadme = Boolean(currentReadme && !currentReadme.toString("utf8").includes("## Generated from"));
|
|
33
|
+
const targetReadme = targetFiles.get("README.md");
|
|
34
|
+
if (customReadme) {
|
|
35
|
+
baseFiles.delete("README.md");
|
|
36
|
+
targetFiles.delete("README.md");
|
|
37
|
+
}
|
|
35
38
|
const actions = planMerge(baseFiles, targetFiles, current);
|
|
39
|
+
if (customReadme && targetReadme && !sameContent(currentReadme, targetReadme)) {
|
|
40
|
+
actions.push({
|
|
41
|
+
path: "README.md",
|
|
42
|
+
kind: "keep",
|
|
43
|
+
reason: "You replaced the generated README with your own.",
|
|
44
|
+
incoming: targetReadme,
|
|
45
|
+
});
|
|
46
|
+
actions.sort((a, b) => a.path.localeCompare(b.path));
|
|
47
|
+
}
|
|
36
48
|
let lockfile = { kind: "unchanged" };
|
|
37
49
|
const baseLock = before.files.get(LOCKFILE);
|
|
38
50
|
const targetLock = after.files.get(LOCKFILE);
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import { deployProject } from "./deploy.js";
|
|
|
15
15
|
import { tryOnRailway } from "./try.js";
|
|
16
16
|
import { describeNewDatabase, neonCommand, provisionNeonDatabase } from "./neon.js";
|
|
17
17
|
import { initializeGit } from "./steps/git.js";
|
|
18
|
-
import { detectLauncher,
|
|
18
|
+
import { detectLauncher, detectPnpm, installDependencies } from "./steps/install.js";
|
|
19
19
|
import { commandSucceeds, run } from "./utils/process.js";
|
|
20
20
|
import { displayNameFromPackageName, packageNameFromDirectory } from "./utils/names.js";
|
|
21
21
|
const minimumNode = [22, 12];
|
|
@@ -148,7 +148,7 @@ async function createProject(args) {
|
|
|
148
148
|
const targetDirectory = resolve(process.cwd(), requested);
|
|
149
149
|
await assertTargetIsUsable(targetDirectory);
|
|
150
150
|
const packageName = packageNameFromDirectory(targetDirectory);
|
|
151
|
-
const identity = { packageName, displayName: displayNameFromPackageName(packageName) };
|
|
151
|
+
const identity = { packageName, displayName: displayNameFromPackageName(packageName), version: "0.1.0" };
|
|
152
152
|
const progress = spinner();
|
|
153
153
|
progress.start("Preparing the Starter");
|
|
154
154
|
const source = await resolveTemplateSource({ ref: args.ref, templateDir: args.templateDir }).catch((error) => {
|
|
@@ -237,9 +237,11 @@ async function createProject(args) {
|
|
|
237
237
|
if (launcher && launcher !== "pnpm") {
|
|
238
238
|
log.info(`This project is a pnpm workspace, so dependencies are installed with pnpm (not ${launcher}).`);
|
|
239
239
|
}
|
|
240
|
-
const pnpm = await
|
|
240
|
+
const { pnpm, problem } = await detectPnpm();
|
|
241
241
|
if (!pnpm) {
|
|
242
|
-
log.warn(
|
|
242
|
+
log.warn(problem
|
|
243
|
+
? `pnpm is installed but did not run, so dependencies were not installed. ${problem} Run \`pnpm install\` in the project once it works.`
|
|
244
|
+
: "pnpm was not found. Enable it with `corepack enable`, then run `pnpm install` in the project.");
|
|
243
245
|
}
|
|
244
246
|
else {
|
|
245
247
|
pnpmCommand = pnpm.join(" ");
|
|
@@ -321,7 +323,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
321
323
|
upgrade: "Upgrade this project to a newer Starter",
|
|
322
324
|
};
|
|
323
325
|
intro(intros[args.command.name]);
|
|
324
|
-
|
|
326
|
+
// Conflicts exit with 0 unless asked: the Starter's upgrade workflow in existing projects
|
|
327
|
+
// runs the newest CLI under `set -o pipefail`, and would stop before opening its pull request.
|
|
328
|
+
if ((await changeProject(args)) === "conflicts" && args.detailedExitCode)
|
|
329
|
+
process.exitCode = 2;
|
|
325
330
|
}
|
|
326
331
|
}
|
|
327
332
|
catch (error) {
|
package/dist/inspect.js
CHANGED
|
@@ -5,14 +5,15 @@ import { readFile } from "node:fs/promises";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import process from "node:process";
|
|
7
7
|
import { dim } from "./cli/ui.js";
|
|
8
|
-
import { checkDatabaseUrls, checkEnvFile, checkLockfile, checkNode, checkPnpm, fetchLatestRelease, nextStep, updateState, } from "./core/health.js";
|
|
8
|
+
import { checkConflicts, checkDatabaseUrls, checkEnvFile, checkLockfile, checkNode, checkPnpm, fetchLatestRelease, nextStep, updateState, } from "./core/health.js";
|
|
9
|
+
import { countConflicts } from "./core/merge.js";
|
|
9
10
|
import { parseEnvFile } from "./core/deploy.js";
|
|
10
11
|
import { neonDatabaseState } from "./core/neon.js";
|
|
11
12
|
import { databaseEnvFile, projectEnvFiles, readProject } from "./core/project.js";
|
|
12
13
|
import { savedDatabaseForUrl } from "./neon.js";
|
|
13
14
|
import { cliCommand } from "./modify.js";
|
|
14
15
|
import { resolvePnpm } from "./steps/install.js";
|
|
15
|
-
import { pathExists } from "./utils/fs.js";
|
|
16
|
+
import { listTemplateFiles, pathExists } from "./utils/fs.js";
|
|
16
17
|
import { commandSucceeds, run, runCapture } from "./utils/process.js";
|
|
17
18
|
async function readText(path) {
|
|
18
19
|
try {
|
|
@@ -78,6 +79,19 @@ function print(result) {
|
|
|
78
79
|
else
|
|
79
80
|
log.error(line);
|
|
80
81
|
}
|
|
82
|
+
export async function findConflictedFiles(root) {
|
|
83
|
+
const files = await listTemplateFiles(root);
|
|
84
|
+
const conflicts = [];
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
const content = await readFile(join(root, file)).catch(() => null);
|
|
87
|
+
if (!content)
|
|
88
|
+
continue;
|
|
89
|
+
const blocks = countConflicts(content);
|
|
90
|
+
if (blocks > 0)
|
|
91
|
+
conflicts.push({ path: file, blocks });
|
|
92
|
+
}
|
|
93
|
+
return conflicts;
|
|
94
|
+
}
|
|
81
95
|
export async function runDoctor(args) {
|
|
82
96
|
const project = await readProject(process.cwd());
|
|
83
97
|
const root = project.root;
|
|
@@ -91,6 +105,7 @@ export async function runDoctor(args) {
|
|
|
91
105
|
const pnpm = await resolvePnpm();
|
|
92
106
|
const pnpmVersion = pnpm ? await runCapture(pnpm[0], [...pnpm.slice(1), "--version"]).catch(() => null) : null;
|
|
93
107
|
check(checkPnpm(pnpmVersion, manifest.packageManager));
|
|
108
|
+
check(checkConflicts(await findConflictedFiles(root)));
|
|
94
109
|
const installed = await pathExists(join(root, "node_modules", ".modules.yaml"));
|
|
95
110
|
check(installed
|
|
96
111
|
? { name: "Dependencies", status: "ok", detail: "installed" }
|
package/dist/mcp-server.js
CHANGED
|
@@ -11,7 +11,7 @@ import { createPlan, describePlan } from "./core/plan.js";
|
|
|
11
11
|
import { resolveTemplateSource } from "./core/source.js";
|
|
12
12
|
import { cliVersion, cliUrl, siteUrl } from "./package-info.js";
|
|
13
13
|
import { initializeGit } from "./steps/git.js";
|
|
14
|
-
import {
|
|
14
|
+
import { detectPnpm, installDependencies } from "./steps/install.js";
|
|
15
15
|
import { commandSucceeds, run } from "./utils/process.js";
|
|
16
16
|
import { displayNameFromPackageName, packageNameFromDirectory } from "./utils/names.js";
|
|
17
17
|
// Keep subprocess output off the MCP JSON-RPC stdout stream, and don't let children read MCP input.
|
|
@@ -217,9 +217,11 @@ function createServer() {
|
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
219
|
if (install_dependencies) {
|
|
220
|
-
const pnpm = await
|
|
220
|
+
const { pnpm, problem } = await detectPnpm();
|
|
221
221
|
if (!pnpm) {
|
|
222
|
-
warnings.push(
|
|
222
|
+
warnings.push(problem
|
|
223
|
+
? `Project created, but pnpm is installed and did not run. ${problem}`
|
|
224
|
+
: "Project created, but pnpm was not found. Install pnpm and run it in the project directory.");
|
|
223
225
|
}
|
|
224
226
|
else {
|
|
225
227
|
try {
|
package/dist/modify.js
CHANGED
|
@@ -18,7 +18,8 @@ import { applyUpdate, INCOMING_DIR, planUpdate } from "./core/update.js";
|
|
|
18
18
|
import { cliVersion, starterPin } from "./package-info.js";
|
|
19
19
|
import { parseEnvFile } from "./core/deploy.js";
|
|
20
20
|
import { describeNewDatabase, projectDatabaseEnv, provisionNeonDatabase } from "./neon.js";
|
|
21
|
-
import {
|
|
21
|
+
import { detectPnpm, installDependencies } from "./steps/install.js";
|
|
22
|
+
import { listTemplateFiles } from "./utils/fs.js";
|
|
22
23
|
import { commandSucceeds, runCapture } from "./utils/process.js";
|
|
23
24
|
/** How to run the CLI in this project: its `clubedge` script when it has one. */
|
|
24
25
|
export async function cliCommand(project) {
|
|
@@ -230,8 +231,27 @@ function describe(plan) {
|
|
|
230
231
|
}
|
|
231
232
|
return lines.join("\n");
|
|
232
233
|
}
|
|
234
|
+
const conflictsOf = (plan) => plan.actions.filter((action) => action.kind === "conflict");
|
|
235
|
+
/**
|
|
236
|
+
* The project's package.json files with conflict markers: this change's, and any an earlier change
|
|
237
|
+
* left that this one does not touch, which its plan does not list.
|
|
238
|
+
*/
|
|
239
|
+
async function conflictedManifests(root) {
|
|
240
|
+
const manifests = (await listTemplateFiles(root)).filter((path) => /(^|\/)package\.json$/.test(path));
|
|
241
|
+
const conflicted = await Promise.all(manifests.map(async (path) => (countConflicts(await readFile(join(root, path))) ? path : null)));
|
|
242
|
+
return conflicted.filter((path) => path !== null);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Why pnpm cannot run yet: it reads every package.json in the workspace and stops at one with
|
|
246
|
+
* conflict markers, so `pnpm install` and `pnpm clubedge` fail until they are resolved.
|
|
247
|
+
*/
|
|
248
|
+
function conflictedManifestWarning(paths, install) {
|
|
249
|
+
const one = paths.length === 1;
|
|
250
|
+
return (`${install ? "Skipped `pnpm install`: " : ""}${paths.join(", ")} ${one ? "has" : "have"} conflict markers, which pnpm cannot read, ` +
|
|
251
|
+
`so pnpm commands, \`pnpm clubedge\` among them, fail until ${one ? "it is" : "they are"} resolved. Then run \`pnpm install\`.`);
|
|
252
|
+
}
|
|
233
253
|
function report(plan) {
|
|
234
|
-
const conflicts = plan
|
|
254
|
+
const conflicts = conflictsOf(plan);
|
|
235
255
|
const kept = plan.actions.filter((action) => action.kind === "keep");
|
|
236
256
|
const incoming = (path) => `${INCOMING_DIR}/${path}`;
|
|
237
257
|
if (conflicts.length) {
|
|
@@ -366,12 +386,12 @@ export async function changeProject(args) {
|
|
|
366
386
|
const complete = command.name === "upgrade" ? command : await completeCommand(baseManifest, project, command, interactive);
|
|
367
387
|
if (!complete) {
|
|
368
388
|
outro("Cancelled; nothing was changed.");
|
|
369
|
-
return;
|
|
389
|
+
return "unchanged";
|
|
370
390
|
}
|
|
371
391
|
const change = await planChange(project, complete, base, baseManifest, target, cli);
|
|
372
392
|
if (!change) {
|
|
373
393
|
outro(upgrading ? `The project is already on Starter ${project.starterRef}.` : "The project already uses that selection; nothing to change.");
|
|
374
|
-
return;
|
|
394
|
+
return "unchanged";
|
|
375
395
|
}
|
|
376
396
|
const plan = await planUpdate(project, change.base, change.target);
|
|
377
397
|
if (change.releases)
|
|
@@ -381,17 +401,32 @@ export async function changeProject(args) {
|
|
|
381
401
|
for (const note of change.notes)
|
|
382
402
|
log.info(note);
|
|
383
403
|
outro("Dry run: nothing was changed. Run again without --dry-run to apply.");
|
|
384
|
-
return;
|
|
404
|
+
return "unchanged";
|
|
385
405
|
}
|
|
386
406
|
await applyUpdate(plan);
|
|
387
407
|
log.success(`Updated ${plan.actions.filter((action) => action.kind !== "keep").length} files`);
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
408
|
+
// The files are written now, so what is left to resolve is reported even when the install fails.
|
|
409
|
+
let installError;
|
|
410
|
+
const manifests = await conflictedManifests(project.root);
|
|
411
|
+
if (manifests.length) {
|
|
412
|
+
log.warn(conflictedManifestWarning(manifests, args.install));
|
|
413
|
+
}
|
|
414
|
+
else if (args.install) {
|
|
415
|
+
const { pnpm, problem } = await detectPnpm();
|
|
416
|
+
if (pnpm) {
|
|
417
|
+
try {
|
|
418
|
+
await installDependencies(project.root, pnpm, plan.lockfile.kind === "install" ? ["--no-frozen-lockfile"] : []);
|
|
419
|
+
log.success("Dependencies updated");
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
installError = error;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
else if (problem) {
|
|
426
|
+
log.warn(`pnpm is installed but did not run, so dependencies were not updated. ${problem} Run \`pnpm install\` once it works.`);
|
|
427
|
+
}
|
|
392
428
|
else {
|
|
393
|
-
|
|
394
|
-
log.success("Dependencies updated");
|
|
429
|
+
log.warn("pnpm was not found. Run `pnpm install` to update dependencies.");
|
|
395
430
|
}
|
|
396
431
|
}
|
|
397
432
|
else if (plan.lockfile.kind !== "unchanged") {
|
|
@@ -400,11 +435,16 @@ export async function changeProject(args) {
|
|
|
400
435
|
report(plan);
|
|
401
436
|
for (const note of change.notes)
|
|
402
437
|
log.info(note);
|
|
438
|
+
if (installError) {
|
|
439
|
+
const reason = installError instanceof Error ? installError.message : String(installError);
|
|
440
|
+
throw new Error(`The files were updated, but \`pnpm install\` did not complete: ${reason}${reason.endsWith(".") ? "" : "."} Fix that, then run \`pnpm install\`.`);
|
|
441
|
+
}
|
|
403
442
|
if (change.target.modules.infra === "neon" && project.modules.infra !== "neon")
|
|
404
443
|
await addNeonDatabase(project, args, cli);
|
|
405
444
|
outro(upgrading
|
|
406
445
|
? "Review the changes with `git diff`, then run `pnpm db:migrate` for new migrations and your tests."
|
|
407
446
|
: "Review the changes with `git diff`, then run `pnpm check:services` and `pnpm db:migrate` if the change added services or tables.");
|
|
447
|
+
return conflictsOf(plan).length ? "conflicts" : "changed";
|
|
408
448
|
}
|
|
409
449
|
catch (error) {
|
|
410
450
|
if (spinning)
|
package/dist/steps/install.js
CHANGED
|
@@ -1,12 +1,35 @@
|
|
|
1
|
+
import { tmpdir } from "node:os";
|
|
1
2
|
import process from "node:process";
|
|
2
|
-
import {
|
|
3
|
-
/**
|
|
3
|
+
import { onPath, run, runCapture, withAbsolutePath } from "../utils/process.js";
|
|
4
|
+
/**
|
|
5
|
+
* Finds pnpm, which the generated workspace relies on (workspace:* and pnpm-workspace.yaml), or
|
|
6
|
+
* corepack, which runs the pnpm version the project pins. Both are checked outside the project:
|
|
7
|
+
* they read the package.json of the folder they start in and fail when it does not parse, such as
|
|
8
|
+
* while it has conflict markers, and that is not pnpm missing.
|
|
9
|
+
*/
|
|
10
|
+
export async function detectPnpm() {
|
|
11
|
+
let problem;
|
|
12
|
+
for (const pnpm of [["pnpm"], ["corepack", "pnpm"]]) {
|
|
13
|
+
const command = pnpm[0];
|
|
14
|
+
try {
|
|
15
|
+
// `corepack --version`, not `corepack pnpm --version`: outside a project, corepack would
|
|
16
|
+
// download its own default pnpm just to answer. PATH's relative folders still mean the
|
|
17
|
+
// folder the CLI runs in, where onPath and the install look.
|
|
18
|
+
await runCapture(command, ["--version"], tmpdir(), withAbsolutePath());
|
|
19
|
+
return { pnpm };
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (!problem && (await onPath(command))) {
|
|
23
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
24
|
+
problem = `\`${command} --version\` failed: ${reason}${reason.endsWith(".") ? "" : "."}`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { pnpm: null, problem };
|
|
29
|
+
}
|
|
30
|
+
/** The command that runs pnpm, or null; detectPnpm also says why an installed one did not run. */
|
|
4
31
|
export async function resolvePnpm() {
|
|
5
|
-
|
|
6
|
-
return ["pnpm"];
|
|
7
|
-
if (await commandSucceeds("corepack", ["pnpm", "--version"]))
|
|
8
|
-
return ["corepack", "pnpm"];
|
|
9
|
-
return null;
|
|
32
|
+
return (await detectPnpm()).pnpm;
|
|
10
33
|
}
|
|
11
34
|
/** The package manager that launched the CLI, from npm_config_user_agent (e.g. "npm/10.8.2 ..."). */
|
|
12
35
|
export function detectLauncher(userAgent = process.env.npm_config_user_agent) {
|
package/dist/utils/process.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { access } from "node:fs/promises";
|
|
3
|
+
import { delimiter, isAbsolute, join, resolve } from "node:path";
|
|
2
4
|
import process from "node:process";
|
|
3
5
|
// Package manager shims on Windows are .cmd files, which need a shell to start.
|
|
4
6
|
const useShell = process.platform === "win32";
|
|
@@ -29,9 +31,9 @@ export async function run(command, args, cwd, stdio = "inherit") {
|
|
|
29
31
|
});
|
|
30
32
|
}
|
|
31
33
|
/** Runs a command and returns trimmed stdout. Rejects with stderr on a non-zero exit. */
|
|
32
|
-
export async function runCapture(command, args, cwd) {
|
|
34
|
+
export async function runCapture(command, args, cwd, env) {
|
|
33
35
|
return await new Promise((resolve, reject) => {
|
|
34
|
-
const child = spawnCommand(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
36
|
+
const child = spawnCommand(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
35
37
|
let stdout = "";
|
|
36
38
|
let stderr = "";
|
|
37
39
|
child.stdout?.on("data", (chunk) => (stdout += chunk.toString()));
|
|
@@ -57,3 +59,35 @@ export async function commandSucceeds(command, args) {
|
|
|
57
59
|
return false;
|
|
58
60
|
}
|
|
59
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* The environment with PATH's relative folders, such as node_modules/.bin, resolved from `base`,
|
|
64
|
+
* for a command started in another folder, which would look them up from there.
|
|
65
|
+
*/
|
|
66
|
+
export function withAbsolutePath(env = process.env, base = process.cwd()) {
|
|
67
|
+
// Windows keeps the name's own case, such as Path.
|
|
68
|
+
const key = Object.keys(env).find((name) => name.toUpperCase() === "PATH");
|
|
69
|
+
if (!key || !env[key])
|
|
70
|
+
return env;
|
|
71
|
+
const folders = env[key].split(delimiter).map((folder) => (folder && !isAbsolute(folder) ? resolve(base, folder) : folder));
|
|
72
|
+
return { ...env, [key]: folders.join(delimiter) };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Whether a command is installed: a file of that name in a PATH folder, with one of PATHEXT's
|
|
76
|
+
* extensions on Windows. It tells a missing command from one that fails, which cmd.exe reports
|
|
77
|
+
* the same way (exit code 1).
|
|
78
|
+
*/
|
|
79
|
+
export async function onPath(command, env = process.env) {
|
|
80
|
+
const extensions = useShell ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
|
|
81
|
+
for (const folder of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
|
|
82
|
+
for (const extension of extensions) {
|
|
83
|
+
try {
|
|
84
|
+
await access(join(folder, command + extension));
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Not in this folder.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|