@bakapiano/ccsm 0.22.5 → 0.22.7
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/CLAUDE.md +538 -538
- package/README.md +189 -189
- package/bin/ccsm.js +235 -235
- package/lib/cliActivity.js +139 -139
- package/lib/codexSeed.js +183 -183
- package/lib/config.js +279 -274
- package/lib/devices.js +229 -229
- package/lib/folders.js +124 -124
- package/lib/localCliSessions.js +519 -519
- package/lib/persistedSessions.js +129 -129
- package/lib/tunnel.js +621 -621
- package/lib/webTerminal.js +225 -225
- package/lib/workspace.js +233 -233
- package/package.json +57 -57
- package/public/css/base.css +99 -99
- package/public/css/cards.css +183 -183
- package/public/css/feedback.css +504 -504
- package/public/css/forms.css +453 -453
- package/public/css/layout.css +177 -176
- package/public/css/modal.css +190 -190
- package/public/css/responsive.css +176 -176
- package/public/css/sidebar.css +707 -707
- package/public/css/terminals.css +547 -553
- package/public/css/tokens.css +81 -81
- package/public/css/wco.css +196 -196
- package/public/css/widgets.css +2725 -2725
- package/public/index.html +152 -152
- package/public/js/api.js +371 -371
- package/public/js/backend.js +149 -149
- package/public/js/components/App.js +73 -73
- package/public/js/components/DirectoryPicker.js +203 -203
- package/public/js/components/EntityFormModal.js +153 -153
- package/public/js/components/Modal.js +57 -57
- package/public/js/components/OfflineBanner.js +67 -67
- package/public/js/components/PageTitleBar.js +13 -13
- package/public/js/components/PendingApprovalOverlay.js +128 -128
- package/public/js/components/Picker.js +179 -179
- package/public/js/components/Popover.js +55 -55
- package/public/js/components/RestartOverlay.js +36 -36
- package/public/js/components/Sidebar.js +380 -380
- package/public/js/components/TerminalInstance.js +28 -9
- package/public/js/components/XtermTerminal.js +62 -2
- package/public/js/components/useDragSort.js +67 -67
- package/public/js/dialog.js +67 -67
- package/public/js/icons.js +212 -212
- package/public/js/main.js +296 -296
- package/public/js/pages/AboutPage.js +90 -90
- package/public/js/pages/ConfigurePage.js +728 -713
- package/public/js/pages/LaunchPage.js +421 -421
- package/public/js/pages/RemotePage.js +743 -743
- package/public/js/pages/SessionsPage.js +73 -80
- package/public/js/state.js +335 -335
- package/scripts/dev.js +149 -149
- package/scripts/install.js +153 -153
- package/scripts/restart-helper.js +96 -96
- package/scripts/upgrade-helper.js +687 -687
- package/server.js +1820 -1807
- package/public/manifest.webmanifest +0 -25
- package/public/setup/index.html +0 -567
package/lib/tunnel.js
CHANGED
|
@@ -1,621 +1,621 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Tunnel manager · spawns and supervises a cloudflared or devtunnel
|
|
4
|
-
// child to expose the local ccsm backend over a public URL. Captures
|
|
5
|
-
// the URL from stdout, exposes state to the API, and tears down the
|
|
6
|
-
// child on stop / server shutdown.
|
|
7
|
-
//
|
|
8
|
-
// Two providers, each with their own CLI quirk:
|
|
9
|
-
// cloudflared · `cloudflared tunnel --url http://localhost:<port>`
|
|
10
|
-
// Prints `https://*.trycloudflare.com` somewhere in
|
|
11
|
-
// the boot banner. No login required for quick tunnels.
|
|
12
|
-
// devtunnel · `devtunnel host -p <port> --allow-anonymous`
|
|
13
|
-
// Prints `Connect via browser: https://*.devtunnels.ms`.
|
|
14
|
-
// Host must be logged in (`devtunnel user login`).
|
|
15
|
-
//
|
|
16
|
-
// Discovery: scan PATH first via `where.exe`, then known winget install
|
|
17
|
-
// dirs. Returns the absolute path so we can spawn the child regardless
|
|
18
|
-
// of whether the post-install PATH refresh has reached this Node process.
|
|
19
|
-
|
|
20
|
-
const { spawn, execFile } = require('node:child_process');
|
|
21
|
-
const path = require('node:path');
|
|
22
|
-
const fs = require('node:fs');
|
|
23
|
-
const os = require('node:os');
|
|
24
|
-
const { promisify } = require('node:util');
|
|
25
|
-
const { loadConfig, saveConfig } = require('./config');
|
|
26
|
-
const execFileP = promisify(execFile);
|
|
27
|
-
|
|
28
|
-
const PROVIDERS = {
|
|
29
|
-
cloudflared: {
|
|
30
|
-
id: 'cloudflared',
|
|
31
|
-
label: 'Cloudflare Tunnel',
|
|
32
|
-
wingetId: 'Cloudflare.cloudflared',
|
|
33
|
-
binary: 'cloudflared.exe',
|
|
34
|
-
knownPaths: [
|
|
35
|
-
path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'cloudflared', 'cloudflared.exe'),
|
|
36
|
-
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'cloudflared', 'cloudflared.exe'),
|
|
37
|
-
],
|
|
38
|
-
args: (port) => ['tunnel', '--url', `http://localhost:${port}`],
|
|
39
|
-
urlRegex: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i,
|
|
40
|
-
},
|
|
41
|
-
devtunnel: {
|
|
42
|
-
id: 'devtunnel',
|
|
43
|
-
label: 'Microsoft Dev Tunnel',
|
|
44
|
-
wingetId: 'Microsoft.devtunnel',
|
|
45
|
-
binary: 'devtunnel.exe',
|
|
46
|
-
knownPaths: [
|
|
47
|
-
path.join(process.env['LOCALAPPDATA'] || '', 'Microsoft', 'WinGet', 'Packages',
|
|
48
|
-
'Microsoft.devtunnel_Microsoft.Winget.Source_8wekyb3d8bbwe', 'devtunnel.exe'),
|
|
49
|
-
],
|
|
50
|
-
args: (port, opts = {}) => {
|
|
51
|
-
// With a persistent (named) tunnel, ports + anonymous access are
|
|
52
|
-
// configured ahead of time via `devtunnel port create` and
|
|
53
|
-
// `devtunnel access create` (see ensureDevtunnelTunnelId +
|
|
54
|
-
// configureDevtunnelTunnel). The host call then takes ONLY the
|
|
55
|
-
// tunnel id — passing -p or --allow-anonymous here makes the CLI
|
|
56
|
-
// try to batch-update the existing tunnel and the service rejects
|
|
57
|
-
// it ("Batch update of ports is not supported"). Anonymous +
|
|
58
|
-
// ephemeral mode keeps the legacy flags as a fallback when no
|
|
59
|
-
// tunnel id has been minted.
|
|
60
|
-
if (opts.tunnelId) {
|
|
61
|
-
return ['host', opts.tunnelId];
|
|
62
|
-
}
|
|
63
|
-
return ['host', '-p', String(port), '--allow-anonymous'];
|
|
64
|
-
},
|
|
65
|
-
// devtunnel sometimes prints two URL forms for the same tunnel:
|
|
66
|
-
// https://<id>.<region>.devtunnels.ms:<port> ← port as suffix
|
|
67
|
-
// https://<id>-<port>.<region>.devtunnels.ms ← port baked into
|
|
68
|
-
// the subdomain
|
|
69
|
-
// The plain `<id>.<region>` form (without a `:<port>` suffix) is
|
|
70
|
-
// unreachable — browsers default to 443 and the tunnel serves
|
|
71
|
-
// nothing there, so we get a 404. We always want the subdomain-
|
|
72
|
-
// port form. Force the regex to require `-<digits>` in the
|
|
73
|
-
// subdomain so the bare form (which our old greedy match would
|
|
74
|
-
// capture first) gets skipped.
|
|
75
|
-
urlRegex: /https:\/\/[a-z0-9]+-\d+\.[a-z0-9-]+\.devtunnels\.ms/i,
|
|
76
|
-
needsLogin: true,
|
|
77
|
-
},
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
// In-memory state. Single tunnel at a time — switching providers tears
|
|
81
|
-
// down the old one first.
|
|
82
|
-
let current = null; // { provider, child, url, startedAt, log: string[] }
|
|
83
|
-
let starting = false; // True while start() is mid-spawn. devtunnel does
|
|
84
|
-
// ~10-20s of async create/configure BEFORE `current`
|
|
85
|
-
// is assigned, so the `if (current)` guard alone
|
|
86
|
-
// can't stop a second concurrent start() (boot
|
|
87
|
-
// auto-start racing a manual click) from spawning a
|
|
88
|
-
// duplicate child. This flag closes that window.
|
|
89
|
-
let token = null; // Remote-access bearer token. Null = no remote
|
|
90
|
-
// access enforced. Set via setToken() or by the
|
|
91
|
-
// start() call. Server.js middleware reads via
|
|
92
|
-
// getToken().
|
|
93
|
-
let login = null; // Pending interactive `devtunnel user login -d`
|
|
94
|
-
// flow · { child, mode, lines, url, code, status,
|
|
95
|
-
// startedAt, finishedAt, error, user }. Single
|
|
96
|
-
// flow at a time. See startDevtunnelLogin().
|
|
97
|
-
|
|
98
|
-
function getToken() { return token; }
|
|
99
|
-
function setToken(t) { token = t ? String(t) : null; return token; }
|
|
100
|
-
|
|
101
|
-
async function findBinary(provider) {
|
|
102
|
-
const p = PROVIDERS[provider];
|
|
103
|
-
if (!p) return null;
|
|
104
|
-
// PATH lookup via where.exe — works regardless of how the CLI got
|
|
105
|
-
// installed (winget, choco, manual, in-tree). windowsHide stops the
|
|
106
|
-
// conhost window from flashing.
|
|
107
|
-
try {
|
|
108
|
-
const { stdout } = await execFileP('where.exe', [p.binary], { windowsHide: true });
|
|
109
|
-
const out = String(stdout).trim().split(/\r?\n/)[0];
|
|
110
|
-
if (out && fs.existsSync(out)) return out;
|
|
111
|
-
} catch { /* not on PATH */ }
|
|
112
|
-
// Fall back to known install locations (winget's PATH update doesn't
|
|
113
|
-
// reach the already-running Node process).
|
|
114
|
-
for (const candidate of p.knownPaths) {
|
|
115
|
-
if (candidate && fs.existsSync(candidate)) return candidate;
|
|
116
|
-
}
|
|
117
|
-
// For devtunnel: winget's package dir has a version suffix that
|
|
118
|
-
// changes between releases. Glob it.
|
|
119
|
-
if (provider === 'devtunnel') {
|
|
120
|
-
const base = path.join(process.env['LOCALAPPDATA'] || '', 'Microsoft', 'WinGet', 'Packages');
|
|
121
|
-
try {
|
|
122
|
-
for (const entry of fs.readdirSync(base)) {
|
|
123
|
-
if (entry.startsWith('Microsoft.devtunnel_')) {
|
|
124
|
-
const candidate = path.join(base, entry, 'devtunnel.exe');
|
|
125
|
-
if (fs.existsSync(candidate)) return candidate;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
} catch {}
|
|
129
|
-
}
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async function getVersion(provider, exe) {
|
|
134
|
-
try {
|
|
135
|
-
const { stdout } = await execFileP(exe, ['--version'], { windowsHide: true });
|
|
136
|
-
return String(stdout).trim().split(/\r?\n/)[0] || null;
|
|
137
|
-
} catch { return null; }
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async function checkDevtunnelLogin(exe) {
|
|
141
|
-
try {
|
|
142
|
-
const { stdout } = await execFileP(exe, ['user', 'show'], { windowsHide: true, timeout: 5000 });
|
|
143
|
-
// "Logged in as <email> using <provider>." vs "Not logged in"
|
|
144
|
-
const m = String(stdout).trim().match(/Logged in as (\S+)/);
|
|
145
|
-
if (m) return { loggedIn: true, user: m[1] };
|
|
146
|
-
return { loggedIn: false, user: null };
|
|
147
|
-
} catch {
|
|
148
|
-
return { loggedIn: false, user: null };
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Mint (or read back) a persistent devtunnel id and stash it in
|
|
153
|
-
// config.devtunnel.tunnelId so subsequent `devtunnel host` invocations
|
|
154
|
-
// reuse the same public URL.
|
|
155
|
-
//
|
|
156
|
-
// Why: every `devtunnel host` without a tunnel id allocates a fresh
|
|
157
|
-
// random id, which means a fresh subdomain and therefore a fresh
|
|
158
|
-
// browser origin on the remote side. localStorage is per-origin, so
|
|
159
|
-
// approved device ids get orphaned and the remote user has to re-
|
|
160
|
-
// register from scratch on every tunnel restart.
|
|
161
|
-
//
|
|
162
|
-
// Behaviour:
|
|
163
|
-
// - If config already has a tunnelId, return it verbatim. We do NOT
|
|
164
|
-
// validate it against `devtunnel list` here — the host child will
|
|
165
|
-
// fail loudly if the id was deleted from another machine, and
|
|
166
|
-
// callers can drop it and retry by deleting config.devtunnel.
|
|
167
|
-
// - Otherwise call `devtunnel create --json`, capture the .tunnelId
|
|
168
|
-
// from the response, persist it, return it.
|
|
169
|
-
// - If the create call fails for any reason, return null and let
|
|
170
|
-
// start() fall back to a temporary tunnel — degraded but working.
|
|
171
|
-
async function ensureDevtunnelTunnelId(exe) {
|
|
172
|
-
try {
|
|
173
|
-
const cfg = await loadConfig();
|
|
174
|
-
if (cfg?.devtunnel?.tunnelId) return cfg.devtunnel.tunnelId;
|
|
175
|
-
const { stdout } = await execFileP(exe, ['create', '--json'], {
|
|
176
|
-
windowsHide: true,
|
|
177
|
-
timeout: 20_000,
|
|
178
|
-
});
|
|
179
|
-
let id = null;
|
|
180
|
-
try {
|
|
181
|
-
const j = JSON.parse(String(stdout));
|
|
182
|
-
// The CLI's JSON shape varies a bit by version; the canonical
|
|
183
|
-
// field is `tunnelId` but older builds nest it under `tunnel`.
|
|
184
|
-
id = j.tunnelId || j.tunnel?.tunnelId || j.id || null;
|
|
185
|
-
} catch {
|
|
186
|
-
// Fall back to scraping the plain-text output for `Tunnel ID: foo`
|
|
187
|
-
const m = String(stdout).match(/Tunnel ID:\s*(\S+)/i);
|
|
188
|
-
if (m) id = m[1];
|
|
189
|
-
}
|
|
190
|
-
if (!id) return null;
|
|
191
|
-
// Persist for next time. Swallow save errors — worst case the next
|
|
192
|
-
// start re-allocates one.
|
|
193
|
-
try { await saveConfig({ devtunnel: { tunnelId: id } }); }
|
|
194
|
-
catch (e) { console.warn('[tunnel] persist devtunnel id failed:', e.message); }
|
|
195
|
-
return id;
|
|
196
|
-
} catch (e) {
|
|
197
|
-
console.warn('[tunnel] ensureDevtunnelTunnelId failed:', e.message);
|
|
198
|
-
return null;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Bring a named tunnel into shape for hosting: make sure the port is
|
|
203
|
-
// in its port list and anonymous access is allowed. Idempotent — the
|
|
204
|
-
// "already exists" errors from `port create` / `access create` are
|
|
205
|
-
// silently absorbed. Required because `devtunnel host <id>` (which
|
|
206
|
-
// we use for persistent tunnels) doesn't accept -p / --allow-anonymous
|
|
207
|
-
// flags after the tunnel exists — passing them triggers the service
|
|
208
|
-
// to reject the call with "Batch update of ports is not supported".
|
|
209
|
-
async function configureDevtunnelTunnel(exe, tunnelId, port) {
|
|
210
|
-
if (!tunnelId) return;
|
|
211
|
-
// Add port. If it already exists the CLI prints an error; swallow it.
|
|
212
|
-
try {
|
|
213
|
-
await execFileP(exe, ['port', 'create', tunnelId, '-p', String(port)], {
|
|
214
|
-
windowsHide: true,
|
|
215
|
-
timeout: 10_000,
|
|
216
|
-
});
|
|
217
|
-
} catch (e) {
|
|
218
|
-
// Only surface failures that aren't "already exists" — those mean
|
|
219
|
-
// something is genuinely broken (auth lost, wrong tunnel id, etc.).
|
|
220
|
-
const msg = String(e.stderr || e.stdout || e.message || '');
|
|
221
|
-
if (!/already/i.test(msg)) {
|
|
222
|
-
console.warn('[tunnel] devtunnel port create failed:', msg.slice(0, 200));
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
// Add anonymous access. Same idempotency story.
|
|
226
|
-
try {
|
|
227
|
-
await execFileP(exe, ['access', 'create', tunnelId, '-a'], {
|
|
228
|
-
windowsHide: true,
|
|
229
|
-
timeout: 10_000,
|
|
230
|
-
});
|
|
231
|
-
} catch (e) {
|
|
232
|
-
const msg = String(e.stderr || e.stdout || e.message || '');
|
|
233
|
-
if (!/already/i.test(msg)) {
|
|
234
|
-
console.warn('[tunnel] devtunnel access create failed:', msg.slice(0, 200));
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Delete the persisted devtunnel id (and the remote tunnel resource
|
|
240
|
-
// itself, best-effort). Used by the Reset affordance in the Remote
|
|
241
|
-
// page when the user wants to rotate the public URL.
|
|
242
|
-
async function resetDevtunnelTunnelId() {
|
|
243
|
-
let prevId = null;
|
|
244
|
-
try {
|
|
245
|
-
const cfg = await loadConfig();
|
|
246
|
-
prevId = cfg?.devtunnel?.tunnelId || null;
|
|
247
|
-
} catch {}
|
|
248
|
-
try { await saveConfig({ devtunnel: { tunnelId: null } }); } catch {}
|
|
249
|
-
if (prevId) {
|
|
250
|
-
const exe = await findBinary('devtunnel');
|
|
251
|
-
if (exe) {
|
|
252
|
-
// Detach from the tunnel resource on the Azure side too; failure
|
|
253
|
-
// is fine (resource may already be gone). 5s cap.
|
|
254
|
-
try { await execFileP(exe, ['delete', prevId, '-f'], { windowsHide: true, timeout: 5_000 }); }
|
|
255
|
-
catch { /* ignore */ }
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
return { previousTunnelId: prevId };
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Probe is cached. Each cold call shells out 4-6 sync execs (where.exe,
|
|
262
|
-
// --version per provider, `devtunnel user show`) — cumulatively ~1s of
|
|
263
|
-
// blocked event loop on Windows. The Remote page polls /api/tunnel/status
|
|
264
|
-
// every 2.5s, and tunnel.start() returns a fresh status() — without this
|
|
265
|
-
// cache, the loop is frozen ~40% of the time during normal operation,
|
|
266
|
-
// and /api/health probes from other clients time out.
|
|
267
|
-
const PROBE_TTL_MS = 30_000;
|
|
268
|
-
let probeCache = null;
|
|
269
|
-
let probeCacheAt = 0;
|
|
270
|
-
|
|
271
|
-
async function probe(force = false) {
|
|
272
|
-
if (!force && probeCache && Date.now() - probeCacheAt < PROBE_TTL_MS) {
|
|
273
|
-
return probeCache;
|
|
274
|
-
}
|
|
275
|
-
// All providers in parallel; within each, --version and the
|
|
276
|
-
// devtunnel `user show` check run together too. Cold probe drops
|
|
277
|
-
// from ~1.5s serial to ~700ms (capped by the slowest exec —
|
|
278
|
-
// typically `devtunnel user show`).
|
|
279
|
-
const ids = Object.keys(PROVIDERS);
|
|
280
|
-
const results = await Promise.all(ids.map(async (id) => {
|
|
281
|
-
const exe = await findBinary(id);
|
|
282
|
-
const p = { installed: !!exe, exe, version: null };
|
|
283
|
-
if (exe) {
|
|
284
|
-
const tasks = [getVersion(id, exe)];
|
|
285
|
-
if (id === 'devtunnel') tasks.push(checkDevtunnelLogin(exe));
|
|
286
|
-
const [version, devUser] = await Promise.all(tasks);
|
|
287
|
-
p.version = version;
|
|
288
|
-
if (devUser) Object.assign(p, devUser);
|
|
289
|
-
}
|
|
290
|
-
return [id, p];
|
|
291
|
-
}));
|
|
292
|
-
probeCache = Object.fromEntries(results);
|
|
293
|
-
probeCacheAt = Date.now();
|
|
294
|
-
return probeCache;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Kick a single background probe refresh, deduped so overlapping callers
|
|
298
|
-
// share one shell-out. Never throws.
|
|
299
|
-
let probeRefreshing = null;
|
|
300
|
-
function kickProbeRefresh() {
|
|
301
|
-
if (!probeRefreshing) {
|
|
302
|
-
probeRefreshing = probe(true)
|
|
303
|
-
.catch(() => probeCache)
|
|
304
|
-
.finally(() => { probeRefreshing = null; });
|
|
305
|
-
}
|
|
306
|
-
return probeRefreshing;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// Invalidate the cache when callers know the on-disk state likely changed
|
|
310
|
-
// (post-install, post-login, etc.) and immediately start repopulating it
|
|
311
|
-
// in the background so the next status poll is already fresh.
|
|
312
|
-
function invalidateProbe() { probeCache = null; probeCacheAt = 0; kickProbeRefresh(); }
|
|
313
|
-
|
|
314
|
-
// Stale-while-revalidate accessor used by status(). NEVER shells out in
|
|
315
|
-
// the request path: returns whatever's cached right now (possibly stale,
|
|
316
|
-
// or null on the very first call before the boot prewarm lands) and kicks
|
|
317
|
-
// off a background refresh when the cache is stale. This is what keeps
|
|
318
|
-
// /api/tunnel/status — and therefore the whole Remote page's live refresh
|
|
319
|
-
// (plus the device list, which the client used to bundle into the same
|
|
320
|
-
// round-trip) — from stalling ~700ms every time the 30s cache expires.
|
|
321
|
-
function probeCachedSWR() {
|
|
322
|
-
const fresh = probeCache && Date.now() - probeCacheAt < PROBE_TTL_MS;
|
|
323
|
-
if (!fresh) kickProbeRefresh();
|
|
324
|
-
return probeCache;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
async function status() {
|
|
328
|
-
// One config read serves both the persisted tunnelId and the
|
|
329
|
-
// auto-start prefs the Remote page's toggle reflects.
|
|
330
|
-
let cfg = null;
|
|
331
|
-
try { cfg = await loadConfig(); } catch { /* fall back to nulls below */ }
|
|
332
|
-
return {
|
|
333
|
-
providers: probeCachedSWR(),
|
|
334
|
-
running: !!current,
|
|
335
|
-
provider: current?.provider || null,
|
|
336
|
-
url: current?.url || null,
|
|
337
|
-
startedAt: current?.startedAt || null,
|
|
338
|
-
pid: current?.child?.pid || null,
|
|
339
|
-
log: current?.log?.slice(-50) || [],
|
|
340
|
-
token,
|
|
341
|
-
tunnelId: current?.tunnelId || cfg?.devtunnel?.tunnelId || null,
|
|
342
|
-
// Persisted auto-start prefs — surfaced so the toggle renders the
|
|
343
|
-
// right state across reloads (the page already polls /status).
|
|
344
|
-
autoStart: cfg?.tunnel?.autoStart ?? false,
|
|
345
|
-
autoStartProvider: cfg?.tunnel?.provider ?? null,
|
|
346
|
-
// Token is echoed back so the Remote page can render the
|
|
347
|
-
// pre-built share URL. The route itself is token-protected
|
|
348
|
-
// (the middleware blocks non-loopback callers without it), so
|
|
349
|
-
// anyone reaching this endpoint already knows the token.
|
|
350
|
-
login: loginSnapshot(),
|
|
351
|
-
};
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// ---- devtunnel interactive login (device-code flow) ----
|
|
355
|
-
//
|
|
356
|
-
// `devtunnel user login -d` prints a Microsoft device-code line then
|
|
357
|
-
// polls Azure until the user authenticates in a browser (or until it
|
|
358
|
-
// times out). We spawn it as a child, parse the URL + code out of the
|
|
359
|
-
// first informational lines, and expose progress via /api/tunnel/status
|
|
360
|
-
// so the Remote page can render a one-click sign-in panel instead of
|
|
361
|
-
// asking the user to paste a command into a terminal.
|
|
362
|
-
//
|
|
363
|
-
// Two modes: 'microsoft' (default, -d) and 'github' (-g -d). GitHub is
|
|
364
|
-
// only worth offering if the user explicitly picks it — Microsoft
|
|
365
|
-
// device code works for Entra ID / personal MS accounts and is what
|
|
366
|
-
// most people land on.
|
|
367
|
-
//
|
|
368
|
-
// State machine:
|
|
369
|
-
// running → child alive, waiting for user
|
|
370
|
-
// done → child exited 0; probe cache is invalidated so the next
|
|
371
|
-
// providers map shows `loggedIn: true`
|
|
372
|
-
// error → child exited non-zero or crashed
|
|
373
|
-
// canceled → cancelDevtunnelLogin() killed the child
|
|
374
|
-
function loginSnapshot() {
|
|
375
|
-
if (!login) return null;
|
|
376
|
-
return {
|
|
377
|
-
mode: login.mode,
|
|
378
|
-
status: login.status,
|
|
379
|
-
url: login.url,
|
|
380
|
-
code: login.code,
|
|
381
|
-
error: login.error || null,
|
|
382
|
-
user: login.user || null,
|
|
383
|
-
startedAt: login.startedAt,
|
|
384
|
-
finishedAt: login.finishedAt || null,
|
|
385
|
-
lines: login.lines.slice(-40),
|
|
386
|
-
};
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
async function startDevtunnelLogin({ mode = 'microsoft' } = {}) {
|
|
390
|
-
if (login && login.status === 'running') {
|
|
391
|
-
// Already in flight · return the snapshot rather than throwing so
|
|
392
|
-
// a double-click on Sign in doesn't error out.
|
|
393
|
-
return loginSnapshot();
|
|
394
|
-
}
|
|
395
|
-
const exe = await findBinary('devtunnel');
|
|
396
|
-
if (!exe) throw new Error('Microsoft Dev Tunnel is not installed');
|
|
397
|
-
// Starting a fresh login drops any existing credentials from disk
|
|
398
|
-
// before the new flow finishes — so the cached probe ("signed in as
|
|
399
|
-
// old-user") is now lying. Invalidate so the next /status round-
|
|
400
|
-
// trip re-shells `devtunnel user show` and the provider line flips
|
|
401
|
-
// to "not signed in" while the device-code panel is up.
|
|
402
|
-
invalidateProbe();
|
|
403
|
-
|
|
404
|
-
// -d picks the Microsoft device-code flow; -g switches it to GitHub.
|
|
405
|
-
// We deliberately stay on device code (no `--use-browser`) — the user
|
|
406
|
-
// may be driving the Remote page from a phone where opening a local
|
|
407
|
-
// browser on the host machine doesn't help.
|
|
408
|
-
const args = mode === 'github' ? ['user', 'login', '-g', '-d'] : ['user', 'login', '-d'];
|
|
409
|
-
const child = spawn(exe, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
410
|
-
const entry = {
|
|
411
|
-
child,
|
|
412
|
-
mode,
|
|
413
|
-
lines: [],
|
|
414
|
-
url: null,
|
|
415
|
-
code: null,
|
|
416
|
-
status: 'running',
|
|
417
|
-
error: null,
|
|
418
|
-
user: null,
|
|
419
|
-
startedAt: Date.now(),
|
|
420
|
-
finishedAt: null,
|
|
421
|
-
};
|
|
422
|
-
login = entry;
|
|
423
|
-
|
|
424
|
-
const URL_RE = /https?:\/\/\S+/i;
|
|
425
|
-
// Microsoft device-code prompt examples (have varied over CLI
|
|
426
|
-
// versions): "...enter the code ABCD-1234 to authenticate"
|
|
427
|
-
// "...code XXXXXXXXX..."
|
|
428
|
-
// GitHub device flow uses 8 chars with a dash, e.g. `XXXX-XXXX`.
|
|
429
|
-
const CODE_RE = /\b([A-Z0-9]{4,}-?[A-Z0-9]{3,})\b/;
|
|
430
|
-
const LOGGED = /Logged in as (\S+)/i;
|
|
431
|
-
|
|
432
|
-
const ingest = (line) => {
|
|
433
|
-
if (!line) return;
|
|
434
|
-
entry.lines.push(line);
|
|
435
|
-
if (entry.lines.length > 100) entry.lines.shift();
|
|
436
|
-
if (!entry.url) {
|
|
437
|
-
const m = line.match(URL_RE);
|
|
438
|
-
if (m) entry.url = m[0].replace(/[.,)]+$/, '');
|
|
439
|
-
}
|
|
440
|
-
if (!entry.code && /code/i.test(line)) {
|
|
441
|
-
// Skip URL-bearing fragments before extracting the code so we
|
|
442
|
-
// don't grab a uuid-ish segment out of the device login URL.
|
|
443
|
-
const sans = line.replace(URL_RE, '');
|
|
444
|
-
const m = sans.match(CODE_RE);
|
|
445
|
-
if (m) entry.code = m[1];
|
|
446
|
-
}
|
|
447
|
-
const u = line.match(LOGGED);
|
|
448
|
-
if (u) entry.user = u[1];
|
|
449
|
-
};
|
|
450
|
-
|
|
451
|
-
child.stdout.setEncoding('utf8');
|
|
452
|
-
child.stderr.setEncoding('utf8');
|
|
453
|
-
child.stdout.on('data', (c) => c.split(/\r?\n/).forEach(ingest));
|
|
454
|
-
child.stderr.on('data', (c) => c.split(/\r?\n/).forEach(ingest));
|
|
455
|
-
|
|
456
|
-
child.on('exit', (code, signal) => {
|
|
457
|
-
entry.finishedAt = Date.now();
|
|
458
|
-
if (entry.status === 'canceled') {
|
|
459
|
-
// already terminal; leave as-is
|
|
460
|
-
} else if (code === 0) {
|
|
461
|
-
entry.status = 'done';
|
|
462
|
-
// The just-completed login means the probe cache is now lying
|
|
463
|
-
// about `loggedIn: false`. Drop it so the next status() call
|
|
464
|
-
// re-shells `devtunnel user show` and the UI flips to signed-in.
|
|
465
|
-
invalidateProbe();
|
|
466
|
-
} else {
|
|
467
|
-
entry.status = 'error';
|
|
468
|
-
entry.error = `devtunnel exited code=${code}${signal ? ` signal=${signal}` : ''}`;
|
|
469
|
-
}
|
|
470
|
-
});
|
|
471
|
-
child.on('error', (err) => {
|
|
472
|
-
entry.status = 'error';
|
|
473
|
-
entry.error = String(err && err.message || err);
|
|
474
|
-
entry.finishedAt = Date.now();
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
return loginSnapshot();
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
function cancelDevtunnelLogin() {
|
|
481
|
-
if (!login || login.status !== 'running') return loginSnapshot();
|
|
482
|
-
login.status = 'canceled';
|
|
483
|
-
login.finishedAt = Date.now();
|
|
484
|
-
try { login.child.kill(); } catch {}
|
|
485
|
-
return loginSnapshot();
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
function clearDevtunnelLogin() {
|
|
489
|
-
if (login && login.status === 'running') {
|
|
490
|
-
try { login.child.kill(); } catch {}
|
|
491
|
-
}
|
|
492
|
-
login = null;
|
|
493
|
-
return null;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// Spawn the tunnel CLI. Resolves once we've parsed the public URL out
|
|
497
|
-
// of stdout (with a timeout safety net). Throws if the CLI isn't
|
|
498
|
-
// installed, the provider is unknown, or another tunnel is running.
|
|
499
|
-
async function start({ provider, port }) {
|
|
500
|
-
if (current) throw new Error('tunnel already running');
|
|
501
|
-
if (starting) throw new Error('tunnel is already starting');
|
|
502
|
-
const p = PROVIDERS[provider];
|
|
503
|
-
if (!p) throw new Error(`unknown provider: ${provider}`);
|
|
504
|
-
|
|
505
|
-
// Hold the `starting` flag across the async setup below. devtunnel's
|
|
506
|
-
// create/configure can take 10-20s, all of it BEFORE `current` is
|
|
507
|
-
// assigned — without this flag a second start() (boot auto-start vs.
|
|
508
|
-
// a manual click) would slip past the `if (current)` guard and spawn
|
|
509
|
-
// a duplicate child. Cleared the moment `current` is set, after which
|
|
510
|
-
// the `if (current)` guard alone is sufficient.
|
|
511
|
-
starting = true;
|
|
512
|
-
let entry;
|
|
513
|
-
let child;
|
|
514
|
-
try {
|
|
515
|
-
const exe = await findBinary(provider);
|
|
516
|
-
if (!exe) throw new Error(`${p.label} is not installed`);
|
|
517
|
-
if (provider === 'devtunnel') {
|
|
518
|
-
const { loggedIn } = await checkDevtunnelLogin(exe);
|
|
519
|
-
if (!loggedIn) throw new Error('devtunnel requires login — run `devtunnel user login` first');
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// Resolve devtunnel's persistent id BEFORE building args so the
|
|
523
|
-
// CLI is invoked with `host <id>` and the public URL stays stable
|
|
524
|
-
// across restarts. Cloudflared has no equivalent here — quick
|
|
525
|
-
// tunnels always rotate URLs and named tunnels require a CF
|
|
526
|
-
// account + DNS setup that's outside ccsm's scope.
|
|
527
|
-
let tunnelId = null;
|
|
528
|
-
if (provider === 'devtunnel') {
|
|
529
|
-
tunnelId = await ensureDevtunnelTunnelId(exe);
|
|
530
|
-
if (tunnelId) {
|
|
531
|
-
// Make sure the port is in the tunnel's port list and anonymous
|
|
532
|
-
// access is enabled. `devtunnel host <id>` doesn't accept port /
|
|
533
|
-
// access flags after the tunnel exists, so this has to be done
|
|
534
|
-
// out of band before host.
|
|
535
|
-
await configureDevtunnelTunnel(exe, tunnelId, port);
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
const args = p.args(port, { tunnelId });
|
|
540
|
-
child = spawn(exe, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
541
|
-
entry = { provider, child, url: null, startedAt: Date.now(), log: [], tunnelId };
|
|
542
|
-
current = entry;
|
|
543
|
-
} finally {
|
|
544
|
-
starting = false;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
const pushLog = (line) => {
|
|
548
|
-
entry.log.push(line);
|
|
549
|
-
if (entry.log.length > 200) entry.log.shift();
|
|
550
|
-
if (!entry.url) {
|
|
551
|
-
const m = line.match(p.urlRegex);
|
|
552
|
-
if (m) entry.url = m[0];
|
|
553
|
-
}
|
|
554
|
-
};
|
|
555
|
-
child.stdout.setEncoding('utf8');
|
|
556
|
-
child.stderr.setEncoding('utf8');
|
|
557
|
-
child.stdout.on('data', (chunk) => chunk.split(/\r?\n/).forEach((l) => l && pushLog(l)));
|
|
558
|
-
child.stderr.on('data', (chunk) => chunk.split(/\r?\n/).forEach((l) => l && pushLog(l)));
|
|
559
|
-
|
|
560
|
-
child.on('exit', (code, signal) => {
|
|
561
|
-
if (current === entry) current = null;
|
|
562
|
-
console.log(`[tunnel] ${provider} exited · code=${code} signal=${signal || ''}`);
|
|
563
|
-
});
|
|
564
|
-
child.on('error', (err) => {
|
|
565
|
-
if (current === entry) current = null;
|
|
566
|
-
console.error(`[tunnel] ${provider} spawn error`, err);
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
// Wait up to 25s for the URL to show up in stdout.
|
|
570
|
-
const deadline = Date.now() + 25_000;
|
|
571
|
-
while (Date.now() < deadline) {
|
|
572
|
-
if (entry.url) return await status();
|
|
573
|
-
if (!current || current !== entry) {
|
|
574
|
-
throw new Error('tunnel exited before reporting a URL · ' + entry.log.slice(-3).join(' / '));
|
|
575
|
-
}
|
|
576
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
577
|
-
}
|
|
578
|
-
// Timed out — keep the child alive (the URL might appear later) but
|
|
579
|
-
// tell the caller we don't have one yet.
|
|
580
|
-
return await status();
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
function stop() {
|
|
584
|
-
if (!current) return false;
|
|
585
|
-
try { current.child.kill(); } catch {}
|
|
586
|
-
current = null;
|
|
587
|
-
return true;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
// Background install via winget. Returns immediately with the spawned
|
|
591
|
-
// pid; the actual install completes asynchronously. Caller polls
|
|
592
|
-
// probe() to learn when the binary appears on disk.
|
|
593
|
-
function installViaWinget(provider) {
|
|
594
|
-
const p = PROVIDERS[provider];
|
|
595
|
-
if (!p) throw new Error(`unknown provider: ${provider}`);
|
|
596
|
-
if (process.platform !== 'win32') throw new Error('winget install only supported on Windows');
|
|
597
|
-
const child = spawn('winget', [
|
|
598
|
-
'install', p.wingetId,
|
|
599
|
-
'--accept-source-agreements',
|
|
600
|
-
'--accept-package-agreements',
|
|
601
|
-
'--silent',
|
|
602
|
-
], { stdio: 'ignore', detached: true, windowsHide: true });
|
|
603
|
-
child.unref();
|
|
604
|
-
return { provider, pid: child.pid };
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
module.exports = {
|
|
608
|
-
PROVIDERS,
|
|
609
|
-
probe,
|
|
610
|
-
status,
|
|
611
|
-
start,
|
|
612
|
-
stop,
|
|
613
|
-
installViaWinget,
|
|
614
|
-
getToken,
|
|
615
|
-
setToken,
|
|
616
|
-
startDevtunnelLogin,
|
|
617
|
-
cancelDevtunnelLogin,
|
|
618
|
-
clearDevtunnelLogin,
|
|
619
|
-
invalidateProbe,
|
|
620
|
-
resetDevtunnelTunnelId,
|
|
621
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Tunnel manager · spawns and supervises a cloudflared or devtunnel
|
|
4
|
+
// child to expose the local ccsm backend over a public URL. Captures
|
|
5
|
+
// the URL from stdout, exposes state to the API, and tears down the
|
|
6
|
+
// child on stop / server shutdown.
|
|
7
|
+
//
|
|
8
|
+
// Two providers, each with their own CLI quirk:
|
|
9
|
+
// cloudflared · `cloudflared tunnel --url http://localhost:<port>`
|
|
10
|
+
// Prints `https://*.trycloudflare.com` somewhere in
|
|
11
|
+
// the boot banner. No login required for quick tunnels.
|
|
12
|
+
// devtunnel · `devtunnel host -p <port> --allow-anonymous`
|
|
13
|
+
// Prints `Connect via browser: https://*.devtunnels.ms`.
|
|
14
|
+
// Host must be logged in (`devtunnel user login`).
|
|
15
|
+
//
|
|
16
|
+
// Discovery: scan PATH first via `where.exe`, then known winget install
|
|
17
|
+
// dirs. Returns the absolute path so we can spawn the child regardless
|
|
18
|
+
// of whether the post-install PATH refresh has reached this Node process.
|
|
19
|
+
|
|
20
|
+
const { spawn, execFile } = require('node:child_process');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const { promisify } = require('node:util');
|
|
25
|
+
const { loadConfig, saveConfig } = require('./config');
|
|
26
|
+
const execFileP = promisify(execFile);
|
|
27
|
+
|
|
28
|
+
const PROVIDERS = {
|
|
29
|
+
cloudflared: {
|
|
30
|
+
id: 'cloudflared',
|
|
31
|
+
label: 'Cloudflare Tunnel',
|
|
32
|
+
wingetId: 'Cloudflare.cloudflared',
|
|
33
|
+
binary: 'cloudflared.exe',
|
|
34
|
+
knownPaths: [
|
|
35
|
+
path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'cloudflared', 'cloudflared.exe'),
|
|
36
|
+
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'cloudflared', 'cloudflared.exe'),
|
|
37
|
+
],
|
|
38
|
+
args: (port) => ['tunnel', '--url', `http://localhost:${port}`],
|
|
39
|
+
urlRegex: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i,
|
|
40
|
+
},
|
|
41
|
+
devtunnel: {
|
|
42
|
+
id: 'devtunnel',
|
|
43
|
+
label: 'Microsoft Dev Tunnel',
|
|
44
|
+
wingetId: 'Microsoft.devtunnel',
|
|
45
|
+
binary: 'devtunnel.exe',
|
|
46
|
+
knownPaths: [
|
|
47
|
+
path.join(process.env['LOCALAPPDATA'] || '', 'Microsoft', 'WinGet', 'Packages',
|
|
48
|
+
'Microsoft.devtunnel_Microsoft.Winget.Source_8wekyb3d8bbwe', 'devtunnel.exe'),
|
|
49
|
+
],
|
|
50
|
+
args: (port, opts = {}) => {
|
|
51
|
+
// With a persistent (named) tunnel, ports + anonymous access are
|
|
52
|
+
// configured ahead of time via `devtunnel port create` and
|
|
53
|
+
// `devtunnel access create` (see ensureDevtunnelTunnelId +
|
|
54
|
+
// configureDevtunnelTunnel). The host call then takes ONLY the
|
|
55
|
+
// tunnel id — passing -p or --allow-anonymous here makes the CLI
|
|
56
|
+
// try to batch-update the existing tunnel and the service rejects
|
|
57
|
+
// it ("Batch update of ports is not supported"). Anonymous +
|
|
58
|
+
// ephemeral mode keeps the legacy flags as a fallback when no
|
|
59
|
+
// tunnel id has been minted.
|
|
60
|
+
if (opts.tunnelId) {
|
|
61
|
+
return ['host', opts.tunnelId];
|
|
62
|
+
}
|
|
63
|
+
return ['host', '-p', String(port), '--allow-anonymous'];
|
|
64
|
+
},
|
|
65
|
+
// devtunnel sometimes prints two URL forms for the same tunnel:
|
|
66
|
+
// https://<id>.<region>.devtunnels.ms:<port> ← port as suffix
|
|
67
|
+
// https://<id>-<port>.<region>.devtunnels.ms ← port baked into
|
|
68
|
+
// the subdomain
|
|
69
|
+
// The plain `<id>.<region>` form (without a `:<port>` suffix) is
|
|
70
|
+
// unreachable — browsers default to 443 and the tunnel serves
|
|
71
|
+
// nothing there, so we get a 404. We always want the subdomain-
|
|
72
|
+
// port form. Force the regex to require `-<digits>` in the
|
|
73
|
+
// subdomain so the bare form (which our old greedy match would
|
|
74
|
+
// capture first) gets skipped.
|
|
75
|
+
urlRegex: /https:\/\/[a-z0-9]+-\d+\.[a-z0-9-]+\.devtunnels\.ms/i,
|
|
76
|
+
needsLogin: true,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// In-memory state. Single tunnel at a time — switching providers tears
|
|
81
|
+
// down the old one first.
|
|
82
|
+
let current = null; // { provider, child, url, startedAt, log: string[] }
|
|
83
|
+
let starting = false; // True while start() is mid-spawn. devtunnel does
|
|
84
|
+
// ~10-20s of async create/configure BEFORE `current`
|
|
85
|
+
// is assigned, so the `if (current)` guard alone
|
|
86
|
+
// can't stop a second concurrent start() (boot
|
|
87
|
+
// auto-start racing a manual click) from spawning a
|
|
88
|
+
// duplicate child. This flag closes that window.
|
|
89
|
+
let token = null; // Remote-access bearer token. Null = no remote
|
|
90
|
+
// access enforced. Set via setToken() or by the
|
|
91
|
+
// start() call. Server.js middleware reads via
|
|
92
|
+
// getToken().
|
|
93
|
+
let login = null; // Pending interactive `devtunnel user login -d`
|
|
94
|
+
// flow · { child, mode, lines, url, code, status,
|
|
95
|
+
// startedAt, finishedAt, error, user }. Single
|
|
96
|
+
// flow at a time. See startDevtunnelLogin().
|
|
97
|
+
|
|
98
|
+
function getToken() { return token; }
|
|
99
|
+
function setToken(t) { token = t ? String(t) : null; return token; }
|
|
100
|
+
|
|
101
|
+
async function findBinary(provider) {
|
|
102
|
+
const p = PROVIDERS[provider];
|
|
103
|
+
if (!p) return null;
|
|
104
|
+
// PATH lookup via where.exe — works regardless of how the CLI got
|
|
105
|
+
// installed (winget, choco, manual, in-tree). windowsHide stops the
|
|
106
|
+
// conhost window from flashing.
|
|
107
|
+
try {
|
|
108
|
+
const { stdout } = await execFileP('where.exe', [p.binary], { windowsHide: true });
|
|
109
|
+
const out = String(stdout).trim().split(/\r?\n/)[0];
|
|
110
|
+
if (out && fs.existsSync(out)) return out;
|
|
111
|
+
} catch { /* not on PATH */ }
|
|
112
|
+
// Fall back to known install locations (winget's PATH update doesn't
|
|
113
|
+
// reach the already-running Node process).
|
|
114
|
+
for (const candidate of p.knownPaths) {
|
|
115
|
+
if (candidate && fs.existsSync(candidate)) return candidate;
|
|
116
|
+
}
|
|
117
|
+
// For devtunnel: winget's package dir has a version suffix that
|
|
118
|
+
// changes between releases. Glob it.
|
|
119
|
+
if (provider === 'devtunnel') {
|
|
120
|
+
const base = path.join(process.env['LOCALAPPDATA'] || '', 'Microsoft', 'WinGet', 'Packages');
|
|
121
|
+
try {
|
|
122
|
+
for (const entry of fs.readdirSync(base)) {
|
|
123
|
+
if (entry.startsWith('Microsoft.devtunnel_')) {
|
|
124
|
+
const candidate = path.join(base, entry, 'devtunnel.exe');
|
|
125
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function getVersion(provider, exe) {
|
|
134
|
+
try {
|
|
135
|
+
const { stdout } = await execFileP(exe, ['--version'], { windowsHide: true });
|
|
136
|
+
return String(stdout).trim().split(/\r?\n/)[0] || null;
|
|
137
|
+
} catch { return null; }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function checkDevtunnelLogin(exe) {
|
|
141
|
+
try {
|
|
142
|
+
const { stdout } = await execFileP(exe, ['user', 'show'], { windowsHide: true, timeout: 5000 });
|
|
143
|
+
// "Logged in as <email> using <provider>." vs "Not logged in"
|
|
144
|
+
const m = String(stdout).trim().match(/Logged in as (\S+)/);
|
|
145
|
+
if (m) return { loggedIn: true, user: m[1] };
|
|
146
|
+
return { loggedIn: false, user: null };
|
|
147
|
+
} catch {
|
|
148
|
+
return { loggedIn: false, user: null };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Mint (or read back) a persistent devtunnel id and stash it in
|
|
153
|
+
// config.devtunnel.tunnelId so subsequent `devtunnel host` invocations
|
|
154
|
+
// reuse the same public URL.
|
|
155
|
+
//
|
|
156
|
+
// Why: every `devtunnel host` without a tunnel id allocates a fresh
|
|
157
|
+
// random id, which means a fresh subdomain and therefore a fresh
|
|
158
|
+
// browser origin on the remote side. localStorage is per-origin, so
|
|
159
|
+
// approved device ids get orphaned and the remote user has to re-
|
|
160
|
+
// register from scratch on every tunnel restart.
|
|
161
|
+
//
|
|
162
|
+
// Behaviour:
|
|
163
|
+
// - If config already has a tunnelId, return it verbatim. We do NOT
|
|
164
|
+
// validate it against `devtunnel list` here — the host child will
|
|
165
|
+
// fail loudly if the id was deleted from another machine, and
|
|
166
|
+
// callers can drop it and retry by deleting config.devtunnel.
|
|
167
|
+
// - Otherwise call `devtunnel create --json`, capture the .tunnelId
|
|
168
|
+
// from the response, persist it, return it.
|
|
169
|
+
// - If the create call fails for any reason, return null and let
|
|
170
|
+
// start() fall back to a temporary tunnel — degraded but working.
|
|
171
|
+
async function ensureDevtunnelTunnelId(exe) {
|
|
172
|
+
try {
|
|
173
|
+
const cfg = await loadConfig();
|
|
174
|
+
if (cfg?.devtunnel?.tunnelId) return cfg.devtunnel.tunnelId;
|
|
175
|
+
const { stdout } = await execFileP(exe, ['create', '--json'], {
|
|
176
|
+
windowsHide: true,
|
|
177
|
+
timeout: 20_000,
|
|
178
|
+
});
|
|
179
|
+
let id = null;
|
|
180
|
+
try {
|
|
181
|
+
const j = JSON.parse(String(stdout));
|
|
182
|
+
// The CLI's JSON shape varies a bit by version; the canonical
|
|
183
|
+
// field is `tunnelId` but older builds nest it under `tunnel`.
|
|
184
|
+
id = j.tunnelId || j.tunnel?.tunnelId || j.id || null;
|
|
185
|
+
} catch {
|
|
186
|
+
// Fall back to scraping the plain-text output for `Tunnel ID: foo`
|
|
187
|
+
const m = String(stdout).match(/Tunnel ID:\s*(\S+)/i);
|
|
188
|
+
if (m) id = m[1];
|
|
189
|
+
}
|
|
190
|
+
if (!id) return null;
|
|
191
|
+
// Persist for next time. Swallow save errors — worst case the next
|
|
192
|
+
// start re-allocates one.
|
|
193
|
+
try { await saveConfig({ devtunnel: { tunnelId: id } }); }
|
|
194
|
+
catch (e) { console.warn('[tunnel] persist devtunnel id failed:', e.message); }
|
|
195
|
+
return id;
|
|
196
|
+
} catch (e) {
|
|
197
|
+
console.warn('[tunnel] ensureDevtunnelTunnelId failed:', e.message);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Bring a named tunnel into shape for hosting: make sure the port is
|
|
203
|
+
// in its port list and anonymous access is allowed. Idempotent — the
|
|
204
|
+
// "already exists" errors from `port create` / `access create` are
|
|
205
|
+
// silently absorbed. Required because `devtunnel host <id>` (which
|
|
206
|
+
// we use for persistent tunnels) doesn't accept -p / --allow-anonymous
|
|
207
|
+
// flags after the tunnel exists — passing them triggers the service
|
|
208
|
+
// to reject the call with "Batch update of ports is not supported".
|
|
209
|
+
async function configureDevtunnelTunnel(exe, tunnelId, port) {
|
|
210
|
+
if (!tunnelId) return;
|
|
211
|
+
// Add port. If it already exists the CLI prints an error; swallow it.
|
|
212
|
+
try {
|
|
213
|
+
await execFileP(exe, ['port', 'create', tunnelId, '-p', String(port)], {
|
|
214
|
+
windowsHide: true,
|
|
215
|
+
timeout: 10_000,
|
|
216
|
+
});
|
|
217
|
+
} catch (e) {
|
|
218
|
+
// Only surface failures that aren't "already exists" — those mean
|
|
219
|
+
// something is genuinely broken (auth lost, wrong tunnel id, etc.).
|
|
220
|
+
const msg = String(e.stderr || e.stdout || e.message || '');
|
|
221
|
+
if (!/already/i.test(msg)) {
|
|
222
|
+
console.warn('[tunnel] devtunnel port create failed:', msg.slice(0, 200));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Add anonymous access. Same idempotency story.
|
|
226
|
+
try {
|
|
227
|
+
await execFileP(exe, ['access', 'create', tunnelId, '-a'], {
|
|
228
|
+
windowsHide: true,
|
|
229
|
+
timeout: 10_000,
|
|
230
|
+
});
|
|
231
|
+
} catch (e) {
|
|
232
|
+
const msg = String(e.stderr || e.stdout || e.message || '');
|
|
233
|
+
if (!/already/i.test(msg)) {
|
|
234
|
+
console.warn('[tunnel] devtunnel access create failed:', msg.slice(0, 200));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Delete the persisted devtunnel id (and the remote tunnel resource
|
|
240
|
+
// itself, best-effort). Used by the Reset affordance in the Remote
|
|
241
|
+
// page when the user wants to rotate the public URL.
|
|
242
|
+
async function resetDevtunnelTunnelId() {
|
|
243
|
+
let prevId = null;
|
|
244
|
+
try {
|
|
245
|
+
const cfg = await loadConfig();
|
|
246
|
+
prevId = cfg?.devtunnel?.tunnelId || null;
|
|
247
|
+
} catch {}
|
|
248
|
+
try { await saveConfig({ devtunnel: { tunnelId: null } }); } catch {}
|
|
249
|
+
if (prevId) {
|
|
250
|
+
const exe = await findBinary('devtunnel');
|
|
251
|
+
if (exe) {
|
|
252
|
+
// Detach from the tunnel resource on the Azure side too; failure
|
|
253
|
+
// is fine (resource may already be gone). 5s cap.
|
|
254
|
+
try { await execFileP(exe, ['delete', prevId, '-f'], { windowsHide: true, timeout: 5_000 }); }
|
|
255
|
+
catch { /* ignore */ }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return { previousTunnelId: prevId };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Probe is cached. Each cold call shells out 4-6 sync execs (where.exe,
|
|
262
|
+
// --version per provider, `devtunnel user show`) — cumulatively ~1s of
|
|
263
|
+
// blocked event loop on Windows. The Remote page polls /api/tunnel/status
|
|
264
|
+
// every 2.5s, and tunnel.start() returns a fresh status() — without this
|
|
265
|
+
// cache, the loop is frozen ~40% of the time during normal operation,
|
|
266
|
+
// and /api/health probes from other clients time out.
|
|
267
|
+
const PROBE_TTL_MS = 30_000;
|
|
268
|
+
let probeCache = null;
|
|
269
|
+
let probeCacheAt = 0;
|
|
270
|
+
|
|
271
|
+
async function probe(force = false) {
|
|
272
|
+
if (!force && probeCache && Date.now() - probeCacheAt < PROBE_TTL_MS) {
|
|
273
|
+
return probeCache;
|
|
274
|
+
}
|
|
275
|
+
// All providers in parallel; within each, --version and the
|
|
276
|
+
// devtunnel `user show` check run together too. Cold probe drops
|
|
277
|
+
// from ~1.5s serial to ~700ms (capped by the slowest exec —
|
|
278
|
+
// typically `devtunnel user show`).
|
|
279
|
+
const ids = Object.keys(PROVIDERS);
|
|
280
|
+
const results = await Promise.all(ids.map(async (id) => {
|
|
281
|
+
const exe = await findBinary(id);
|
|
282
|
+
const p = { installed: !!exe, exe, version: null };
|
|
283
|
+
if (exe) {
|
|
284
|
+
const tasks = [getVersion(id, exe)];
|
|
285
|
+
if (id === 'devtunnel') tasks.push(checkDevtunnelLogin(exe));
|
|
286
|
+
const [version, devUser] = await Promise.all(tasks);
|
|
287
|
+
p.version = version;
|
|
288
|
+
if (devUser) Object.assign(p, devUser);
|
|
289
|
+
}
|
|
290
|
+
return [id, p];
|
|
291
|
+
}));
|
|
292
|
+
probeCache = Object.fromEntries(results);
|
|
293
|
+
probeCacheAt = Date.now();
|
|
294
|
+
return probeCache;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Kick a single background probe refresh, deduped so overlapping callers
|
|
298
|
+
// share one shell-out. Never throws.
|
|
299
|
+
let probeRefreshing = null;
|
|
300
|
+
function kickProbeRefresh() {
|
|
301
|
+
if (!probeRefreshing) {
|
|
302
|
+
probeRefreshing = probe(true)
|
|
303
|
+
.catch(() => probeCache)
|
|
304
|
+
.finally(() => { probeRefreshing = null; });
|
|
305
|
+
}
|
|
306
|
+
return probeRefreshing;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Invalidate the cache when callers know the on-disk state likely changed
|
|
310
|
+
// (post-install, post-login, etc.) and immediately start repopulating it
|
|
311
|
+
// in the background so the next status poll is already fresh.
|
|
312
|
+
function invalidateProbe() { probeCache = null; probeCacheAt = 0; kickProbeRefresh(); }
|
|
313
|
+
|
|
314
|
+
// Stale-while-revalidate accessor used by status(). NEVER shells out in
|
|
315
|
+
// the request path: returns whatever's cached right now (possibly stale,
|
|
316
|
+
// or null on the very first call before the boot prewarm lands) and kicks
|
|
317
|
+
// off a background refresh when the cache is stale. This is what keeps
|
|
318
|
+
// /api/tunnel/status — and therefore the whole Remote page's live refresh
|
|
319
|
+
// (plus the device list, which the client used to bundle into the same
|
|
320
|
+
// round-trip) — from stalling ~700ms every time the 30s cache expires.
|
|
321
|
+
function probeCachedSWR() {
|
|
322
|
+
const fresh = probeCache && Date.now() - probeCacheAt < PROBE_TTL_MS;
|
|
323
|
+
if (!fresh) kickProbeRefresh();
|
|
324
|
+
return probeCache;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function status() {
|
|
328
|
+
// One config read serves both the persisted tunnelId and the
|
|
329
|
+
// auto-start prefs the Remote page's toggle reflects.
|
|
330
|
+
let cfg = null;
|
|
331
|
+
try { cfg = await loadConfig(); } catch { /* fall back to nulls below */ }
|
|
332
|
+
return {
|
|
333
|
+
providers: probeCachedSWR(),
|
|
334
|
+
running: !!current,
|
|
335
|
+
provider: current?.provider || null,
|
|
336
|
+
url: current?.url || null,
|
|
337
|
+
startedAt: current?.startedAt || null,
|
|
338
|
+
pid: current?.child?.pid || null,
|
|
339
|
+
log: current?.log?.slice(-50) || [],
|
|
340
|
+
token,
|
|
341
|
+
tunnelId: current?.tunnelId || cfg?.devtunnel?.tunnelId || null,
|
|
342
|
+
// Persisted auto-start prefs — surfaced so the toggle renders the
|
|
343
|
+
// right state across reloads (the page already polls /status).
|
|
344
|
+
autoStart: cfg?.tunnel?.autoStart ?? false,
|
|
345
|
+
autoStartProvider: cfg?.tunnel?.provider ?? null,
|
|
346
|
+
// Token is echoed back so the Remote page can render the
|
|
347
|
+
// pre-built share URL. The route itself is token-protected
|
|
348
|
+
// (the middleware blocks non-loopback callers without it), so
|
|
349
|
+
// anyone reaching this endpoint already knows the token.
|
|
350
|
+
login: loginSnapshot(),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---- devtunnel interactive login (device-code flow) ----
|
|
355
|
+
//
|
|
356
|
+
// `devtunnel user login -d` prints a Microsoft device-code line then
|
|
357
|
+
// polls Azure until the user authenticates in a browser (or until it
|
|
358
|
+
// times out). We spawn it as a child, parse the URL + code out of the
|
|
359
|
+
// first informational lines, and expose progress via /api/tunnel/status
|
|
360
|
+
// so the Remote page can render a one-click sign-in panel instead of
|
|
361
|
+
// asking the user to paste a command into a terminal.
|
|
362
|
+
//
|
|
363
|
+
// Two modes: 'microsoft' (default, -d) and 'github' (-g -d). GitHub is
|
|
364
|
+
// only worth offering if the user explicitly picks it — Microsoft
|
|
365
|
+
// device code works for Entra ID / personal MS accounts and is what
|
|
366
|
+
// most people land on.
|
|
367
|
+
//
|
|
368
|
+
// State machine:
|
|
369
|
+
// running → child alive, waiting for user
|
|
370
|
+
// done → child exited 0; probe cache is invalidated so the next
|
|
371
|
+
// providers map shows `loggedIn: true`
|
|
372
|
+
// error → child exited non-zero or crashed
|
|
373
|
+
// canceled → cancelDevtunnelLogin() killed the child
|
|
374
|
+
function loginSnapshot() {
|
|
375
|
+
if (!login) return null;
|
|
376
|
+
return {
|
|
377
|
+
mode: login.mode,
|
|
378
|
+
status: login.status,
|
|
379
|
+
url: login.url,
|
|
380
|
+
code: login.code,
|
|
381
|
+
error: login.error || null,
|
|
382
|
+
user: login.user || null,
|
|
383
|
+
startedAt: login.startedAt,
|
|
384
|
+
finishedAt: login.finishedAt || null,
|
|
385
|
+
lines: login.lines.slice(-40),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function startDevtunnelLogin({ mode = 'microsoft' } = {}) {
|
|
390
|
+
if (login && login.status === 'running') {
|
|
391
|
+
// Already in flight · return the snapshot rather than throwing so
|
|
392
|
+
// a double-click on Sign in doesn't error out.
|
|
393
|
+
return loginSnapshot();
|
|
394
|
+
}
|
|
395
|
+
const exe = await findBinary('devtunnel');
|
|
396
|
+
if (!exe) throw new Error('Microsoft Dev Tunnel is not installed');
|
|
397
|
+
// Starting a fresh login drops any existing credentials from disk
|
|
398
|
+
// before the new flow finishes — so the cached probe ("signed in as
|
|
399
|
+
// old-user") is now lying. Invalidate so the next /status round-
|
|
400
|
+
// trip re-shells `devtunnel user show` and the provider line flips
|
|
401
|
+
// to "not signed in" while the device-code panel is up.
|
|
402
|
+
invalidateProbe();
|
|
403
|
+
|
|
404
|
+
// -d picks the Microsoft device-code flow; -g switches it to GitHub.
|
|
405
|
+
// We deliberately stay on device code (no `--use-browser`) — the user
|
|
406
|
+
// may be driving the Remote page from a phone where opening a local
|
|
407
|
+
// browser on the host machine doesn't help.
|
|
408
|
+
const args = mode === 'github' ? ['user', 'login', '-g', '-d'] : ['user', 'login', '-d'];
|
|
409
|
+
const child = spawn(exe, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
410
|
+
const entry = {
|
|
411
|
+
child,
|
|
412
|
+
mode,
|
|
413
|
+
lines: [],
|
|
414
|
+
url: null,
|
|
415
|
+
code: null,
|
|
416
|
+
status: 'running',
|
|
417
|
+
error: null,
|
|
418
|
+
user: null,
|
|
419
|
+
startedAt: Date.now(),
|
|
420
|
+
finishedAt: null,
|
|
421
|
+
};
|
|
422
|
+
login = entry;
|
|
423
|
+
|
|
424
|
+
const URL_RE = /https?:\/\/\S+/i;
|
|
425
|
+
// Microsoft device-code prompt examples (have varied over CLI
|
|
426
|
+
// versions): "...enter the code ABCD-1234 to authenticate"
|
|
427
|
+
// "...code XXXXXXXXX..."
|
|
428
|
+
// GitHub device flow uses 8 chars with a dash, e.g. `XXXX-XXXX`.
|
|
429
|
+
const CODE_RE = /\b([A-Z0-9]{4,}-?[A-Z0-9]{3,})\b/;
|
|
430
|
+
const LOGGED = /Logged in as (\S+)/i;
|
|
431
|
+
|
|
432
|
+
const ingest = (line) => {
|
|
433
|
+
if (!line) return;
|
|
434
|
+
entry.lines.push(line);
|
|
435
|
+
if (entry.lines.length > 100) entry.lines.shift();
|
|
436
|
+
if (!entry.url) {
|
|
437
|
+
const m = line.match(URL_RE);
|
|
438
|
+
if (m) entry.url = m[0].replace(/[.,)]+$/, '');
|
|
439
|
+
}
|
|
440
|
+
if (!entry.code && /code/i.test(line)) {
|
|
441
|
+
// Skip URL-bearing fragments before extracting the code so we
|
|
442
|
+
// don't grab a uuid-ish segment out of the device login URL.
|
|
443
|
+
const sans = line.replace(URL_RE, '');
|
|
444
|
+
const m = sans.match(CODE_RE);
|
|
445
|
+
if (m) entry.code = m[1];
|
|
446
|
+
}
|
|
447
|
+
const u = line.match(LOGGED);
|
|
448
|
+
if (u) entry.user = u[1];
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
child.stdout.setEncoding('utf8');
|
|
452
|
+
child.stderr.setEncoding('utf8');
|
|
453
|
+
child.stdout.on('data', (c) => c.split(/\r?\n/).forEach(ingest));
|
|
454
|
+
child.stderr.on('data', (c) => c.split(/\r?\n/).forEach(ingest));
|
|
455
|
+
|
|
456
|
+
child.on('exit', (code, signal) => {
|
|
457
|
+
entry.finishedAt = Date.now();
|
|
458
|
+
if (entry.status === 'canceled') {
|
|
459
|
+
// already terminal; leave as-is
|
|
460
|
+
} else if (code === 0) {
|
|
461
|
+
entry.status = 'done';
|
|
462
|
+
// The just-completed login means the probe cache is now lying
|
|
463
|
+
// about `loggedIn: false`. Drop it so the next status() call
|
|
464
|
+
// re-shells `devtunnel user show` and the UI flips to signed-in.
|
|
465
|
+
invalidateProbe();
|
|
466
|
+
} else {
|
|
467
|
+
entry.status = 'error';
|
|
468
|
+
entry.error = `devtunnel exited code=${code}${signal ? ` signal=${signal}` : ''}`;
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
child.on('error', (err) => {
|
|
472
|
+
entry.status = 'error';
|
|
473
|
+
entry.error = String(err && err.message || err);
|
|
474
|
+
entry.finishedAt = Date.now();
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
return loginSnapshot();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function cancelDevtunnelLogin() {
|
|
481
|
+
if (!login || login.status !== 'running') return loginSnapshot();
|
|
482
|
+
login.status = 'canceled';
|
|
483
|
+
login.finishedAt = Date.now();
|
|
484
|
+
try { login.child.kill(); } catch {}
|
|
485
|
+
return loginSnapshot();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function clearDevtunnelLogin() {
|
|
489
|
+
if (login && login.status === 'running') {
|
|
490
|
+
try { login.child.kill(); } catch {}
|
|
491
|
+
}
|
|
492
|
+
login = null;
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Spawn the tunnel CLI. Resolves once we've parsed the public URL out
|
|
497
|
+
// of stdout (with a timeout safety net). Throws if the CLI isn't
|
|
498
|
+
// installed, the provider is unknown, or another tunnel is running.
|
|
499
|
+
async function start({ provider, port }) {
|
|
500
|
+
if (current) throw new Error('tunnel already running');
|
|
501
|
+
if (starting) throw new Error('tunnel is already starting');
|
|
502
|
+
const p = PROVIDERS[provider];
|
|
503
|
+
if (!p) throw new Error(`unknown provider: ${provider}`);
|
|
504
|
+
|
|
505
|
+
// Hold the `starting` flag across the async setup below. devtunnel's
|
|
506
|
+
// create/configure can take 10-20s, all of it BEFORE `current` is
|
|
507
|
+
// assigned — without this flag a second start() (boot auto-start vs.
|
|
508
|
+
// a manual click) would slip past the `if (current)` guard and spawn
|
|
509
|
+
// a duplicate child. Cleared the moment `current` is set, after which
|
|
510
|
+
// the `if (current)` guard alone is sufficient.
|
|
511
|
+
starting = true;
|
|
512
|
+
let entry;
|
|
513
|
+
let child;
|
|
514
|
+
try {
|
|
515
|
+
const exe = await findBinary(provider);
|
|
516
|
+
if (!exe) throw new Error(`${p.label} is not installed`);
|
|
517
|
+
if (provider === 'devtunnel') {
|
|
518
|
+
const { loggedIn } = await checkDevtunnelLogin(exe);
|
|
519
|
+
if (!loggedIn) throw new Error('devtunnel requires login — run `devtunnel user login` first');
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Resolve devtunnel's persistent id BEFORE building args so the
|
|
523
|
+
// CLI is invoked with `host <id>` and the public URL stays stable
|
|
524
|
+
// across restarts. Cloudflared has no equivalent here — quick
|
|
525
|
+
// tunnels always rotate URLs and named tunnels require a CF
|
|
526
|
+
// account + DNS setup that's outside ccsm's scope.
|
|
527
|
+
let tunnelId = null;
|
|
528
|
+
if (provider === 'devtunnel') {
|
|
529
|
+
tunnelId = await ensureDevtunnelTunnelId(exe);
|
|
530
|
+
if (tunnelId) {
|
|
531
|
+
// Make sure the port is in the tunnel's port list and anonymous
|
|
532
|
+
// access is enabled. `devtunnel host <id>` doesn't accept port /
|
|
533
|
+
// access flags after the tunnel exists, so this has to be done
|
|
534
|
+
// out of band before host.
|
|
535
|
+
await configureDevtunnelTunnel(exe, tunnelId, port);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const args = p.args(port, { tunnelId });
|
|
540
|
+
child = spawn(exe, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
541
|
+
entry = { provider, child, url: null, startedAt: Date.now(), log: [], tunnelId };
|
|
542
|
+
current = entry;
|
|
543
|
+
} finally {
|
|
544
|
+
starting = false;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const pushLog = (line) => {
|
|
548
|
+
entry.log.push(line);
|
|
549
|
+
if (entry.log.length > 200) entry.log.shift();
|
|
550
|
+
if (!entry.url) {
|
|
551
|
+
const m = line.match(p.urlRegex);
|
|
552
|
+
if (m) entry.url = m[0];
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
child.stdout.setEncoding('utf8');
|
|
556
|
+
child.stderr.setEncoding('utf8');
|
|
557
|
+
child.stdout.on('data', (chunk) => chunk.split(/\r?\n/).forEach((l) => l && pushLog(l)));
|
|
558
|
+
child.stderr.on('data', (chunk) => chunk.split(/\r?\n/).forEach((l) => l && pushLog(l)));
|
|
559
|
+
|
|
560
|
+
child.on('exit', (code, signal) => {
|
|
561
|
+
if (current === entry) current = null;
|
|
562
|
+
console.log(`[tunnel] ${provider} exited · code=${code} signal=${signal || ''}`);
|
|
563
|
+
});
|
|
564
|
+
child.on('error', (err) => {
|
|
565
|
+
if (current === entry) current = null;
|
|
566
|
+
console.error(`[tunnel] ${provider} spawn error`, err);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// Wait up to 25s for the URL to show up in stdout.
|
|
570
|
+
const deadline = Date.now() + 25_000;
|
|
571
|
+
while (Date.now() < deadline) {
|
|
572
|
+
if (entry.url) return await status();
|
|
573
|
+
if (!current || current !== entry) {
|
|
574
|
+
throw new Error('tunnel exited before reporting a URL · ' + entry.log.slice(-3).join(' / '));
|
|
575
|
+
}
|
|
576
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
577
|
+
}
|
|
578
|
+
// Timed out — keep the child alive (the URL might appear later) but
|
|
579
|
+
// tell the caller we don't have one yet.
|
|
580
|
+
return await status();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function stop() {
|
|
584
|
+
if (!current) return false;
|
|
585
|
+
try { current.child.kill(); } catch {}
|
|
586
|
+
current = null;
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Background install via winget. Returns immediately with the spawned
|
|
591
|
+
// pid; the actual install completes asynchronously. Caller polls
|
|
592
|
+
// probe() to learn when the binary appears on disk.
|
|
593
|
+
function installViaWinget(provider) {
|
|
594
|
+
const p = PROVIDERS[provider];
|
|
595
|
+
if (!p) throw new Error(`unknown provider: ${provider}`);
|
|
596
|
+
if (process.platform !== 'win32') throw new Error('winget install only supported on Windows');
|
|
597
|
+
const child = spawn('winget', [
|
|
598
|
+
'install', p.wingetId,
|
|
599
|
+
'--accept-source-agreements',
|
|
600
|
+
'--accept-package-agreements',
|
|
601
|
+
'--silent',
|
|
602
|
+
], { stdio: 'ignore', detached: true, windowsHide: true });
|
|
603
|
+
child.unref();
|
|
604
|
+
return { provider, pid: child.pid };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
module.exports = {
|
|
608
|
+
PROVIDERS,
|
|
609
|
+
probe,
|
|
610
|
+
status,
|
|
611
|
+
start,
|
|
612
|
+
stop,
|
|
613
|
+
installViaWinget,
|
|
614
|
+
getToken,
|
|
615
|
+
setToken,
|
|
616
|
+
startDevtunnelLogin,
|
|
617
|
+
cancelDevtunnelLogin,
|
|
618
|
+
clearDevtunnelLogin,
|
|
619
|
+
invalidateProbe,
|
|
620
|
+
resetDevtunnelTunnelId,
|
|
621
|
+
};
|