@clubedge/create-clubedge-app 0.19.1 → 0.20.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,18 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.20.0
6
+
7
+ - Conflicts are easier to resolve after `add`, `remove`, and `upgrade`:
8
+ - The Starter's whole new version of each conflicted file is saved in `.clubedge/incoming/<file>`, and the report prints a `git diff --no-index` command to compare it with yours. Files left for you to check by hand get theirs too, when the Starter has one. Git ignores the folder; each change replaces the previous change's files.
9
+ - Without a module on the command line, the `add` and `remove` menus pick several modules at once; `add` then asks for each one's option, and the changes apply together.
10
+ - Sharing a `try` preview:
11
+ - When the app is ready, `try` asks whether to share it. Claiming the VM, a free Railway sign-up, is what makes the URL work for anyone, so it opens the claim page. `--claim` does that without asking; scripts are never asked.
12
+ - The closing message explains Railway's "Access denied" page and prints a port-forward command that opens the app at http://localhost:3000 from any network.
13
+ - `try --status` shows the last VM's preview, time left, and claim link. It connects only while the VM is running, so it never creates a new one.
14
+ - Release workflow: GitHub Actions on their Node 24 versions, Linux jobs on Ubuntu 24.04, a sturdier wait for npm in the smoke tests, and GitHub Release pages created from this changelog.
15
+ - Still on Starter `v0.15.0`.
16
+
5
17
  ## 0.19.1
6
18
 
7
19
  - `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.
package/README.md CHANGED
@@ -136,7 +136,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
136
136
 
137
137
  ## Change an existing project
138
138
 
139
- Inside a project created by the CLI, `add` and `remove` change modules, and `upgrade` moves the project to a newer Starter release. Projects from Starter v0.8.0 on have a `clubedge` script, so `pnpm clubedge add cache redis` works too. Without a module or option, `add` and `remove` ask for them:
139
+ Inside a project created by the CLI, `add` and `remove` change modules, and `upgrade` moves the project to a newer Starter release. Projects from Starter v0.8.0 on have a `clubedge` script, so `pnpm clubedge add cache redis` works too. Without a module or option, `add` and `remove` ask for them, with a list where you can pick several modules at once:
140
140
 
141
141
  ```sh
142
142
  pnpm clubedge add # choose a module and option from a list
@@ -152,7 +152,7 @@ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.15.0 # or a specific tag
152
152
  The CLI renders the project twice, from the Starter commit and modules recorded in your `package.json` and from the new state, and merges the difference into your project. The new state has other modules for `add` and `remove`, and the newer Starter with the same modules for `upgrade`. Modules a newer Starter introduces start as `none` where possible, so an upgrade never adds a service on its own.
153
153
 
154
154
  - Files you never changed are updated, added, or deleted.
155
- - Files you changed keep your edits. Where the module change touches the same lines, the file gets conflict markers (`<<<<<<< yours` ... `>>>>>>> clubedge`) for you to resolve.
155
+ - Files you changed keep your edits. Where the module change touches the same lines, the file gets conflict markers (`<<<<<<< yours` ... `>>>>>>> clubedge`) for you to resolve. The Starter's whole new version of each such file is saved in `.clubedge/incoming/` to compare with yours (`git diff --no-index <file> .clubedge/incoming/<file>`); Git ignores that folder, so delete it when you are done.
156
156
  - Your own files are never touched.
157
157
  - `apps/web/.env.local` keeps your values. Values still at their generated defaults follow the new selection, and new variables are added.
158
158
  - `pnpm-lock.yaml` is replaced if you never changed your dependencies; otherwise `pnpm install` reconciles it.
@@ -187,14 +187,14 @@ The VM has no Docker, so the project uses `--infra local` (PGlite) and Better Au
187
187
 
188
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
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.
190
+ When the app is ready, the CLI opens the preview in your browser (`--no-open` turns that off) and asks whether to share it: claiming the VM, a free Railway sign-up, is what makes the URL work for anyone. `--claim` opens the claim page without asking. `try --status` shows the last VM's preview, time left, and claim link; it connects only while the VM is running, so it never creates a new one. To open the app from another network yourself, forward it: `ssh -i <key> -N -L 3000:127.0.0.1:3000 railway.new`, then http://localhost:3000 (the CLI prints the exact command). 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
191
 
