@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 +14 -0
- package/README.md +9 -4
- package/dist/cli/args.js +5 -2
- package/dist/cli/ui.js +8 -4
- package/dist/core/railway.js +71 -25
- package/dist/core/upload.js +91 -0
- package/dist/try.js +77 -14
- package/dist/utils/open.js +27 -0
- package/dist/utils/tar.js +67 -0
- package/package.json +1 -1
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
|
|
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.
|
|
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.
|
|
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.
|
|
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],
|
|
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
|
|
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.
|
|
112
|
-
|
|
113
|
-
its
|
|
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
|
package/dist/core/railway.js
CHANGED
|
@@ -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
|
|
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",
|
|
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(
|
|
102
|
-
const
|
|
103
|
-
const
|
|
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
|
|
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(
|
|
153
|
+
setsid nohup node ${remoteProxyPath} "$port" 3000 ${shellQuote(previewUrl)} >>${remoteDir}/clubedge-proxy.log 2>&1 < /dev/null &
|
|
123
154
|
|
|
124
|
-
|
|
155
|
+
base=${shellQuote(base)}
|
|
156
|
+
dir="$base"
|
|
125
157
|
n=2
|
|
126
|
-
while [ -e "$dir" ]; do dir="
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`•
|
|
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
|
|
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
|
-
`•
|
|
141
|
-
|
|
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
|
-
|
|
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({
|
|
226
|
+
await upload(connection, remoteScriptPath, tryScript({ source, modules: what.modules, previewUrl: vm.previewUrl }));
|
|
167
227
|
const tail = [];
|
|
168
228
|
let failure = "";
|
|
169
|
-
let directory = "
|
|
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()
|
|
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
|
-
"
|
|
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