@clubedge/create-clubedge-app 0.40.0 → 0.41.1
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 +18 -0
- package/README.md +16 -11
- 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 +3 -3
- package/template/.github/workflows/clubedge-upgrade.yml +81 -22
- package/template/apps/api/package.json +1 -1
- package/template/apps/api-fastapi/package.json +1 -1
- package/template/apps/api-fastapi/pyproject.toml +1 -1
- package/template/apps/api-fastapi/uv.lock +1 -1
- package/template/apps/start/package.json +1 -1
- package/template/apps/vite/package.json +1 -1
- package/template/apps/web/package.json +1 -1
- package/template/clubedge.template.json +1 -0
- package/template/package.json +1 -1
- package/template/packages/auth/package.json +1 -1
- package/template/packages/auth-appwrite/package.json +1 -1
- package/template/packages/auth-better-auth/package.json +1 -1
- package/template/packages/auth-firebase/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/contract/package.json +1 -1
- package/template/packages/core/package.json +1 -1
- package/template/packages/db/package.json +1 -1
- package/template/packages/email/package.json +1 -1
- package/template/packages/email-resend/package.json +1 -1
- package/template/packages/email-smtp/package.json +1 -1
- package/template/packages/jobs/package.json +1 -1
- package/template/packages/jobs-bullmq/package.json +1 -1
- package/template/packages/jobs-inngest/package.json +1 -1
- package/template/packages/jobs-pg-boss/package.json +1 -1
- package/template/packages/local-db/package.json +1 -1
- package/template/packages/observability/package.json +1 -1
- package/template/packages/observability-otel/package.json +1 -1
- package/template/packages/observability-sentry/package.json +1 -1
- package/template/packages/payments/package.json +1 -1
- package/template/packages/payments-stripe/package.json +1 -1
- package/template/packages/storage/package.json +1 -1
- 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.lock.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-clubedge-app` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.41.1
|
|
6
|
+
|
|
7
|
+
- Default to Starter `v0.26.1` (#130): new projects' weekly upgrade workflow opens its pull request when the upgrade leaves conflict markers in a `package.json`, listing them, instead of stopping without one. It runs `upgrade --detailed-exit-code`, from 0.41.0. Existing projects get the workflow through their next upgrade pull request.
|
|
8
|
+
|
|
9
|
+
## 0.41.0
|
|
10
|
+
|
|
11
|
+
- **A `package.json` with conflict markers no longer breaks the next commands** (#122, by @NishilRathod; issue #111):
|
|
12
|
+
- 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.
|
|
13
|
+
- 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.
|
|
14
|
+
- `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`.
|
|
15
|
+
- `--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.
|
|
16
|
+
- **`doctor` fails on unresolved conflict markers** in the project's files, listing them with their block counts (#123, by @ahmed-husssain; issue #109).
|
|
17
|
+
- **Projects own their version and README** (#125, by @ahmed-husssain; issue #124):
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
20
|
+
- CI covers these cases end to end on Linux and Windows (#128).
|
|
21
|
+
- Still on Starter `v0.26.0`.
|
|
22
|
+
|
|
5
23
|
## 0.40.0
|
|
6
24
|
|
|
7
25
|
- **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
|
@@ -141,7 +141,7 @@ They meet at one HTTP contract, `packages/contract/openapi.yaml`: the frontend c
|
|
|
141
141
|
|
|
142
142
|
With `--auth supabase`, the Vite app owns the browser session and attaches its access token to contract requests. Hono or FastAPI verifies that token with Supabase Auth and returns the contract's shared user shape. Configure `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_KEY`, `SUPABASE_URL`, and `SUPABASE_PUBLISHABLE_KEY` in the generated env examples; no Supabase service-role key goes to the browser.
|
|
143
143
|
|
|
144
|
-
With the Hono API, the decoupled stacks support every module option the full-stack frameworks do (since Starter v0.26.
|
|
144
|
+
With the Hono API, the decoupled stacks support every module option the full-stack frameworks do (since Starter v0.26.1), each running in the API with the matching UI in the frontend; with FastAPI, they support Supabase Auth, Better Auth, Firebase, Appwrite, or `none`, plus memory cache. Both work with any local infrastructure (the API's `apps/api/.env.local` holds `DATABASE_URL`, since the API owns the database), and Docker or Railway deployment. With Docker, `pnpm clubedge deploy` writes a `compose.yaml` that builds the frontend and API as separate services; copy the variables of `.env.example` and `apps/api/.env.example` to `.env.production`, fill in their values, and run `docker compose --env-file .env.production up --build -d`. The frontend's `VITE_*` values are build arguments, compiled into its bundle; with Better Auth, its Node server also gets the auth variables (`DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, and the email settings). With Railway, deploy provisions a public frontend and a private API, wires the frontend proxy to the API, and runs migrations before releasing the API. See [docs/stack-packs.md](docs/stack-packs.md) for how stacks and deployment facts are described.
|
|
145
145
|
|
|
146
146
|
## Choosing modules
|
|
147
147
|
|
|
@@ -207,7 +207,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
|
|
|
207
207
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
208
208
|
|
|
209
209
|
# Reproduce a specific Starter release or commit
|
|
210
|
-
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.26.
|
|
210
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.26.1
|
|
211
211
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
|
|
212
212
|
|
|
213
213
|
# Test local Starter changes before releasing them
|
|
@@ -226,7 +226,7 @@ pnpm clubedge add email resend payments stripe # several modules as
|
|
|
226
226
|
pnpm clubedge remove storage payments
|
|
227
227
|
pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
|
|
228
228
|
pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
|
|
229
|
-
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.26.
|
|
229
|
+
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.26.1 # or a specific tag, branch, or commit
|
|
230
230
|
```
|
|
231
231
|
|
|
232
232
|
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.
|
|
@@ -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,27 +341,27 @@ 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.1
|
|
341
345
|
|
|
|
342
346
|
v
|
|
343
|
-
Clubedge Starter v0.26.
|
|
344
|
-
commit
|
|
347
|
+
Clubedge Starter v0.26.1
|
|
348
|
+
commit 93429784e265c9dc8eb8f8c87169e7b42732719f
|
|
345
349
|
```
|
|
346
350
|
|
|
347
351
|
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.1` | `clubedge-starter@v0.26.1` | 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
|
|
|
355
|
-
The `v0.26.
|
|
356
|
-
`
|
|
359
|
+
The `v0.26.1` tag is expected to resolve to commit
|
|
360
|
+
`93429784e265c9dc8eb8f8c87169e7b42732719f`. The CLI verifies this before
|
|
357
361
|
scaffolding with the default reference and fails rather than silently using a
|
|
358
362
|
moved tag.
|
|
359
363
|
|
|
360
|
-
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.26.
|
|
364
|
+
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.26.1`. 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.
|
|
361
365
|
|
|
362
366
|
Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
|
|
363
367
|
|
|
@@ -412,7 +416,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
|
|
|
412
416
|
1. Make and validate changes in `clubedge-starter`.
|
|
413
417
|
2. Run lint, typecheck, unit tests, browser tests, and production build.
|
|
414
418
|
3. Update the Starter package versions.
|
|
415
|
-
4. Commit `main`, create a release tag such as `v0.26.
|
|
419
|
+
4. Commit `main`, create a release tag such as `v0.26.1`, and publish the Starter release.
|
|
416
420
|
|
|
417
421
|
### CLI maintainers
|
|
418
422
|
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/create-clubedge-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.1",
|
|
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",
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
},
|
|
25
25
|
"clubedge": {
|
|
26
26
|
"starterRepository": "Clubedge/clubedge-starter",
|
|
27
|
-
"starterRef": "v0.26.
|
|
28
|
-
"starterCommit": "
|
|
27
|
+
"starterRef": "v0.26.1",
|
|
28
|
+
"starterCommit": "93429784e265c9dc8eb8f8c87169e7b42732719f"
|
|
29
29
|
},
|
|
30
30
|
"license": "Apache-2.0",
|
|
31
31
|
"repository": {
|
|
@@ -37,14 +37,50 @@ jobs:
|
|
|
37
37
|
id: upgrade
|
|
38
38
|
shell: bash
|
|
39
39
|
run: |
|
|
40
|
-
|
|
40
|
+
# A field of `clubedge` in package.json, read with fs: while a package.json has conflict
|
|
41
|
+
# markers, require() fails for every file in the project, and pnpm cannot read it. After
|
|
42
|
+
# an upgrade that left them, the Starter's version saved in .clubedge/incoming has the
|
|
43
|
+
# new values; pass it as the fallback.
|
|
44
|
+
starter_field() {
|
|
45
|
+
node -e '
|
|
46
|
+
const fs = require("node:fs");
|
|
47
|
+
const [field, fallback] = process.argv.slice(1);
|
|
48
|
+
const read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
49
|
+
let manifest;
|
|
50
|
+
try {
|
|
51
|
+
manifest = read("package.json");
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (!fallback) {
|
|
54
|
+
console.error(`package.json could not be read (${error.message}). Fix it on the default branch, then run this workflow again.`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
manifest = read(fallback);
|
|
59
|
+
} catch {
|
|
60
|
+
console.error(`Neither package.json nor ${fallback} could be read after the upgrade.`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
console.log(manifest.clubedge?.[field] ?? "");
|
|
65
|
+
' "$@"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
before=$(starter_field starterRef)
|
|
41
69
|
if [ -z "$before" ]; then
|
|
42
70
|
echo "package.json records no Starter release: this project was not created by create-clubedge-app."
|
|
43
71
|
exit 1
|
|
44
72
|
fi
|
|
45
|
-
# The newest CLI upgrades to the Starter release it is pinned to.
|
|
46
|
-
|
|
47
|
-
|
|
73
|
+
# The newest CLI upgrades to the Starter release it is pinned to. With
|
|
74
|
+
# --detailed-exit-code it exits with 2 when the upgrade left conflicts to resolve, which
|
|
75
|
+
# the pull request lists; any other failure stops here.
|
|
76
|
+
status=0
|
|
77
|
+
pnpm dlx @clubedge/create-clubedge-app@latest upgrade --no-install --detailed-exit-code 2>&1 | tee "$RUNNER_TEMP/upgrade.log" || status=$?
|
|
78
|
+
if [ "$status" -ne 0 ] && [ "$status" -ne 2 ]; then
|
|
79
|
+
echo "The upgrade failed (exit code $status); nothing was pushed."
|
|
80
|
+
exit "$status"
|
|
81
|
+
fi
|
|
82
|
+
after=$(starter_field starterRef .clubedge/incoming/package.json)
|
|
83
|
+
repository=$(starter_field starterRepository .clubedge/incoming/package.json)
|
|
48
84
|
if [ "$after" = "$before" ] && [ -z "$(git status --porcelain)" ]; then
|
|
49
85
|
echo "Already on the newest Starter release, $before."
|
|
50
86
|
exit 0
|
|
@@ -54,12 +90,21 @@ jobs:
|
|
|
54
90
|
echo "$branch already exists: its pull request is open, or was closed on purpose."
|
|
55
91
|
exit 0
|
|
56
92
|
fi
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
93
|
+
conflicts=""
|
|
94
|
+
if [ "$status" -eq 2 ]; then conflicts=$(git grep -l --untracked '^<<<<<<< yours' -- . || true); fi
|
|
95
|
+
# The lockfile follows the upgraded package.json files. pnpm cannot read one with
|
|
96
|
+
# conflict markers, so the install waits until they are resolved.
|
|
97
|
+
if printf '%s\n' "$conflicts" | grep -qE '(^|/)package\.json$'; then
|
|
98
|
+
install="skipped"
|
|
99
|
+
else
|
|
100
|
+
install="updated"
|
|
101
|
+
pnpm install --no-frozen-lockfile >"$RUNNER_TEMP/install.log" 2>&1 || install="failed"
|
|
102
|
+
fi
|
|
103
|
+
printf '%s\n' "$conflicts" | sed '/^$/d' >"$RUNNER_TEMP/conflicts.txt"
|
|
60
104
|
{
|
|
61
105
|
echo "before=$before"
|
|
62
106
|
echo "after=$after"
|
|
107
|
+
echo "repository=${repository:-Clubedge/clubedge-starter}"
|
|
63
108
|
echo "branch=$branch"
|
|
64
109
|
echo "install=$install"
|
|
65
110
|
if [ -d .clubedge/incoming ]; then echo "incoming=true"; fi
|
|
@@ -79,21 +124,17 @@ jobs:
|
|
|
79
124
|
OWN_TOKEN: ${{ secrets.CLUBEDGE_UPGRADE_TOKEN != '' }}
|
|
80
125
|
BEFORE: ${{ steps.upgrade.outputs.before }}
|
|
81
126
|
AFTER: ${{ steps.upgrade.outputs.after }}
|
|
127
|
+
REPOSITORY: ${{ steps.upgrade.outputs.repository }}
|
|
82
128
|
BRANCH: ${{ steps.upgrade.outputs.branch }}
|
|
83
129
|
INSTALL: ${{ steps.upgrade.outputs.install }}
|
|
130
|
+
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
84
131
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
85
132
|
run: |
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
git add --all
|
|
90
|
-
git commit --quiet --message "chore: upgrade to Clubedge Starter $AFTER"
|
|
91
|
-
git push --quiet --set-upstream origin "$BRANCH"
|
|
92
|
-
|
|
93
|
-
repository=$(node -p "require('./package.json').clubedge.starterRepository ?? 'Clubedge/clubedge-starter'")
|
|
94
|
-
conflicts=$(git grep -l '^<<<<<<< yours' -- . || true)
|
|
133
|
+
# The pull request's text is written before anything is pushed: it reads nothing from
|
|
134
|
+
# the upgraded files, whose package.json may have conflict markers.
|
|
135
|
+
conflicts=$(cat "$RUNNER_TEMP/conflicts.txt")
|
|
95
136
|
{
|
|
96
|
-
echo "Upgrades this project from Clubedge Starter \`$BEFORE\` to [\`$AFTER\`](https://github.com/$
|
|
137
|
+
echo "Upgrades this project from Clubedge Starter \`$BEFORE\` to [\`$AFTER\`](https://github.com/$REPOSITORY/releases/tag/$AFTER), with \`pnpm clubedge upgrade\`. Your own edits are merged in; files you never changed take the new version."
|
|
97
138
|
echo
|
|
98
139
|
if [ -n "$conflicts" ]; then
|
|
99
140
|
echo "### Conflicts to resolve"
|
|
@@ -105,15 +146,20 @@ jobs:
|
|
|
105
146
|
echo "The Starter's whole new version of each is in the **starter-versions** artifact of [the run]($RUN_URL), to compare with yours."
|
|
106
147
|
echo
|
|
107
148
|
fi
|
|
108
|
-
if [ "$INSTALL" = "
|
|
149
|
+
if [ "$INSTALL" = "skipped" ]; then
|
|
150
|
+
echo "### pnpm install"
|
|
151
|
+
echo
|
|
152
|
+
echo "\`pnpm install\` did not run: pnpm cannot read a \`package.json\` with conflict markers, so \`pnpm-lock.yaml\` and \`pnpm clubedge\` wait for them. Resolve the conflicts in the \`package.json\` files above, then run \`pnpm install\` and commit \`pnpm-lock.yaml\`."
|
|
153
|
+
echo
|
|
154
|
+
elif [ "$INSTALL" = "failed" ]; then
|
|
109
155
|
echo "### pnpm install"
|
|
110
156
|
echo
|
|
111
|
-
echo "\`pnpm install\` did not complete on the upgraded project,
|
|
157
|
+
echo "\`pnpm install\` did not complete on the upgraded project, so \`pnpm-lock.yaml\` is not updated. Run \`pnpm install\` to see why, then commit \`pnpm-lock.yaml\`."
|
|
112
158
|
echo
|
|
113
159
|
fi
|
|
114
160
|
echo "### Before merging"
|
|
115
161
|
echo
|
|
116
|
-
echo "- Read the [release notes](https://github.com/$
|
|
162
|
+
echo "- Read the [release notes](https://github.com/$REPOSITORY/releases/tag/$AFTER) for anything to do by hand."
|
|
117
163
|
echo "- Run \`pnpm db:migrate\` for new migrations, and your tests."
|
|
118
164
|
echo
|
|
119
165
|
echo "<details><summary>Upgrade output</summary>"
|
|
@@ -126,8 +172,21 @@ jobs:
|
|
|
126
172
|
echo
|
|
127
173
|
echo "Opened by the Starter upgrade workflow (\`.github/workflows/clubedge-upgrade.yml\`)."
|
|
128
174
|
} >"$RUNNER_TEMP/body.md"
|
|
129
|
-
|
|
130
|
-
|
|
175
|
+
|
|
176
|
+
git config user.name "github-actions[bot]"
|
|
177
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
178
|
+
git switch --create "$BRANCH"
|
|
179
|
+
git add --all
|
|
180
|
+
git commit --quiet --message "chore: upgrade to Clubedge Starter $AFTER"
|
|
181
|
+
git push --quiet --set-upstream origin "$BRANCH"
|
|
182
|
+
# A pushed branch without its pull request would make every later run skip this release,
|
|
183
|
+
# so it is deleted for the next run to try again.
|
|
184
|
+
if ! gh pr create --base "$DEFAULT_BRANCH" --head "$BRANCH" \
|
|
185
|
+
--title "chore: upgrade to Clubedge Starter $AFTER" --body-file "$RUNNER_TEMP/body.md"; then
|
|
186
|
+
echo "::error::The pull request could not be opened, so $BRANCH is deleted and the next run tries again."
|
|
187
|
+
git push --quiet origin --delete "$BRANCH" || echo "::warning::Delete $BRANCH by hand, or later runs skip $AFTER."
|
|
188
|
+
exit 1
|
|
189
|
+
fi
|
|
131
190
|
# Pull requests opened with the default token start no workflows: start CI on the branch.
|
|
132
191
|
if [ "$OWN_TOKEN" != "true" ] && [ -f .github/workflows/ci.yml ]; then
|
|
133
192
|
gh workflow run ci.yml --ref "$BRANCH" || echo "Start CI on $BRANCH by hand."
|
package/template/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/observability",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.1",
|
|
4
4
|
"description": "Observability interface: error reports and spans for @clubedge/core's telemetry hooks, and an in-memory provider for tests.",
|
|
5
5
|
"private": true,
|
|
6
6
|
"license": "Apache-2.0",
|
package/template.lock.json
CHANGED