192
192
  ## Version relationship
193
193
 
194
194
  Each CLI release is tested against one specific Starter release:
195
195
 
196
196
  ```text
197
- create-clubedge-app v0.19.1
197
+ create-clubedge-app v0.20.0
198
198
  |
199
199
  v
200
200
  Clubedge Starter v0.15.0
@@ -205,7 +205,7 @@ The current relationship is:
205
205
 
206
206
  | CLI release | Default Starter release | Meaning |
207
207
  | --- | --- | --- |
208
- | `create-clubedge-app@0.19.1` | `clubedge-starter@v0.15.0` | The CLI's required, tested default |
208
+ | `create-clubedge-app@0.20.0` | `clubedge-starter@v0.15.0` | The CLI's required, tested default |
209
209
 
210
210
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
211
211
 
@@ -298,6 +298,7 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
298
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
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
300
  | 0.19.0 | v0.15.0 | `pnpm clubedge try` uploads an existing project to the VM; the preview opens in the browser |
301
+ | 0.20.0 | v0.15.0 | The Starter's version of each conflicted file in `.clubedge/incoming/`, several modules at once, sharing a `try` preview by claiming the VM, `try --status` |
301
302
  | next | | Show the Starter's version of a conflicted file next to yours |
302
303
 
303
304
  ## Community
package/dist/cli/args.js CHANGED
@@ -14,6 +14,8 @@ const OPTIONS = {
14
14
  force: { type: "boolean", default: false },
15
15
  services: { type: "boolean", default: true },
16
16
  open: { type: "boolean", default: true },
17
+ claim: { type: "boolean", default: false },
18
+ status: { type: "boolean", default: false },
17
19
  help: { type: "boolean", short: "h", default: false },
18
20
  version: { type: "boolean", short: "v", default: false },
19
21
  };
@@ -87,6 +89,9 @@ export function parseCliArgs(argv) {
87
89
  if (!creates && command.name !== "upgrade" && values.ref !== undefined) {
88
90
  throw new UsageError("--ref only applies when creating or upgrading a project.");
89
91
  }
92
+ if (command.name !== "try" && (values.claim || values.status)) {
93
+ throw new UsageError("--claim and --status only apply to try.");
94
+ }
90
95
  if (command.name === "try" && values["template-dir"] !== undefined) {
91
96
  throw new UsageError("--template-dir does not apply to try: the VM downloads the Starter itself.");
92
97
  }
@@ -113,6 +118,8 @@ export function parseCliArgs(argv) {
113
118
  force: values.force,
114
119
  services: values.services,
115
120
  open: values.open,
121
+ claim: values.claim,
122
+ status: values.status,
116
123
  help: values.help,
117
124
  version: values.version,
118
125
  };
package/dist/cli/ui.js CHANGED
@@ -124,6 +124,8 @@ ${selectionHelp(manifest)}
124
124
  --force add/remove: change a project with uncommitted changes or without Git
125
125
  --no-services doctor: skip pnpm check:services
126
126
  --no-open try: do not open the preview in the browser
127
+ --claim try: open the page that claims the VM, to share the preview
128
+ --status try: the last VM's preview, time left, and claim link
127
129
  -y, --yes Accept defaults and never prompt (default directory: my-app)
128
130
  --no-install Skip dependency installation
129
131
  --no-git Skip Git repository initialization
@@ -53,9 +53,11 @@ export function mergeText(current, base, target) {
53
53
  function merged(path, current, base, target, binaryReason) {
54
54
  const result = mergeText(current, base, target);
55
55
  if (!result)
56
- return { path, kind: "keep", reason: binaryReason };
56
+ return { path, kind: "keep", reason: binaryReason, incoming: target };
57
57
  const content = withLineEndings(result.text, current);
58
- return { path, kind: result.conflict ? "conflict" : "merge", content };
58
+ if (result.conflict)
59
+ return { path, kind: "conflict", content, incoming: withLineEndings(normalize(target), current) };
60
+ return { path, kind: "merge", content };
59
61
  }
60
62
  /**
61
63
  * Decides what happens to every file the base or the target contains. Files only the project
@@ -89,7 +91,12 @@ export function planMerge(base, target, current) {
89
91
  }
90
92
  if (before && after) {
91
93
  if (!mine) {
92
- actions.push({ path, kind: "keep", reason: "You deleted this file, and the new selection changes it. Restore it if you need the change." });
94
+ actions.push({
95
+ path,
96
+ kind: "keep",
97
+ reason: "You deleted this file, and the new selection changes it. Restore it if you need the change.",
98
+ incoming: after,
99
+ });
93
100
  }
94
101
  else if (sameContent(mine, before)) {
95
102
  const content = isBinary(after) ? after : withLineEndings(normalize(after), mine);
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
3
+ import { dirname, join } from "node:path";
4
4
  import process from "node:process";
5
5
  import { UsageError } from "../cli/args.js";
6
6
  /*
@@ -61,6 +61,27 @@ export function railwayKeyPath(env = process.env, platform = process.platform) {
61
61
  const configHome = env.XDG_CONFIG_HOME || (platform === "win32" && env.APPDATA ? env.APPDATA : join(homedir(), ".config"));
62
62
  return join(configHome, "clubedge", "railway_ed25519");
63
63
  }
64
+ /** Saved next to the CLI's key, in its config directory. */
65
+ export function lastVmPath(env = process.env, platform = process.platform) {
66
+ return join(dirname(railwayKeyPath(env, platform)), "railway-vm.json");
67
+ }
68
+ /** Railway keeps an unclaimed VM claimable for 24 hours after its build window. */
69
+ export const claimWindowMs = 24 * 60 * 60 * 1000;
70
+ /**
71
+ * Where a VM stands: running (inside its 60 minutes), claimable (past them, still claimable), or
72
+ * gone. Connecting to a VM that is gone would create a new one, so status never does.
73
+ */
74
+ export function vmPhase(vm, now = new Date()) {
75
+ const expires = new Date(vm.expiresAt).getTime();
76
+ const claimUntil = new Date(expires + claimWindowMs);
77
+ const minutesLeft = Math.max(0, Math.round((expires - now.getTime()) / 60_000));
78
+ const phase = now.getTime() < expires ? "running" : now.getTime() < claimUntil.getTime() ? "claimable" : "gone";
79
+ return { phase, minutesLeft, claimUntil };
80
+ }
81
+ /** A local port forward to the app on the VM: works from this machine whatever its IP, nothing shared. */
82
+ export function portForwardCommand(keyPath) {
83
+ return `ssh -i "${keyPath}" -N -L 3000:127.0.0.1:3000 ${railwayHost}`;
84
+ }
64
85
  /**
65
86
  * The modules for a project on the VM. It has no Docker, so the database is PGlite (--infra
66
87
  * local) and sign-in is Better Auth; email is off, since nothing there receives it. Flags the
@@ -7,6 +7,11 @@ import { updateEnvFile } from "./env.js";
7
7
  import { planMerge, sameContent } from "./merge.js";
8
8
  import { readProjectFiles, renderProject } from "./project.js";
9
9
  const LOCKFILE = "pnpm-lock.yaml";
10
+ /**
11
+ * Where the Starter's versions of files left to resolve are saved, next to the project's own,
12
+ * for comparing by hand. The folder ignores itself, so it never shows in git status or a commit.
13
+ */
14
+ export const INCOMING_DIR = ".clubedge/incoming";
10
15
  export async function planUpdate(project, base, target) {
11
16
  const render = (side) => renderProject({
12
17
  source: side.source,
@@ -64,8 +69,28 @@ async function removeEmptyFolders(root, path) {
64
69
  folder = dirname(folder);
65
70
  }
66
71
  }
72
+ /** The Starter's version of each file left for you: conflicts, and kept files it has a version of. */
73
+ export function incomingFiles(plan) {
74
+ return plan.actions.flatMap((action) => (action.kind === "conflict" || action.kind === "keep") && action.incoming ? [{ path: action.path, content: action.incoming }] : []);
75
+ }
76
+ /** Replaces the previous change's incoming files with this change's. */
77
+ async function writeIncoming(plan) {
78
+ const folder = join(plan.project.root, INCOMING_DIR);
79
+ await rm(folder, { recursive: true, force: true });
80
+ const files = incomingFiles(plan);
81
+ if (!files.length)
82
+ return;
83
+ await mkdir(dirname(folder), { recursive: true });
84
+ await writeFile(join(dirname(folder), ".gitignore"), "# Written by create-clubedge-app: the Starter's versions of files to compare. Delete when done.\n*\n");
85
+ for (const file of files) {
86
+ const path = join(folder, file.path);
87
+ await mkdir(dirname(path), { recursive: true });
88
+ await writeFile(path, file.content);
89
+ }
90
+ }
67
91
  export async function applyUpdate(plan) {
68
92
  const { root } = plan.project;
93
+ await writeIncoming(plan);
69
94
  for (const action of plan.actions) {
70
95
  const path = join(root, action.path);
71
96
  if (action.kind === "delete") {
package/dist/modify.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // `add`, `remove`, and `upgrade` change an existing project. The project's recorded Starter
2
2
  // commit and modules (base) and the new state (target: other modules, or a newer Starter) are
3
3
  // rendered in memory, and the difference is merged into the project (see core/update.ts).
4
- import { confirm, isCancel, log, outro, select, spinner } from "@clack/prompts";
4
+ import { confirm, isCancel, log, multiselect, outro, select, spinner } from "@clack/prompts";
5
5
  import { readFile } from "node:fs/promises";
6
6
  import { join } from "node:path";
7
7
  import process from "node:process";
@@ -14,7 +14,7 @@ import { describeReleases, fetchReleases, releasesBetween } from "./core/release
14
14
  import { selectModules } from "./core/plan.js";
15
15
  import { readProject } from "./core/project.js";
16
16
  import { downloadGitHubTemplate, readDirectoryTemplate, resolveTemplateSource, } from "./core/source.js";
17
- import { applyUpdate, planUpdate } from "./core/update.js";
17
+ import { applyUpdate, INCOMING_DIR, planUpdate } from "./core/update.js";
18
18
  import { cliVersion, starterPin } from "./package-info.js";
19
19
  import { installDependencies, resolvePnpm } from "./steps/install.js";
20
20
  import { commandSucceeds, runCapture } from "./utils/process.js";
@@ -99,13 +99,17 @@ export async function completeCommand(manifest, project, command, interactive) {
99
99
  ? "Usage: add <module> <option>, for example: add cache redis. Run it in a terminal to choose from a list."
100
100
  : "Usage: remove <module>, for example: remove storage. Run it in a terminal to choose from a list.");
101
101
  }
102
- let moduleId = command.name === "add" ? command.changes[0]?.module : undefined;
103
- if (!moduleId) {
102
+ // The modules: named on the command line, or picked from a list (several at once).
103
+ let moduleIds = command.name === "add" ? command.changes.map((change) => change.module) : [];
104
+ if (!moduleIds.length) {
104
105
  const choices = Object.entries(manifest.modules).filter(([id, module]) => command.name === "add" ? true : Boolean(module.options.none) && project.modules[id] !== "none");
105
106
  if (!choices.length)
106
107
  throw new UsageError("No module can be removed: every removable module is already none.");
107
- const value = await select({
108
- message: command.name === "add" ? "Which module do you want to add or switch?" : "Which module do you want to remove?",
108
+ const value = await multiselect({
109
+ message: command.name === "add"
110
+ ? "Which modules do you want to add or switch? (space to pick, enter to confirm)"
111
+ : "Which modules do you want to remove? (space to pick, enter to confirm)",
112
+ required: true,
109
113
  options: choices.map(([id, module]) => ({
110
114
  value: id,
111
115
  label: module.name,
@@ -114,28 +118,39 @@ export async function completeCommand(manifest, project, command, interactive) {
114
118
  });
115
119
  if (isCancel(value))
116
120
  return null;
117
- moduleId = value;
121
+ moduleIds = value;
118
122
  }
119
123
  if (command.name === "remove")
120
- return { name: "remove", modules: [moduleId] };
121
- const module = manifest.modules[moduleId];
122
- if (!module) {
123
- throw new UsageError(`Unknown module "${moduleId}". Choose one of: ${Object.keys(manifest.modules).join(", ")}.`);
124
+ return { name: "remove", modules: moduleIds };
125
+ // Then an option for each module that was not given one. They apply together, so options that
126
+ // only work together (Supabase Auth with Supabase Storage) can be picked in one go.
127
+ const changes = [];
128
+ for (const moduleId of moduleIds) {
129
+ const given = command.changes.find((change) => change.module === moduleId)?.option;
130
+ if (given) {
131
+ changes.push({ module: moduleId, option: given });
132
+ continue;
133
+ }
134
+ const module = manifest.modules[moduleId];
135
+ if (!module) {
136
+ throw new UsageError(`Unknown module "${moduleId}". Choose one of: ${Object.keys(manifest.modules).join(", ")}.`);
137
+ }
138
+ const options = Object.keys(module.options).filter((id) => id !== project.modules[moduleId] && (moduleIds.length > 1 || available(manifest, project, moduleId, id)));
139
+ if (!options.length)
140
+ throw new UsageError(`${module.name} has no other option this project can use.`);
141
+ const option = await select({
142
+ message: `${module.name}: which option?`,
143
+ options: options.map((id) => ({
144
+ value: id,
145
+ label: module.options[id].name,
146
+ ...(id === module.default ? { hint: "default" } : {}),
147
+ })),
148
+ });
149
+ if (isCancel(option))
150
+ return null;
151
+ changes.push({ module: moduleId, option });
124
152
  }
125
- const options = Object.keys(module.options).filter((id) => id !== project.modules[moduleId] && available(manifest, project, moduleId, id));
126
- if (!options.length)
127
- throw new UsageError(`${module.name} has no other option this project can use.`);
128
- const option = await select({
129
- message: `${module.name}: which option?`,
130
- options: options.map((id) => ({
131
- value: id,
132
- label: module.options[id].name,
133
- ...(id === module.default ? { hint: "default" } : {}),
134
- })),
135
- });
136
- if (isCancel(option))
137
- return null;
138
- return { name: "add", changes: [{ module: moduleId, option }] };
153
+ return { name: "add", changes };
139
154
  }
140
155
  /**
141
156
  * The selection after add or remove, validated against the Starter's manifest. Every change
@@ -213,17 +228,24 @@ function describe(plan) {
213
228
  function report(plan) {
214
229
  const conflicts = plan.actions.filter((action) => action.kind === "conflict");
215
230
  const kept = plan.actions.filter((action) => action.kind === "keep");
231
+ const incoming = (path) => `${INCOMING_DIR}/${path}`;
216
232
  if (conflicts.length) {
217
233
  const blocks = conflicts.map((action) => countConflicts(action.content));
218
234
  const total = blocks.reduce((sum, count) => sum + count, 0);
235
+ const first = conflicts[0].path;
219
236
  log.warn(`Resolve ${total} ${total === 1 ? "conflict" : "conflicts"} in ${conflicts.length} ${conflicts.length === 1 ? "file" : "files"}. ` +
220
237
  "Each is marked <<<<<<< yours ... ======= ... >>>>>>> clubedge: keep your lines, the Starter's, or a mix, then delete the markers.\n" +
221
238
  conflicts
222
239
  .map((action, i) => ` ${action.path} ${dim(`(${blocks[i]} ${blocks[i] === 1 ? "block" : "blocks"})`)}`)
223
- .join("\n"));
240
+ .join("\n") +
241
+ `\n\nThe Starter's whole new version of each is in ${INCOMING_DIR}/, to compare with yours:\n` +
242
+ ` git diff --no-index ${first} ${incoming(first)}\n` +
243
+ dim(`Git ignores ${INCOMING_DIR.split("/")[0]}/; delete it when you are done.`));
224
244
  }
