@clubedge/create-clubedge-app 0.18.0 → 0.19.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 CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.19.1
6
+
7
+ - `try` now says who can open the preview as Railway does: until the VM is claimed, only the IP address that created it. Another device on mobile data, a VPN, or anyone you send the link to gets Railway's "Access denied" page; claiming makes the URL shareable.
8
+ - Still on Starter `v0.15.0`.
9
+
10
+ ## 0.19.0
11
+
12
+ - `try` inside a project: `pnpm clubedge try` uploads the project to a free Railway VM and runs it there, to share work in progress without deploying.
13
+ - It uploads what Git would commit (tracked files, plus untracked ones that are not ignored), and never `.env` files other than examples, `node_modules`, or build output. If Git cannot list a repository's files, nothing is uploaded. `--dry-run` lists the files.
14
+ - On the VM, the env file is made from `.env.example` as `create` does, then the database (infra `local`), migrations, and the app start as for a new project.
15
+ - Projects that need Docker or Supabase Auth are refused, with the `pnpm clubedge add` command that switches them.
16
+ - `try` opens the preview in the browser when it is ready, at a terminal; `--no-open` turns that off.
17
+ - Still on Starter `v0.15.0`.
18
+
5
19
  ## 0.18.0
6
20
 
7
21
  - 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.
package/README.md CHANGED
@@ -181,16 +181,20 @@ pnpm dlx @clubedge/create-clubedge-app try
181
181
  pnpm dlx @clubedge/create-clubedge-app try --framework tanstack-start --auth none
182
182
  ```
183
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.
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 the IP address that created it (not from another device on mobile data, or through a VPN); claiming makes the URL shareable. Railway allows 3 free VMs per network per day.
185
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.
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.
187
+
188
+ Inside a project, `pnpm clubedge try` uploads that project instead, to share work in progress without deploying it. It sends what Git would commit (tracked files, plus untracked ones that are not ignored) and never `.env` files other than examples, `node_modules`, or build output; on the VM, the env file is made from `.env.example` as `create` does. The project must use `infra local` or `none`, and not Supabase Auth; `pnpm clubedge add infra local` switches a Docker project. Add `--dry-run` to list the files without uploading anything.
189
+
190
+ When the app is ready, the CLI opens the preview in your browser; `--no-open` turns that off. 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
191
 
188
192
  ## Version relationship
189
193
 
190
194
  Each CLI release is tested against one specific Starter release:
191
195
 
192
196
  ```text
193
- create-clubedge-app v0.18.0
197
+ create-clubedge-app v0.19.1
194
198
  |
195
199
  v
196
200
  Clubedge Starter v0.15.0
@@ -201,7 +205,7 @@ The current relationship is:
201
205
 
202
206
  | CLI release | Default Starter release | Meaning |
203
207
  | --- | --- | --- |
204
- | `create-clubedge-app@0.18.0` | `clubedge-starter@v0.15.0` | The CLI's required, tested default |
208
+ | `create-clubedge-app@0.19.1` | `clubedge-starter@v0.15.0` | The CLI's required, tested default |
205
209
 
206
210
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
207
211
 
@@ -293,6 +297,7 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
293
297
  | 0.16.0 | v0.14.0 | Account settings page, browser error reporting and Sentry source maps, auth emails through the jobs queue |
294
298
  | 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
299
  | 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) |
300
+ | 0.19.0 | v0.15.0 | `pnpm clubedge try` uploads an existing project to the VM; the preview opens in the browser |
296
301
  | next | | Show the Starter's version of a conflicted file next to yours |
297
302
 
298
303
  ## Community
package/dist/cli/args.js CHANGED
@@ -13,6 +13,7 @@ const OPTIONS = {
13
13
  "dry-run": { type: "boolean", default: false },
14
14
  force: { type: "boolean", default: false },
15
15
  services: { type: "boolean", default: true },
16
+ open: { type: "boolean", default: true },
16
17
  help: { type: "boolean", short: "h", default: false },
17
18
  version: { type: "boolean", short: "v", default: false },
18
19
  };
@@ -111,6 +112,7 @@ export function parseCliArgs(argv) {
111
112
  dryRun: values["dry-run"],
112
113
  force: values.force,
113
114
  services: values.services,
115
+ open: values.open,
114
116
  help: values.help,
115
117
  version: values.version,
116
118
  };
