@bakapiano/ccsm 0.22.6 → 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.
Files changed (58) hide show
  1. package/CLAUDE.md +538 -538
  2. package/README.md +189 -189
  3. package/bin/ccsm.js +235 -235
  4. package/lib/cliActivity.js +139 -139
  5. package/lib/codexSeed.js +183 -183
  6. package/lib/config.js +279 -274
  7. package/lib/devices.js +229 -229
  8. package/lib/folders.js +124 -124
  9. package/lib/localCliSessions.js +519 -519
  10. package/lib/persistedSessions.js +129 -129
  11. package/lib/tunnel.js +621 -621
  12. package/lib/webTerminal.js +225 -225
  13. package/lib/workspace.js +233 -233
  14. package/package.json +57 -57
  15. package/public/css/base.css +99 -99
  16. package/public/css/cards.css +183 -183
  17. package/public/css/feedback.css +504 -504
  18. package/public/css/forms.css +453 -453
  19. package/public/css/layout.css +154 -154
  20. package/public/css/modal.css +190 -190
  21. package/public/css/responsive.css +176 -176
  22. package/public/css/sidebar.css +707 -707
  23. package/public/css/terminals.css +546 -546
  24. package/public/css/tokens.css +81 -81
  25. package/public/css/wco.css +196 -196
  26. package/public/css/widgets.css +2725 -2725
  27. package/public/index.html +152 -152
  28. package/public/js/api.js +371 -371
  29. package/public/js/backend.js +149 -149
  30. package/public/js/components/App.js +73 -73
  31. package/public/js/components/DirectoryPicker.js +203 -203
  32. package/public/js/components/EntityFormModal.js +153 -153
  33. package/public/js/components/Modal.js +57 -57
  34. package/public/js/components/OfflineBanner.js +67 -67
  35. package/public/js/components/PageTitleBar.js +13 -13
  36. package/public/js/components/PendingApprovalOverlay.js +128 -128
  37. package/public/js/components/Picker.js +179 -179
  38. package/public/js/components/Popover.js +55 -55
  39. package/public/js/components/RestartOverlay.js +36 -36
  40. package/public/js/components/Sidebar.js +380 -380
  41. package/public/js/components/TerminalInstance.js +28 -0
  42. package/public/js/components/useDragSort.js +67 -67
  43. package/public/js/dialog.js +67 -67
  44. package/public/js/icons.js +212 -212
  45. package/public/js/main.js +296 -296
  46. package/public/js/pages/AboutPage.js +90 -90
  47. package/public/js/pages/ConfigurePage.js +728 -713
  48. package/public/js/pages/LaunchPage.js +421 -421
  49. package/public/js/pages/RemotePage.js +743 -743
  50. package/public/js/pages/SessionsPage.js +53 -53
  51. package/public/js/state.js +335 -335
  52. package/scripts/dev.js +149 -149
  53. package/scripts/install.js +153 -153
  54. package/scripts/restart-helper.js +96 -96
  55. package/scripts/upgrade-helper.js +687 -687
  56. package/server.js +1820 -1807
  57. package/public/manifest.webmanifest +0 -25
  58. 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
+ };