225
245
  if (kept.length) {
226
- log.info(`Check these files by hand:\n${kept.map((action) => ` ${action.path}: ${action.reason}`).join("\n")}`);
246
+ log.info(`Check these files by hand:\n${kept
247
+ .map((action) => ` ${action.path}: ${action.reason}${action.incoming ? dim(` The Starter's version: ${incoming(action.path)}`) : ""}`)
248
+ .join("\n")}`);
227
249
  }
228
250
  if (plan.env && !plan.env.update) {
229
251
  log.info(`${plan.env.path} does not exist yet; create it from the example file.`);
package/dist/try.js CHANGED
@@ -1,4 +1,4 @@
1
- import { cancel, confirm, isCancel, log, note, outro, spinner } from "@clack/prompts";
1
+ import { cancel, confirm, isCancel, log, note, outro, select, spinner } from "@clack/prompts";
2
2
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { userInfo } from "node:os";
4
4
  import { dirname } from "node:path";
@@ -6,7 +6,7 @@ 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, projectTryProblem, proxySource, railwayFreeVmUrl, railwayHost, railwayKeyPath, railwayTermsUrl, remoteArchivePath, remoteDevLog, remoteLog, remoteName, remoteProxyPath, remoteScriptPath, shellQuote, trustedHostKeys, tryCreateArgs, tryModules, tryScript, } from "./core/railway.js";
9
+ import { hostKeyFingerprint, lastVmPath, portForwardCommand, parseRailwayStatus, projectTryProblem, proxySource, railwayFreeVmUrl, railwayHost, railwayKeyPath, railwayTermsUrl, remoteArchivePath, remoteDevLog, remoteLog, remoteName, remoteProxyPath, remoteScriptPath, shellQuote, trustedHostKeys, tryCreateArgs, tryModules, tryScript, vmPhase, } from "./core/railway.js";
10
10
  import { ProjectError, readProject } from "./core/project.js";
