@vincentt-xr/cli 0.1.5 → 0.2.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.
Files changed (66) hide show
  1. package/README.md +94 -0
  2. package/dist/cli/args.d.ts +13 -0
  3. package/dist/cli/args.js +41 -0
  4. package/dist/cli/index.js +362 -92
  5. package/dist/lib/backend.d.ts +45 -5
  6. package/dist/lib/backend.js +99 -18
  7. package/dist/lib/diagnostics.d.ts +27 -2
  8. package/dist/lib/diagnostics.js +73 -8
  9. package/dist/lib/initCommand.d.ts +14 -0
  10. package/dist/lib/initCommand.js +75 -0
  11. package/dist/lib/lifecycle.d.ts +73 -15
  12. package/dist/lib/lifecycle.js +111 -114
  13. package/dist/lib/org.d.ts +29 -0
  14. package/dist/lib/org.js +45 -0
  15. package/dist/lib/projects.d.ts +82 -0
  16. package/dist/lib/projects.js +217 -0
  17. package/dist/lib/relayInfo.d.ts +41 -3
  18. package/dist/lib/relayInfo.js +94 -0
  19. package/dist/lib/whoami.d.ts +11 -0
  20. package/dist/lib/whoami.js +38 -0
  21. package/dist/login/authBackend.d.ts +30 -0
  22. package/dist/login/authBackend.js +80 -0
  23. package/dist/login/guard.d.ts +13 -0
  24. package/dist/login/guard.js +17 -0
  25. package/dist/login/loginCommand.d.ts +39 -0
  26. package/dist/login/loginCommand.js +72 -0
  27. package/dist/login/pkce.d.ts +47 -0
  28. package/dist/login/pkce.js +106 -0
  29. package/dist/login/pkceLogin.d.ts +53 -0
  30. package/dist/login/pkceLogin.js +152 -0
  31. package/dist/mcp/server.js +47 -20
  32. package/dist/preview/api.d.ts +84 -0
  33. package/dist/preview/api.js +174 -0
  34. package/dist/preview/command.d.ts +96 -0
  35. package/dist/preview/command.js +276 -0
  36. package/dist/preview/copy.d.ts +209 -0
  37. package/dist/preview/copy.js +444 -0
  38. package/dist/preview/index.d.ts +2 -3
  39. package/dist/preview/index.js +5 -6
  40. package/dist/preview/proxy.d.ts +12 -1
  41. package/dist/preview/proxy.js +17 -7
  42. package/dist/preview/qr.d.ts +30 -0
  43. package/dist/preview/qr.js +89 -0
  44. package/dist/preview/runner.d.ts +155 -30
  45. package/dist/preview/runner.js +219 -68
  46. package/dist/preview/stream.d.ts +52 -0
  47. package/dist/preview/stream.js +171 -0
  48. package/dist/preview/viewers.d.ts +113 -0
  49. package/dist/preview/viewers.js +242 -0
  50. package/dist/relay/server.d.ts +32 -0
  51. package/dist/relay/server.js +80 -12
  52. package/dist/relay/store.d.ts +32 -3
  53. package/dist/relay/store.js +76 -22
  54. package/dist/relay/testers.d.ts +90 -0
  55. package/dist/relay/testers.js +144 -0
  56. package/dist/shared/authError.d.ts +1 -0
  57. package/dist/shared/authError.js +11 -0
  58. package/dist/shared/config.d.ts +73 -5
  59. package/dist/shared/config.js +133 -20
  60. package/package.json +2 -3
  61. package/dist/login/login.d.ts +0 -34
  62. package/dist/login/login.js +0 -148
  63. package/dist/preview/cloudflared.d.ts +0 -13
  64. package/dist/preview/cloudflared.js +0 -46
  65. package/dist/preview/tunnel.d.ts +0 -14
  66. package/dist/preview/tunnel.js +0 -28
@@ -7,6 +7,7 @@
7
7
  // remixer would inherit — and publish over — the original's project.
8
8
  // Env vars (VINCENTT_API_URL / VINCENTT_PAT) override the file for local/dogfood
9
9
  // runs so a creator can point at a dev backend without editing the machine config.
10
+ // The binding's optional apiUrl does NOT — see decideApiUrl.
10
11
  import { promises as fs } from "node:fs";
