@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +25 -10
  3. package/dist/cli/args.js +15 -5
  4. package/dist/cli/ui.js +8 -0
  5. package/dist/core/railway.js +222 -0
  6. package/dist/index.js +5 -0
  7. package/dist/try.js +213 -0
  8. package/package.json +6 -4
  9. package/template/apps/start/package.json +1 -1
  10. package/template/apps/start/src/components/dashboard/account-button.tsx +33 -15
  11. package/template/apps/start/src/components/dashboard/dashboard-shell.tsx +590 -0
  12. package/template/apps/start/src/routes/dashboard.tsx +215 -249
  13. package/template/apps/start/src/styles.css +53 -0
  14. package/template/apps/web/package.json +1 -1
  15. package/template/apps/web/src/app/dashboard/_components/account-button.tsx +33 -15
  16. package/template/apps/web/src/app/dashboard/_components/dashboard-palette.css +52 -0
  17. package/template/apps/web/src/app/dashboard/_components/dashboard-shell.tsx +594 -0
  18. package/template/apps/web/src/app/dashboard/page.tsx +214 -248
  19. package/template/e2e/settings.spec.ts +3 -3
  20. package/template/package.json +1 -1
  21. package/template/packages/auth/package.json +1 -1
  22. package/template/packages/auth-better-auth/package.json +1 -1
  23. package/template/packages/auth-supabase/package.json +1 -1
  24. package/template/packages/cache/package.json +1 -1
  25. package/template/packages/cache-redis/package.json +1 -1
  26. package/template/packages/core/package.json +1 -1
  27. package/template/packages/db/package.json +1 -1
  28. package/template/packages/email/package.json +1 -1
  29. package/template/packages/email-resend/package.json +1 -1
  30. package/template/packages/email-smtp/package.json +1 -1
  31. package/template/packages/jobs/package.json +1 -1
  32. package/template/packages/jobs-pg-boss/package.json +1 -1
  33. package/template/packages/local-db/package.json +1 -1
  34. package/template/packages/observability/package.json +1 -1
  35. package/template/packages/observability-otel/package.json +1 -1
  36. package/template/packages/observability-sentry/package.json +1 -1
  37. package/template/packages/payments/package.json +1 -1
  38. package/template/packages/payments-stripe/package.json +1 -1
  39. package/template/packages/storage/package.json +1 -1
  40. package/template/packages/storage-s3/package.json +1 -1
  41. package/template/packages/storage-supabase/package.json +1 -1
  42. package/template/packages/ui/package.json +1 -1
  43. package/template.lock.json +5 -4
  44. package/template/apps/start/src/components/dashboard/app-sidebar.tsx +0 -90
  45. 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.14.0
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.14.0 # or a specific tag, branch, or commit
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.16.0
193
+ create-clubedge-app v0.18.0
181
194
  |
182
195
  v
183
- Clubedge Starter v0.14.0
184
- commit 2219a21c2eb237b7d8ee004ea8bbeb098b70106e
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.16.0` | `clubedge-starter@v0.14.0` | The CLI's required, tested default |
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.14.0` tag is expected to resolve to commit
196
- `2219a21c2eb237b7d8ee004ea8bbeb098b70106e`. The CLI verifies this before
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.14.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.
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.14.0`, and publish the Starter release.
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 (command.name !== "create" && (values.framework !== undefined || Object.keys(modules).length)) {
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 (command.name !== "create" && command.name !== "upgrade" && values.ref !== undefined) {
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, and `status`
120
- * and `doctor` inspect one; anything else is the directory of a new project. Create a project
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
+ }