@clubedge/create-clubedge-app 0.16.0 → 0.18.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 +13 -0
- package/README.md +25 -10
- package/dist/cli/args.js +15 -5
- package/dist/cli/ui.js +8 -0
- package/dist/core/railway.js +222 -0
- package/dist/index.js +5 -0
- package/dist/try.js +213 -0
- package/package.json +6 -4
- package/template/apps/start/package.json +1 -1
- package/template/apps/start/src/components/dashboard/account-button.tsx +33 -15
- package/template/apps/start/src/components/dashboard/dashboard-shell.tsx +590 -0
- package/template/apps/start/src/routes/dashboard.tsx +215 -249
- package/template/apps/start/src/styles.css +53 -0
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/src/app/dashboard/_components/account-button.tsx +33 -15
- package/template/apps/web/src/app/dashboard/_components/dashboard-palette.css +52 -0
- package/template/apps/web/src/app/dashboard/_components/dashboard-shell.tsx +594 -0
- package/template/apps/web/src/app/dashboard/page.tsx +214 -248
- package/template/e2e/settings.spec.ts +3 -3
- package/template/package.json +1 -1
- package/template/packages/auth/package.json +1 -1
- package/template/packages/auth-better-auth/package.json +1 -1
- package/template/packages/auth-supabase/package.json +1 -1
- package/template/packages/cache/package.json +1 -1
- package/template/packages/cache-redis/package.json +1 -1
- package/template/packages/core/package.json +1 -1
- package/template/packages/db/package.json +1 -1
- package/template/packages/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-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 +5 -4
- package/template/apps/start/src/components/dashboard/app-sidebar.tsx +0 -90
- package/template/apps/web/src/app/dashboard/_components/app-sidebar.tsx +0 -91
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-clubedge-app` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.18.0
|
|
6
|
+
|
|
7
|
+
- Add `try` (experimental): `pnpm dlx @clubedge/create-clubedge-app try` creates a new project on a free, temporary [Railway VM](https://railway.com/free-vm) instead of your machine, and prints its preview URL and the link to claim the VM. No Railway account, database, or Docker needed.
|
|
8
|
+
- The VM has no Docker, so the project uses `--infra local` (PGlite) and Better Auth, with email off; `--framework` and other module flags apply as usual. `--infra` other than `local` and `--auth supabase` are rejected.
|
|
9
|
+
- It asks before creating anything (`--yes` agrees), keeps its own SSH key in the CLI's config directory apart from `~/.ssh`, and connects only to Railway's known host key (`CLUBEDGE_RAILWAY_HOST_KEY` adds one if Railway rotates it).
|
|
10
|
+
- The VM lasts 60 minutes and can be claimed for 24 hours after; until then, the preview opens only from your network. Railway allows 3 free VMs per network per day.
|
|
11
|
+
- Still on Starter `v0.15.0`.
|
|
12
|
+
|
|
13
|
+
## 0.17.0
|
|
14
|
+
|
|
15
|
+
- Default to Starter `v0.15.0`, whose dashboard has the docs' layout and colors in both frameworks: a collapsible sidebar with a section switcher, the dashboard's sections, resources, and an account card with Settings and Sign out; the page in a rounded card that scrolls on its own; a top bar with Home, GitHub, and a light and dark switch; and a drawer on phones.
|
|
16
|
+
- None of this needs CLI code: it all comes from the Starter.
|
|
17
|
+
|
|
5
18
|
## 0.16.0
|
|
6
19
|
|
|
7
20
|
- Default to Starter `v0.14.0`, which adds:
|
package/README.md
CHANGED
|
@@ -127,7 +127,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
|
|
|
127
127
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
128
128
|
|
|
129
129
|
# Reproduce a specific Starter release or commit
|
|
130
|
-
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.
|
|
130
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.15.0
|
|
131
131
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
|
|
132
132
|
|
|
133
133
|
# Test local Starter changes before releasing them
|
|
@@ -146,7 +146,7 @@ pnpm clubedge add email resend payments stripe # several modules as
|
|
|
146
146
|
pnpm clubedge remove storage payments
|
|
147
147
|
pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
|
|
148
148
|
pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
|
|
149
|
-
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.
|
|
149
|
+
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.15.0 # or a specific tag, branch, or commit
|
|
150
150
|
```
|
|
151
151
|
|
|
152
152
|
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.
|
|
@@ -172,32 +172,45 @@ pnpm clubedge doctor # Node.js, pnpm, dependencies, lockfile, env file, and p
|
|
|
172
172
|
|
|
173
173
|
`status` ends with the command to run next, such as `pnpm clubedge upgrade` when a newer Starter release is available; it asks npm for the newest CLI and still works offline. `doctor` checks that Node.js and pnpm meet the project's versions, that dependencies are installed, that `pnpm-lock.yaml` matches every `package.json`, and that `apps/web/.env.local` has every variable in `.env.example`, then runs `pnpm check:services` (skip it with `--no-services`). It exits with an error when a check fails, so it also works in CI.
|
|
174
174
|
|
|
175
|
+
## Try it on a Railway VM (experimental)
|
|
176
|
+
|
|
177
|
+
No Node.js setup, database, or Docker to spare? `try` creates the project on a free, temporary Linux VM from [Railway](https://railway.com/free-vm) instead of your machine, and prints a preview URL:
|
|
178
|
+
|
|
179
|
+
```sh
|
|
180
|
+
pnpm dlx @clubedge/create-clubedge-app try
|
|
181
|
+
pnpm dlx @clubedge/create-clubedge-app try --framework tanstack-start --auth none
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The VM needs no Railway account and lasts 60 minutes; to keep it, open the claim link the CLI prints within 24 hours after, which moves the VM, its files, and its URL into a Railway account. Keep that link private. Until the VM is claimed, the preview opens only from your network. Railway allows 3 free VMs per network per day.
|
|
185
|
+
|
|
186
|
+
The VM has no Docker, so the project uses `--infra local` (PGlite) and Better Auth unless you choose `--auth none`, with email off; other module flags apply as usual. The CLI asks before creating anything (`--yes` agrees), keeps its own SSH key in your config directory (`~/.config/clubedge`, or `%APPDATA%\clubedge` on Windows) apart from `~/.ssh`, and connects only to Railway's known host key. Railway's [terms](https://railway.com/legal/terms) apply, and Railway can change or pause its free VM.
|
|
187
|
+
|
|
175
188
|
## Version relationship
|
|
176
189
|
|
|
177
190
|
Each CLI release is tested against one specific Starter release:
|
|
178
191
|
|
|
179
192
|
```text
|
|
180
|
-
create-clubedge-app v0.
|
|
193
|
+
create-clubedge-app v0.18.0
|
|
181
194
|
|
|
|
182
195
|
v
|
|
183
|
-
Clubedge Starter v0.
|
|
184
|
-
commit
|
|
196
|
+
Clubedge Starter v0.15.0
|
|
197
|
+
commit 239f9e9fd575165d72f70a91592cafbd43bb7574
|
|
185
198
|
```
|
|
186
199
|
|
|
187
200
|
The current relationship is:
|
|
188
201
|
|
|
189
202
|
| CLI release | Default Starter release | Meaning |
|
|
190
203
|
| --- | --- | --- |
|
|
191
|
-
| `create-clubedge-app@0.
|
|
204
|
+
| `create-clubedge-app@0.18.0` | `clubedge-starter@v0.15.0` | The CLI's required, tested default |
|
|
192
205
|
|
|
193
206
|
This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
|
|
194
207
|
|
|
195
|
-
The `v0.
|
|
196
|
-
`
|
|
208
|
+
The `v0.15.0` tag is expected to resolve to commit
|
|
209
|
+
`239f9e9fd575165d72f70a91592cafbd43bb7574`. The CLI verifies this before
|
|
197
210
|
scaffolding with the default reference and fails rather than silently using a
|
|
198
211
|
moved tag.
|
|
199
212
|
|
|
200
|
-
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.
|
|
213
|
+
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.15.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
|
|
201
214
|
|
|
202
215
|
Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
|
|
203
216
|
|
|
@@ -252,7 +265,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
|
|
|
252
265
|
1. Make and validate changes in `clubedge-starter`.
|
|
253
266
|
2. Run lint, typecheck, unit tests, browser tests, and production build.
|
|
254
267
|
3. Update the Starter package versions.
|
|
255
|
-
4. Commit `main`, create a release tag such as `v0.
|
|
268
|
+
4. Commit `main`, create a release tag such as `v0.15.0`, and publish the Starter release.
|
|
256
269
|
|
|
257
270
|
### CLI maintainers
|
|
258
271
|
|
|
@@ -278,6 +291,8 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
|
|
|
278
291
|
| 0.14.0 | v0.13.0 | Observability |
|
|
279
292
|
| 0.15.0 | v0.13.1 | `status` and `doctor`, several modules in one change, conflict and upgrade summaries, macOS CI |
|
|
280
293
|
| 0.16.0 | v0.14.0 | Account settings page, browser error reporting and Sentry source maps, auth emails through the jobs queue |
|
|
294
|
+
| 0.17.0 | v0.15.0 | Docs-style dashboard: sidebar with a section switcher and account card, page in a scrolling card, phone drawer |
|
|
295
|
+
| 0.18.0 | v0.15.0 | `try`: a new project on a free, temporary Railway VM, with a preview URL and a claim link (experimental) |
|
|
281
296
|
| next | | Show the Starter's version of a conflicted file next to yours |
|
|
282
297
|
|
|
283
298
|
## Community
|
package/dist/cli/args.js
CHANGED
|
@@ -75,15 +75,20 @@ export function parseCliArgs(argv) {
|
|
|
75
75
|
}
|
|
76
76
|
const { values, positionals } = parsed;
|
|
77
77
|
const command = parseCommand(positionals);
|
|
78
|
+
// try creates a project too, on a VM, so it takes the same selection flags as create.
|
|
79
|
+
const creates = command.name === "create" || command.name === "try";
|
|
78
80
|
if (command.name === "create" && positionals.length > 1) {
|
|
79
81
|
throw new UsageError("Only one project directory can be provided.");
|
|
80
82
|
}
|
|
81
|
-
if (
|
|
83
|
+
if (!creates && (values.framework !== undefined || Object.keys(modules).length)) {
|
|
82
84
|
throw new UsageError("--framework and module flags only apply when creating a project; use add or remove to change modules.");
|
|
83
85
|
}
|
|
84
|
-
if (
|
|
86
|
+
if (!creates && command.name !== "upgrade" && values.ref !== undefined) {
|
|
85
87
|
throw new UsageError("--ref only applies when creating or upgrading a project.");
|
|
86
88
|
}
|
|
89
|
+
if (command.name === "try" && values["template-dir"] !== undefined) {
|
|
90
|
+
throw new UsageError("--template-dir does not apply to try: the VM downloads the Starter itself.");
|
|
91
|
+
}
|
|
87
92
|
if (values.framework !== undefined && !values.framework.trim()) {
|
|
88
93
|
throw new UsageError("--framework requires a framework id, such as next or tanstack-start.");
|
|
89
94
|
}
|
|
@@ -116,9 +121,9 @@ function assertDistinct(modules) {
|
|
|
116
121
|
throw new UsageError(`The module "${repeated}" is named more than once.`);
|
|
117
122
|
}
|
|
118
123
|
/**
|
|
119
|
-
* `add <module> <option>...`, `remove <module>...`, and `upgrade` change an existing project,
|
|
120
|
-
* and `doctor` inspect one
|
|
121
|
-
* literally named "add" with `./add`.
|
|
124
|
+
* `add <module> <option>...`, `remove <module>...`, and `upgrade` change an existing project, `status`
|
|
125
|
+
* and `doctor` inspect one, and `try` creates one on a Railway VM; anything else is the directory
|
|
126
|
+
* of a new project. Create a project literally named "add" with `./add`.
|
|
122
127
|
*/
|
|
123
128
|
function parseCommand(positionals) {
|
|
124
129
|
const [name, ...rest] = positionals;
|
|
@@ -142,6 +147,11 @@ function parseCommand(positionals) {
|
|
|
142
147
|
throw new UsageError(`Usage: ${name}, run inside a project.`);
|
|
143
148
|
return { name };
|
|
144
149
|
}
|
|
150
|
+
if (name === "try") {
|
|
151
|
+
if (module !== undefined)
|
|
152
|
+
throw new UsageError("Usage: try [--framework <id>] [module flags], for example: try --framework tanstack-start");
|
|
153
|
+
return { name };
|
|
154
|
+
}
|
|
145
155
|
if (name === "upgrade") {
|
|
146
156
|
if (module !== undefined)
|
|
147
157
|
throw new UsageError("Usage: upgrade [--ref <tag>], for example: upgrade --ref v0.8.0");
|
package/dist/cli/ui.js
CHANGED
|
@@ -91,6 +91,7 @@ Usage:
|
|
|
91
91
|
pnpm dlx @clubedge/create-clubedge-app upgrade [--ref <tag>] (inside a project)
|
|
92
92
|
pnpm dlx @clubedge/create-clubedge-app status (inside a project)
|
|
93
93
|
pnpm dlx @clubedge/create-clubedge-app doctor (inside a project)
|
|
94
|
+
pnpm dlx @clubedge/create-clubedge-app try [options] (experimental)
|
|
94
95
|
|
|
95
96
|
Inside a project with the clubedge script, use pnpm clubedge add, remove,
|
|
96
97
|
upgrade, status, or doctor. Without a module or option, add and remove ask for them.
|
|
@@ -105,6 +106,12 @@ Usage:
|
|
|
105
106
|
newer Starter is available. doctor checks Node.js, pnpm, the dependencies, the
|
|
106
107
|
lockfile, and the env file, then runs pnpm check:services; it changes nothing.
|
|
107
108
|
|
|
109
|
+
try creates a new project on a free, temporary Railway VM (no account, 60 minutes)
|
|
110
|
+
and prints its preview URL and the link to claim the VM. The VM has no Docker, so
|
|
111
|
+
it uses --infra local (PGlite) and Better Auth, with email off. It asks first, and
|
|
112
|
+
keeps its own SSH key apart from ~/.ssh. Experimental: Railway can change or pause
|
|
113
|
+
its free VM.
|
|
114
|
+
|
|
108
115
|
Options:
|
|
109
116
|
${selectionHelp(manifest)}
|
|
110
117
|
--ref <ref> Download this Starter tag, branch, or commit from GitHub instead
|
|
@@ -132,6 +139,7 @@ Examples:
|
|
|
132
139
|
pnpm dlx @clubedge/create-clubedge-app@latest upgrade
|
|
133
140
|
pnpm clubedge status
|
|
134
141
|
pnpm clubedge doctor --no-services
|
|
142
|
+
pnpm dlx @clubedge/create-clubedge-app try --framework tanstack-start
|
|
135
143
|
|
|
136
144
|
Learn more:
|
|
137
145
|
CLI ${cliUrl}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { UsageError } from "../cli/args.js";
|
|
6
|
+
/*
|
|
7
|
+
* Railway's free VM (https://railway.com/free-vm): `ssh railway.new` gives a temporary Linux box
|
|
8
|
+
* with no account. Everything this CLI relies on about it is in this file, so a change on
|
|
9
|
+
* Railway's side is a change here.
|
|
10
|
+
*/
|
|
11
|
+
export const railwayHost = "railway.new";
|
|
12
|
+
export const railwayTermsUrl = "https://railway.com/legal/terms";
|
|
13
|
+
export const railwayFreeVmUrl = "https://railway.com/free-vm";
|
|
14
|
+
/**
|
|
15
|
+
* The host keys railway.new presents, as OpenSSH SHA256 fingerprints. Connecting to anything
|
|
16
|
+
* else fails. CLUBEDGE_RAILWAY_HOST_KEY adds one (comma-separated), for when Railway rotates its
|
|
17
|
+
* key before a CLI release catches up.
|
|
18
|
+
*/
|
|
19
|
+
const pinnedHostKeys = ["SHA256:+S1xg92FrnHz6pY3bpkmh1OGtWQGNANXilPzlxA7B1g"];
|
|
20
|
+
export function trustedHostKeys(env = process.env) {
|
|
21
|
+
const extra = (env.CLUBEDGE_RAILWAY_HOST_KEY ?? "")
|
|
22
|
+
.split(",")
|
|
23
|
+
.map((value) => value.trim())
|
|
24
|
+
.filter(Boolean);
|
|
25
|
+
return [...pinnedHostKeys, ...extra];
|
|
26
|
+
}
|
|
27
|
+
/** The OpenSSH fingerprint of a raw host key: "SHA256:" and unpadded base64, as ssh prints it. */
|
|
28
|
+
export function hostKeyFingerprint(key) {
|
|
29
|
+
return `SHA256:${createHash("sha256").update(key).digest("base64").replace(/=+$/, "")}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Finds Railway's JSON status line in a command's stderr. Returns null when there is none, and
|
|
33
|
+
* throws when there is one without the fields this CLI needs.
|
|
34
|
+
*/
|
|
35
|
+
export function parseRailwayStatus(stderr) {
|
|
36
|
+
for (const line of stderr.split(/\r?\n/)) {
|
|
37
|
+
const trimmed = line.trim();
|
|
38
|
+
if (!trimmed.startsWith("{"))
|
|
39
|
+
continue;
|
|
40
|
+
let value;
|
|
41
|
+
try {
|
|
42
|
+
value = JSON.parse(trimmed);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (typeof value.status !== "string")
|
|
48
|
+
continue;
|
|
49
|
+
const previewUrl = value.preview_url;
|
|
50
|
+
const claimUrl = value.human_claim_url;
|
|
51
|
+
const expiresAt = new Date(String(value.build_expires_at));
|
|
52
|
+
if (typeof previewUrl !== "string" || !/^https:\/\//.test(previewUrl) || typeof claimUrl !== "string" || Number.isNaN(expiresAt.getTime())) {
|
|
53
|
+
throw new Error(`Railway answered, but not as this CLI expects (status "${value.status}"). Railway may have changed its free VM; update the CLI, or see ${railwayFreeVmUrl}.`);
|
|
54
|
+
}
|
|
55
|
+
return { status: value.status, state: String(value.state ?? ""), previewUrl: previewUrl.replace(/\/+$/, ""), claimUrl, expiresAt };
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
/** Where the CLI keeps its own SSH key for Railway, apart from the user's ~/.ssh. */
|
|
60
|
+
export function railwayKeyPath(env = process.env, platform = process.platform) {
|
|
61
|
+
const configHome = env.XDG_CONFIG_HOME || (platform === "win32" && env.APPDATA ? env.APPDATA : join(homedir(), ".config"));
|
|
62
|
+
return join(configHome, "clubedge", "railway_ed25519");
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The modules for a project on the VM. It has no Docker, so the database is PGlite (--infra
|
|
66
|
+
* local) and sign-in is Better Auth; email is off, since nothing there receives it. Flags the
|
|
67
|
+
* user passed win, unless the VM cannot run them.
|
|
68
|
+
*/
|
|
69
|
+
export function tryModules(requested) {
|
|
70
|
+
const infra = requested.infra ?? "local";
|
|
71
|
+
if (infra !== "local") {
|
|
72
|
+
throw new UsageError(`try runs on a VM without Docker, so it needs --infra local (not --infra ${infra}).`);
|
|
73
|
+
}
|
|
74
|
+
const auth = requested.auth ?? "better-auth";
|
|
75
|
+
if (auth === "supabase") {
|
|
76
|
+
throw new UsageError("try cannot run Supabase Auth, which needs Docker or a Supabase project. Use --auth better-auth or --auth none.");
|
|
77
|
+
}
|
|
78
|
+
return { ...requested, infra, auth, email: requested.email ?? "none" };
|
|
79
|
+
}
|
|
80
|
+
/** A value as one single-quoted shell word. */
|
|
81
|
+
export const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
|
|
82
|
+
/** The create command run on the VM: this CLI's own version, with the chosen selection. */
|
|
83
|
+
export function tryCreateArgs({ framework, modules, ref }) {
|
|
84
|
+
return [
|
|
85
|
+
...(framework ? ["--framework", framework] : []),
|
|
86
|
+
...Object.entries(modules).flatMap(([module, option]) => [`--${module}`, option]),
|
|
87
|
+
...(ref ? ["--ref", ref] : []),
|
|
88
|
+
"--yes",
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
export const remoteDir = "/app";
|
|
92
|
+
export const remoteLog = `${remoteDir}/clubedge-try.log`;
|
|
93
|
+
export const remoteDevLog = `${remoteDir}/clubedge-dev.log`;
|
|
94
|
+
export const remoteScriptPath = `${remoteDir}/clubedge-try.sh`;
|
|
95
|
+
export const remoteProxyPath = `${remoteDir}/clubedge-proxy.mjs`;
|
|
96
|
+
/**
|
|
97
|
+
* The script the VM runs. It reports progress on stdout as "@@step <text>", then "@@ready", or
|
|
98
|
+
* "@@fail <text>" followed by the end of the log as "@@log <line>". The servers it starts keep
|
|
99
|
+
* running after the SSH session ends.
|
|
100
|
+
*/
|
|
101
|
+
export function tryScript(options) {
|
|
102
|
+
const create = ["pnpm", "dlx", `@clubedge/create-clubedge-app@${options.cliVersion}`, '"$dir"', ...tryCreateArgs(options).map(shellQuote)].join(" ");
|
|
103
|
+
const betterAuth = options.modules.auth === "better-auth";
|
|
104
|
+
return `#!/usr/bin/env bash
|
|
105
|
+
# Written by create-clubedge-app try. Logs: ${remoteLog}, ${remoteDevLog}
|
|
106
|
+
set -uo pipefail
|
|
107
|
+
cd ${remoteDir}
|
|
108
|
+
log=${remoteLog}
|
|
109
|
+
: > "$log"
|
|
110
|
+
step() { echo "@@step $*"; echo "== $*" >> "$log"; }
|
|
111
|
+
fail() { echo "@@fail $*"; tail -n 40 "$log" | sed 's/^/@@log /'; exit 1; }
|
|
112
|
+
port="\${PORT:-8080}"
|
|
113
|
+
|
|
114
|
+
step "Preparing pnpm"
|
|
115
|
+
command -v pnpm >/dev/null 2>&1 || corepack enable pnpm >>"$log" 2>&1 || npm install -g pnpm@10 >>"$log" 2>&1 || fail "pnpm could not be installed"
|
|
116
|
+
|
|
117
|
+
# A second run on the same VM replaces the servers of the first, and creates a new directory.
|
|
118
|
+
pkill -f clubedge-proxy.mjs >/dev/null 2>&1
|
|
119
|
+
pkill -f "next dev|vite dev|local-db" >/dev/null 2>&1
|
|
120
|
+
|
|
121
|
+
step "Starting the preview proxy"
|
|
122
|
+
setsid nohup node ${remoteProxyPath} "$port" 3000 ${shellQuote(options.previewUrl)} >>${remoteDir}/clubedge-proxy.log 2>&1 < /dev/null &
|
|
123
|
+
|
|
124
|
+
dir=my-app
|
|
125
|
+
n=2
|
|
126
|
+
while [ -e "$dir" ]; do dir="my-app-$n"; n=$((n + 1)); done
|
|
127
|
+
step "Creating the project in ${remoteDir}/$dir"
|
|
128
|
+
${create} >>"$log" 2>&1 || fail "The project could not be created"
|
|
129
|
+
cd "$dir" || fail "The project directory is missing"
|
|
130
|
+
${betterAuth
|
|
131
|
+
? `
|
|
132
|
+
envfile=$(ls apps/*/.env.local 2>/dev/null | head -n 1)
|
|
133
|
+
[ -n "$envfile" ] || fail "The project has no apps/*/.env.local"
|
|
134
|
+
secret=$(node -e 'console.log(require("node:crypto").randomBytes(32).toString("hex"))')
|
|
135
|
+
if grep -q '^BETTER_AUTH_SECRET=' "$envfile"; then
|
|
136
|
+
sed -i "s/^BETTER_AUTH_SECRET=.*/BETTER_AUTH_SECRET=$secret/" "$envfile"
|
|
137
|
+
else
|
|
138
|
+
echo "BETTER_AUTH_SECRET=$secret" >> "$envfile"
|
|
139
|
+
fi
|
|
140
|
+
`
|
|
141
|
+
: ""}
|
|
142
|
+
step "Starting the local database"
|
|
143
|
+
setsid nohup pnpm infra:up >>${remoteDir}/clubedge-db.log 2>&1 < /dev/null &
|
|
144
|
+
for _ in $(seq 120); do grep -q "Local database ready" ${remoteDir}/clubedge-db.log && break; sleep 1; done
|
|
145
|
+
grep -q "Local database ready" ${remoteDir}/clubedge-db.log || { cat ${remoteDir}/clubedge-db.log >> "$log"; fail "The local database did not start"; }
|
|
146
|
+
|
|
147
|
+
step "Migrating the database"
|
|
148
|
+
pnpm run --if-present db:migrate >>"$log" 2>&1 || fail "The database migration failed"
|
|
149
|
+
|
|
150
|
+
step "Starting the app"
|
|
151
|
+
setsid nohup pnpm dev >>${remoteDevLog} 2>&1 < /dev/null &
|
|
152
|
+
for _ in $(seq 300); do
|
|
153
|
+
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/ || true)
|
|
154
|
+
[ "$code" != "000" ] && break
|
|
155
|
+
sleep 1
|
|
156
|
+
done
|
|
157
|
+
[ "$code" != "000" ] || { tail -n 40 ${remoteDevLog} >> "$log"; fail "The app did not start"; }
|
|
158
|
+
echo "@@ready $dir"
|
|
159
|
+
`;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* A small reverse proxy for the VM: Railway's preview URL reaches it on $PORT, and it forwards to
|
|
163
|
+
* the dev server on localhost. The Starter's dev servers only serve their scripts to localhost
|
|
164
|
+
* (Next.js allowedDevOrigins, Vite's host check), and Next.js compares Origin with the forwarded
|
|
165
|
+
* host for form posts, so requests are made to look local, and redirects to localhost are pointed
|
|
166
|
+
* back at the preview URL. Until the app answers, it shows a page that reloads itself.
|
|
167
|
+
*/
|
|
168
|
+
export const proxySource = `import http from "node:http";
|
|
169
|
+
import net from "node:net";
|
|
170
|
+
|
|
171
|
+
const [port = "8080", upstreamPort = "3000", publicOrigin = ""] = process.argv.slice(2);
|
|
172
|
+
const local = "http://localhost:" + upstreamPort;
|
|
173
|
+
|
|
174
|
+
function toLocal(headers) {
|
|
175
|
+
const result = { ...headers, host: "localhost:" + upstreamPort };
|
|
176
|
+
delete result["x-forwarded-host"];
|
|
177
|
+
delete result.forwarded;
|
|
178
|
+
if (result.origin) result.origin = local;
|
|
179
|
+
if (result.referer && publicOrigin) result.referer = result.referer.replace(publicOrigin, local);
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const starting = '<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="5"><title>Starting</title>' +
|
|
184
|
+
'<body style="font:16px system-ui;display:grid;place-items:center;height:90vh;color:#444">' +
|
|
185
|
+
"<p>Your Clubedge app is starting. This page reloads by itself.</p>";
|
|
186
|
+
|
|
187
|
+
const server = http.createServer((req, res) => {
|
|
188
|
+
const upstream = http.request(
|
|
189
|
+
{ host: "127.0.0.1", port: Number(upstreamPort), method: req.method, path: req.url, headers: toLocal(req.headers) },
|
|
190
|
+
(answer) => {
|
|
191
|
+
const headers = { ...answer.headers };
|
|
192
|
+
if (headers.location && publicOrigin) headers.location = headers.location.replace(local, publicOrigin);
|
|
193
|
+
res.writeHead(answer.statusCode ?? 502, headers);
|
|
194
|
+
answer.pipe(res);
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
upstream.on("error", () => {
|
|
198
|
+
if (res.headersSent) return res.destroy();
|
|
199
|
+
res.writeHead(503, { "content-type": "text/html; charset=utf-8", "retry-after": "5" });
|
|
200
|
+
res.end(starting);
|
|
201
|
+
});
|
|
202
|
+
req.pipe(upstream);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Hot reload: pass WebSocket upgrades through with the same local headers.
|
|
206
|
+
server.on("upgrade", (req, socket, head) => {
|
|
207
|
+
const upstream = net.connect(Number(upstreamPort), "127.0.0.1", () => {
|
|
208
|
+
const lines = [req.method + " " + req.url + " HTTP/1.1"];
|
|
209
|
+
for (const [name, value] of Object.entries(toLocal(req.headers))) {
|
|
210
|
+
for (const item of [].concat(value)) lines.push(name + ": " + item);
|
|
211
|
+
}
|
|
212
|
+
upstream.write(lines.join("\\r\\n") + "\\r\\n\\r\\n");
|
|
213
|
+
if (head && head.length) upstream.write(head);
|
|
214
|
+
upstream.pipe(socket);
|
|
215
|
+
socket.pipe(upstream);
|
|
216
|
+
});
|
|
217
|
+
upstream.on("error", () => socket.destroy());
|
|
218
|
+
socket.on("error", () => upstream.destroy());
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
server.listen(Number(port), "0.0.0.0");
|
|
222
|
+
`;
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { readBundledTemplate, resolveTemplateSource } from "./core/source.js";
|
|
|
11
11
|
import { cliUrl, cliVersion, siteUrl, starterPin } from "./package-info.js";
|
|
12
12
|
import { runDoctor, showStatus } from "./inspect.js";
|
|
13
13
|
import { changeProject } from "./modify.js";
|
|
14
|
+
import { tryOnRailway } from "./try.js";
|
|
14
15
|
import { initializeGit } from "./steps/git.js";
|
|
15
16
|
import { detectLauncher, installDependencies, resolvePnpm } from "./steps/install.js";
|
|
16
17
|
import { displayNameFromPackageName, packageNameFromDirectory } from "./utils/names.js";
|
|
@@ -234,6 +235,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
234
235
|
intro("Check this project's setup");
|
|
235
236
|
await runDoctor(args);
|
|
236
237
|
}
|
|
238
|
+
else if (args.command.name === "try") {
|
|
239
|
+
intro("Try a project on a free Railway VM");
|
|
240
|
+
await tryOnRailway(args);
|
|
241
|
+
}
|
|
237
242
|
else {
|
|
238
243
|
const intros = {
|
|
239
244
|
add: "Add a module to this project",
|
package/dist/try.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { cancel, confirm, isCancel, log, note, outro, spinner } from "@clack/prompts";
|
|
2
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { userInfo } from "node:os";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import ssh2 from "ssh2";
|
|
7
|
+
import { UsageError } from "./cli/args.js";
|
|
8
|
+
import { bold, dim } from "./cli/ui.js";
|
|
9
|
+
import { hostKeyFingerprint, parseRailwayStatus, proxySource, railwayFreeVmUrl, railwayHost, railwayKeyPath, railwayTermsUrl, remoteDevLog, remoteLog, remoteProxyPath, remoteScriptPath, shellQuote, trustedHostKeys, tryCreateArgs, tryModules, tryScript, } from "./core/railway.js";
|
|
10
|
+
import { cliVersion } from "./package-info.js";
|
|
11
|
+
const { Client, utils } = ssh2;
|
|
12
|
+
/** The CLI's own key: created on first use, then reused so later runs reach the same VM. */
|
|
13
|
+
async function loadOrCreateKey(path) {
|
|
14
|
+
try {
|
|
15
|
+
return { privateKey: await readFile(path, "utf8"), created: false };
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
const pair = utils.generateKeyPairSync("ed25519", { comment: "create-clubedge-app try" });
|
|
19
|
+
await mkdir(dirname(path), { recursive: true });
|
|
20
|
+
await writeFile(path, pair.private, { mode: 0o600 });
|
|
21
|
+
await chmod(path, 0o600).catch(() => { });
|
|
22
|
+
await writeFile(`${path}.pub`, `${pair.public}\n`);
|
|
23
|
+
return { privateKey: pair.private, created: true };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Opens the SSH connection, accepting only Railway's pinned host key. */
|
|
27
|
+
async function connect(privateKey) {
|
|
28
|
+
const trusted = trustedHostKeys();
|
|
29
|
+
let seen = "";
|
|
30
|
+
return await new Promise((resolve, reject) => {
|
|
31
|
+
const connection = new Client();
|
|
32
|
+
connection
|
|
33
|
+
.once("ready", () => resolve(connection))
|
|
34
|
+
.once("error", (error) => {
|
|
35
|
+
if (seen && !trusted.includes(seen)) {
|
|
36
|
+
reject(new Error(`${railwayHost} presented an unknown host key (${seen}), so the CLI did not connect. ` +
|
|
37
|
+
`If Railway announced a new key, set CLUBEDGE_RAILWAY_HOST_KEY=${seen} and run again.`));
|
|
38
|
+
}
|
|
39
|
+
else if (/authentication/i.test(error.message)) {
|
|
40
|
+
reject(new Error(`Railway refused the connection. The free VM may be paused or at its daily limit; see ${railwayFreeVmUrl}.`));
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
reject(new Error(`Could not reach ${railwayHost}: ${error.message}`));
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
.connect({
|
|
47
|
+
host: railwayHost,
|
|
48
|
+
port: 22,
|
|
49
|
+
username: userInfo().username || "clubedge",
|
|
50
|
+
privateKey,
|
|
51
|
+
readyTimeout: 30_000,
|
|
52
|
+
keepaliveInterval: 15_000,
|
|
53
|
+
hostVerifier: (key) => {
|
|
54
|
+
seen = hostKeyFingerprint(key);
|
|
55
|
+
return trusted.includes(seen);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Runs a command on the VM. Railway's status line on stderr is taken out and parsed; stdout lines
|
|
62
|
+
* go to onLine as they arrive.
|
|
63
|
+
*/
|
|
64
|
+
async function exec(connection, command, { input, onLine } = {}) {
|
|
65
|
+
return await new Promise((resolve, reject) => {
|
|
66
|
+
connection.exec(command, (error, stream) => {
|
|
67
|
+
if (error)
|
|
68
|
+
return reject(error);
|
|
69
|
+
let stdout = "";
|
|
70
|
+
let stderr = "";
|
|
71
|
+
let pending = "";
|
|
72
|
+
stream.on("data", (chunk) => {
|
|
73
|
+
const text = chunk.toString();
|
|
74
|
+
stdout += text;
|
|
75
|
+
pending += text;
|
|
76
|
+
const lines = pending.split("\n");
|
|
77
|
+
pending = lines.pop() ?? "";
|
|
78
|
+
for (const line of lines)
|
|
79
|
+
onLine?.(line.replace(/\r$/, ""));
|
|
80
|
+
});
|
|
81
|
+
stream.stderr.on("data", (chunk) => (stderr += chunk.toString()));
|
|
82
|
+
stream.once("close", (code) => {
|
|
83
|
+
if (pending)
|
|
84
|
+
onLine?.(pending);
|
|
85
|
+
try {
|
|
86
|
+
resolve({ code: code ?? 1, stdout, vm: parseRailwayStatus(stderr) });
|
|
87
|
+
}
|
|
88
|
+
catch (parseError) {
|
|
89
|
+
reject(parseError);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
if (input !== undefined)
|
|
93
|
+
stream.end(input);
|
|
94
|
+
else
|
|
95
|
+
stream.end();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/** Writes a file on the VM through the command's stdin. */
|
|
100
|
+
async function upload(connection, path, content) {
|
|
101
|
+
const { code } = await exec(connection, `cat > ${shellQuote(path)}`, { input: content });
|
|
102
|
+
if (code !== 0)
|
|
103
|
+
throw new Error(`Could not write ${path} on the VM.`);
|
|
104
|
+
}
|
|
105
|
+
// A spinner line wider than the terminal wraps, and each frame then prints a new line.
|
|
106
|
+
const fit = (text) => {
|
|
107
|
+
const width = (process.stdout.columns ?? 80) - 6;
|
|
108
|
+
return text.length > width ? `${text.slice(0, width - 1)}…` : text;
|
|
109
|
+
};
|
|
110
|
+
function minutesLeft(until) {
|
|
111
|
+
return Math.max(0, Math.round((until.getTime() - Date.now()) / 60_000));
|
|
112
|
+
}
|
|
113
|
+
async function agree(args, keyPath) {
|
|
114
|
+
note([
|
|
115
|
+
"This creates a free, temporary Linux VM on Railway and runs a new project there.",
|
|
116
|
+
"",
|
|
117
|
+
`• No Railway account is needed. You get 60 minutes, then 24 hours to claim the VM to keep it.`,
|
|
118
|
+
`• The preview opens only from your network until you claim the VM.`,
|
|
119
|
+
`• Railway allows 3 free VMs per network per day.`,
|
|
120
|
+
`• The CLI uses its own SSH key, ${keyPath}; your ~/.ssh is not used.`,
|
|
121
|
+
`• Railway's terms apply: ${railwayTermsUrl}`,
|
|
122
|
+
"",
|
|
123
|
+
dim(`Experimental: Railway can change or pause its free VM (${railwayFreeVmUrl}).`),
|
|
124
|
+
].join("\n"), "Try it on Railway");
|
|
125
|
+
if (args.yes)
|
|
126
|
+
return true;
|
|
127
|
+
if (!process.stdin.isTTY) {
|
|
128
|
+
throw new UsageError("try creates a VM on Railway. Run it in a terminal to confirm, or pass --yes to agree.");
|
|
129
|
+
}
|
|
130
|
+
const answer = await confirm({ message: "Create the VM?", initialValue: true });
|
|
131
|
+
return !isCancel(answer) && answer;
|
|
132
|
+
}
|
|
133
|
+
export async function tryOnRailway(args) {
|
|
134
|
+
const modules = tryModules(args.modules);
|
|
135
|
+
const keyPath = railwayKeyPath();
|
|
136
|
+
const createArgs = tryCreateArgs({ framework: args.framework, modules, ref: args.ref });
|
|
137
|
+
if (args.dryRun) {
|
|
138
|
+
log.message([
|
|
139
|
+
`• Connect to ${railwayHost} with the key in ${keyPath}`,
|
|
140
|
+
`• Create the project: create-clubedge-app@${cliVersion} my-app ${createArgs.join(" ")}`,
|
|
141
|
+
"• Start the local database (PGlite), migrate, and start the app behind the preview URL",
|
|
142
|
+
].join("\n"));
|
|
143
|
+
outro("Dry run: no VM was created. Run again without --dry-run to try it.");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (!(await agree(args, keyPath))) {
|
|
147
|
+
cancel("No VM was created.");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const progress = spinner();
|
|
151
|
+
progress.start(`Connecting to ${railwayHost}`);
|
|
152
|
+
let spinning = "connect";
|
|
153
|
+
let connection;
|
|
154
|
+
try {
|
|
155
|
+
const key = await loadOrCreateKey(keyPath);
|
|
156
|
+
if (key.created)
|
|
157
|
+
log.info(`Created an SSH key for Railway in ${keyPath}`);
|
|
158
|
+
connection = await connect(key.privateKey);
|
|
159
|
+
const { vm } = await exec(connection, "true");
|
|
160
|
+
if (!vm)
|
|
161
|
+
throw new Error(`${railwayHost} did not describe the VM. Railway may have changed its free VM; see ${railwayFreeVmUrl}.`);
|
|
162
|
+
progress.stop(`VM ready, ${minutesLeft(vm.expiresAt)} minutes to try it`);
|
|
163
|
+
spinning = "setup";
|
|
164
|
+
progress.start("Preparing the VM");
|
|
165
|
+
await upload(connection, remoteProxyPath, proxySource);
|
|
166
|
+
await upload(connection, remoteScriptPath, tryScript({ cliVersion, framework: args.framework, modules, ref: args.ref, previewUrl: vm.previewUrl }));
|
|
167
|
+
const tail = [];
|
|
168
|
+
let failure = "";
|
|
169
|
+
let directory = "my-app";
|
|
170
|
+
const started = Date.now();
|
|
171
|
+
const { code } = await exec(connection, `bash ${remoteScriptPath}`, {
|
|
172
|
+
onLine: (line) => {
|
|
173
|
+
if (line.startsWith("@@step "))
|
|
174
|
+
progress.message(fit(`${line.slice(7)} (${Math.round((Date.now() - started) / 1000)}s)`));
|
|
175
|
+
else if (line.startsWith("@@log "))
|
|
176
|
+
tail.push(line.slice(6));
|
|
177
|
+
else if (line.startsWith("@@fail "))
|
|
178
|
+
failure = line.slice(7);
|
|
179
|
+
else if (line.startsWith("@@ready"))
|
|
180
|
+
directory = line.slice(8).trim() || directory;
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
if (code !== 0 || failure) {
|
|
184
|
+
spinning = null;
|
|
185
|
+
progress.stop(failure || "Setup on the VM failed");
|
|
186
|
+
if (tail.length)
|
|
187
|
+
log.message(dim(tail.join("\n")));
|
|
188
|
+
throw new Error(`Setup on the VM failed. Its log is ${remoteLog}; read it with: ssh -i "${keyPath}" ${railwayHost} cat ${remoteLog}`);
|
|
189
|
+
}
|
|
190
|
+
spinning = null;
|
|
191
|
+
progress.stop(`The app is running in /app/${directory}`);
|
|
192
|
+
note([
|
|
193
|
+
`${bold("Preview")} ${vm.previewUrl}`,
|
|
194
|
+
`${bold("Time")} ${minutesLeft(vm.expiresAt)} minutes left (until ${vm.expiresAt.toLocaleTimeString()})`,
|
|
195
|
+
`${bold("Claim")} ${vm.claimUrl}`,
|
|
196
|
+
].join("\n"), "Your app on Railway");
|
|
197
|
+
log.warn("Keep the claim link private: whoever opens it takes over the VM, its files, and its URL.");
|
|
198
|
+
outro([
|
|
199
|
+
"The preview opens only from your network until you claim the VM. Claiming moves it to your Railway account.",
|
|
200
|
+
"",
|
|
201
|
+
`${dim("Shell")} ssh -i "${keyPath}" ${railwayHost}`,
|
|
202
|
+
`${dim("Logs")} ssh -i "${keyPath}" ${railwayHost} tail -f ${remoteDevLog}`,
|
|
203
|
+
].join("\n"));
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (spinning)
|
|
207
|
+
progress.stop(spinning === "connect" ? "Could not start a VM on Railway" : "Setup on the VM failed");
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
connection?.end();
|
|
212
|
+
}
|
|
213
|
+
}
|