@clubedge/create-clubedge-app 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.19.0
6
+
7
+ - `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.
8
+ - 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.
9
+ - 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.
10
+ - Projects that need Docker or Supabase Auth are refused, with the `pnpm clubedge add` command that switches them.
11
+ - `try` opens the preview in the browser when it is ready, at a terminal; `--no-open` turns that off.
12
+ - Still on Starter `v0.15.0`.
13
+
14
+ ## 0.18.0
15
+
16
+ - 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.
17
+ - 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.
18
+ - 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).
19
+ - 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.
20
+ - Still on Starter `v0.15.0`.
21
+
5
22
  ## 0.17.0
6
23
 
7
24
  - 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.
package/README.md CHANGED
@@ -172,12 +172,29 @@ 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.
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.
191
+
175
192
  ## Version relationship
176
193
 
177
194
  Each CLI release is tested against one specific Starter release:
178
195
 
179
196
  ```text
180
- create-clubedge-app v0.17.0
197
+ create-clubedge-app v0.19.0
181
198
  |
182
199
  v
183
200
  Clubedge Starter v0.15.0
@@ -188,7 +205,7 @@ The current relationship is:
188
205
 
189
206
  | CLI release | Default Starter release | Meaning |
190
207
  | --- | --- | --- |
191
- | `create-clubedge-app@0.17.0` | `clubedge-starter@v0.15.0` | The CLI's required, tested default |
208
+ | `create-clubedge-app@0.19.0` | `clubedge-starter@v0.15.0` | The CLI's required, tested default |
192
209
 
193
210
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
194
211
 
@@ -279,6 +296,8 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
279
296
  | 0.15.0 | v0.13.1 | `status` and `doctor`, several modules in one change, conflict and upgrade summaries, macOS CI |
280
297
  | 0.16.0 | v0.14.0 | Account settings page, browser error reporting and Sentry source maps, auth emails through the jobs queue |
281
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 |
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 |
282
301
  | next | | Show the Starter's version of a conflicted file next to yours |
283
302
 
284
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
  };
@@ -75,15 +76,20 @@ export function parseCliArgs(argv) {
75
76
  }
76
77
  const { values, positionals } = parsed;
77
78
  const command = parseCommand(positionals);
79
+ // try creates a project too, on a VM, so it takes the same selection flags as create.
80
+ const creates = command.name === "create" || command.name === "try";
78
81
  if (command.name === "create" && positionals.length > 1) {
79
82
  throw new UsageError("Only one project directory can be provided.");
80
83
  }
