@vincentt-xr/cli 0.1.2 → 0.1.4

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
@@ -12,9 +12,11 @@
12
12
  import os from "node:os";
13
13
  import path from "node:path";
14
14
  import { runLogin } from "../login/login.js";
15
+ import { ensureLoggedIn } from "../login/ensure.js";
15
16
  import { clearMachineToken, clearProjectBinding, loadMachineConfig, } from "../shared/config.js";
16
17
  import { getMe } from "../lib/backend.js";
17
- import { diagQuery, diagRead, previewStart, previewStop, projectCreate, projectPublish, } from "../lib/lifecycle.js";
18
+ import { diagQuery, diagRead, previewStart, previewStop, previewStopSignal, projectCreate, projectPublish, } from "../lib/lifecycle.js";
19
+ import { writeQrPage } from "../preview/qrPage.js";
18
20
  import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
19
21
  const DEFAULT_EDITOR_URL = "https://editor.vincentt.studio";
20
22
  const DEFAULT_API_URL = "https://api.vincentt.studio";
@@ -50,6 +52,18 @@ async function login() {
50
52
  console.log(`\n✓ Signed in. Access token saved to ${configPath}`);
51
53
  console.log(` API: ${saved}`);
52
54
  }
55
+ /** The token that would authenticate a machine call, if any (env wins over file). */
56
+ async function currentToken() {
57
+ return process.env.VINCENTT_PAT ?? (await loadMachineConfig()).pat;
58
+ }
59
+ /** Guard an auth-requiring verb: log in inline on first run instead of erroring. */
60
+ async function requireLogin() {
61
+ await ensureLoggedIn({
62
+ currentToken,
63
+ runLogin: login,
64
+ notify: (m) => console.error(m),
65
+ });
66
+ }
53
67
  async function logout() {
54
68
  const { path: p, hadToken } = await clearMachineToken();
55
69
  console.log(hadToken ? `✓ Signed out. Token removed from ${p}` : "Already signed out.");
@@ -78,6 +92,7 @@ async function whoami() {
78
92
  }
79
93
  // ---- dev loop ---------------------------------------------------------------
80
94
  async function create() {
95
+ await requireLogin();
81
96
  const result = await projectCreate(projectDir(), {
82
97
  name: flag("--name"),
83
98
  slug: flag("--slug"),
@@ -101,6 +116,7 @@ async function unbind() {
101
116
  console.log(hadBinding ? `✓ Unbound. Removed ${p}` : "This directory has no project binding.");
102
117
  }
103
118
  async function publish() {
119
+ await requireLogin();
104
120
  const result = await projectPublish(projectDir(), {
105
121
  distDir: flag("--dist"),
106
122
  note: flag("--note"),
@@ -110,15 +126,34 @@ async function publish() {
110
126
  process.exitCode = 1;
111
127
  }
112
128
  async function preview() {
129
+ // `--stop` reaps a preview started by another process (e.g. a backgrounded run)
130
+ // via its pidfile — no need to hunt for the cloudflared pid by hand.
131
+ if (has("--stop")) {
132
+ const { message } = await previewStopSignal(projectDir());
133
+ console.log(message);
134
+ return;
135
+ }
136
+ await requireLogin();
113
137
  const { preview: p, started } = await previewStart(projectDir(), (m) => console.error(`[preview] ${m}`));
114
138
  if (!started) {
115
139
  console.log(`A preview is already running:\n${p.url}`);
116
140
  return;
117
141
  }
118
- console.log(`\nLive preview — open on your device:\n ${p.url}\n`);
119
- console.log("Diagnostics: vincentt logs | network | trace");
142
+ console.log("\nLive preview:");
143
+ console.log(` Open on your phone: ${p.url}`);
144
+ // A scannable QR in the terminal is unreliable across emulators, so hand back a
145
+ // desktop QR page to open and scan instead. Best-effort — a preview is still
146
+ // usable via the URL above if writing the page fails.
147
+ try {
148
+ const qrUrl = await writeQrPage(projectDir(), p.url);
149
+ console.log(` Show QR (desktop): ${qrUrl}`);
150
+ }
151
+ catch {
152
+ /* non-fatal: the URL above is enough to preview */
153
+ }
154
+ console.log("\nDiagnostics: vincentt logs | network | trace");
120
155
  console.log("Feedback: vincentt feedback --wait");
121
- console.log("\nPress Ctrl-C to stop.");
156
+ console.log("\nStop: vincentt preview --stop (or Ctrl-C here).");
122
157
  if (!p.appReady) {
123
158
  console.log(`\nNote: the app on :${p.appPort} isn't responding yet — the URL may be blank until its build finishes.`);
124
159
  }
@@ -201,7 +236,8 @@ function usage() {
201
236
  ` [--name <s>] [--slug <s>] [--preview] [--force]\n` +
202
237
  ` unbind Detach this directory from its project\n` +
203
238
  ` publish Upload the built dist; go live at <slug>.vincentt.app\n` +
204
- ` preview Serve + tunnel the app for on-device preview\n\n` +
239
+ ` preview Serve + tunnel the app for on-device preview\n` +
240
+ ` (prints a desktop QR page to scan) [--stop]\n\n` +
205
241
  `Diagnostics (need a running preview)\n` +
206
242
  ` logs [--errors] Console logs from the device [--json]\n` +
207
243
  ` 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
  }
@@ -0,0 +1,10 @@
1
+ export interface EnsureLoginDeps {
2
+ /** Resolve the current token (env override or machine config), or undefined. */
3
+ currentToken: () => Promise<string | undefined>;
4
+ /** Run the interactive login flow (loopback OAuth), persisting a token. */
5
+ runLogin: () => Promise<void>;
6
+ /** Surface a line to the user (stderr, so it never pollutes machine-readable stdout). */
7
+ notify: (message: string) => void;
8
+ }
9
+ /** No-op when a token is already present; otherwise log-and-login before returning. */
10
+ export declare function ensureLoggedIn(deps: EnsureLoginDeps): Promise<void>;
@@ -0,0 +1,13 @@
1
+ // First-run login guard. An auth-requiring verb (create / preview / publish) calls
2
+ // this before doing any work: if there's no token yet, it runs the login flow inline
3
+ // and continues, instead of failing with "not signed in". Login stays a human browser
4
+ // approval — this only removes the separate `vincentt login` step a user had to know
5
+ // to run first. Deps are injected so the branch is unit-testable without a real
6
+ // browser round-trip or a real config file.
7
+ /** No-op when a token is already present; otherwise log-and-login before returning. */
8
+ export async function ensureLoggedIn(deps) {
9
+ if (await deps.currentToken())
10
+ return;
11
+ deps.notify("You're not signed in yet — opening the browser to authorize this device…");
12
+ await deps.runLogin();
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.4",
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": {