@vincentt-xr/cli 0.1.2 → 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
+ }
@@ -34,6 +34,9 @@ export interface RunningPreview {
34
34
  relayPort: number;
35
35
  /** The app dev-serve port the tunnel ultimately serves. */
36
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;
37
40
  /** Whether the app was responding when we returned (false = URL may be blank). */
38
41
  appReady: boolean;
39
42
  /** Tear down tunnel (+ DNS route), app dev serve, front proxy, and relay. */
@@ -77,7 +77,7 @@ export async function startPreview(opts) {
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.1.2",
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": {