11
11
  import { uploadableFiles } from "./core/upload.js";
12
12
  import { cliVersion } from "./package-info.js";
@@ -177,7 +177,94 @@ async function agree(args, keyPath, what) {
177
177
  const answer = await confirm({ message: "Create the VM?", initialValue: true });
178
178
  return !isCancel(answer) && answer;
179
179
  }
180
+ async function saveLastVm(vm) {
181
+ const path = lastVmPath();
182
+ await mkdir(dirname(path), { recursive: true });
183
+ await writeFile(path, `${JSON.stringify(vm, null, 2)}\n`);
184
+ }
185
+ async function readLastVm() {
186
+ try {
187
+ return JSON.parse(await readFile(lastVmPath(), "utf8"));
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ }
193
+ /**
194
+ * Claiming moves the VM into a Railway account, which is what makes the preview shareable: offer
195
+ * it at a terminal, or open it straight away with --claim. Nothing happens in scripts without it.
196
+ */
197
+ async function offerClaim(args, atTerminal, claimUrl) {
198
+ let claim = args.claim;
199
+ if (!claim && atTerminal && !args.yes) {
200
+ const answer = await select({
201
+ message: "Share the preview with others?",
202
+ initialValue: "later",
203
+ options: [
204
+ { value: "later", label: "Not now", hint: "only you can open it, from this connection" },
205
+ { value: "claim", label: "Claim the VM", hint: "free Railway sign-up; then the URL works for anyone" },
206
+ ],
207
+ });
208
+ claim = !isCancel(answer) && answer === "claim";
209
+ }
210
+ if (!claim)
211
+ return;
212
+ if (atTerminal && (await openUrl(claimUrl)))
213
+ log.info("Opened the claim page. Sign up, pick a workspace, and the VM, its files, and its URL move to your account.");
214
+ else
215
+ log.info(`Claim the VM here: ${claimUrl}`);
216
+ }
217
+ /**
218
+ * The last VM, from what the CLI saved: its preview, time left, and claim link. It connects only
219
+ * while the VM is inside its 60 minutes, since connecting to a VM that is gone creates a new one.
220
+ */
221
+ async function showStatus(keyPath) {
222
+ const last = await readLastVm();
223
+ if (!last) {
224
+ outro("No VM yet. Run try to create one.");
225
+ return;
226
+ }
227
+ const { phase, minutesLeft: left, claimUntil } = vmPhase(last);
228
+ if (phase === "gone") {
229
+ outro(`The last VM is gone: its claim window ended ${claimUntil.toLocaleString()}. Run try to create a new one.`);
230
+ return;
231
+ }
232
+ let app = dim("not checked");
233
+ if (phase === "running") {
234
+ const progress = spinner();
235
+ progress.start("Checking the app on the VM");
236
+ try {
237
+ const connection = await connect(await readFile(keyPath, "utf8"));
238
+ try {
239
+ const { stdout } = await exec(connection, "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/ || true");
240
+ app = /^[1-4]\d\d$/.test(stdout.trim()) ? "running" : "not answering";
241
+ }
242
+ finally {
243
+ connection.end();
244
+ }
245
+ progress.stop("Checked the app");
246
+ }
247
+ catch (error) {
248
+ progress.stop("Could not reach the VM");
249
+ app = dim(error instanceof Error ? error.message : String(error));
250
+ }
251
+ }
252
+ note([
253
+ `${bold("Preview")} ${last.previewUrl}`,
254
+ `${bold("Project")} ${last.directory}`,
255
+ `${bold("App")} ${phase === "running" ? app : "stopped: the 60 minutes are over"}`,
256
+ phase === "running"
257
+ ? `${bold("Time")} ${left} minutes left (until ${new Date(last.expiresAt).toLocaleTimeString()})`
258
+ : `${bold("Time")} claimable until ${claimUntil.toLocaleString()}`,
259
+ `${bold("Claim")} ${last.claimUrl}`,
260
+ ].join("\n"), "Your VM on Railway");
261
+ outro(phase === "running"
262
+ ? "Until you claim the VM, only this IP address can open the preview. Claim it to share the URL."
263
+ : "Claim the VM to keep it: its files and URL move to your Railway account.");
264
+ }
180
265
  export async function tryOnRailway(args) {
266
+ if (args.status)
267
+ return await showStatus(railwayKeyPath());
181
268
  const what = await plan(args);
182
269
  const keyPath = railwayKeyPath();
183
270
  if (args.dryRun) {
@@ -255,14 +342,19 @@ export async function tryOnRailway(args) {
255
342
  `${bold("Claim")} ${vm.claimUrl}`,
256
343
  ].join("\n"), "Your app on Railway");
257
344
  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)))
