@bakapiano/ccsm 0.21.5 → 0.22.1
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 +2 -0
- package/lib/config.js +23 -3
- package/lib/persistedSessions.js +5 -1
- package/lib/tunnel.js +55 -29
- package/package.json +1 -1
- package/public/css/terminals.css +38 -1
- package/public/css/widgets.css +26 -0
- package/public/js/api.js +14 -0
- package/public/js/components/Sidebar.js +3 -4
- package/public/js/icons.js +8 -0
- package/public/js/pages/LaunchPage.js +28 -11
- package/public/js/pages/RemotePage.js +37 -1
- package/public/js/pages/SessionsPage.js +106 -8
- package/server.js +115 -3
package/CLAUDE.md
CHANGED
|
@@ -311,6 +311,8 @@ allows `https://bakapiano.github.io` only — never `*`.
|
|
|
311
311
|
| GET | `/api/sessions` | list persisted sessions |
|
|
312
312
|
| PUT | `/api/sessions/:id` | rename / move to folder |
|
|
313
313
|
| DELETE | `/api/sessions/:id` | kill PTY + drop record |
|
|
314
|
+
| POST | `/api/sessions/:id/switch-cli` | change the persisted `cliId` for future resumes; current and target CLI must share `type` |
|
|
315
|
+
| POST | `/api/sessions/:id/stop` | kill the live PTY but keep the record; sets `manualStopped:true` so UI won't auto-resume |
|
|
314
316
|
| POST | `/api/sessions/new` | body `{cliId, cwd?, repos?, folderId?, title?}` — NDJSON stream (workspace · clone-progress · launched) |
|
|
315
317
|
| POST | `/api/sessions/:id/resume` | re-spawn at `cwd` with `cli.resumeIdArgs <id>` (fallback `resumeArgs`) |
|
|
316
318
|
| GET | `/api/cli-sessions/:type` | scan disk for unimported `claude`/`codex`/`copilot` sessions |
|
package/lib/config.js
CHANGED
|
@@ -91,6 +91,13 @@ const DEFAULTS = {
|
|
|
91
91
|
// remote browsers' approval records — stable. `devtunnel delete <id>`
|
|
92
92
|
// is invoked when the user explicitly rotates via the Reset button.
|
|
93
93
|
devtunnel: { tunnelId: null },
|
|
94
|
+
// Provider-agnostic tunnel prefs. When autoStart is on, the backend
|
|
95
|
+
// brings the tunnel up during its own startup (server.js boot hook) —
|
|
96
|
+
// NOT an OS-level autostart. token is persisted so share URLs survive
|
|
97
|
+
// a backend restart; it's written ONLY while autoStart is on and is
|
|
98
|
+
// stripped from /api/config so remote devices can't read it. provider
|
|
99
|
+
// is 'devtunnel' | 'cloudflared'.
|
|
100
|
+
tunnel: { autoStart: false, provider: null, token: null },
|
|
94
101
|
};
|
|
95
102
|
|
|
96
103
|
function ensureDataDir() {
|
|
@@ -122,9 +129,10 @@ migrateLegacyDataIfNeeded();
|
|
|
122
129
|
// object so callers don't mutate DEFAULTS.
|
|
123
130
|
function mergeWithDefaults(partial) {
|
|
124
131
|
const out = { ...DEFAULTS, ...partial };
|
|
125
|
-
// Deep-merge devtunnel so a partial save (just .tunnelId
|
|
126
|
-
// wipe sibling keys
|
|
132
|
+
// Deep-merge devtunnel + tunnel so a partial save (just .tunnelId, or
|
|
133
|
+
// just .autoStart) doesn't wipe sibling keys.
|
|
127
134
|
out.devtunnel = { ...DEFAULTS.devtunnel, ...(partial?.devtunnel || {}) };
|
|
135
|
+
out.tunnel = { ...DEFAULTS.tunnel, ...(partial?.tunnel || {}) };
|
|
128
136
|
// Drop v0.x keys that the new architecture doesn't use.
|
|
129
137
|
delete out.terminal;
|
|
130
138
|
delete out.commandShell;
|
|
@@ -237,7 +245,19 @@ async function saveConfig(partial) {
|
|
|
237
245
|
ensureDataDir();
|
|
238
246
|
return withFileLock(CONFIG_PATH, async () => {
|
|
239
247
|
const current = await loadConfig();
|
|
240
|
-
|
|
248
|
+
// mergeWithDefaults re-merges nested objects (devtunnel, tunnel)
|
|
249
|
+
// against DEFAULTS only, so a partial save like
|
|
250
|
+
// saveConfig({ tunnel: { autoStart: true } }) would reset the
|
|
251
|
+
// sibling token/provider back to defaults. Pre-merge the nested
|
|
252
|
+
// blocks against `current` so a partial update preserves siblings.
|
|
253
|
+
const merged = { ...current, ...partial };
|
|
254
|
+
if (partial && partial.devtunnel) {
|
|
255
|
+
merged.devtunnel = { ...current.devtunnel, ...partial.devtunnel };
|
|
256
|
+
}
|
|
257
|
+
if (partial && partial.tunnel) {
|
|
258
|
+
merged.tunnel = { ...current.tunnel, ...partial.tunnel };
|
|
259
|
+
}
|
|
260
|
+
const next = mergeWithDefaults(merged);
|
|
241
261
|
await atomicWriteJson(CONFIG_PATH, next);
|
|
242
262
|
return next;
|
|
243
263
|
});
|
package/lib/persistedSessions.js
CHANGED
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
// // newSessionIdArgs (claude, copilot); set
|
|
27
27
|
// // from disk for adopted sessions. Used
|
|
28
28
|
// // for precise --resume <id>.
|
|
29
|
+
// manualStopped: false, // true only when the user explicitly stopped
|
|
30
|
+
// // it from ccsm; prevents auto-resume until
|
|
31
|
+
// // they press Resume.
|
|
29
32
|
// }
|
|
30
33
|
|
|
31
34
|
const path = require('node:path');
|
|
@@ -73,6 +76,7 @@ async function create(opts) {
|
|
|
73
76
|
exitCode: null,
|
|
74
77
|
pid: null,
|
|
75
78
|
cliSessionId,
|
|
79
|
+
manualStopped: false,
|
|
76
80
|
};
|
|
77
81
|
list.push(entry);
|
|
78
82
|
await saveAll(list);
|
|
@@ -110,7 +114,7 @@ async function remove(id) {
|
|
|
110
114
|
// Convenience helpers used at runtime so callers don't have to do
|
|
111
115
|
// load/find/update/save themselves.
|
|
112
116
|
async function markRunning(id, pid) {
|
|
113
|
-
return update(id, { status: 'running', pid, exitedAt: null, exitCode: null, lastActiveAt: Date.now() });
|
|
117
|
+
return update(id, { status: 'running', pid, exitedAt: null, exitCode: null, manualStopped: false, lastActiveAt: Date.now() });
|
|
114
118
|
}
|
|
115
119
|
|
|
116
120
|
async function markExited(id, exitCode) {
|
package/lib/tunnel.js
CHANGED
|
@@ -80,6 +80,12 @@ const PROVIDERS = {
|
|
|
80
80
|
// In-memory state. Single tunnel at a time — switching providers tears
|
|
81
81
|
// down the old one first.
|
|
82
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.
|
|
83
89
|
let token = null; // Remote-access bearer token. Null = no remote
|
|
84
90
|
// access enforced. Set via setToken() or by the
|
|
85
91
|
// start() call. Server.js middleware reads via
|
|
@@ -319,6 +325,10 @@ function probeCachedSWR() {
|
|
|
319
325
|
}
|
|
320
326
|
|
|
321
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 */ }
|
|
322
332
|
return {
|
|
323
333
|
providers: probeCachedSWR(),
|
|
324
334
|
running: !!current,
|
|
@@ -328,10 +338,11 @@ async function status() {
|
|
|
328
338
|
pid: current?.child?.pid || null,
|
|
329
339
|
log: current?.log?.slice(-50) || [],
|
|
330
340
|
token,
|
|
331
|
-
tunnelId: current?.tunnelId ||
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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,
|
|
335
346
|
// Token is echoed back so the Remote page can render the
|
|
336
347
|
// pre-built share URL. The route itself is token-protected
|
|
337
348
|
// (the middleware blocks non-loopback callers without it), so
|
|
@@ -487,36 +498,51 @@ function clearDevtunnelLogin() {
|
|
|
487
498
|
// installed, the provider is unknown, or another tunnel is running.
|
|
488
499
|
async function start({ provider, port }) {
|
|
489
500
|
if (current) throw new Error('tunnel already running');
|
|
501
|
+
if (starting) throw new Error('tunnel is already starting');
|
|
490
502
|
const p = PROVIDERS[provider];
|
|
491
503
|
if (!p) throw new Error(`unknown provider: ${provider}`);
|
|
492
|
-
const exe = await findBinary(provider);
|
|
493
|
-
if (!exe) throw new Error(`${p.label} is not installed`);
|
|
494
|
-
if (provider === 'devtunnel') {
|
|
495
|
-
const { loggedIn } = await checkDevtunnelLogin(exe);
|
|
496
|
-
if (!loggedIn) throw new Error('devtunnel requires login — run `devtunnel user login` first');
|
|
497
|
-
}
|
|
498
504
|
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
await
|
|
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');
|
|
513
520
|
}
|
|
514
|
-
}
|
|
515
521
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
+
}
|
|
520
546
|
|
|
521
547
|
const pushLog = (line) => {
|
|
522
548
|
entry.log.push(line);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.1",
|
|
4
4
|
"description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
package/public/css/terminals.css
CHANGED
|
@@ -282,6 +282,7 @@
|
|
|
282
282
|
display: flex;
|
|
283
283
|
align-items: center;
|
|
284
284
|
flex-shrink: 0;
|
|
285
|
+
gap: 4px;
|
|
285
286
|
padding-right: 2px;
|
|
286
287
|
}
|
|
287
288
|
/* Close the gap to the page-title-bar above — only when there IS one.
|
|
@@ -335,6 +336,27 @@
|
|
|
335
336
|
.session-tab-add:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
|
336
337
|
.session-tab-add svg { width: 14px; height: 14px; }
|
|
337
338
|
|
|
339
|
+
.session-controls {
|
|
340
|
+
display: inline-flex;
|
|
341
|
+
align-items: center;
|
|
342
|
+
flex-shrink: 0;
|
|
343
|
+
}
|
|
344
|
+
.session-control-btn.danger {
|
|
345
|
+
color: #f4b8b8;
|
|
346
|
+
}
|
|
347
|
+
.session-control-btn.danger:hover:not(:disabled) {
|
|
348
|
+
color: #ffd1d1;
|
|
349
|
+
background: rgba(183, 63, 63, 0.22);
|
|
350
|
+
}
|
|
351
|
+
.session-control-btn:disabled {
|
|
352
|
+
opacity: .55;
|
|
353
|
+
cursor: wait;
|
|
354
|
+
}
|
|
355
|
+
.session-control-btn svg {
|
|
356
|
+
width: 13px;
|
|
357
|
+
height: 13px;
|
|
358
|
+
}
|
|
359
|
+
|
|
338
360
|
/* Kebab in the page-title-bar (top-right). Compact 24px square so it
|
|
339
361
|
doesn't dominate the masthead. In WCO mode the title-bar already
|
|
340
362
|
reserves padding-right for OS controls, so this slides cleanly to
|
|
@@ -359,7 +381,8 @@
|
|
|
359
381
|
}
|
|
360
382
|
/* Neutral-grey hover tint works on either strip colour (darkens the light
|
|
361
383
|
one, lightens the dark one) without needing a per-theme override. */
|
|
362
|
-
.session-menu-btn:hover { background: rgba(128, 128, 128, 0.2); color: var(--term-on); }
|
|
384
|
+
.session-menu-btn:hover:not(:disabled) { background: rgba(128, 128, 128, 0.2); color: var(--term-on); }
|
|
385
|
+
.session-menu-btn:disabled { opacity: .55; cursor: wait; }
|
|
363
386
|
.session-menu-btn svg { width: 16px; height: 16px; }
|
|
364
387
|
|
|
365
388
|
.session-menu {
|
|
@@ -391,6 +414,20 @@
|
|
|
391
414
|
.session-menu-item.danger { color: var(--danger, #b73f3f); }
|
|
392
415
|
.session-menu-item.danger:hover { background: rgba(183, 63, 63, 0.08); }
|
|
393
416
|
.session-menu-item svg { width: 14px; height: 14px; }
|
|
417
|
+
.session-menu-item img { width: 14px; height: 14px; }
|
|
418
|
+
.session-menu-separator {
|
|
419
|
+
height: 1px;
|
|
420
|
+
background: var(--border);
|
|
421
|
+
margin: 3px 2px;
|
|
422
|
+
}
|
|
423
|
+
.session-menu-label {
|
|
424
|
+
padding: 4px 10px 2px;
|
|
425
|
+
font-size: 11px;
|
|
426
|
+
line-height: 1.2;
|
|
427
|
+
color: var(--ink-muted);
|
|
428
|
+
text-transform: uppercase;
|
|
429
|
+
letter-spacing: 0;
|
|
430
|
+
}
|
|
394
431
|
|
|
395
432
|
.session-pane-head {
|
|
396
433
|
display: flex;
|
package/public/css/widgets.css
CHANGED
|
@@ -1838,6 +1838,32 @@
|
|
|
1838
1838
|
Running: a status banner (state · provider · uptime · stop link)
|
|
1839
1839
|
followed by a copyable share URL block and an optional collapsed
|
|
1840
1840
|
CLI log. */
|
|
1841
|
+
.tunnel-autostart {
|
|
1842
|
+
display: flex;
|
|
1843
|
+
flex-direction: column;
|
|
1844
|
+
gap: 6px;
|
|
1845
|
+
margin-bottom: var(--s-4);
|
|
1846
|
+
}
|
|
1847
|
+
.tunnel-autostart-row {
|
|
1848
|
+
display: flex;
|
|
1849
|
+
align-items: center;
|
|
1850
|
+
gap: 9px;
|
|
1851
|
+
cursor: pointer;
|
|
1852
|
+
font-size: 13px;
|
|
1853
|
+
color: var(--ink-mid);
|
|
1854
|
+
}
|
|
1855
|
+
.tunnel-autostart-row input {
|
|
1856
|
+
flex: 0 0 auto;
|
|
1857
|
+
cursor: pointer;
|
|
1858
|
+
}
|
|
1859
|
+
.tunnel-autostart-label {
|
|
1860
|
+
user-select: none;
|
|
1861
|
+
}
|
|
1862
|
+
.tunnel-autostart-hint {
|
|
1863
|
+
/* indent under the checkbox so it lines up with the label text */
|
|
1864
|
+
padding-left: 26px;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1841
1867
|
.tunnel-hero {
|
|
1842
1868
|
display: flex;
|
|
1843
1869
|
align-items: center;
|
package/public/js/api.js
CHANGED
|
@@ -254,6 +254,20 @@ export async function setSessionTitle(sessionId, title) {
|
|
|
254
254
|
await loadSessions();
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
export async function switchSessionCli(sessionId, cliId) {
|
|
258
|
+
const r = await api('POST', `/api/sessions/${sessionId}/switch-cli`, { cliId });
|
|
259
|
+
resumeFailed.delete(sessionId);
|
|
260
|
+
await loadSessions();
|
|
261
|
+
return r;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function stopSession(sessionId) {
|
|
265
|
+
const r = await api('POST', `/api/sessions/${sessionId}/stop`);
|
|
266
|
+
resumeFailed.delete(sessionId);
|
|
267
|
+
await loadSessions();
|
|
268
|
+
return r.session;
|
|
269
|
+
}
|
|
270
|
+
|
|
257
271
|
export async function deleteSession(sessionId) {
|
|
258
272
|
await api('DELETE', `/api/sessions/${sessionId}`);
|
|
259
273
|
await loadSessions();
|
|
@@ -54,10 +54,9 @@ function SessionRow({ s, folderId, siblingIds }) {
|
|
|
54
54
|
const onClick = async (ev) => {
|
|
55
55
|
ev.preventDefault();
|
|
56
56
|
selectSession(s.id);
|
|
57
|
-
// Auto-resume on click if the session
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
if (s.status !== 'running') {
|
|
57
|
+
// Auto-resume on click if the session stopped on its own. Explicitly
|
|
58
|
+
// stopped sessions stay stopped until the user presses Resume.
|
|
59
|
+
if (s.status !== 'running' && !s.manualStopped) {
|
|
61
60
|
try { await resumeSession(s.id); }
|
|
62
61
|
catch (e) { setToast(e.message, 'error'); }
|
|
63
62
|
}
|
package/public/js/icons.js
CHANGED
|
@@ -141,6 +141,14 @@ export const IconMoreVert = ic('0 0 24 24', html`
|
|
|
141
141
|
<circle cx="12" cy="19" r="1.6" fill="currentColor" stroke="none"/>
|
|
142
142
|
`, 16);
|
|
143
143
|
|
|
144
|
+
export const IconPlay = ic('0 0 24 24', html`
|
|
145
|
+
<polygon points="8 5 19 12 8 19 8 5"/>
|
|
146
|
+
`, 14);
|
|
147
|
+
|
|
148
|
+
export const IconStop = ic('0 0 24 24', html`
|
|
149
|
+
<rect x="7" y="7" width="10" height="10" rx="1.5"/>
|
|
150
|
+
`, 14);
|
|
151
|
+
|
|
144
152
|
// Broadcast / remote — radiating arcs over a centre dot. Used on the
|
|
145
153
|
// Remote nav tab; reads as "this machine is broadcasting" / "remote
|
|
146
154
|
// access available".
|
|
@@ -38,16 +38,29 @@ function saveLaunchState(s) {
|
|
|
38
38
|
try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch {}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function initRepoSelection(repos,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
function initRepoSelection(repos, saved) {
|
|
42
|
+
const valid = new Set(repos.map((r) => r.name));
|
|
43
|
+
const sel = new Set();
|
|
44
|
+
// Start from the persisted selection (last-used picks), keeping only
|
|
45
|
+
// repos that still exist.
|
|
46
|
+
if (saved && Array.isArray(saved.repos)) {
|
|
47
|
+
for (const n of saved.repos) if (valid.has(n)) sel.add(n);
|
|
48
48
|
}
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
// Auto-check any repo whose "pre-select on launch" default is newly
|
|
50
|
+
// active — i.e. it wasn't a default the last time we saved. This
|
|
51
|
+
// covers both a brand-new default repo and an existing repo the user
|
|
52
|
+
// just flipped to default in Configure. A default the user previously
|
|
53
|
+
// unchecked stays unchecked (it's an old default, already in
|
|
54
|
+
// knownDefaults). With no saved knownDefaults (fresh user / old
|
|
55
|
+
// state), every default applies.
|
|
56
|
+
const knownDefaults = saved && Array.isArray(saved.knownDefaults)
|
|
57
|
+
? new Set(saved.knownDefaults) : null;
|
|
58
|
+
for (const r of repos) {
|
|
59
|
+
if (r.defaultSelected && (knownDefaults === null || !knownDefaults.has(r.name))) {
|
|
60
|
+
sel.add(r.name);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
selectedRepos.value = sel;
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
function LaunchHero() {
|
|
@@ -86,17 +99,21 @@ function LaunchHero() {
|
|
|
86
99
|
}, [folderId, folders.value.length]);
|
|
87
100
|
|
|
88
101
|
// Persist every change. JSON-stringifying a Set isn't useful, so
|
|
89
|
-
// we materialize selectedRepos to an array here.
|
|
102
|
+
// we materialize selectedRepos to an array here. knownDefaults records
|
|
103
|
+
// which repos were marked "pre-select" at save time so
|
|
104
|
+
// initRepoSelection can tell a newly-flipped default apart from one the
|
|
105
|
+
// user deliberately unchecked.
|
|
90
106
|
useEffect(() => {
|
|
91
107
|
saveLaunchState({
|
|
92
108
|
cliId, folderId, mode, cwd,
|
|
93
109
|
repos: [...selectedRepos.value],
|
|
110
|
+
knownDefaults: repos.filter((r) => r.defaultSelected).map((r) => r.name),
|
|
94
111
|
});
|
|
95
112
|
}, [cliId, folderId, mode, cwd, selectedRepos.value]);
|
|
96
113
|
|
|
97
114
|
|
|
98
115
|
const sig = repos.map((r) => r.name + ':' + r.defaultSelected).join('|');
|
|
99
|
-
useStateOnce(sig, () => initRepoSelection(repos, saved
|
|
116
|
+
useStateOnce(sig, () => initRepoSelection(repos, saved));
|
|
100
117
|
|
|
101
118
|
const cli = clis.find((c) => c.id === cliId) || clis[0];
|
|
102
119
|
const folder = folders.value.find((f) => f.id === folderId);
|
|
@@ -423,11 +423,35 @@ export function RemotePage() {
|
|
|
423
423
|
const fresh = genToken();
|
|
424
424
|
setTokenLocal(fresh);
|
|
425
425
|
try {
|
|
426
|
-
|
|
426
|
+
// When auto-start is on the token must be PERSISTED, else the
|
|
427
|
+
// rotated token is lost on the next backend restart and every
|
|
428
|
+
// share URL built from it 401s. Route through the persisting
|
|
429
|
+
// endpoint in that case; otherwise the in-memory-only token
|
|
430
|
+
// endpoint is enough.
|
|
431
|
+
const s = status?.autoStart
|
|
432
|
+
? await api('POST', '/api/tunnel/autostart', { autoStart: true, provider, token: fresh })
|
|
433
|
+
: await api('POST', '/api/tunnel/token', { token: fresh });
|
|
427
434
|
setStatus(s);
|
|
428
435
|
setToast('New token in effect', 'ok');
|
|
429
436
|
} catch (e) { setToast(`token save failed · ${e.message}`, 'error'); }
|
|
430
437
|
}
|
|
438
|
+
// Persist (or clear) the auto-start preference. On enable with no
|
|
439
|
+
// token yet, mint one first so the backend has something to reuse on
|
|
440
|
+
// its next startup. Approved devices keep working regardless of the
|
|
441
|
+
// token — it only gates NEW device registration.
|
|
442
|
+
async function onToggleAutoStart(next) {
|
|
443
|
+
setBusy(true);
|
|
444
|
+
try {
|
|
445
|
+
let tok = token;
|
|
446
|
+
if (next && (!tok || tok.length < 8)) { tok = genToken(); setTokenLocal(tok); }
|
|
447
|
+
const s = await api('POST', '/api/tunnel/autostart',
|
|
448
|
+
next ? { autoStart: true, provider, token: tok } : { autoStart: false });
|
|
449
|
+
setStatus(s);
|
|
450
|
+
setToast(next ? 'Auto-start on · tunnel comes up when ccsm starts' : 'Auto-start off', 'ok');
|
|
451
|
+
} catch (e) {
|
|
452
|
+
setToast(`auto-start ${next ? 'enable' : 'disable'} failed · ${e.message}`, 'error');
|
|
453
|
+
} finally { setBusy(false); }
|
|
454
|
+
}
|
|
431
455
|
async function onInstall(p) {
|
|
432
456
|
const ok = await ccsmConfirm(`Install ${p} via winget? Runs in the background — refresh after ~30s.`, {
|
|
433
457
|
title: 'Install tunnel provider', okLabel: 'Install',
|
|
@@ -553,6 +577,18 @@ export function RemotePage() {
|
|
|
553
577
|
meta=${running
|
|
554
578
|
? html`Provider <code>${status?.provider}</code> · started ${new Date(status.startedAt).toLocaleTimeString()}`
|
|
555
579
|
: html`Not running.`}>
|
|
580
|
+
<div class="tunnel-autostart">
|
|
581
|
+
<label class="tunnel-autostart-row">
|
|
582
|
+
<input type="checkbox" checked=${!!status?.autoStart} disabled=${busy}
|
|
583
|
+
onChange=${(e) => onToggleAutoStart(e.target.checked)} />
|
|
584
|
+
<span class="tunnel-autostart-label">Start this tunnel automatically when ccsm starts</span>
|
|
585
|
+
</label>
|
|
586
|
+
${status?.autoStart && provider === 'cloudflared' ? html`
|
|
587
|
+
<span class="hint tunnel-autostart-hint">
|
|
588
|
+
Cloudflare quick tunnels get a new URL each launch — the share URL will change on
|
|
589
|
+
restart and approved devices must re-register. Use Microsoft Dev Tunnel for a stable URL.
|
|
590
|
+
</span>` : null}
|
|
591
|
+
</div>
|
|
556
592
|
${!running ? html`
|
|
557
593
|
<div class="tunnel-hero">
|
|
558
594
|
<div class="tunnel-hero-body">
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
import { html } from '../html.js';
|
|
8
8
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
9
9
|
import { activeSessionId, sessions, config, selectTab, selectSession, clockTick } from '../state.js';
|
|
10
|
-
import { resumeSession, clearResumeFailure, deleteSession, setSessionTitle, openSessionInEditor } from '../api.js';
|
|
10
|
+
import { resumeSession, clearResumeFailure, deleteSession, setSessionTitle, switchSessionCli, stopSession, openSessionInEditor } from '../api.js';
|
|
11
11
|
import { setToast } from '../toast.js';
|
|
12
12
|
import { ccsmConfirm, ccsmPrompt } from '../dialog.js';
|
|
13
13
|
import { TerminalView } from '../components/TerminalView.js';
|
|
14
14
|
import { PageTitleBar } from '../components/PageTitleBar.js';
|
|
15
15
|
import { Popover } from '../components/Popover.js';
|
|
16
|
-
import { IconMoreVert, IconPencil, IconClose, IconPlus, IconForCliType, IconTerminal, IconExternal } from '../icons.js';
|
|
16
|
+
import { IconMoreVert, IconPencil, IconClose, IconPlus, IconForCliType, IconTerminal, IconExternal, IconPlay, IconStop } from '../icons.js';
|
|
17
17
|
import { fmtAgo } from '../util.js';
|
|
18
18
|
|
|
19
19
|
function SessionTabs({ activeId, onActivate, onNew, kebab }) {
|
|
@@ -51,7 +51,7 @@ function SessionTabs({ activeId, onActivate, onNew, kebab }) {
|
|
|
51
51
|
</div>`;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function SessionMenu({ session, onRename, onDelete, onOpenEditor }) {
|
|
54
|
+
function SessionMenu({ session, switchableClis, onRename, onDelete, onOpenEditor, onSwitchCli }) {
|
|
55
55
|
const [open, setOpen] = useState(false);
|
|
56
56
|
const anchor = useRef(null);
|
|
57
57
|
return html`
|
|
@@ -67,6 +67,18 @@ function SessionMenu({ session, onRename, onDelete, onOpenEditor }) {
|
|
|
67
67
|
<button class="session-menu-item" onClick=${() => { setOpen(false); onOpenEditor(); }}>
|
|
68
68
|
<${IconExternal} /> Open in editor
|
|
69
69
|
</button>
|
|
70
|
+
${switchableClis.length ? html`
|
|
71
|
+
<div class="session-menu-separator"></div>
|
|
72
|
+
<div class="session-menu-label">Switch CLI</div>
|
|
73
|
+
${switchableClis.map((target) => {
|
|
74
|
+
const TargetIcon = IconForCliType(target.type) || IconTerminal;
|
|
75
|
+
return html`
|
|
76
|
+
<button class="session-menu-item" key=${target.id}
|
|
77
|
+
onClick=${() => { setOpen(false); onSwitchCli(target); }}>
|
|
78
|
+
<${TargetIcon} /> Switch to ${target.name}
|
|
79
|
+
</button>`;
|
|
80
|
+
})}
|
|
81
|
+
` : null}
|
|
70
82
|
<button class="session-menu-item" onClick=${() => { setOpen(false); onRename(); }}>
|
|
71
83
|
<${IconPencil} /> Rename
|
|
72
84
|
</button>
|
|
@@ -77,12 +89,35 @@ function SessionMenu({ session, onRename, onDelete, onOpenEditor }) {
|
|
|
77
89
|
</${Popover}>` : null}`;
|
|
78
90
|
}
|
|
79
91
|
|
|
92
|
+
function SessionControls({ running, busy, onStop, onResume }) {
|
|
93
|
+
return html`
|
|
94
|
+
<div class="session-controls">
|
|
95
|
+
${running ? html`
|
|
96
|
+
<button class="session-menu-btn session-control-btn danger" type="button"
|
|
97
|
+
title="Stop session" aria-label="Stop session"
|
|
98
|
+
disabled=${busy}
|
|
99
|
+
onClick=${onStop}>
|
|
100
|
+
<${IconStop} />
|
|
101
|
+
</button>
|
|
102
|
+
` : html`
|
|
103
|
+
<button class="session-menu-btn session-control-btn" type="button"
|
|
104
|
+
title=${busy ? 'Resuming session' : 'Resume session'}
|
|
105
|
+
aria-label=${busy ? 'Resuming session' : 'Resume session'}
|
|
106
|
+
disabled=${busy}
|
|
107
|
+
onClick=${onResume}>
|
|
108
|
+
<${IconPlay} />
|
|
109
|
+
</button>
|
|
110
|
+
`}
|
|
111
|
+
</div>`;
|
|
112
|
+
}
|
|
113
|
+
|
|
80
114
|
export function SessionsPage() {
|
|
81
115
|
clockTick.value; // resubscribe fmtAgo
|
|
82
116
|
const id = activeSessionId.value;
|
|
83
117
|
const list = sessions.value;
|
|
84
118
|
const session = id ? list.find((s) => s.id === id) : null;
|
|
85
119
|
const [resumeError, setResumeError] = useState(null);
|
|
120
|
+
const [actionBusy, setActionBusy] = useState(false);
|
|
86
121
|
// Bumps to force the auto-resume effect to re-run on Retry without
|
|
87
122
|
// mutating any signal. Primitive in the dep array → identity changes.
|
|
88
123
|
const [retryNonce, setRetryNonce] = useState(0);
|
|
@@ -100,22 +135,50 @@ export function SessionsPage() {
|
|
|
100
135
|
useEffect(() => {
|
|
101
136
|
if (!session) return;
|
|
102
137
|
if (session.status === 'running') { setResumeError(null); return; }
|
|
138
|
+
if (session.manualStopped) { setResumeError(null); return; }
|
|
103
139
|
setResumeError(null);
|
|
104
140
|
resumeSession(session.id)
|
|
105
141
|
.then((launched) => { if (launched?.id) selectSession(launched.id); })
|
|
106
142
|
.catch((e) => { setResumeError(e.message); setToast(e.message, 'error'); });
|
|
107
|
-
}, [session?.id, session?.status, retryNonce]);
|
|
143
|
+
}, [session?.id, session?.status, session?.cliId, session?.manualStopped, retryNonce]);
|
|
108
144
|
|
|
109
145
|
if (!session) return null;
|
|
110
146
|
|
|
111
147
|
const cli = (config.value?.clis || []).find((c) => c.id === session.cliId);
|
|
148
|
+
const switchableClis = cli
|
|
149
|
+
? (config.value?.clis || []).filter((c) => c.id !== cli.id && c.type === cli.type)
|
|
150
|
+
: [];
|
|
112
151
|
const running = session.status === 'running';
|
|
113
152
|
const title = session.title || session.workspace || session.id.slice(0, 12);
|
|
114
153
|
|
|
115
|
-
const
|
|
154
|
+
const onResume = async () => {
|
|
116
155
|
clearResumeFailure(session.id);
|
|
117
156
|
setResumeError(null);
|
|
118
|
-
|
|
157
|
+
setActionBusy(true);
|
|
158
|
+
try {
|
|
159
|
+
const launched = await resumeSession(session.id);
|
|
160
|
+
if (launched?.id) selectSession(launched.id);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
setResumeError(e.message);
|
|
163
|
+
setToast(e.message, 'error');
|
|
164
|
+
} finally {
|
|
165
|
+
setActionBusy(false);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const onRetry = () => {
|
|
169
|
+
onResume();
|
|
170
|
+
};
|
|
171
|
+
const onStop = async () => {
|
|
172
|
+
setActionBusy(true);
|
|
173
|
+
try {
|
|
174
|
+
await stopSession(session.id);
|
|
175
|
+
setResumeError(null);
|
|
176
|
+
setToast('Session stopped');
|
|
177
|
+
} catch (e) {
|
|
178
|
+
setToast(e.message, 'error');
|
|
179
|
+
} finally {
|
|
180
|
+
setActionBusy(false);
|
|
181
|
+
}
|
|
119
182
|
};
|
|
120
183
|
const onRename = async () => {
|
|
121
184
|
const next = await ccsmPrompt('Rename session', title, { okLabel: 'Save' });
|
|
@@ -138,6 +201,26 @@ export function SessionsPage() {
|
|
|
138
201
|
setToast(`Opening in ${r?.editor || 'editor'}…`);
|
|
139
202
|
} catch (e) { setToast(e.message, 'error'); }
|
|
140
203
|
};
|
|
204
|
+
const onSwitchCli = async (target) => {
|
|
205
|
+
const fromName = cli?.name || session.cliId;
|
|
206
|
+
if (running) {
|
|
207
|
+
const ok = await ccsmConfirm(
|
|
208
|
+
`Switch ${title} from ${fromName} to ${target.name}? The running terminal keeps its current process; ${target.name} is used next time this session resumes.`,
|
|
209
|
+
{ title: 'Switch CLI', okLabel: 'Switch' },
|
|
210
|
+
);
|
|
211
|
+
if (!ok) return;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const r = await switchSessionCli(session.id, target.id);
|
|
215
|
+
setToast(r.running
|
|
216
|
+
? `CLI switched to ${target.name} for next resume`
|
|
217
|
+
: `CLI switched to ${target.name}`);
|
|
218
|
+
if (!running && !session.manualStopped) {
|
|
219
|
+
clearResumeFailure(session.id);
|
|
220
|
+
setRetryNonce((n) => n + 1);
|
|
221
|
+
}
|
|
222
|
+
} catch (e) { setToast(e.message, 'error'); }
|
|
223
|
+
};
|
|
141
224
|
|
|
142
225
|
return html`
|
|
143
226
|
<${PageTitleBar} title=${html`
|
|
@@ -148,14 +231,24 @@ export function SessionsPage() {
|
|
|
148
231
|
<span>${cli ? cli.name : session.cliId}</span>
|
|
149
232
|
${session.repos.length ? html`<span>·</span><span>${session.repos.join(', ')}</span>` : null}
|
|
150
233
|
<span>·</span>
|
|
151
|
-
<span>${running ? 'running' : (resumeError ? 'resume failed' : 'resuming…')}</span>
|
|
234
|
+
<span>${running ? 'running' : (resumeError ? 'resume failed' : (session.manualStopped ? 'stopped' : 'resuming…'))}</span>
|
|
152
235
|
</span>
|
|
153
236
|
`} />
|
|
154
237
|
<${SessionTabs}
|
|
155
238
|
activeId=${session.id}
|
|
156
239
|
onActivate=${(sid) => selectSession(sid)}
|
|
157
240
|
onNew=${() => selectTab('launch')}
|
|
158
|
-
kebab=${html
|
|
241
|
+
kebab=${html`
|
|
242
|
+
<${SessionControls} running=${running}
|
|
243
|
+
busy=${actionBusy}
|
|
244
|
+
onStop=${onStop}
|
|
245
|
+
onResume=${onResume} />
|
|
246
|
+
<${SessionMenu} session=${session}
|
|
247
|
+
switchableClis=${switchableClis}
|
|
248
|
+
onRename=${onRename}
|
|
249
|
+
onDelete=${onDelete}
|
|
250
|
+
onOpenEditor=${onOpenEditor}
|
|
251
|
+
onSwitchCli=${onSwitchCli} />`} />
|
|
159
252
|
<div class="session-pane">
|
|
160
253
|
<div class="session-pane-body">
|
|
161
254
|
${running
|
|
@@ -165,6 +258,11 @@ export function SessionsPage() {
|
|
|
165
258
|
${resumeError ? html`
|
|
166
259
|
<div>Failed to resume: <span class="mono">${resumeError}</span></div>
|
|
167
260
|
<button class="action primary" onClick=${onRetry}>Retry</button>
|
|
261
|
+
` : session.manualStopped ? html`
|
|
262
|
+
<div>Session stopped</div>
|
|
263
|
+
<button class="action primary" onClick=${onResume} disabled=${actionBusy}>
|
|
264
|
+
${actionBusy ? 'Resuming…' : 'Resume'}
|
|
265
|
+
</button>
|
|
168
266
|
` : html`
|
|
169
267
|
<div>Resuming session…</div>
|
|
170
268
|
`}
|
package/server.js
CHANGED
|
@@ -217,6 +217,10 @@ function pickCli(cfg, requestedId) {
|
|
|
217
217
|
return cfg.clis.find((c) => c.id === wanted) || cfg.clis[0];
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
function findCliById(cfg, id) {
|
|
221
|
+
return (cfg.clis || []).find((c) => c.id === id) || null;
|
|
222
|
+
}
|
|
223
|
+
|
|
220
224
|
// Resolve how to spawn a CLI command. Windows quirks:
|
|
221
225
|
// v1.1 — spawn strategy is now caller-controlled via cli.shell:
|
|
222
226
|
// 'direct' — pty.spawn(command, args). Real .exe / absolute paths only.
|
|
@@ -456,13 +460,27 @@ function decorateConfigWithProbes(cfg) {
|
|
|
456
460
|
};
|
|
457
461
|
}
|
|
458
462
|
|
|
463
|
+
// The tunnel + devtunnel config blocks are managed exclusively through
|
|
464
|
+
// /api/tunnel/* (host-only) — they hold the persisted remote-access
|
|
465
|
+
// token and the named tunnelId. Strip them from /api/config so (a) the
|
|
466
|
+
// plaintext token never reaches an approved remote device reading config
|
|
467
|
+
// and (b) the frontend's whole-object config round-trip on save can't
|
|
468
|
+
// clobber tunnelId/token with a stale snapshot.
|
|
469
|
+
function stripTunnelKeys(cfg) {
|
|
470
|
+
const rest = { ...cfg };
|
|
471
|
+
delete rest.tunnel;
|
|
472
|
+
delete rest.devtunnel;
|
|
473
|
+
return rest;
|
|
474
|
+
}
|
|
459
475
|
app.get('/api/config', asyncH(async (_req, res) => {
|
|
460
|
-
res.json(decorateConfigWithProbes(await loadConfig()));
|
|
476
|
+
res.json(decorateConfigWithProbes(stripTunnelKeys(await loadConfig())));
|
|
461
477
|
}));
|
|
462
478
|
|
|
463
479
|
app.put('/api/config', asyncH(async (req, res) => {
|
|
464
|
-
const
|
|
465
|
-
|
|
480
|
+
const body = { ...(req.body || {}) };
|
|
481
|
+
delete body.tunnel;
|
|
482
|
+
delete body.devtunnel;
|
|
483
|
+
res.json(decorateConfigWithProbes(stripTunnelKeys(await saveConfig(body))));
|
|
466
484
|
}));
|
|
467
485
|
|
|
468
486
|
// ---- CLI probe / test ----
|
|
@@ -625,6 +643,64 @@ app.put('/api/sessions/:id', asyncH(async (req, res) => {
|
|
|
625
643
|
res.json({ session: updated });
|
|
626
644
|
}));
|
|
627
645
|
|
|
646
|
+
// Switch the CLI config used to resume an existing session. This is
|
|
647
|
+
// intentionally narrower than the generic PUT route: a session can only
|
|
648
|
+
// move between configured CLIs of the same type (e.g. one claude wrapper
|
|
649
|
+
// to another) so its captured upstream cliSessionId stays meaningful.
|
|
650
|
+
app.post('/api/sessions/:id/switch-cli', asyncH(async (req, res) => {
|
|
651
|
+
const targetCliId = typeof req.body?.cliId === 'string' ? req.body.cliId.trim() : '';
|
|
652
|
+
if (!targetCliId) return res.status(400).json({ error: 'cliId required' });
|
|
653
|
+
|
|
654
|
+
const record = await persistedSessions.get(req.params.id);
|
|
655
|
+
if (!record) return res.status(404).json({ error: 'session not found' });
|
|
656
|
+
|
|
657
|
+
const cfg = await loadConfig();
|
|
658
|
+
const currentCli = findCliById(cfg, record.cliId);
|
|
659
|
+
const targetCli = findCliById(cfg, targetCliId);
|
|
660
|
+
if (!currentCli) return res.status(400).json({ error: `current CLI ${record.cliId} no longer configured` });
|
|
661
|
+
if (!targetCli) return res.status(400).json({ error: `target CLI ${targetCliId} not configured` });
|
|
662
|
+
if (currentCli.type !== targetCli.type) {
|
|
663
|
+
return res.status(400).json({
|
|
664
|
+
error: `cannot switch ${currentCli.type} session to ${targetCli.type} CLI`,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (record.cliId === targetCli.id) {
|
|
669
|
+
const live = webTerminal.get(record.id);
|
|
670
|
+
return res.json({ session: record, changed: false, running: !!(live && !live.exitedAt) });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const updated = await persistedSessions.update(record.id, { cliId: targetCli.id });
|
|
674
|
+
const live = webTerminal.get(record.id);
|
|
675
|
+
res.json({
|
|
676
|
+
session: updated,
|
|
677
|
+
changed: true,
|
|
678
|
+
running: !!(live && !live.exitedAt),
|
|
679
|
+
fromCliId: currentCli.id,
|
|
680
|
+
toCliId: targetCli.id,
|
|
681
|
+
cliType: targetCli.type,
|
|
682
|
+
});
|
|
683
|
+
}));
|
|
684
|
+
|
|
685
|
+
// Stop the live PTY for a session without deleting its persisted record.
|
|
686
|
+
// Unlike a natural CLI exit, this is a user intent signal: the frontend
|
|
687
|
+
// should not auto-resume it again until the user explicitly presses Resume.
|
|
688
|
+
app.post('/api/sessions/:id/stop', asyncH(async (req, res) => {
|
|
689
|
+
const record = await persistedSessions.get(req.params.id);
|
|
690
|
+
if (!record) return res.status(404).json({ error: 'session not found' });
|
|
691
|
+
const stopped = webTerminal.kill(record.id);
|
|
692
|
+
const updated = await persistedSessions.update(record.id, {
|
|
693
|
+
status: 'exited',
|
|
694
|
+
pid: null,
|
|
695
|
+
exitCode: null,
|
|
696
|
+
exitedAt: Date.now(),
|
|
697
|
+
manualStopped: true,
|
|
698
|
+
lastActiveAt: Date.now(),
|
|
699
|
+
});
|
|
700
|
+
try { require('./lib/cliActivity').releaseSession(record.id); } catch {}
|
|
701
|
+
res.json({ stopped, session: updated });
|
|
702
|
+
}));
|
|
703
|
+
|
|
628
704
|
app.delete('/api/sessions/:id', asyncH(async (req, res) => {
|
|
629
705
|
// Kill PTY first if it's still alive, then drop the record.
|
|
630
706
|
try { webTerminal.kill(req.params.id); } catch {}
|
|
@@ -1162,6 +1238,28 @@ app.post('/api/tunnel/token', asyncH(async (req, res) => {
|
|
|
1162
1238
|
tunnel.setToken(t);
|
|
1163
1239
|
res.json(await tunnel.status());
|
|
1164
1240
|
}));
|
|
1241
|
+
// Persist auto-start prefs. When ON, the backend brings this tunnel up
|
|
1242
|
+
// during its own startup (the boot hook in the listen IIFE below) using
|
|
1243
|
+
// the persisted token, so share URLs survive a backend restart. The
|
|
1244
|
+
// token is written to config ONLY while auto-start is on; turning it off
|
|
1245
|
+
// wipes the persisted token from disk. setToken keeps the in-memory copy
|
|
1246
|
+
// in lockstep so the share URL the page renders stays valid immediately.
|
|
1247
|
+
app.post('/api/tunnel/autostart', asyncH(async (req, res) => {
|
|
1248
|
+
const { autoStart, provider, token } = req.body || {};
|
|
1249
|
+
if (autoStart) {
|
|
1250
|
+
if (!token || String(token).length < 8) {
|
|
1251
|
+
return res.status(400).json({ error: 'token required (≥ 8 chars)' });
|
|
1252
|
+
}
|
|
1253
|
+
if (!['devtunnel', 'cloudflared'].includes(provider)) {
|
|
1254
|
+
return res.status(400).json({ error: 'valid provider required' });
|
|
1255
|
+
}
|
|
1256
|
+
tunnel.setToken(token);
|
|
1257
|
+
await saveConfig({ tunnel: { autoStart: true, provider, token } });
|
|
1258
|
+
} else {
|
|
1259
|
+
await saveConfig({ tunnel: { autoStart: false, provider: null, token: null } });
|
|
1260
|
+
}
|
|
1261
|
+
res.json(await tunnel.status());
|
|
1262
|
+
}));
|
|
1165
1263
|
app.post('/api/tunnel/install', asyncH(async (req, res) => {
|
|
1166
1264
|
const { provider } = req.body || {};
|
|
1167
1265
|
try {
|
|
@@ -1675,6 +1773,20 @@ function openInBrowser(url) {
|
|
|
1675
1773
|
// is warm by the time anyone clicks.
|
|
1676
1774
|
try { tunnel.probe(true).catch(() => {}); } catch {}
|
|
1677
1775
|
|
|
1776
|
+
// Auto-start the tunnel if the user enabled it on the Remote page.
|
|
1777
|
+
// This is the BACKEND PROCESS bringing its own tunnel up on startup —
|
|
1778
|
+
// not an OS-level autostart (no registry / scheduled task). Reuses the
|
|
1779
|
+
// persisted token so share URLs stay valid across restarts. Strictly
|
|
1780
|
+
// fire-and-forget: a failure here (devtunnel not signed in, provider
|
|
1781
|
+
// uninstalled, etc.) must never crash boot — it just logs and the user
|
|
1782
|
+
// can start manually from the Remote page.
|
|
1783
|
+
if (cfg.tunnel?.autoStart && cfg.tunnel?.token && cfg.tunnel?.provider) {
|
|
1784
|
+
tunnel.setToken(cfg.tunnel.token);
|
|
1785
|
+
tunnel.start({ provider: cfg.tunnel.provider, port: currentPort })
|
|
1786
|
+
.then((s) => console.log(`[ccsm] tunnel auto-started · ${cfg.tunnel.provider} · ${s.url || 'URL pending'}`))
|
|
1787
|
+
.catch((e) => console.warn(`[ccsm] tunnel auto-start failed · ${e.message}`));
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1678
1790
|
if (webTerminal.available) {
|
|
1679
1791
|
let WebSocketServer;
|
|
1680
1792
|
try { ({ WebSocketServer } = require('ws')); } catch {}
|