11
12
  import os from "node:os";
12
13
  import path from "node:path";
@@ -30,19 +31,30 @@ export async function loadMachineConfig() {
30
31
  return (await readJsonIfExists(MACHINE_CONFIG_PATH)) ?? {};
31
32
  }
32
33
  /**
33
- * Merge a patch into ~/.vincentt/config.json (used by `harness login` to persist
34
- * a freshly minted PAT). Preserves any other fields already present. The file
35
- * holds a bearer credential, so the dir/file get owner-only perms.
34
+ * Persist the machine config with owner-only perms. `writeFile`'s `mode` only applies when
35
+ * it CREATES the file, so a pre-existing config.json with looser perms (e.g. 0644) would keep
36
+ * them and leave a bearer credential world-readable. An explicit chmod after the write tightens
37
+ * it to 0600 every time, whether the file was just created or already existed.
36
38
  */
37
- export async function writeMachineConfig(patch) {
38
- const next = { ...(await loadMachineConfig()), ...patch };
39
+ async function persistMachineConfig(next) {
39
40
  await fs.mkdir(path.dirname(MACHINE_CONFIG_PATH), { recursive: true, mode: 0o700 });
40
41
  await fs.writeFile(MACHINE_CONFIG_PATH, JSON.stringify(next, null, 2) + "\n", {
41
42
  encoding: "utf8",
42
43
  mode: 0o600,
43
44
  });
45
+ await fs.chmod(MACHINE_CONFIG_PATH, 0o600);
44
46
  return MACHINE_CONFIG_PATH;
45
47
  }
48
+ /**
49
+ * Merge a patch into ~/.vincentt/config.json (used by `harness login` to persist
50
+ * a freshly minted PAT). Preserves any other fields already present. The file
51
+ * holds a bearer credential, so the dir/file get owner-only perms (enforced on
52
+ * every write, even when the file pre-existed with looser perms).
53
+ */
54
+ export async function writeMachineConfig(patch) {
55
+ const next = { ...(await loadMachineConfig()), ...patch };
56
+ return persistMachineConfig(next);
57
+ }
46
58
  /**
47
59
  * Remove the stored PAT from ~/.vincentt/config.json (used by `vincentt logout`),
48
60
  * preserving apiUrl and any other fields. Returns whether a token was present.
@@ -51,16 +63,44 @@ export async function clearMachineToken() {
51
63
  const current = await loadMachineConfig();
52
64
  const hadToken = Boolean(current.pat);
53
65
  delete current.pat;
54
- await fs.mkdir(path.dirname(MACHINE_CONFIG_PATH), { recursive: true, mode: 0o700 });
55
- await fs.writeFile(MACHINE_CONFIG_PATH, JSON.stringify(current, null, 2) + "\n", {
56
- encoding: "utf8",
57
- mode: 0o600,
58
- });
66
+ await persistMachineConfig(current);
59
67
  return { path: MACHINE_CONFIG_PATH, hadToken };
60
68
  }
61
69
  export async function loadProjectBinding(cwd) {
62
70
  return readJsonIfExists(projectBindingPath(cwd));
63
71
  }
72
+ /**
73
+ * Locate the binding by PRECEDENCE, never inference: cwd, then each ancestor up
74
+ * to and including the git root. Nothing is guessed from the folder name and
75
+ * there is no "you only have one project" fallback — a wrong-but-plausible
76
+ * binding is silent retargeting, which B12 forbids.
77
+ *
78
+ * The walk stops at the git root so a binding in a parent repo cannot capture an
79
+ * unrelated sibling tree, and at the filesystem root when there is no repo.
80
+ */
81
+ export async function locateProjectBinding(cwd) {
82
+ let dir = path.resolve(cwd);
83
+ for (;;) {
84
+ const binding = await readJsonIfExists(projectBindingPath(dir));
85
+ if (binding)
86
+ return { binding, dir, path: projectBindingPath(dir) };
87
+ if (await isGitRoot(dir))
88
+ return undefined;
89
+ const parent = path.dirname(dir);
90
+ if (parent === dir)
91
+ return undefined;
92
+ dir = parent;
93
+ }
94
+ }
95
+ async function isGitRoot(dir) {
96
+ try {
97
+ await fs.access(path.join(dir, ".git"));
98
+ return true;
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
64
104
  /**
65
105
  * Write the per-tree binding and make sure `.vincentt/` is gitignored (so the
66
106
  * secret-free-but-tenant-scoped binding never rides a commit or `git archive`).
@@ -96,22 +136,95 @@ async function ensureGitignored(cwd, pattern) {
96
136
  // A missing/unwritable .gitignore must not fail project creation.
97
137
  }
98
138
  }
139
+ export const DEFAULT_API_URL = "http://localhost:5051";
140
+ export function decideApiUrl(input) {
141
+ const configuredUrl = input.envApiUrl ?? input.machineApiUrl ?? DEFAULT_API_URL;
142
+ const binding = input.bindingApiUrl;
143
+ if (!binding || strip(binding) === strip(configuredUrl)) {
144
+ return { apiUrl: configuredUrl, configuredUrl };
145
+ }
146
+ if (input.allowCustomApi) {
147
+ return { apiUrl: binding, configuredUrl, usedBindingApiUrl: binding };
148
+ }
149
+ return { apiUrl: configuredUrl, configuredUrl, ignoredBindingApiUrl: binding };
150
+ }
151
+ function strip(url) {
152
+ return url.replace(/\/$/, "");
153
+ }
99
154
  /**
100
- * Resolve the API URL + PAT for a machine call. Precedence: a project binding's
101
- * apiUrl wins for the URL; env vars override the machine-config file; the file is
102
- * the base. Throws a clear, actionable error when no PAT is configured.
155
+ * Resolve the API URL + PAT for a machine call. Throws a clear, actionable error
156
+ * when no PAT is configured. When a binding's apiUrl diverges from the configured
157
+ * one, the safe branch has ALREADY been taken by the time the advisory prints —
158
+ * it reports a decision rather than asking for one.
103
159
  */
104
- export async function resolveConfig(cwd) {
160
+ export async function resolveConfig(cwd, opts = {}) {
105
161
  const machine = await loadMachineConfig();
106
- const binding = await loadProjectBinding(cwd);
107
- const apiUrl = binding?.apiUrl ??
108
- process.env.VINCENTT_API_URL ??
109
- machine.apiUrl ??
110
- "http://localhost:5051";
162
+ const located = await locateProjectBinding(cwd);
163
+ const decided = decideApiUrl({
164
+ bindingApiUrl: located?.binding.apiUrl,
165
+ envApiUrl: process.env.VINCENTT_API_URL,
166
+ machineApiUrl: machine.apiUrl,
167
+ allowCustomApi: opts.allowCustomApi,
168
+ });
169
+ const warn = opts.warn;
170
+ if (warn) {
171
+ for (const line of apiUrlAdvisory(decided, {
172
+ bindingPath: located ? relativeBindingPath(cwd, located) : BINDING_REL_PATH,
173
+ })) {
174
+ warn(line);
175
+ }
176
+ }
111
177
  const pat = process.env.VINCENTT_PAT ?? machine.pat;
112
178
  if (!pat) {
113
179
  throw new Error(`No Vincentt access token found. Add {"apiUrl":"…","pat":"…"} to ${MACHINE_CONFIG_PATH}, ` +
114
180
  `or set the VINCENTT_PAT env var.`);
115
181
  }
116
- return { apiUrl: apiUrl.replace(/\/$/, ""), pat };
182
+ return { apiUrl: strip(decided.apiUrl), pat };
183
+ }
184
+ const BINDING_REL_PATH = `${BINDING_DIR}/${BINDING_FILE}`;
185
+ function relativeBindingPath(cwd, located) {
186
+ const rel = path.relative(path.resolve(cwd), located.path);
187
+ return rel.startsWith("..") ? located.path : rel;
188
+ }
189
+ /**
190
+ * The Flow L12 transcripts. Ignoring an override is the loud case (both hosts on
191
+ * their own aligned lines, the mechanism named, and where to go look); honoring one
192
+ * on purpose is two quiet lines, because a creator who typed the flag has already
193
+ * stated intent and re-reading the lecture every run is how advisories get
194
+ * filtered out mentally — taking the un-flagged one with them.
195
+ */
196
+ export function apiUrlAdvisory(decided, ctx) {
197
+ if (decided.usedBindingApiUrl) {
198
+ return [
199
+ `! Using this folder's API override: ${decided.usedBindingApiUrl}`,
200
+ ` (your machine config is ${decided.configuredUrl})`,
201
+ "",
202
+ ];
203
+ }
204
+ if (!decided.ignoredBindingApiUrl)
205
+ return [];
206
+ const label = `binding (${ctx.bindingPath}):`;
207
+ const pad = " ".repeat(Math.max(1, label.length - "your machine config:".length));
208
+ return [
209
+ "! This folder's binding points at a different Vincentt API.",
210
+ "",
211
+ ` ${label} ${decided.ignoredBindingApiUrl}`,
212
+ ` your machine config:${pad} ${decided.configuredUrl}`,
213
+ "",
214
+ " Your access token is sent with every request, so it would go to",
215
+ ` ${hostOf(decided.ignoredBindingApiUrl)}. A binding file travels with a repository —`,
216
+ " anyone who can commit to it can set this.",
217
+ "",
218
+ " Ignoring the binding and using your machine config.",
219
+ " To use the binding's API on purpose, re-run with --allow-custom-api.",
220
+ "",
221
+ ];
222
+ }
223
+ function hostOf(url) {
224
+ try {
225
+ return new URL(url).host;
226
+ }
227
+ catch {
228
+ return url;
229
+ }
117
230
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
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",
@@ -22,11 +22,10 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@modelcontextprotocol/sdk": "^1.29.0",
25
- "cloudflared": "^0.7.1",
26
25
  "qrcode": "^1.5.4",
27
26
  "ws": "^8.18.0",
28
27
  "zod": "^3.23.8",
29
- "@vincentt-xr/harness": "1.0.0"
28
+ "@vincentt-xr/harness": "1.1.0"
30
29
  },
31
30
  "devDependencies": {
32
31
  "@types/qrcode": "^1.5.5",
@@ -1,34 +0,0 @@
1
- export interface LoginOptions {
2
- /** Editor base URL that serves the /oauth consent page. */
3
- editorUrl: string;
4
- /** Backend API base URL the minted PAT authenticates against (paired with editorUrl). */
5
- apiUrl: string;
6
- /** Human label stored with the token (shown in the editor's token list). */
7
- tokenName: string;
8
- /** Abort if the browser round-trip doesn't complete in time. Default 5 min. */
9
- timeoutMs?: number;
10
- /** Set false to skip launching a browser (tests / headless). Default true. */
11
- openInBrowser?: boolean;
12
- /** Called with the authorize URL once the listener is up (for a printable fallback). */
13
- onUrl?: (url: string) => void;
14
- }
15
- /** Prefix a scheme when the user passed a bare host; localhost defaults to http. */
16
- export declare function normalizeBaseUrl(u: string): string;
17
- export declare function buildAuthorizeUrl(editorBase: string, redirectUri: string, state: string, tokenName: string): string;
18
- export type CallbackResult = {
19
- ok: true;
20
- token: string;
21
- } | {
22
- ok: false;
23
- message: string;
24
- };
25
- /** Interpret the editor's loopback redirect. State is verified first, always. */
26
- export declare function parseCallback(params: URLSearchParams, expectedState: string): CallbackResult;
27
- /**
28
- * Run the loopback OAuth round-trip and persist the resulting PAT. Resolves with
29
- * the apiUrl written to config; rejects on cancel, state mismatch, or timeout.
30
- */
31
- export declare function runLogin(opts: LoginOptions): Promise<{
32
- apiUrl: string;
33
- configPath: string;
34
- }>;
@@ -1,148 +0,0 @@
1
- // `harness login` — obtain a Personal Access Token via the editor's /oauth flow.
2
- //
3
- // The CLI can't mint a PAT directly (POST /access-tokens is Cognito-only, so a
4
- // machine token can never mint another). Instead it delegates to the editor:
5
- // 1. Start a loopback HTTP listener on 127.0.0.1:<ephemeral port>.
6
- // 2. Open the browser to <editor>/oauth?redirect_uri=…&state=…&name=… — the
7
- // editor reuses its own passwordless login + a consent screen and mints the
8
- // PAT on the user's behalf.
9
- // 3. The editor redirects ?token=<vpat_…>&state=… back to the loopback.
10
- // 4. Verify state, write the PAT to ~/.vincentt/config.json.
11
- // The PAT crosses only over loopback on the user's own machine.
12
- import { spawn } from "node:child_process";
13
- import { createServer } from "node:http";
14
- import { randomBytes } from "node:crypto";
15
- import { getFreePort } from "../preview/net.js";
16
- import { writeMachineConfig } from "../shared/config.js";
17
- /** Prefix a scheme when the user passed a bare host; localhost defaults to http. */
18
- export function normalizeBaseUrl(u) {
19
- if (/^https?:\/\//i.test(u))
20
- return u;
21
- const local = /^(localhost|127\.0\.0\.1)(:|\/|$)/i.test(u);
22
- return `${local ? "http" : "https"}://${u}`;
23
- }
24
- export function buildAuthorizeUrl(editorBase, redirectUri, state, tokenName) {
25
- const u = new URL("/oauth", normalizeBaseUrl(editorBase));
26
- u.searchParams.set("redirect_uri", redirectUri);
27
- u.searchParams.set("state", state);
28
- u.searchParams.set("name", tokenName);
29
- return u.toString();
30
- }
31
- /** Interpret the editor's loopback redirect. State is verified first, always. */
32
- export function parseCallback(params, expectedState) {
33
- if (params.get("state") !== expectedState) {
34
- return { ok: false, message: "State mismatch — ignoring an unexpected callback." };
35
- }
36
- const error = params.get("error");
37
- if (error) {
38
- return {
39
- ok: false,
40
- message: error === "access_denied" ? "Authorization was cancelled." : `Authorization failed: ${error}`,
41
- };
42
- }
43
- const token = params.get("token");
44
- if (!token)
45
- return { ok: false, message: "No token in the authorization response." };
46
- return { ok: true, token };
47
- }
48
- function escapeHtml(s) {
49
- return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
50
- }
51
- // Standalone success/failure page shown in the browser tab after the loopback
52
- // round-trip. Fully self-contained (inline CSS, no assets) since it's served off
53
- // the CLI's own listener; theme-aware to match the editor's sand/ink palette.
54
- function browserPage(ok, heading, body) {
55
- const accent = ok ? "#16a34a" : "#dc2626";
56
- const glyph = ok ? "&check;" : "&times;";
57
- return `<!doctype html>
58
- <html lang="en"><head><meta charset="utf-8">
59
- <meta name="viewport" content="width=device-width,initial-scale=1"><title>Vincentt XR</title>
60
- <style>
61
- :root { color-scheme: light dark; }
62
- * { box-sizing: border-box; }
63
- body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
64
- font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
65
- background:#f4efe6; color:#1c1917; }
66
- .card { width:min(92vw,380px); background:#fff; border:1px solid #e7e0d3; border-radius:16px;
67
- padding:40px 36px; text-align:center; box-shadow:0 10px 40px rgba(0,0,0,.08); }
68
- .brand { display:flex; align-items:center; justify-content:center; gap:8px; margin-bottom:26px;
69
- font-weight:700; font-size:15px; letter-spacing:-.01em; }
70
- .dot { width:26px; height:26px; border-radius:8px; background:linear-gradient(135deg,#7c3aed,#2563eb); }
71
- .glyph { width:48px; height:48px; margin:0 auto 16px; border-radius:50%; color:#fff;
72
- display:flex; align-items:center; justify-content:center; font-size:26px; line-height:1; background:${accent}; }
73
- h1 { font-size:19px; margin:0 0 6px; }
74
- p { font-size:13px; opacity:.7; margin:0; line-height:1.5; }
75
- @media (prefers-color-scheme: dark) {
76
- body { background:#1c1917; color:#f4efe6; }
77
- .card { background:#292524; border-color:#3f3a36; }
78
- }
79
- </style></head>
80
- <body><div class="card">
81
- <div class="brand"><span class="dot"></span>Vincentt XR</div>
82
- <div class="glyph">${glyph}</div>
83
- <h1>${escapeHtml(heading)}</h1>
84
- <p>${escapeHtml(body)}</p>
85
- </div></body></html>`;
86
- }
87
- function openBrowser(url) {
88
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
89
- const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
90
- try {
91
- const child = spawn(cmd, args, { stdio: "ignore", detached: true });
92
- child.on("error", () => { });
93
- child.unref();
94
- }
95
- catch {
96
- // Falls back to the printed URL (onUrl) — never fatal.
97
- }
98
- }
99
- /**
100
- * Run the loopback OAuth round-trip and persist the resulting PAT. Resolves with
101
- * the apiUrl written to config; rejects on cancel, state mismatch, or timeout.
102
- */
103
- export async function runLogin(opts) {
104
- const state = randomBytes(16).toString("hex");
105
- const port = await getFreePort();
106
- const redirectUri = `http://127.0.0.1:${port}/callback`;
107
- const authorizeUrl = buildAuthorizeUrl(opts.editorUrl, redirectUri, state, opts.tokenName);
108
- const token = await new Promise((resolve, reject) => {
109
- const server = createServer((req, res) => {
110
- const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
111
- if (url.pathname !== "/callback") {
112
- res.writeHead(404, { "content-type": "text/plain" });
113
- res.end("Not found");
114
- return;
115
- }
116
- const result = parseCallback(url.searchParams, state);
117
- res.writeHead(result.ok ? 200 : 400, { "content-type": "text/html; charset=utf-8" });
118
- res.end(result.ok
119
- ? browserPage(true, "You're signed in", "You can close this tab and return to your terminal.")
120
- : browserPage(false, "Sign-in failed", result.message));
121
- // A state-mismatch response is answered but must NOT settle the flow — a
122
- // stray/forged request shouldn't tear down the listener the real callback
123
- // still needs.
124
- if (result.ok) {
125
- server.close();
126
- resolve(result.token);
127
- }
128
- else if (url.searchParams.get("state") === state) {
129
- server.close();
130
- reject(new Error(result.message));
131
- }
132
- });
133
- server.on("error", reject);
134
- server.listen(port, "127.0.0.1", () => {
135
- opts.onUrl?.(authorizeUrl);
136
- if (opts.openInBrowser !== false)
137
- openBrowser(authorizeUrl);
138
- });
139
- const timer = setTimeout(() => {
140
- server.close();
141
- reject(new Error("Timed out waiting for browser authorization."));
142
- }, opts.timeoutMs ?? 5 * 60_000);
143
- timer.unref();
144
- });
145
- const apiUrl = opts.apiUrl.replace(/\/$/, "");
146
- const configPath = await writeMachineConfig({ apiUrl, pat: token });
147
- return { apiUrl, configPath };
148
- }
@@ -1,13 +0,0 @@
1
- export interface EnsureCloudflaredOptions {
2
- /** Progress sink for the one-time download (it is ~40MB and takes a few seconds). */
3
- onLog?: (message: string) => void;
4
- }
5
- /**
6
- * Return a spawnable cloudflared command/path, provisioning one if the host has
7
- * none. Resolves to `"cloudflared"` when a system install is on PATH, else to the
8
- * `cloudflared` package's managed binary (already fetched by its postinstall, or
9
- * downloaded here on demand when install scripts were skipped). Throws only if the
10
- * download itself fails (offline / blocked egress) — surface that to the user
11
- * with the manual `brew install cloudflared` fallback.
12
- */
13
- export declare function ensureCloudflared(opts?: EnsureCloudflaredOptions): Promise<string>;
@@ -1,46 +0,0 @@
1
- // Resolve a runnable cloudflared binary so a preview never hard-fails on a
2
- // missing system install. The tunnel protocol is proprietary QUIC to Cloudflare's
3
- // edge — there is no pure-Node connector, so *some* cloudflared binary is
4
- // required. We prefer one already on PATH (respects a user's own install) and
5
- // otherwise fall back to the copy the `cloudflared` npm package provisions, so a
6
- // fresh machine that installed only the harness can still preview on a device.
7
- import { spawnSync } from "node:child_process";
8
- import { existsSync } from "node:fs";
9
- import { mkdir } from "node:fs/promises";
10
- import path from "node:path";
11
- function isOnPath() {
12
- // `which`/`where` exits 0 only when the binary resolves on PATH.
13
- const probe = process.platform === "win32" ? "where" : "which";
14
- return spawnSync(probe, ["cloudflared"], { stdio: "ignore" }).status === 0;
15
- }
16
- /**
17
- * Return a spawnable cloudflared command/path, provisioning one if the host has
18
- * none. Resolves to `"cloudflared"` when a system install is on PATH, else to the
19
- * `cloudflared` package's managed binary (already fetched by its postinstall, or
20
- * downloaded here on demand when install scripts were skipped). Throws only if the
21
- * download itself fails (offline / blocked egress) — surface that to the user
22
- * with the manual `brew install cloudflared` fallback.
23
- */
24
- export async function ensureCloudflared(opts = {}) {
25
- if (isOnPath())
26
- return "cloudflared";
27
- const cloudflared = await import("cloudflared");
28
- if (existsSync(cloudflared.bin))
29
- return cloudflared.bin;
30
- // Reached only when the package's postinstall was skipped (e.g. --ignore-scripts).
31
- const log = opts.onLog ?? (() => undefined);
32
- log("cloudflared not found — downloading a one-time copy…");
33
- await mkdir(path.dirname(cloudflared.bin), { recursive: true });
34
- // The download may print progress. On an MCP stdio server, ANY stdout write
35
- // corrupts the JSON-RPC stream — redirect stdout→stderr for its duration.
36
- const realWrite = process.stdout.write.bind(process.stdout);
37
- process.stdout.write = process.stderr.write.bind(process.stderr);
38
- try {
39
- await cloudflared.install(cloudflared.bin);
40
- }
41
- finally {
42
- process.stdout.write = realWrite;
43
- }
44
- log(`cloudflared ready at ${cloudflared.bin}`);
45
- return cloudflared.bin;
46
- }
@@ -1,14 +0,0 @@
1
- import { type MintedTunnel } from "../lib/backend.js";
2
- export interface SessionTunnel extends MintedTunnel {
3
- /** Public https URL to open on the device. */
4
- url: string;
5
- /** Tear the tunnel down (DNS route + tunnel). Idempotent; call on SIGINT. */
6
- reap: () => Promise<void>;
7
- }
8
- /**
9
- * Mint a per-session dev tunnel for the project bound to `projectCwd`, routing
10
- * <slug>-<token>.<apex> to the local `localPort`. Returns the URL to open and a
11
- * `reap()` for teardown. Throws with an actionable message when the directory is
12
- * unbound (run project_create) or no backend/PAT is configured.
13
- */
14
- export declare function startSessionTunnel(projectCwd: string, localPort: number): Promise<SessionTunnel>;
@@ -1,28 +0,0 @@
1
- // Per-session dev tunnel: resolve the project binding + machine config and mint a
2
- // named tunnel via the backend (which holds the Cloudflare account creds). Stays
3
- // free of any child-process / cloudflared coupling — the caller runs cloudflared
4
- // with the returned run token (see runner.ts) and calls reap() on exit.
5
- import { loadProjectBinding, resolveConfig } from "../shared/config.js";
6
- import { mintTunnel, reapTunnel } from "../lib/backend.js";
7
- /**
8
- * Mint a per-session dev tunnel for the project bound to `projectCwd`, routing
9
- * <slug>-<token>.<apex> to the local `localPort`. Returns the URL to open and a
10
- * `reap()` for teardown. Throws with an actionable message when the directory is
11
- * unbound (run project_create) or no backend/PAT is configured.
12
- */
13
- export async function startSessionTunnel(projectCwd, localPort) {
14
- const binding = await loadProjectBinding(projectCwd);
15
- if (!binding) {
16
- throw new Error("No project bound to this directory — run project_create first.");
17
- }
18
- const cfg = await resolveConfig(projectCwd);
19
- const minted = await mintTunnel(cfg, binding.projectId, localPort);
20
- return {
21
- ...minted,
22
- url: `https://${minted.hostname}`,
23
- reap: () => reapTunnel(cfg, binding.projectId, {
24
- tunnelId: minted.tunnelId,
25
- dnsRecordId: minted.dnsRecordId,
26
- }),
27
- };
28
- }