345
+ await saveLastVm({ previewUrl: vm.previewUrl, claimUrl: vm.claimUrl, expiresAt: vm.expiresAt.toISOString(), directory: `/app/${directory}` });
346
+ // Only for a person at a terminal: scripts and CI get the URLs above, and nothing opens.
347
+ const atTerminal = Boolean(process.stdin.isTTY && process.stdout.isTTY);
348
+ if (args.open && atTerminal && (await openUrl(vm.previewUrl)))
260
349
  log.info("Opened the preview in your browser.");
350
+ await offerClaim(args, atTerminal, vm.claimUrl);
261
351
  outro([
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.",
352
+ "Until you claim the VM, only you can open the preview, from the IP address that created it. Another device on mobile data, a VPN, or anyone you send it to gets Railway's \"Access denied\" page. Claiming makes the URL shareable.",
263
353
  "",
264
- `${dim("Shell")} ssh -i "${keyPath}" ${railwayHost}`,
265
- `${dim("Logs")} ssh -i "${keyPath}" ${railwayHost} tail -f ${remoteDevLog}`,
354
+ `${dim("Status")} ${dim("pnpm dlx @clubedge/create-clubedge-app try --status")}`,
355
+ `${dim("Local")} ${portForwardCommand(keyPath)} ${dim("then http://localhost:3000, from any network")}`,
356
+ `${dim("Shell")} ssh -i "${keyPath}" ${railwayHost}`,
357
+ `${dim("Logs")} ssh -i "${keyPath}" ${railwayHost} tail -f ${remoteDevLog}`,
266
358
  ].join("\n"));
267
359
  }
268
360
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.19.1",
3
+ "version": "0.20.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",