81
- if (command.name !== "create" && (values.framework !== undefined || Object.keys(modules).length)) {
84
+ if (!creates && (values.framework !== undefined || Object.keys(modules).length)) {
82
85
  throw new UsageError("--framework and module flags only apply when creating a project; use add or remove to change modules.");
83
86
  }
84
- if (command.name !== "create" && command.name !== "upgrade" && values.ref !== undefined) {
87
+ if (!creates && command.name !== "upgrade" && values.ref !== undefined) {
85
88
  throw new UsageError("--ref only applies when creating or upgrading a project.");
86
89
  }
90
+ if (command.name === "try" && values["template-dir"] !== undefined) {
91
+ throw new UsageError("--template-dir does not apply to try: the VM downloads the Starter itself.");
92
+ }
87
93
  if (values.framework !== undefined && !values.framework.trim()) {
88
94
  throw new UsageError("--framework requires a framework id, such as next or tanstack-start.");
89
95
  }
@@ -106,6 +112,7 @@ export function parseCliArgs(argv) {
106
112
  dryRun: values["dry-run"],
107
113
  force: values.force,
108
114
  services: values.services,
115
+ open: values.open,
109
116
  help: values.help,
110
117
  version: values.version,
111
118
  };
@@ -116,9 +123,9 @@ function assertDistinct(modules) {
116
123
  throw new UsageError(`The module "${repeated}" is named more than once.`);
117
124
  }
118
125
  /**
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`.
126
+ * `add <module> <option>...`, `remove <module>...`, and `upgrade` change an existing project, `status`
127
+ * and `doctor` inspect one, and `try` creates one on a Railway VM; anything else is the directory
128
+ * of a new project. Create a project literally named "add" with `./add`.
122
129
  */
123
130
  function parseCommand(positionals) {
124
131
  const [name, ...rest] = positionals;
@@ -142,6 +149,12 @@ function parseCommand(positionals) {
142
149
  throw new UsageError(`Usage: ${name}, run inside a project.`);
143
150
  return { name };
144
151
  }
152
+ if (name === "try") {
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
+ }
156
+ return { name };
157
+ }
145
158
  if (name === "upgrade") {
146
159
  if (module !== undefined)
147
160
  throw new UsageError("Usage: upgrade [--ref <tag>], for example: upgrade --ref v0.8.0");
package/dist/cli/ui.js CHANGED
@@ -91,9 +91,11 @@ 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
- 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.
97
99
 
98
100
  add and remove change modules of an existing project, several at once as one
99
101
  change, and upgrade moves it to a newer Starter (this CLI's release unless --ref
@@ -105,6 +107,14 @@ Usage:
105
107
  newer Starter is available. doctor checks Node.js, pnpm, the dependencies, the
106
108
  lockfile, and the env file, then runs pnpm check:services; it changes nothing.
107
109
 
110
+ try creates a new project on a free, temporary Railway VM (no account, 60 minutes)
111
+ and prints its preview URL and the link to claim the VM. The VM has no Docker, so
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.
117
+
108
118
  Options:
109
119
  ${selectionHelp(manifest)}
110
120
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
@@ -113,6 +123,7 @@ ${selectionHelp(manifest)}
113
123
  --dry-run Show what would be created or changed without writing anything
114
124
  --force add/remove: change a project with uncommitted changes or without Git
115
125
  --no-services doctor: skip pnpm check:services
126
+ --no-open try: do not open the preview in the browser
116
127
  -y, --yes Accept defaults and never prompt (default directory: my-app)
117
128
  --no-install Skip dependency installation
118
129
  --no-git Skip Git repository initialization
@@ -132,6 +143,7 @@ Examples:
132
143
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade
133
144
  pnpm clubedge status
134
145
  pnpm clubedge doctor --no-services
146
+ pnpm dlx @clubedge/create-clubedge-app try --framework tanstack-start
135
147
 
136
148
  Learn more:
137
149
  CLI ${cliUrl}
@@ -0,0 +1,268 @@
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
+ /**
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
+ }
99
+ /** A value as one single-quoted shell word. */
100
+ export const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
101
+ /** The create command's flags on the VM: the chosen selection, without prompts. */
102
+ export function tryCreateArgs({ framework, modules, ref }) {
103
+ return [
104
+ ...(framework ? ["--framework", framework] : []),
105
+ ...Object.entries(modules).flatMap(([module, option]) => [`--${module}`, option]),
106
+ ...(ref ? ["--ref", ref] : []),
107
+ "--yes",
108
+ ];
109
+ }
110
+ export const remoteDir = "/app";
111
+ export const remoteLog = `${remoteDir}/clubedge-try.log`;
112
+ export const remoteDevLog = `${remoteDir}/clubedge-dev.log`;
113
+ export const remoteScriptPath = `${remoteDir}/clubedge-try.sh`;
114
+ export const remoteProxyPath = `${remoteDir}/clubedge-proxy.mjs`;
115
+ export const remoteArchivePath = `${remoteDir}/clubedge-upload.tgz`;
116
+ /**
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
119
+ * running after the SSH session ends.
120
+ */
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";
135
+ return `#!/usr/bin/env bash
136
+ # Written by create-clubedge-app try. Logs: ${remoteLog}, ${remoteDevLog}
137
+ set -uo pipefail
138
+ cd ${remoteDir}
139
+ log=${remoteLog}
140
+ : > "$log"
141
+ step() { echo "@@step $*"; echo "== $*" >> "$log"; }
142
+ fail() { echo "@@fail $*"; tail -n 40 "$log" | sed 's/^/@@log /'; exit 1; }
143
+ port="\${PORT:-8080}"
144
+
145
+ step "Preparing pnpm"
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"
147
+
148
+ # A second run on the same VM replaces the servers of the first, and uses a new directory.
149
+ pkill -f clubedge-proxy.mjs >/dev/null 2>&1
150
+ pkill -f "next dev|vite dev|local-db" >/dev/null 2>&1
151
+
152
+ step "Starting the preview proxy"
153
+ setsid nohup node ${remoteProxyPath} "$port" 3000 ${shellQuote(previewUrl)} >>${remoteDir}/clubedge-proxy.log 2>&1 < /dev/null &
154
+
155
+ base=${shellQuote(base)}
156
+ dir="$base"
157
+ n=2
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
169
+ ${betterAuth
170
+ ? `
171
+ secret=$(node -e 'console.log(require("node:crypto").randomBytes(32).toString("hex"))')
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
180
+ `
181
+ : ""}${localDb
182
+ ? `
183
+ step "Starting the local database"
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"; }
189
+
190
+ step "Migrating the database"
191
+ pnpm run --if-present db:migrate >>"$log" 2>&1 || fail "The database migration failed"
192
+ `
193
+ : ""}
194
+ step "Starting the app"
195
+ : > ${remoteDevLog}
196
+ setsid nohup pnpm dev >>${remoteDevLog} 2>&1 < /dev/null &
197
+ code=000
198
+ for _ in $(seq 300); do
199
+ code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/ || true)
200
+ [ "$code" != "000" ] && break
201
+ sleep 1
202
+ done
203
+ [ "$code" != "000" ] || { tail -n 40 ${remoteDevLog} >> "$log"; fail "The app did not start"; }
204
+ echo "@@ready $dir"
205
+ `;
206
+ }
207
+ /**
208
+ * A small reverse proxy for the VM: Railway's preview URL reaches it on $PORT, and it forwards to
209
+ * the dev server on localhost. The Starter's dev servers only serve their scripts to localhost
210
+ * (Next.js allowedDevOrigins, Vite's host check), and Next.js compares Origin with the forwarded
211
+ * host for form posts, so requests are made to look local, and redirects to localhost are pointed
212
+ * back at the preview URL. Until the app answers, it shows a page that reloads itself.
213
+ */
214
+ export const proxySource = `import http from "node:http";
215
+ import net from "node:net";
216
+
217
+ const [port = "8080", upstreamPort = "3000", publicOrigin = ""] = process.argv.slice(2);
218
+ const local = "http://localhost:" + upstreamPort;
219
+
220
+ function toLocal(headers) {
221
+ const result = { ...headers, host: "localhost:" + upstreamPort };
222
+ delete result["x-forwarded-host"];
223
+ delete result.forwarded;
224
+ if (result.origin) result.origin = local;
225
+ if (result.referer && publicOrigin) result.referer = result.referer.replace(publicOrigin, local);
226
+ return result;
227
+ }
228
+
229
+ const starting = '<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="5"><title>Starting</title>' +
230
+ '<body style="font:16px system-ui;display:grid;place-items:center;height:90vh;color:#444">' +
231
+ "<p>Your Clubedge app is starting. This page reloads by itself.</p>";
232
+
233
+ const server = http.createServer((req, res) => {
234
+ const upstream = http.request(
235
+ { host: "127.0.0.1", port: Number(upstreamPort), method: req.method, path: req.url, headers: toLocal(req.headers) },
236
+ (answer) => {
237
+ const headers = { ...answer.headers };
238
+ if (headers.location && publicOrigin) headers.location = headers.location.replace(local, publicOrigin);
239
+ res.writeHead(answer.statusCode ?? 502, headers);
240
+ answer.pipe(res);
241
+ },
242
+ );
243
+ upstream.on("error", () => {
244
+ if (res.headersSent) return res.destroy();
245
+ res.writeHead(503, { "content-type": "text/html; charset=utf-8", "retry-after": "5" });
246
+ res.end(starting);
247
+ });
248
+ req.pipe(upstream);
249
+ });
250
+
251
+ // Hot reload: pass WebSocket upgrades through with the same local headers.
252
+ server.on("upgrade", (req, socket, head) => {
253
+ const upstream = net.connect(Number(upstreamPort), "127.0.0.1", () => {
254
+ const lines = [req.method + " " + req.url + " HTTP/1.1"];
255
+ for (const [name, value] of Object.entries(toLocal(req.headers))) {
256
+ for (const item of [].concat(value)) lines.push(name + ": " + item);
257
+ }
258
+ upstream.write(lines.join("\\r\\n") + "\\r\\n\\r\\n");
259
+ if (head && head.length) upstream.write(head);
260
+ upstream.pipe(socket);
261
+ socket.pipe(upstream);
262
+ });
263
+ upstream.on("error", () => socket.destroy());
264
+ socket.on("error", () => upstream.destroy());
265
+ });
266
+
267
+ server.listen(Number(port), "0.0.0.0");
268
+ `;
@@ -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/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,276 @@
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, 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";
12
+ import { cliVersion } from "./package-info.js";
13
+ import { openUrl } from "./utils/open.js";
14
+ import { createTarGz } from "./utils/tar.js";
15
+ const { Client, utils } = ssh2;
16
+ /** The CLI's own key: created on first use, then reused so later runs reach the same VM. */
17
+ async function loadOrCreateKey(path) {
18
+ try {
19
+ return { privateKey: await readFile(path, "utf8"), created: false };
20
+ }
21
+ catch {
22
+ const pair = utils.generateKeyPairSync("ed25519", { comment: "create-clubedge-app try" });
23
+ await mkdir(dirname(path), { recursive: true });
24
+ await writeFile(path, pair.private, { mode: 0o600 });
25
+ await chmod(path, 0o600).catch(() => { });
26
+ await writeFile(`${path}.pub`, `${pair.public}\n`);
27
+ return { privateKey: pair.private, created: true };
28
+ }
29
+ }
30
+ /** Opens the SSH connection, accepting only Railway's pinned host key. */
31
+ async function connect(privateKey) {
32
+ const trusted = trustedHostKeys();
33
+ let seen = "";
34
+ return await new Promise((resolve, reject) => {
35
+ const connection = new Client();
36
+ connection
37
+ .once("ready", () => resolve(connection))
38
+ .once("error", (error) => {
39
+ if (seen && !trusted.includes(seen)) {
40
+ reject(new Error(`${railwayHost} presented an unknown host key (${seen}), so the CLI did not connect. ` +
41
+ `If Railway announced a new key, set CLUBEDGE_RAILWAY_HOST_KEY=${seen} and run again.`));
42
+ }
43
+ else if (/authentication/i.test(error.message)) {
44
+ reject(new Error(`Railway refused the connection. The free VM may be paused or at its daily limit; see ${railwayFreeVmUrl}.`));
45
+ }
46
+ else {
47
+ reject(new Error(`Could not reach ${railwayHost}: ${error.message}`));
48
+ }
49
+ })
50
+ .connect({
51
+ host: railwayHost,
52
+ port: 22,
53
+ username: userInfo().username || "clubedge",
54
+ privateKey,
55
+ readyTimeout: 30_000,
56
+ keepaliveInterval: 15_000,
57
+ hostVerifier: (key) => {
58
+ seen = hostKeyFingerprint(key);
59
+ return trusted.includes(seen);
60
+ },
61
+ });
62
+ });
63
+ }
64
+ /**
65
+ * Runs a command on the VM. Railway's status line on stderr is taken out and parsed; stdout lines
66
+ * go to onLine as they arrive.
67
+ */
68
+ async function exec(connection, command, { input, onLine } = {}) {
69
+ return await new Promise((resolve, reject) => {
70
+ connection.exec(command, (error, stream) => {
71
+ if (error)
72
+ return reject(error);
73
+ let stdout = "";
74
+ let stderr = "";
75
+ let pending = "";
76
+ stream.on("data", (chunk) => {
77
+ const text = chunk.toString();
78
+ stdout += text;
79
+ pending += text;
80
+ const lines = pending.split("\n");
81
+ pending = lines.pop() ?? "";
82
+ for (const line of lines)
83
+ onLine?.(line.replace(/\r$/, ""));
84
+ });
85
+ stream.stderr.on("data", (chunk) => (stderr += chunk.toString()));
86
+ stream.once("close", (code) => {
87
+ if (pending)
88
+ onLine?.(pending);
89
+ try {
90
+ resolve({ code: code ?? 1, stdout, vm: parseRailwayStatus(stderr) });
91
+ }
92
+ catch (parseError) {
93
+ reject(parseError);
94
+ }
95
+ });
96
+ if (input !== undefined)
97
+ stream.end(input);
98
+ else
99
+ stream.end();
100
+ });
101
+ });
102
+ }
103
+ /** Writes a file on the VM through the command's stdin. */
104
+ async function upload(connection, path, content) {
105
+ const { code } = await exec(connection, `cat > ${shellQuote(path)}`, { input: content });
106
+ if (code !== 0)
107
+ throw new Error(`Could not write ${path} on the VM.`);
108
+ }
109
+ // A spinner line wider than the terminal wraps, and each frame then prints a new line.
110
+ const fit = (text) => {
111
+ const width = (process.stdout.columns ?? 80) - 6;
112
+ return text.length > width ? `${text.slice(0, width - 1)}…` : text;
113
+ };
114
+ function minutesLeft(until) {
115
+ return Math.max(0, Math.round((until.getTime() - Date.now()) / 60_000));
116
+ }
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) {
154
+ note([
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.",
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
+ : []),
164
+ `• No Railway account is needed. You get 60 minutes, then 24 hours to claim the VM to keep it.`,
165
+ `• The preview opens only from your network until you claim the VM.`,
166
+ `• Railway allows 3 free VMs per network per day.`,
167
+ `• The CLI uses its own SSH key, ${keyPath}; your ~/.ssh is not used.`,
168
+ `• Railway's terms apply: ${railwayTermsUrl}`,
169
+ "",
170
+ dim(`Experimental: Railway can change or pause its free VM (${railwayFreeVmUrl}).`),
171
+ ].join("\n"), "Try it on Railway");
172
+ if (args.yes)
173
+ return true;
174
+ if (!process.stdin.isTTY) {
175
+ throw new UsageError("try creates a VM on Railway. Run it in a terminal to confirm, or pass --yes to agree.");
176
+ }
177
+ const answer = await confirm({ message: "Create the VM?", initialValue: true });
178
+ return !isCancel(answer) && answer;
179
+ }
180
+ export async function tryOnRailway(args) {
181
+ const what = await plan(args);
182
+ const keyPath = railwayKeyPath();
183
+ if (args.dryRun) {
184
+ log.message([
185
+ `• Connect to ${railwayHost} with the key in ${keyPath}`,
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"))] : []),
191
+ ].join("\n"));
192
+ outro("Dry run: no VM was created. Run again without --dry-run to try it.");
193
+ return;
194
+ }
195
+ if (!(await agree(args, keyPath, what))) {
196
+ cancel("No VM was created.");
197
+ return;
198
+ }
199
+ const progress = spinner();
200
+ progress.start(`Connecting to ${railwayHost}`);
201
+ let spinning = "connect";
202
+ let connection;
203
+ try {
204
+ const key = await loadOrCreateKey(keyPath);
205
+ if (key.created)
206
+ log.info(`Created an SSH key for Railway in ${keyPath}`);
207
+ connection = await connect(key.privateKey);
208
+ const { vm } = await exec(connection, "true");
209
+ if (!vm)
210
+ throw new Error(`${railwayHost} did not describe the VM. Railway may have changed its free VM; see ${railwayFreeVmUrl}.`);
211
+ progress.stop(`VM ready, ${minutesLeft(vm.expiresAt)} minutes to try it`);
212
+ spinning = "setup";
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
+ }
225
+ await upload(connection, remoteProxyPath, proxySource);
226
+ await upload(connection, remoteScriptPath, tryScript({ source, modules: what.modules, previewUrl: vm.previewUrl }));
227
+ const tail = [];
228
+ let failure = "";
229
+ let directory = "";
230
+ const started = Date.now();
231
+ const { code } = await exec(connection, `bash ${remoteScriptPath}`, {
232
+ onLine: (line) => {
233
+ if (line.startsWith("@@step "))
234
+ progress.message(fit(`${line.slice(7)} (${Math.round((Date.now() - started) / 1000)}s)`));
235
+ else if (line.startsWith("@@log "))
236
+ tail.push(line.slice(6));
237
+ else if (line.startsWith("@@fail "))
238
+ failure = line.slice(7);
239
+ else if (line.startsWith("@@ready"))
240
+ directory = line.slice(8).trim();
241
+ },
242
+ });
243
+ if (code !== 0 || failure) {
244
+ spinning = null;
245
+ progress.stop(failure || "Setup on the VM failed");
246
+ if (tail.length)
247
+ log.message(dim(tail.join("\n")));
248
+ throw new Error(`Setup on the VM failed. Its log is ${remoteLog}; read it with: ssh -i "${keyPath}" ${railwayHost} cat ${remoteLog}`);
249
+ }
250
+ spinning = null;
251
+ progress.stop(`The app is running in /app/${directory}`);
252
+ note([
253
+ `${bold("Preview")} ${vm.previewUrl}`,
254
+ `${bold("Time")} ${minutesLeft(vm.expiresAt)} minutes left (until ${vm.expiresAt.toLocaleTimeString()})`,
255
+ `${bold("Claim")} ${vm.claimUrl}`,
256
+ ].join("\n"), "Your app on Railway");
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.");
261
+ outro([
262
+ "The preview opens only from your network until you claim the VM. Claiming moves it to your Railway account.",
263
+ "",
264
+ `${dim("Shell")} ssh -i "${keyPath}" ${railwayHost}`,
265
+ `${dim("Logs")} ssh -i "${keyPath}" ${railwayHost} tail -f ${remoteDevLog}`,
266
+ ].join("\n"));
267
+ }
268
+ catch (error) {
269
+ if (spinning)
270
+ progress.stop(spinning === "connect" ? "Could not start a VM on Railway" : "Setup on the VM failed");
271
+ throw error;
272
+ }
273
+ finally {
274
+ connection?.end();
275
+ }
276
+ }
@@ -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.17.0",
3
+ "version": "0.19.0",
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",
@@ -53,10 +53,12 @@
53
53
  "dependencies": {
54
54
  "@clack/prompts": "^1.8.1",
55
55
  "giget": "^2.0.0",
56
- "node-diff3": "^3.2.1"
56
+ "node-diff3": "^3.2.1",
57
+ "ssh2": "^1.17.0"
57
58
  },
58
59
  "devDependencies": {
59
60
  "@types/node": "^22.20.5",
61
+ "@types/ssh2": "^1.15.6",
60
62
  "typescript": "^5.9.3",
61
63
  "vitest": "^5.0.3"
62
64
  }