@vincentt-xr/cli 0.1.1 → 0.1.3

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/dist/cli/index.js CHANGED
@@ -14,7 +14,8 @@ import path from "node:path";
14
14
  import { runLogin } from "../login/login.js";
15
15
  import { clearMachineToken, clearProjectBinding, loadMachineConfig, } from "../shared/config.js";
16
16
  import { getMe } from "../lib/backend.js";
17
- import { diagQuery, diagRead, previewStart, previewStop, projectCreate, projectPublish, } from "../lib/lifecycle.js";
17
+ import { diagQuery, diagRead, previewStart, previewStop, previewStopSignal, projectCreate, projectPublish, } from "../lib/lifecycle.js";
18
+ import { writeQrPage } from "../preview/qrPage.js";
18
19
  import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
19
20
  const DEFAULT_EDITOR_URL = "https://editor.vincentt.studio";
20
21
  const DEFAULT_API_URL = "https://api.vincentt.studio";
@@ -110,15 +111,33 @@ async function publish() {
110
111
  process.exitCode = 1;
111
112
  }
112
113
  async function preview() {
114
+ // `--stop` reaps a preview started by another process (e.g. a backgrounded run)
115
+ // via its pidfile — no need to hunt for the cloudflared pid by hand.
116
+ if (has("--stop")) {
117
+ const { message } = await previewStopSignal(projectDir());
118
+ console.log(message);
119
+ return;
120
+ }
113
121
  const { preview: p, started } = await previewStart(projectDir(), (m) => console.error(`[preview] ${m}`));
114
122
  if (!started) {
115
123
  console.log(`A preview is already running:\n${p.url}`);
116
124
  return;
117
125
  }
118
- console.log(`\nLive preview — open on your device:\n ${p.url}\n`);
119
- console.log("Diagnostics: vincentt logs | network | trace");
126
+ console.log("\nLive preview:");
127
+ console.log(` Open on your phone: ${p.url}`);
128
+ // A scannable QR in the terminal is unreliable across emulators, so hand back a
129
+ // desktop QR page to open and scan instead. Best-effort — a preview is still
130
+ // usable via the URL above if writing the page fails.
131
+ try {
132
+ const qrUrl = await writeQrPage(projectDir(), p.url);
133
+ console.log(` Show QR (desktop): ${qrUrl}`);
134
+ }
135
+ catch {
136
+ /* non-fatal: the URL above is enough to preview */
137
+ }
138
+ console.log("\nDiagnostics: vincentt logs | network | trace");
120
139
  console.log("Feedback: vincentt feedback --wait");
121
- console.log("\nPress Ctrl-C to stop.");
140
+ console.log("\nStop: vincentt preview --stop (or Ctrl-C here).");
122
141
  if (!p.appReady) {
123
142
  console.log(`\nNote: the app on :${p.appPort} isn't responding yet — the URL may be blank until its build finishes.`);
124
143
  }
@@ -201,7 +220,8 @@ function usage() {
201
220
  ` [--name <s>] [--slug <s>] [--preview] [--force]\n` +
202
221
  ` unbind Detach this directory from its project\n` +
203
222
  ` publish Upload the built dist; go live at <slug>.vincentt.app\n` +
204
- ` preview Serve + tunnel the app for on-device preview\n\n` +
223
+ ` preview Serve + tunnel the app for on-device preview\n` +
224
+ ` (prints a desktop QR page to scan) [--stop]\n\n` +
205
225
  `Diagnostics (need a running preview)\n` +
206
226
  ` logs [--errors] Console logs from the device [--json]\n` +
207
227
  ` network fetch/XHR requests [--json]\n` +
@@ -37,6 +37,20 @@ export interface PreviewStartResult {
37
37
  }
38
38
  export declare function previewStart(projectCwd: string, onLog: (msg: string) => void): Promise<PreviewStartResult>;
39
39
  export declare function previewStop(): Promise<boolean>;
40
+ export interface PreviewStopOutcome {
41
+ stopped: boolean;
42
+ message: string;
43
+ }
44
+ /**
45
+ * Stop a preview started by a *different* process (the common case: `vincentt
46
+ * preview` runs in the background and the agent later wants it down). Reads the
47
+ * pidfile and signals the recorded pids rather than the in-process `activePreview`.
48
+ * SIGTERM to the preview process runs its graceful handler (which reaps the tunnel
49
+ * + backend DNS route); if that process is already gone, its cloudflared child may
50
+ * be orphaned to init, so kill the recorded tunnel pid directly. Either way, clear
51
+ * the (now stale) relay info.
52
+ */
53
+ export declare function previewStopSignal(projectCwd: string): Promise<PreviewStopOutcome>;
40
54
  export interface DiagQueryOptions {
41
55
  sessionId?: string;
42
56
  since?: number;
@@ -110,7 +110,7 @@ export async function projectPublish(projectCwd, opts = {}) {
110
110
  commitSha,
111
111
  });
112
112
  return {
113
- message: `Published v${result.version}. Live at ${result.liveUrl}\nThis version: ${result.url}`,
113
+ message: `Published v${result.version}. Live at ${result.liveUrl}`,
114
114
  notBound: false,
115
115
  version: result.version,
116
116
  liveUrl: result.liveUrl,
@@ -135,6 +135,7 @@ export async function previewStart(projectCwd, onLog) {
135
135
  relayPort: preview.relayPort,
136
136
  url: preview.url,
137
137
  pid: process.pid,
138
+ tunnelPid: preview.tunnelPid,
138
139
  harnessPath: "/__harness",
139
140
  });
140
141
  return { preview, started: true };
@@ -151,6 +152,55 @@ export async function previewStop() {
151
152
  await clearRelayInfo(cwd);
152
153
  return true;
153
154
  }
155
+ function isAlive(pid) {
156
+ try {
157
+ process.kill(pid, 0); // signal 0 = existence check, kills nothing
158
+ return true;
159
+ }
160
+ catch {
161
+ return false;
162
+ }
163
+ }
164
+ /**
165
+ * Stop a preview started by a *different* process (the common case: `vincentt
166
+ * preview` runs in the background and the agent later wants it down). Reads the
167
+ * pidfile and signals the recorded pids rather than the in-process `activePreview`.
168
+ * SIGTERM to the preview process runs its graceful handler (which reaps the tunnel
169
+ * + backend DNS route); if that process is already gone, its cloudflared child may
170
+ * be orphaned to init, so kill the recorded tunnel pid directly. Either way, clear
171
+ * the (now stale) relay info.
172
+ */
173
+ export async function previewStopSignal(projectCwd) {
174
+ const info = await readRelayInfo(projectCwd);
175
+ if (!info) {
176
+ return { stopped: false, message: "No preview is running for this directory." };
177
+ }
178
+ const previewAlive = info.pid !== undefined && isAlive(info.pid);
179
+ const reaped = [];
180
+ if (previewAlive) {
181
+ try {
182
+ process.kill(info.pid, "SIGTERM");
183
+ reaped.push("preview process");
184
+ }
185
+ catch {
186
+ /* raced with its own exit — fall through to tunnel cleanup */
187
+ }
188
+ }
189
+ // Only force-kill the tunnel directly when no live preview process will reap it.
190
+ if (!previewAlive && info.tunnelPid !== undefined && isAlive(info.tunnelPid)) {
191
+ try {
192
+ process.kill(info.tunnelPid, "SIGKILL");
193
+ reaped.push("orphaned tunnel");
194
+ }
195
+ catch {
196
+ /* already gone */
197
+ }
198
+ }
199
+ await clearRelayInfo(projectCwd);
200
+ return reaped.length > 0
201
+ ? { stopped: true, message: `Stopped preview (${reaped.join(", ")}).` }
202
+ : { stopped: false, message: "No live preview process found; cleared stale relay info." };
203
+ }
154
204
  /** Resolve the relay client for a project's running preview, or throw a hint. */
155
205
  async function relayForProject(projectCwd, override) {
156
206
  if (override)
@@ -5,6 +5,9 @@ export interface RelayInfo {
5
5
  url: string;
6
6
  /** The preview process pid, so a stale file can be detected. */
7
7
  pid: number;
8
+ /** The cloudflared child pid, so `preview --stop` can reap an orphaned tunnel
9
+ * even after the preview process itself is gone (reparented to init). */
10
+ tunnelPid?: number;
8
11
  /** WS/HTTP path prefix routed to the relay (default /__harness). */
9
12
  harnessPath: string;
10
13
  }
@@ -17,7 +17,16 @@ export function proxyWeb(req, res, targetPort) {
17
17
  method: req.method,
18
18
  headers: localHeaders(req.headers, targetPort),
19
19
  }, (proxyRes) => {
20
- res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
20
+ // The preview tunnel is a *proxied* Cloudflare hostname, so its CDN edge
21
+ // caches static extensions (.js/.css) by default whenever the origin sends
22
+ // no Cache-Control — and esbuild's dev serve sends none. That made a phone
23
+ // reload after every edit serve a stale bundle. Force no-store on every
24
+ // preview response so neither the CDN nor the device caches it, and drop the
25
+ // validators that would otherwise drive a 304 back to a cached copy.
26
+ const headers = { ...proxyRes.headers, "cache-control": "no-store" };
27
+ delete headers.etag;
28
+ delete headers["last-modified"];
29
+ res.writeHead(proxyRes.statusCode ?? 502, headers);
21
30
  proxyRes.pipe(res);
22
31
  });
23
32
  proxyReq.on("error", () => {
@@ -0,0 +1,4 @@
1
+ /** Full HTML for the QR page — inline SVG QR + the tappable URL, no external assets. */
2
+ export declare function buildQrPage(url: string): Promise<string>;
3
+ /** Write the QR page under .vincentt/ and return a file:// URL to open on the desktop. */
4
+ export declare function writeQrPage(projectCwd: string, url: string): Promise<string>;
@@ -0,0 +1,62 @@
1
+ // A tiny self-contained QR page for the preview URL. Rendering a QR in the terminal
2
+ // is unreliable (font/rows vary by emulator, long tunnel URLs don't scan), so instead
3
+ // `vincentt preview` writes this page and prints a file:// link: the developer opens
4
+ // it on their desktop and scans the big SVG QR with their phone. Written under the
5
+ // gitignored .vincentt/ dir, so it never rides a commit.
6
+ import { promises as fs } from "node:fs";
7
+ import path from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+ import QRCode from "qrcode";
10
+ const VINCENTT_DIR = ".vincentt";
11
+ const QR_FILE = "preview-qr.html";
12
+ /** Full HTML for the QR page — inline SVG QR + the tappable URL, no external assets. */
13
+ export async function buildQrPage(url) {
14
+ // Medium ECC + a quiet margin keeps a ~50-char tunnel URL comfortably scannable.
15
+ const svg = await QRCode.toString(url, {
16
+ type: "svg",
17
+ margin: 2,
18
+ errorCorrectionLevel: "M",
19
+ });
20
+ const safe = escapeHtml(url);
21
+ return `<!doctype html>
22
+ <html lang="en">
23
+ <head>
24
+ <meta charset="utf-8" />
25
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
26
+ <title>Scan to preview — ${safe}</title>
27
+ <style>
28
+ :root { color-scheme: light dark; }
29
+ body { margin:0; min-height:100vh; display:grid; place-items:center;
30
+ font:16px/1.5 system-ui, sans-serif; background:#fff; color:#111; }
31
+ @media (prefers-color-scheme: dark){ body{ background:#0b0b0c; color:#f4f4f5; } }
32
+ .card { text-align:center; padding:2rem; max-width:min(90vw,420px); }
33
+ .qr { width:min(72vw,320px); aspect-ratio:1; margin:0 auto 1.25rem; }
34
+ .qr svg { width:100%; height:100%; }
35
+ h1 { font-size:1.05rem; font-weight:600; margin:0 0 .35rem; }
36
+ p { margin:0 0 1.25rem; opacity:.7; }
37
+ a.url { display:inline-block; word-break:break-all; font:500 .95rem/1.4 ui-monospace,monospace;
38
+ color:#2563eb; text-decoration:none; }
39
+ @media (prefers-color-scheme: dark){ a.url{ color:#60a5fa; } }
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <div class="card">
44
+ <div class="qr">${svg}</div>
45
+ <h1>Scan to open the preview</h1>
46
+ <p>Point your phone's camera at the code.</p>
47
+ <a class="url" href="${safe}">${safe}</a>
48
+ </div>
49
+ </body>
50
+ </html>
51
+ `;
52
+ }
53
+ /** Write the QR page under .vincentt/ and return a file:// URL to open on the desktop. */
54
+ export async function writeQrPage(projectCwd, url) {
55
+ const p = path.join(projectCwd, VINCENTT_DIR, QR_FILE);
56
+ await fs.mkdir(path.dirname(p), { recursive: true });
57
+ await fs.writeFile(p, await buildQrPage(url), "utf8");
58
+ return pathToFileURL(p).href;
59
+ }
60
+ function escapeHtml(s) {
61
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
62
+ }
@@ -16,7 +16,9 @@ export interface StartPreviewOptions {
16
16
  /** stdio for the app dev serve. "ignore" (default) keeps an MCP server's stdout
17
17
  * clean; a CLI can pass "inherit" to surface build output. */
18
18
  appStdio?: "ignore" | "inherit";
19
- /** How long to wait for cloudflared to register before giving up (default 45s). */
19
+ /** How long to wait for cloudflared to register before giving up (default 90s).
20
+ * Generous because on a network that blocks the QUIC edge, cloudflared spends
21
+ * ~20-30s failing QUIC before it falls back to the http2 edge, which does work. */
20
22
  registerTimeoutMs?: number;
21
23
  /** How long to wait for the app to serve before returning anyway (default 8s).
22
24
  * Kept short so an MCP tool call stays within strict clients' ~30s timeout;
@@ -32,6 +34,9 @@ export interface RunningPreview {
32
34
  relayPort: number;
33
35
  /** The app dev-serve port the tunnel ultimately serves. */
34
36
  appPort: number;
37
+ /** The cloudflared child pid, recorded so a separate `preview --stop` can reap
38
+ * an orphaned tunnel by pid. Undefined if the spawn never yielded one. */
39
+ tunnelPid?: number;
35
40
  /** Whether the app was responding when we returned (false = URL may be blank). */
36
41
  appReady: boolean;
37
42
  /** Tear down tunnel (+ DNS route), app dev serve, front proxy, and relay. */
@@ -40,6 +45,9 @@ export interface RunningPreview {
40
45
  export declare function startPreview(opts: StartPreviewOptions): Promise<RunningPreview>;
41
46
  /**
42
47
  * Resolve once cloudflared reports a live edge connection, reject if it exits
43
- * first or never registers. Exported for unit testing the log-scan/timeout logic.
48
+ * first or never registers. On failure the rejection carries cloudflared's own
49
+ * recent output — otherwise "did not register" hides the real cause (an invalid
50
+ * run token, or a network that blocks the QUIC edge, the two common culprits).
51
+ * `onLine` streams each log line out live. Exported for unit testing.
44
52
  */
45
- export declare function waitForRegister(tunnel: ChildProcess, timeoutMs: number): Promise<void>;
53
+ export declare function waitForRegister(tunnel: ChildProcess, timeoutMs: number, onLine?: (line: string) => void): Promise<void>;
@@ -16,7 +16,7 @@ import { startSessionTunnel } from "./tunnel.js";
16
16
  import { proxyWeb, proxyWs } from "./proxy.js";
17
17
  import { getFreePort, isPortListening, waitForApp } from "./net.js";
18
18
  export async function startPreview(opts) {
19
- const { projectCwd, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", registerTimeoutMs = 45_000, appReadyTimeoutMs = 8_000, onLog = () => undefined, } = opts;
19
+ const { projectCwd, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", registerTimeoutMs = 90_000, appReadyTimeoutMs = 8_000, onLog = () => undefined, } = opts;
20
20
  // Resolve ports. The app port defaults to 5173; the relay + front ports are
21
21
  // harness-internal, so auto-pick free ones (the relay port is returned for the
22
22
  // MCP diag_* tools to point at) instead of colliding on fixed defaults.
@@ -70,14 +70,14 @@ export async function startPreview(opts) {
70
70
  tunnel = spawn(cloudflaredBin, ["tunnel", "run", "--token", session.runToken], {
71
71
  stdio: ["ignore", "pipe", "pipe"],
72
72
  });
73
- await waitForRegister(tunnel, registerTimeoutMs);
73
+ await waitForRegister(tunnel, registerTimeoutMs, (line) => onLog(`[cloudflared] ${line}`));
74
74
  // Don't hand back a live URL that serves a blank page: wait for the app to
75
75
  // actually respond. Non-fatal — a slow build still returns, flagged not-ready.
76
76
  const appReady = reuseApp ? true : await waitForApp(appPort, appReadyTimeoutMs);
77
77
  if (!appReady) {
78
78
  onLog(`app on :${appPort} isn't responding yet — the URL may be blank until it builds`);
79
79
  }
80
- return { url: session.url, relayPort, appPort, appReady, stop };
80
+ return { url: session.url, relayPort, appPort, tunnelPid: tunnel.pid, appReady, stop };
81
81
  }
82
82
  catch (err) {
83
83
  await stop();
@@ -86,11 +86,15 @@ export async function startPreview(opts) {
86
86
  }
87
87
  /**
88
88
  * Resolve once cloudflared reports a live edge connection, reject if it exits
89
- * first or never registers. Exported for unit testing the log-scan/timeout logic.
89
+ * first or never registers. On failure the rejection carries cloudflared's own
90
+ * recent output — otherwise "did not register" hides the real cause (an invalid
91
+ * run token, or a network that blocks the QUIC edge, the two common culprits).
92
+ * `onLine` streams each log line out live. Exported for unit testing.
90
93
  */
91
- export function waitForRegister(tunnel, timeoutMs) {
94
+ export function waitForRegister(tunnel, timeoutMs, onLine) {
92
95
  return new Promise((resolve, reject) => {
93
96
  let settled = false;
97
+ const recent = [];
94
98
  const finish = (fn) => {
95
99
  if (settled)
96
100
  return;
@@ -98,14 +102,25 @@ export function waitForRegister(tunnel, timeoutMs) {
98
102
  clearTimeout(timer);
99
103
  fn();
100
104
  };
105
+ const tail = () => {
106
+ const lines = recent.slice(-15).join("\n").trim();
107
+ return lines ? `\ncloudflared output:\n${lines}` : "";
108
+ };
101
109
  const scan = (buf) => {
102
- if (/Registered tunnel connection|Connection [^ ]+ registered/.test(String(buf))) {
110
+ const text = String(buf);
111
+ for (const line of text.split(/\r?\n/)) {
112
+ if (!line.trim())
113
+ continue;
114
+ recent.push(line);
115
+ onLine?.(line);
116
+ }
117
+ if (/Registered tunnel connection|Connection [^ ]+ registered/.test(text)) {
103
118
  finish(resolve);
104
119
  }
105
120
  };
106
121
  tunnel.stdout?.on("data", scan);
107
122
  tunnel.stderr?.on("data", scan);
108
- tunnel.on("exit", (code) => finish(() => reject(new Error(`cloudflared exited before registering (code ${code}).`))));
109
- const timer = setTimeout(() => finish(() => reject(new Error(`cloudflared did not register within ${timeoutMs}ms.`))), timeoutMs);
123
+ tunnel.on("exit", (code) => finish(() => reject(new Error(`cloudflared exited before registering (code ${code}).${tail()}`))));
124
+ const timer = setTimeout(() => finish(() => reject(new Error(`cloudflared did not register within ${timeoutMs}ms.${tail()}`))), timeoutMs);
110
125
  });
111
126
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay, mcp)",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -26,11 +26,13 @@
26
26
  "dependencies": {
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
28
  "cloudflared": "^0.7.1",
29
+ "qrcode": "^1.5.4",
29
30
  "ws": "^8.18.0",
30
31
  "zod": "^3.23.8",
31
32
  "@vincentt-xr/harness": "1.0.0"
32
33
  },
33
34
  "devDependencies": {
35
+ "@types/qrcode": "^1.5.5",
34
36
  "@types/ws": "^8.5.13"
35
37
  },
36
38
  "scripts": {