@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 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.40.0
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.40.0` | `clubedge-starter@v0.26.0` | The CLI's required, tested default |
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
@@ -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];
@@ -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
+ }
@@ -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}`;
@@ -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
- let current = resolve(directory);
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 manifest = JSON.parse(await readFile(manifestPath, "utf8"));
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: { packageName: manifest.name, displayName: displayNameFromPackageName(manifest.name) },
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,
@@ -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, installDependencies, resolvePnpm } from "./steps/install.js";
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 resolvePnpm();
240
+ const { pnpm, problem } = await detectPnpm();
241
241
  if (!pnpm) {
242
- log.warn("pnpm was not found. Enable it with `corepack enable`, then run `pnpm install` in the project.");
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
- await changeProject(args);
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" }
@@ -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 { installDependencies, resolvePnpm } from "./steps/install.js";
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 resolvePnpm();
220
+ const { pnpm, problem } = await detectPnpm();
221
221
  if (!pnpm) {
222
- warnings.push("Project created, but pnpm was not found. Install pnpm and run it in the project directory.");
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 { installDependencies, resolvePnpm } from "./steps/install.js";
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.actions.filter((action) => action.kind === "conflict");
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
- if (args.install) {
389
- const pnpm = await resolvePnpm();
390
- if (!pnpm)
391
- log.warn("pnpm was not found. Run `pnpm install` to update dependencies.");
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
- await installDependencies(project.root, pnpm, plan.lockfile.kind === "install" ? ["--no-frozen-lockfile"] : []);
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)
@@ -1,12 +1,35 @@
1
+ import { tmpdir } from "node:os";
1
2
  import process from "node:process";
2
- import { commandSucceeds, run } from "../utils/process.js";
3
- /** The generated workspace relies on pnpm features (workspace:* and pnpm-workspace.yaml). */
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
- if (await commandSucceeds("pnpm", ["--version"]))
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) {
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Create React apps from Clubedge Starter with a CLI or local MCP server.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.9.0",