@@ -148,8 +150,9 @@ function parseCommand(positionals) {
148
150
  return { name };
149
151
  }
150
152
  if (name === "try") {
151
- if (module !== undefined)
152
- throw new UsageError("Usage: try [--framework <id>] [module flags], for example: try --framework tanstack-start");
153
+ if (module !== undefined) {
154
+ throw new UsageError("Usage: try [--framework <id>] [module flags] (a new project), or try inside a project to upload it.");
155
+ }
153
156
  return { name };
154
157
  }
155
158
  if (name === "upgrade") {
package/dist/cli/ui.js CHANGED
@@ -94,7 +94,8 @@ Usage:
94
94
  pnpm dlx @clubedge/create-clubedge-app try [options] (experimental)
95
95
 
96
96
  Inside a project with the clubedge script, use pnpm clubedge add, remove,
97
- upgrade, status, or doctor. Without a module or option, add and remove ask for them.
97
+ upgrade, status, doctor, or try. Without a module or option, add and remove ask
98
+ for them.
98
99
 
99
100
  add and remove change modules of an existing project, several at once as one
100
101
  change, and upgrade moves it to a newer Starter (this CLI's release unless --ref
@@ -108,9 +109,11 @@ Usage:
108
109
 
109
110
  try creates a new project on a free, temporary Railway VM (no account, 60 minutes)
110
111
  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.
112
+ it uses --infra local (PGlite) and Better Auth, with email off. Inside a project,
113
+ try uploads that project instead (what Git would commit, never .env files or
114
+ node_modules); it needs infra local or none. It asks first, keeps its own SSH key
115
+ apart from ~/.ssh, and opens the preview when ready. Experimental: Railway can
116
+ change or pause its free VM.
114
117
 
115
118
  Options:
116
119
  ${selectionHelp(manifest)}
@@ -120,6 +123,7 @@ ${selectionHelp(manifest)}
120
123
  --dry-run Show what would be created or changed without writing anything
121
124
  --force add/remove: change a project with uncommitted changes or without Git
122
125
  --no-services doctor: skip pnpm check:services
126
+ --no-open try: do not open the preview in the browser
123
127
  -y, --yes Accept defaults and never prompt (default directory: my-app)
124
128
  --no-install Skip dependency installation
125
129
  --no-git Skip Git repository initialization
@@ -77,9 +77,28 @@ export function tryModules(requested) {
77
77
  }
78
78
  return { ...requested, infra, auth, email: requested.email ?? "none" };
79
79
  }
80
+ /**
81
+ * Why an existing project cannot run on the VM, or null when it can. The VM has no Docker, so the
82
+ * database must be PGlite (infra local) or none, and Supabase Auth is out.
83
+ */
84
+ export function projectTryProblem(modules) {
85
+ const infra = modules.infra ?? "none";
86
+ if (infra !== "local" && infra !== "none") {
87
+ return `This project runs its services with infra ${infra}, which needs Docker; the VM has none. Switch with \`pnpm clubedge add infra local\` (PGlite, no Docker), then run try again.`;
88
+ }
89
+ if (modules.auth === "supabase") {
90
+ return "This project signs in with Supabase Auth, which needs Docker or a Supabase project; the VM has neither. Switch with `pnpm clubedge add auth better-auth`, then run try again.";
91
+ }
92
+ return null;
93
+ }
94
+ /** A project's package name as a directory name on the VM. */
95
+ export function remoteName(packageName) {
96
+ const name = packageName.replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
97
+ return name || "my-app";
98
+ }
80
99
  /** A value as one single-quoted shell word. */
81
100
  export const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
82
- /** The create command run on the VM: this CLI's own version, with the chosen selection. */
101
+ /** The create command's flags on the VM: the chosen selection, without prompts. */
83
102
  export function tryCreateArgs({ framework, modules, ref }) {
84
103
  return [
85
104
  ...(framework ? ["--framework", framework] : []),
@@ -93,14 +112,26 @@ export const remoteLog = `${remoteDir}/clubedge-try.log`;
93
112
  export const remoteDevLog = `${remoteDir}/clubedge-dev.log`;
94
113
  export const remoteScriptPath = `${remoteDir}/clubedge-try.sh`;
95
114
  export const remoteProxyPath = `${remoteDir}/clubedge-proxy.mjs`;
115
+ export const remoteArchivePath = `${remoteDir}/clubedge-upload.tgz`;
96
116
  /**
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
117
+ * The script the VM runs. It reports progress on stdout as "@@step <text>", then "@@ready <dir>",
118
+ * or "@@fail <text>" followed by the end of the log as "@@log <line>". The servers it starts keep
99
119
  * running after the SSH session ends.
100
120
  */
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";
121
+ export function tryScript({ source, modules, previewUrl }) {
122
+ const base = source.kind === "create" ? "my-app" : source.name;
123
+ const prepare = source.kind === "create"
124
+ ? `step "Creating the project in ${remoteDir}/$dir"
125
+ ${["pnpm", "dlx", `@clubedge/create-clubedge-app@${source.cliVersion}`, '"$dir"', ...tryCreateArgs({ ...source, modules }).map(shellQuote)].join(" ")} >>"$log" 2>&1 || fail "The project could not be created"
126
+ cd "$dir" || fail "The project directory is missing"`
127
+ : `step "Unpacking the project in ${remoteDir}/$dir"
128
+ mkdir -p "$dir" && tar xzf ${remoteArchivePath} -C "$dir" >>"$log" 2>&1 || fail "The upload could not be unpacked"
129
+ rm -f ${remoteArchivePath}
130
+ cd "$dir" || fail "The project directory is missing"
131
+ step "Installing dependencies"
132
+ pnpm install >>"$log" 2>&1 || fail "pnpm install failed"`;
133
+ const betterAuth = modules.auth === "better-auth";
134
+ const localDb = modules.infra === "local";
104
135
  return `#!/usr/bin/env bash
105
136
  # Written by create-clubedge-app try. Logs: ${remoteLog}, ${remoteDevLog}
106
137
  set -uo pipefail
@@ -114,41 +145,56 @@ port="\${PORT:-8080}"
114
145
  step "Preparing pnpm"
115
146
  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
147
 
117
- # A second run on the same VM replaces the servers of the first, and creates a new directory.
148
+ # A second run on the same VM replaces the servers of the first, and uses a new directory.
118
149
  pkill -f clubedge-proxy.mjs >/dev/null 2>&1
119
150
  pkill -f "next dev|vite dev|local-db" >/dev/null 2>&1
120
151
 
121
152
  step "Starting the preview proxy"
122
- setsid nohup node ${remoteProxyPath} "$port" 3000 ${shellQuote(options.previewUrl)} >>${remoteDir}/clubedge-proxy.log 2>&1 < /dev/null &
153
+ setsid nohup node ${remoteProxyPath} "$port" 3000 ${shellQuote(previewUrl)} >>${remoteDir}/clubedge-proxy.log 2>&1 < /dev/null &
123
154
 
124
- dir=my-app
155
+ base=${shellQuote(base)}
156
+ dir="$base"
125
157
  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"
158
+ while [ -e "$dir" ]; do dir="$base-$n"; n=$((n + 1)); done
159
+ ${prepare}
160
+
161
+ # The local env file from the example, as create does (the Starter's env.example and env.target);
162
+ # an uploaded project has none, since env files never leave the user's machine.
163
+ if [ -f .env.example ] && [ -d apps/web ] && [ ! -f apps/web/.env.local ]; then cp .env.example apps/web/.env.local; fi
164
+ for example in apps/*/.env.example; do
165
+ [ -f "$example" ] || continue
166
+ target="\${example%.example}.local"
167
+ [ -f "$target" ] || cp "$example" "$target"
168
+ done
130
169
  ${betterAuth
131
170
  ? `
132
- envfile=$(ls apps/*/.env.local 2>/dev/null | head -n 1)
133
- [ -n "$envfile" ] || fail "The project has no apps/*/.env.local"
134
171
  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
172
+ for envfile in apps/*/.env.local; do
173
+ [ -f "$envfile" ] || continue
174
+ if grep -q '^BETTER_AUTH_SECRET=' "$envfile"; then
175
+ sed -i "s/^BETTER_AUTH_SECRET=.*/BETTER_AUTH_SECRET=$secret/" "$envfile"
176
+ else
177
+ echo "BETTER_AUTH_SECRET=$secret" >> "$envfile"
178
+ fi
179
+ done
140
180
  `
141
- : ""}
181
+ : ""}${localDb
182
+ ? `
142
183
  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"; }
184
+ db=${remoteDir}/clubedge-db.log
185
+ : > "$db"
186
+ setsid nohup pnpm infra:up >>"$db" 2>&1 < /dev/null &
187
+ for _ in $(seq 120); do grep -q "Local database ready" "$db" && break; sleep 1; done
188
+ grep -q "Local database ready" "$db" || { cat "$db" >> "$log"; fail "The local database did not start"; }
146
189
 
147
190
  step "Migrating the database"
148
191
  pnpm run --if-present db:migrate >>"$log" 2>&1 || fail "The database migration failed"
149
-
192
+ `
193
+ : ""}
150
194
  step "Starting the app"
195
+ : > ${remoteDevLog}
151
196
  setsid nohup pnpm dev >>${remoteDevLog} 2>&1 < /dev/null &
197
+ code=000
152
198
  for _ in $(seq 300); do
153
199
  code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/ || true)
154
200
  [ "$code" != "000" ] && break
@@ -0,0 +1,91 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { promisify } from "node:util";
5
+ /*
6
+ * Which files of a project leave the machine when `try` uploads it: what Git would commit, never
7
+ * an env file other than an example, and never installed or built output.
8
+ */
9
+ const skippedDirectories = new Set([
10
+ "node_modules",
11
+ ".git",
12
+ ".next",
13
+ ".turbo",
14
+ ".output",
15
+ ".vinxi",
16
+ ".tanstack",
17
+ ".vercel",
18
+ ".netlify",
19
+ ".data",
20
+ "dist",
21
+ "coverage",
22
+ "playwright-report",
23
+ "test-results",
24
+ ]);
25
+ /** Whether a project-relative path (forward slashes) may be uploaded. */
26
+ export function isUploadable(path) {
27
+ const segments = path.split("/");
28
+ if (segments.some((segment) => skippedDirectories.has(segment)))
29
+ return false;
30
+ const name = segments.at(-1) ?? "";
31
+ // .env, .env.local, .env.production... hold secrets; .env.example only names the variables.
32
+ if (/^\.env(\.|$)/.test(name) && !name.endsWith(".example"))
33
+ return false;
34
+ return name !== ".DS_Store";
35
+ }
36
+ async function walk(root, directory = "") {
37
+ const entries = await readdir(join(root, directory), { withFileTypes: true });
38
+ const paths = [];
39
+ for (const entry of entries) {
40
+ const path = directory ? `${directory}/${entry.name}` : entry.name;
41
+ if (entry.isDirectory()) {
42
+ if (!skippedDirectories.has(entry.name))
43
+ paths.push(...(await walk(root, path)));
44
+ }
45
+ else if (entry.isFile()) {
46
+ paths.push(path);
47
+ }
48
+ }
49
+ return paths;
50
+ }
51
+ /** Whether `root` or a folder above it is a Git repository (a .git directory, or a .git file in a worktree). */
52
+ async function insideGit(root) {
53
+ for (let current = resolve(root);; current = dirname(current)) {
54
+ try {
55
+ await stat(join(current, ".git"));
56
+ return true;
57
+ }
58
+ catch (error) {
59
+ if (error.code !== "ENOENT")
60
+ throw error;
61
+ }
62
+ if (dirname(current) === current)
63
+ return false;
64
+ }
65
+ }
66
+ /**
67
+ * The project's files to upload. In a Git repository: tracked files plus untracked ones that are
68
+ * not ignored, so .gitignore decides as it does for a commit. Elsewhere: every file outside the
69
+ * skipped directories. Either way, isUploadable has the last word.
70
+ */
71
+ export async function uploadableFiles(root) {
72
+ let paths;
73
+ try {
74
+ // git runs without a shell (it is an executable on every platform), with room for large repositories.
75
+ const { stdout } = await promisify(execFile)("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
76
+ cwd: root,
77
+ maxBuffer: 64 * 1024 * 1024,
78
+ });
79
+ paths = [...new Set(stdout.split("\0").filter(Boolean))];
80
+ }
81
+ catch (error) {
82
+ // Inside a repository, only Git knows what .gitignore keeps out; walking instead could upload
83
+ // ignored files. So fall back only where there is no repository at all.
84
+ if (await insideGit(root)) {
85
+ const reason = error instanceof Error ? (error.message.split(/\r?\n/)[0] ?? error.message) : String(error);
86
+ throw new Error(`Could not list the project's files with Git, so nothing was uploaded: ${reason}`);
87
+ }
88
+ paths = await walk(root);
89
+ }
90
+ return paths.filter(isUploadable).sort();
91
+ }
package/dist/try.js CHANGED
@@ -6,8 +6,12 @@ import process from "node:process";
6
6
  import ssh2 from "ssh2";
7
7
  import { UsageError } from "./cli/args.js";
8
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";
9
+ import { hostKeyFingerprint, parseRailwayStatus, projectTryProblem, proxySource, railwayFreeVmUrl, railwayHost, railwayKeyPath, railwayTermsUrl, remoteArchivePath, remoteDevLog, remoteLog, remoteName, remoteProxyPath, remoteScriptPath, shellQuote, trustedHostKeys, tryCreateArgs, tryModules, tryScript, } from "./core/railway.js";
10
+ import { ProjectError, readProject } from "./core/project.js";
11
+ import { uploadableFiles } from "./core/upload.js";
10
12
  import { cliVersion } from "./package-info.js";
13
+ import { openUrl } from "./utils/open.js";
14
+ import { createTarGz } from "./utils/tar.js";
11
15
  const { Client, utils } = ssh2;
12
16
  /** The CLI's own key: created on first use, then reused so later runs reach the same VM. */
13
17
  async function loadOrCreateKey(path) {
@@ -110,12 +114,55 @@ const fit = (text) => {
110
114
  function minutesLeft(until) {
111
115
  return Math.max(0, Math.round((until.getTime() - Date.now()) / 60_000));
112
116
  }
113
- async function agree(args, keyPath) {
117
+ const megabytes = (bytes) => `${(bytes / 1_048_576).toFixed(1)} MB`;
118
+ /** The project this command runs in, or null outside one. A project too old to read is an error. */
119
+ async function currentProject() {
120
+ try {
121
+ return await readProject(process.cwd());
122
+ }
123
+ catch (error) {
124
+ if (error instanceof ProjectError && /No Clubedge project found/.test(error.message))
125
+ return null;
126
+ throw error;
127
+ }
128
+ }
129
+ async function plan(args) {
130
+ const project = await currentProject();
131
+ if (!project) {
132
+ const modules = tryModules(args.modules);
133
+ const flags = tryCreateArgs({ framework: args.framework, modules, ref: args.ref }).join(" ");
134
+ return { kind: "create", modules, summary: `Create a new project on the VM: create-clubedge-app@${cliVersion} my-app ${flags}` };
135
+ }
136
+ if (args.framework !== undefined || Object.keys(args.modules).length || args.ref !== undefined) {
137
+ throw new UsageError("--framework, module flags, and --ref choose a new project. Inside a project, try uploads the project as it is; run try outside it to create a new one.");
138
+ }
139
+ const problem = projectTryProblem(project.modules);
140
+ if (problem)
141
+ throw new UsageError(problem);
142
+ const paths = await uploadableFiles(project.root);
143
+ if (!paths.includes("package.json"))
144
+ throw new UsageError(`Nothing to upload from ${project.root}.`);
145
+ return {
146
+ kind: "upload",
147
+ modules: project.modules,
148
+ project,
149
+ paths,
150
+ summary: `Upload ${project.identity.packageName} (${paths.length} files from ${project.root}): what Git would commit, without .env files or node_modules`,
151
+ };
152
+ }
153
+ async function agree(args, keyPath, what) {
114
154
  note([
115
- "This creates a free, temporary Linux VM on Railway and runs a new project there.",
155
+ what.kind === "create"
156
+ ? "This creates a free, temporary Linux VM on Railway and runs a new project there."
157
+ : "This creates a free, temporary Linux VM on Railway, uploads this project, and runs it there.",
116
158
  "",
159
+ ...(what.kind === "upload"
160
+ ? [
161
+ `• Uploads ${what.paths.length} files: what Git would commit. .env files (other than examples) and node_modules stay here; the VM makes its env files from .env.example.`,
162
+ ]
163
+ : []),
117
164
  `• 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.`,
165
+ `• Until you claim the VM, only you can open the preview: from the IP address that created it (this connection). Your phone on mobile data, a VPN, or anyone else get Access denied.`,
119
166
  `• Railway allows 3 free VMs per network per day.`,
120
167
  `• The CLI uses its own SSH key, ${keyPath}; your ~/.ssh is not used.`,
121
168
  `• Railway's terms apply: ${railwayTermsUrl}`,
@@ -131,19 +178,21 @@ async function agree(args, keyPath) {
131
178
  return !isCancel(answer) && answer;
132
179
  }
133
180
  export async function tryOnRailway(args) {
134
- const modules = tryModules(args.modules);
181
+ const what = await plan(args);
135
182
  const keyPath = railwayKeyPath();
136
- const createArgs = tryCreateArgs({ framework: args.framework, modules, ref: args.ref });
137
183
  if (args.dryRun) {
138
184
  log.message([
139
185
  `• 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",
186
+ `• ${what.summary}`,
187
+ what.modules.infra === "local"
188
+ ? "• Start the local database (PGlite), migrate, and start the app behind the preview URL"
189
+ : "• Start the app behind the preview URL",
190
+ ...(what.kind === "upload" ? ["", dim(what.paths.join("\n"))] : []),
142
191
  ].join("\n"));
143
192
  outro("Dry run: no VM was created. Run again without --dry-run to try it.");
144
193
  return;
145
194
  }
146
- if (!(await agree(args, keyPath))) {
195
+ if (!(await agree(args, keyPath, what))) {
147
196
  cancel("No VM was created.");
148
197
  return;
149
198
  }
@@ -161,12 +210,23 @@ export async function tryOnRailway(args) {
161
210
  throw new Error(`${railwayHost} did not describe the VM. Railway may have changed its free VM; see ${railwayFreeVmUrl}.`);
162
211
  progress.stop(`VM ready, ${minutesLeft(vm.expiresAt)} minutes to try it`);
163
212
  spinning = "setup";
164
- progress.start("Preparing the VM");
213
+ let source;
214
+ if (what.kind === "upload") {
215
+ progress.start(`Uploading ${what.paths.length} files`);
216
+ const { archive, bytes } = await createTarGz(what.project.root, what.paths);
217
+ await upload(connection, remoteArchivePath, archive);
218
+ progress.message(`Uploaded ${megabytes(bytes)} (${megabytes(archive.length)} compressed)`);
219
+ source = { kind: "upload", name: remoteName(what.project.identity.packageName) };
220
+ }
221
+ else {
222
+ progress.start("Preparing the VM");
223
+ source = { kind: "create", cliVersion, framework: args.framework, ref: args.ref };
224
+ }
165
225
  await upload(connection, remoteProxyPath, proxySource);
166
- await upload(connection, remoteScriptPath, tryScript({ cliVersion, framework: args.framework, modules, ref: args.ref, previewUrl: vm.previewUrl }));
226
+ await upload(connection, remoteScriptPath, tryScript({ source, modules: what.modules, previewUrl: vm.previewUrl }));
167
227
  const tail = [];
168
228
  let failure = "";
169
- let directory = "my-app";
229
+ let directory = "";
170
230
  const started = Date.now();
171
231
  const { code } = await exec(connection, `bash ${remoteScriptPath}`, {
172
232
  onLine: (line) => {
@@ -177,7 +237,7 @@ export async function tryOnRailway(args) {
177
237
  else if (line.startsWith("@@fail "))
178
238
  failure = line.slice(7);
179
239
  else if (line.startsWith("@@ready"))
180
- directory = line.slice(8).trim() || directory;
240
+ directory = line.slice(8).trim();
181
241
  },
182
242
  });
183
243
  if (code !== 0 || failure) {
@@ -195,8 +255,11 @@ export async function tryOnRailway(args) {
195
255
  `${bold("Claim")} ${vm.claimUrl}`,
196
256
  ].join("\n"), "Your app on Railway");
197
257
  log.warn("Keep the claim link private: whoever opens it takes over the VM, its files, and its URL.");
258
+ // Only for a person at a terminal: scripts and CI get the URL above, and nothing opens.
259
+ if (args.open && process.stdout.isTTY && (await openUrl(vm.previewUrl)))
260
+ log.info("Opened the preview in your browser.");
198
261
  outro([
199
- "The preview opens only from your network until you claim the VM. Claiming moves it to your Railway account.",
262
+ "Until you claim the VM, the preview opens only from the IP address that created it. Claiming moves it to your Railway account and makes the URL shareable.",
200
263
  "",
201
264
  `${dim("Shell")} ssh -i "${keyPath}" ${railwayHost}`,
202
265
  `${dim("Logs")} ssh -i "${keyPath}" ${railwayHost} tail -f ${remoteDevLog}`,
@@ -0,0 +1,27 @@
1
+ import { spawn } from "node:child_process";
2
+ import process from "node:process";
3
+ /** The command that opens a URL in the default browser on this platform. */
4
+ export function openCommand(url, platform = process.platform) {
5
+ if (platform === "win32")
6
+ return ["rundll32", ["url.dll,FileProtocolHandler", url]];
7
+ if (platform === "darwin")
8
+ return ["open", [url]];
9
+ return ["xdg-open", [url]];
10
+ }
11
+ /** Opens a URL in the default browser. Best effort: returns false when nothing could open it. */
12
+ export async function openUrl(url) {
13
+ const [command, args] = openCommand(url);
14
+ return await new Promise((resolve) => {
15
+ try {
16
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
17
+ child.once("error", () => resolve(false));
18
+ child.once("spawn", () => {
19
+ child.unref();
20
+ resolve(true);
21
+ });
22
+ }
23
+ catch {
24
+ resolve(false);
25
+ }
26
+ });
27
+ }
@@ -0,0 +1,67 @@
1
+ import { lstat, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { gzipSync } from "node:zlib";
4
+ /*
5
+ * A minimal ustar writer: regular files only, as a gzipped archive in memory. Enough to send a
6
+ * project's source to a VM in one stream, without a tar dependency or a system tar on Windows.
7
+ */
8
+ const block = 512;
9
+ function octal(value, length) {
10
+ return value.toString(8).padStart(length - 1, "0") + "\0";
11
+ }
12
+ /** ustar keeps the path in name (100 bytes) and prefix (155 bytes), split at a slash. */
13
+ function splitPath(path) {
14
+ if (Buffer.byteLength(path) <= 100)
15
+ return { name: path, prefix: "" };
16
+ for (let i = path.indexOf("/"); i !== -1; i = path.indexOf("/", i + 1)) {
17
+ const prefix = path.slice(0, i);
18
+ const name = path.slice(i + 1);
19
+ if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(name) <= 100)
20
+ return { name, prefix };
21
+ }
22
+ throw new Error(`The path ${path} is too long to upload.`);
23
+ }
24
+ function header(path, size, mode, mtime) {
25
+ const buffer = Buffer.alloc(block);
26
+ const { name, prefix } = splitPath(path);
27
+ buffer.write(name, 0, 100, "utf8");
28
+ buffer.write(octal(mode, 8), 100, 8, "ascii");
29
+ buffer.write(octal(0, 8), 108, 8, "ascii");
30
+ buffer.write(octal(0, 8), 116, 8, "ascii");
31
+ buffer.write(octal(size, 12), 124, 12, "ascii");
32
+ buffer.write(octal(mtime, 12), 136, 12, "ascii");
33
+ buffer.write(" ", 148, 8, "ascii");
34
+ buffer.write("0", 156, 1, "ascii");
35
+ buffer.write("ustar\0", 257, 6, "ascii");
36
+ buffer.write("00", 263, 2, "ascii");
37
+ buffer.write(prefix, 345, 155, "utf8");
38
+ let sum = 0;
39
+ for (const byte of buffer)
40
+ sum += byte;
41
+ buffer.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148, 8, "ascii");
42
+ return buffer;
43
+ }
44
+ /**
45
+ * Packs the files at `paths` (relative, with forward slashes) under `root` into a .tar.gz.
46
+ * Missing files and anything that is not a regular file are skipped.
47
+ */
48
+ export async function createTarGz(root, paths) {
49
+ const parts = [];
50
+ let files = 0;
51
+ let bytes = 0;
52
+ for (const path of paths) {
53
+ const stats = await lstat(join(root, path)).catch(() => null);
54
+ if (!stats?.isFile())
55
+ continue;
56
+ const content = await readFile(join(root, path));
57
+ const executable = (stats.mode & 0o111) !== 0;
58
+ parts.push(header(path, content.length, executable ? 0o755 : 0o644, Math.floor(stats.mtimeMs / 1000)), content);
59
+ const padding = (block - (content.length % block)) % block;
60
+ if (padding)
61
+ parts.push(Buffer.alloc(padding));
62
+ files += 1;
63
+ bytes += content.length;
64
+ }
65
+ parts.push(Buffer.alloc(block * 2));
66
+ return { archive: gzipSync(Buffer.concat(parts)), files, bytes };
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
4
  "description": "Create a Next.js or TanStack Start application from the Clubedge Starter reference implementation.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.9.0",