@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 +25 -5
- package/dist/lib/lifecycle.d.ts +14 -0
- package/dist/lib/lifecycle.js +51 -1
- package/dist/lib/relayInfo.d.ts +3 -0
- package/dist/preview/proxy.js +10 -1
- package/dist/preview/qrPage.d.ts +4 -0
- package/dist/preview/qrPage.js +62 -0
- package/dist/preview/runner.d.ts +3 -0
- package/dist/preview/runner.js +1 -1
- package/package.json +3 -1
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(
|
|
119
|
-
console.log(
|
|
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("\
|
|
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
|
|
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` +
|
package/dist/lib/lifecycle.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/lifecycle.js
CHANGED
|
@@ -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}
|
|
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)
|
package/dist/lib/relayInfo.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/preview/proxy.js
CHANGED
|
@@ -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
|
-
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c);
|
|
62
|
+
}
|
package/dist/preview/runner.d.ts
CHANGED
|
@@ -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. */
|
package/dist/preview/runner.js
CHANGED
|
@@ -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.
|
|
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": {
|