@bakapiano/ccsm 0.17.11 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,446 @@
1
+ // Remote · expose this backend over a public tunnel URL so the same
2
+ // frontend can be loaded from a phone / another laptop / wherever.
3
+ // All API + WS calls are gated by a token the user sets here; the
4
+ // share URL embeds it (?token=…) so the remote browser captures it
5
+ // on first arrival and stashes it in localStorage.
6
+ //
7
+ // Layout mirrors ConfigurePage: .settings-scroll wrapper → Section →
8
+ // .config-grid → .field rows with label + content. No bespoke cards.
9
+
10
+ import { html } from '../html.js';
11
+ import { useState, useEffect, useRef } from 'preact/hooks';
12
+ import { api } from '../api.js';
13
+ import { PageTitleBar } from '../components/PageTitleBar.js';
14
+ import { setToast } from '../toast.js';
15
+ import { ccsmConfirm, ccsmPrompt } from '../dialog.js';
16
+ import { IconCopy, IconRecycle, IconExternal, IconInfo, IconPencil, IconClose } from '../icons.js';
17
+ import { fmtAgo } from '../util.js';
18
+ import { clockTick } from '../state.js';
19
+
20
+ function genToken() {
21
+ const a = new Uint8Array(18);
22
+ crypto.getRandomValues(a);
23
+ let s = '';
24
+ for (const b of a) s += String.fromCharCode(b);
25
+ return btoa(s).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
26
+ }
27
+
28
+ async function copy(text) {
29
+ try {
30
+ await navigator.clipboard.writeText(text);
31
+ setToast('Copied', 'ok');
32
+ } catch {
33
+ setToast('Copy failed · select + Ctrl+C', 'error');
34
+ }
35
+ }
36
+
37
+ function shareUrl(tunnelUrl, token) {
38
+ if (!tunnelUrl || !token) return '';
39
+ try {
40
+ const u = new URL(tunnelUrl);
41
+ u.searchParams.set('token', token);
42
+ return u.toString();
43
+ } catch { return ''; }
44
+ }
45
+
46
+ function Section({ title, meta, children }) {
47
+ return html`
48
+ <section class="settings-section">
49
+ <header class="settings-section-head">
50
+ <h2 class="settings-section-title">${title}</h2>
51
+ ${meta ? html`<p class="settings-section-meta">${meta}</p>` : null}
52
+ </header>
53
+ <div class="settings-section-body">${children}</div>
54
+ </section>`;
55
+ }
56
+
57
+ function DeviceRow({ d, kind, onApprove, onReject, onRevoke, onRename, onDelete }) {
58
+ const lastSeen = d.lastSeen ? fmtAgo(d.lastSeen) : '—';
59
+ const ipShort = d.ip ? d.ip.split(',')[0].trim() : null;
60
+ return html`
61
+ <div class=${`remote-device is-${kind}`}>
62
+ <div class="remote-device-main">
63
+ <div class="remote-device-label">
64
+ ${d.label || 'Unknown device'}
65
+ ${kind === 'approved' ? html`
66
+ <button class="icon-btn" title="Rename" onClick=${onRename}><${IconPencil} /></button>
67
+ ` : null}
68
+ </div>
69
+ <div class="remote-device-meta">
70
+ ${ipShort ? html`<span class="mono">${ipShort}</span> · ` : null}
71
+ <span>seen ${lastSeen}</span>
72
+ ${d.userAgent ? html` · <span class="remote-device-ua" title=${d.userAgent}>${d.userAgent.slice(0, 60)}${d.userAgent.length > 60 ? '…' : ''}</span>` : null}
73
+ </div>
74
+ </div>
75
+ <div class="remote-device-actions">
76
+ ${kind === 'pending' ? html`
77
+ <button class="action primary small" onClick=${onApprove}>Approve</button>
78
+ <button class="action subtle small" onClick=${onReject}>Reject</button>
79
+ ` : null}
80
+ ${kind === 'approved' ? html`
81
+ <button class="action subtle danger small" onClick=${onRevoke}><${IconClose} /> Revoke</button>
82
+ ` : null}
83
+ ${kind === 'rejected' ? html`
84
+ <button class="action subtle small" onClick=${onApprove}>Re-approve</button>
85
+ <button class="action subtle danger small" onClick=${onDelete}><${IconClose} /> Delete</button>
86
+ ` : null}
87
+ </div>
88
+ </div>`;
89
+ }
90
+
91
+ function ProviderChip({ id, label, selected, onSelect }) {
92
+ return html`
93
+ <label class=${`chip${selected ? ' checked' : ''}`}>
94
+ <input type="radio" name="provider" value=${id} checked=${selected}
95
+ onChange=${() => onSelect(id)} />
96
+ ${label}
97
+ </label>`;
98
+ }
99
+
100
+ function ProviderStatus({ id, info, onInstall, onLogin }) {
101
+ if (!info) return html`<span class="muted">probing…</span>`;
102
+ if (!info.installed) {
103
+ return html`
104
+ <span class="warn">not installed</span>
105
+ <button type="button" class="action subtle small" onClick=${onInstall}>
106
+ Install via winget
107
+ </button>`;
108
+ }
109
+ const tag = id === 'devtunnel'
110
+ ? (info.loggedIn ? html`signed in as <code>${info.user}</code>` : html`<span class="warn">not signed in</span>`)
111
+ : html`anonymous`;
112
+ return html`
113
+ <span>${tag}</span>
114
+ ${info.version ? html` · <span class="mono small-mono">${info.version}</span>` : null}
115
+ ${id === 'devtunnel' && !info.loggedIn ? html`
116
+ <button type="button" class="action subtle small" onClick=${onLogin}>How to sign in</button>
117
+ ` : null}`;
118
+ }
119
+
120
+ export function RemotePage() {
121
+ clockTick.value; // re-tick fmtAgo "last seen" labels
122
+ const [status, setStatus] = useState(null);
123
+ const [provider, setProvider] = useState('cloudflared');
124
+ const [token, setTokenLocal] = useState('');
125
+ const [busy, setBusy] = useState(false);
126
+ const [deviceList, setDeviceList] = useState([]);
127
+ const pollRef = useRef(null);
128
+
129
+ async function refresh() {
130
+ try {
131
+ const [s, devs] = await Promise.all([
132
+ api('GET', '/api/tunnel/status'),
133
+ api('GET', '/api/devices').catch(() => ({ devices: [] })),
134
+ ]);
135
+ setStatus(s);
136
+ setDeviceList(devs.devices || []);
137
+ setTokenLocal((cur) => cur || s.token || '');
138
+ setProvider((cur) => {
139
+ if (s.running && s.provider) return s.provider;
140
+ if (cur) return cur;
141
+ if (s.providers?.cloudflared?.installed) return 'cloudflared';
142
+ if (s.providers?.devtunnel?.installed) return 'devtunnel';
143
+ return cur || 'cloudflared';
144
+ });
145
+ } catch (e) { setToast(`status load failed · ${e.message}`, 'error'); }
146
+ }
147
+
148
+ useEffect(() => {
149
+ refresh();
150
+ pollRef.current = setInterval(refresh, 2500);
151
+ return () => clearInterval(pollRef.current);
152
+ }, []);
153
+
154
+ async function onApproveDevice(id) {
155
+ try { await api('POST', `/api/devices/${encodeURIComponent(id)}/approve`); refresh(); setToast('Device approved', 'ok'); }
156
+ catch (e) { setToast(`approve failed · ${e.message}`, 'error'); }
157
+ }
158
+ async function onRejectDevice(id) {
159
+ try { await api('POST', `/api/devices/${encodeURIComponent(id)}/reject`); refresh(); setToast('Device rejected', 'ok'); }
160
+ catch (e) { setToast(`reject failed · ${e.message}`, 'error'); }
161
+ }
162
+ async function onDeleteDevice(d) {
163
+ const ok = await ccsmConfirm(
164
+ `Forget "${d.label || d.id}"? The device disappears from this list. If it ever tries again it'll come back as a fresh pending request.`,
165
+ { title: 'Delete device record', okLabel: 'Delete', danger: true },
166
+ );
167
+ if (!ok) return;
168
+ try { await api('DELETE', `/api/devices/${encodeURIComponent(d.id)}`); refresh(); setToast('Device deleted', 'ok'); }
169
+ catch (e) { setToast(`delete failed · ${e.message}`, 'error'); }
170
+ }
171
+ async function onRevokeDevice(d) {
172
+ const ok = await ccsmConfirm(`Revoke access for "${d.label || d.id}"? Any open tabs lose access immediately.`, {
173
+ title: 'Revoke device', okLabel: 'Revoke', danger: true,
174
+ });
175
+ if (!ok) return;
176
+ try { await api('POST', `/api/devices/${encodeURIComponent(d.id)}/revoke`); refresh(); setToast('Access revoked', 'ok'); }
177
+ catch (e) { setToast(`revoke failed · ${e.message}`, 'error'); }
178
+ }
179
+ async function onRenameDevice(d) {
180
+ const next = await ccsmPrompt('Rename device', d.label || '', { okLabel: 'Save' });
181
+ if (next === null) return;
182
+ try { await api('PUT', `/api/devices/${encodeURIComponent(d.id)}`, { label: next.trim() }); refresh(); }
183
+ catch (e) { setToast(`rename failed · ${e.message}`, 'error'); }
184
+ }
185
+
186
+ async function onStart() {
187
+ if (!token || token.length < 8) {
188
+ setToast('Token must be at least 8 characters', 'error');
189
+ return;
190
+ }
191
+ setBusy(true);
192
+ try {
193
+ const s = await api('POST', '/api/tunnel/start', { provider, token });
194
+ setStatus(s);
195
+ setToast(s.url ? 'Tunnel up' : 'Tunnel starting · URL appearing shortly', 'ok');
196
+ } catch (e) {
197
+ setToast(`start failed · ${e.message}`, 'error');
198
+ } finally { setBusy(false); }
199
+ }
200
+ async function onStop() {
201
+ setBusy(true);
202
+ try {
203
+ const s = await api('POST', '/api/tunnel/stop');
204
+ setStatus(s);
205
+ setToast('Tunnel stopped', 'ok');
206
+ } catch (e) { setToast(`stop failed · ${e.message}`, 'error'); }
207
+ finally { setBusy(false); }
208
+ }
209
+ // Generate is the only path that mutates the token now — local React
210
+ // state and the server's stored token stay in lockstep, so the Share
211
+ // URL preview always embeds a token the server will accept. (The
212
+ // previous design had a separate Save step; users would Generate +
213
+ // copy the URL without saving, then the remote would 401 because
214
+ // its embedded token didn't match what the server still had.)
215
+ async function onGenerateToken() {
216
+ const fresh = genToken();
217
+ setTokenLocal(fresh);
218
+ try {
219
+ const s = await api('POST', '/api/tunnel/token', { token: fresh });
220
+ setStatus(s);
221
+ setToast('New token in effect', 'ok');
222
+ } catch (e) { setToast(`token save failed · ${e.message}`, 'error'); }
223
+ }
224
+ async function onInstall(p) {
225
+ const ok = await ccsmConfirm(`Install ${p} via winget? Runs in the background — refresh after ~30s.`, {
226
+ title: 'Install tunnel provider', okLabel: 'Install',
227
+ });
228
+ if (!ok) return;
229
+ try {
230
+ await api('POST', '/api/tunnel/install', { provider: p });
231
+ setToast(`${p} install running in background · check back in a minute`, 'ok');
232
+ } catch (e) { setToast(`install failed · ${e.message}`, 'error'); }
233
+ }
234
+ function onLogin(p) {
235
+ if (p === 'devtunnel') {
236
+ copy('devtunnel user login');
237
+ setToast('Command copied · paste in a terminal to sign in', 'ok');
238
+ }
239
+ }
240
+
241
+ const running = status?.running;
242
+ const url = status?.url;
243
+ const share = shareUrl(url, token);
244
+ const log = status?.log || [];
245
+ const cf = status?.providers?.cloudflared;
246
+ const dt = status?.providers?.devtunnel;
247
+
248
+ return html`
249
+ <${PageTitleBar} title="Remote" />
250
+ <div class="settings-scroll">
251
+
252
+ <${Section}
253
+ title="Connection"
254
+ meta=${html`Pick the tunnel CLI ccsm should spawn. Loopback callers on this machine bypass the token automatically.`}>
255
+ <div class="config-grid">
256
+ <div class="field">
257
+ <span class="label">Provider</span>
258
+ <div class="chip-row">
259
+ <${ProviderChip} id="cloudflared" label="Cloudflare Tunnel"
260
+ selected=${provider === 'cloudflared'} onSelect=${setProvider} />
261
+ <${ProviderChip} id="devtunnel" label="Microsoft Dev Tunnel"
262
+ selected=${provider === 'devtunnel'} onSelect=${setProvider} />
263
+ </div>
264
+ </div>
265
+ <div class="field">
266
+ <span class="label">Cloudflare Tunnel</span>
267
+ <div class="remote-status-line">
268
+ <${ProviderStatus} id="cloudflared" info=${cf}
269
+ onInstall=${() => onInstall('cloudflared')} />
270
+ </div>
271
+ </div>
272
+ <div class="field">
273
+ <span class="label">Microsoft Dev Tunnel</span>
274
+ <div class="remote-status-line">
275
+ <${ProviderStatus} id="devtunnel" info=${dt}
276
+ onInstall=${() => onInstall('devtunnel')}
277
+ onLogin=${() => onLogin('devtunnel')} />
278
+ </div>
279
+ </div>
280
+ </div>
281
+ </${Section}>
282
+
283
+ <${Section}
284
+ title="Registration token"
285
+ meta=${html`Embedded in the share URL. Only needed to <em>register</em> a new device for approval — once you approve a device, it keeps working even if you rotate the token.`}>
286
+ <div class="config-grid">
287
+ <div class="field">
288
+ <span class="label">Token</span>
289
+ <div class="remote-token-row">
290
+ <input type="text" class="input remote-token-input"
291
+ readonly
292
+ placeholder="click Generate to create a token"
293
+ value=${token} />
294
+ <button type="button" class="action" title="Generate a new token and save it"
295
+ onClick=${onGenerateToken}>
296
+ <${IconRecycle} /> Generate
297
+ </button>
298
+ <button type="button" class="action"
299
+ disabled=${!token}
300
+ onClick=${() => copy(token)}>
301
+ <${IconCopy} /> Copy
302
+ </button>
303
+ </div>
304
+ <span class="hint">
305
+ ${(!status?.token && !token)
306
+ ? html`<span class="warn">No token set · new devices can't register.</span>`
307
+ : html`Active. Rotating it invalidates outstanding share URLs but doesn't kick out devices you've already approved.`}
308
+ </span>
309
+ </div>
310
+ </div>
311
+ </${Section}>
312
+
313
+ <${Section}
314
+ title="Tunnel"
315
+ meta=${running
316
+ ? html`Provider <code>${status?.provider}</code> · started ${new Date(status.startedAt).toLocaleTimeString()}`
317
+ : html`Not running.`}>
318
+ <div class="config-grid">
319
+ <div class="field">
320
+ <span class="label">State</span>
321
+ <div>
322
+ ${!running ? html`
323
+ <button type="button" class="action primary"
324
+ disabled=${busy || !token || token.length < 8}
325
+ onClick=${onStart}>
326
+ Start tunnel
327
+ </button>
328
+ ` : html`
329
+ <button type="button" class="action danger"
330
+ disabled=${busy}
331
+ onClick=${onStop}>
332
+ Stop tunnel
333
+ </button>
334
+ `}
335
+ ${running && !url ? html`<span class="hint inline">Waiting for URL…</span>` : null}
336
+ </div>
337
+ </div>
338
+
339
+ ${running && url ? html`
340
+ <div class="field">
341
+ <span class="label">Share URL</span>
342
+ <div class="remote-url-line">
343
+ <code class="remote-url-value">${share}</code>
344
+ <button type="button" class="action" onClick=${() => copy(share)}>
345
+ <${IconCopy} /> Copy
346
+ </button>
347
+ <a class="action" href=${share} target="_blank" rel="noreferrer noopener">
348
+ <${IconExternal} /> Open
349
+ </a>
350
+ </div>
351
+ <span class="hint">
352
+ Send this to the remote device · token embedded, stripped from the URL on first arrival.
353
+ </span>
354
+ </div>
355
+ ` : null}
356
+
357
+ ${log.length ? html`
358
+ <div class="field">
359
+ <span class="label">CLI log</span>
360
+ <details class="remote-log">
361
+ <summary>${log.length} lines</summary>
362
+ <pre>${log.join('\n')}</pre>
363
+ </details>
364
+ </div>
365
+ ` : null}
366
+ </div>
367
+ </${Section}>
368
+
369
+ <${Section}
370
+ title="Devices"
371
+ meta=${html`Browsers that loaded the share URL. Approve once per device — token alone is not enough.`}>
372
+ ${(() => {
373
+ const pending = deviceList.filter((d) => d.status === 'pending');
374
+ const approved = deviceList.filter((d) => d.status === 'approved');
375
+ const rejected = deviceList.filter((d) => d.status === 'rejected');
376
+ if (!deviceList.length) {
377
+ return html`<p class="remote-empty">No devices yet. Send the share URL to a phone or another laptop to add the first one.</p>`;
378
+ }
379
+ return html`
380
+ <div class="remote-devices">
381
+ ${pending.length ? html`
382
+ <div class="remote-devices-group">
383
+ <div class="remote-devices-group-head">
384
+ <span class="remote-devices-group-title">Pending approval</span>
385
+ <span class="remote-devices-group-count">${pending.length}</span>
386
+ </div>
387
+ ${pending.map((d) => html`<${DeviceRow}
388
+ key=${d.id} d=${d} kind="pending"
389
+ onApprove=${() => onApproveDevice(d.id)}
390
+ onReject=${() => onRejectDevice(d.id)} />`)}
391
+ </div>
392
+ ` : null}
393
+ ${approved.length ? html`
394
+ <div class="remote-devices-group">
395
+ <div class="remote-devices-group-head">
396
+ <span class="remote-devices-group-title">Approved</span>
397
+ <span class="remote-devices-group-count">${approved.length}</span>
398
+ </div>
399
+ ${approved.map((d) => html`<${DeviceRow}
400
+ key=${d.id} d=${d} kind="approved"
401
+ onRename=${() => onRenameDevice(d)}
402
+ onRevoke=${() => onRevokeDevice(d)} />`)}
403
+ </div>
404
+ ` : null}
405
+ ${rejected.length ? html`
406
+ <div class="remote-devices-group">
407
+ <div class="remote-devices-group-head">
408
+ <span class="remote-devices-group-title">Rejected</span>
409
+ <span class="remote-devices-group-count">${rejected.length}</span>
410
+ <span class="remote-devices-group-hint">auto-clears 1h after rejection</span>
411
+ </div>
412
+ ${rejected.map((d) => html`<${DeviceRow}
413
+ key=${d.id} d=${d} kind="rejected"
414
+ onApprove=${() => onApproveDevice(d.id)}
415
+ onDelete=${() => onDeleteDevice(d)} />`)}
416
+ </div>
417
+ ` : null}
418
+ </div>`;
419
+ })()}
420
+ </${Section}>
421
+
422
+ <${Section} title="How access works" meta="What the token does, what an approved device gets, and how to lock things back down.">
423
+ <dl class="remote-facts">
424
+ <div class="remote-fact">
425
+ <dt>The token is just a knock</dt>
426
+ <dd>
427
+ The share URL embeds the token and the remote browser uses it once — only to register itself in the <strong>Pending</strong> list. After that the URL + token grant nothing on their own. Until you click <strong>Approve</strong>, the visitor's <code>/api/*</code> calls all return 403.
428
+ </dd>
429
+ </div>
430
+ <div class="remote-fact">
431
+ <dt>Approved devices are sticky</dt>
432
+ <dd>
433
+ Once approved, the device's per-browser UUID becomes the credential — every API + WebSocket call rides on that alone. <strong>Rotating the token doesn't kick them out</strong>; that only blocks new arrivals. To lock an existing device out, hit <strong>Revoke</strong> in the Devices list above.
434
+ </dd>
435
+ </div>
436
+ <div class="remote-fact">
437
+ <dt>This machine is exempt</dt>
438
+ <dd>
439
+ Loopback callers (<code>localhost</code>, <code>127.0.0.1</code>) skip both checks — your own browser on this host needs nothing. Tunnel traffic is distinguished by the <code>X-Forwarded-*</code> headers the proxies inject, so it can't masquerade as local.
440
+ </dd>
441
+ </div>
442
+ </dl>
443
+ </${Section}>
444
+
445
+ </div>`;
446
+ }
@@ -26,6 +26,11 @@ export const sidebarCollapsed = signal(false);
26
26
  // by the responsive layout — the toggle button hides in that case so the
27
27
  // user can't try (and fail) to expand it.
28
28
  export const sidebarForcedCollapsed = signal(false);
29
+ // True on phone-sized viewports (≤ 640px). The sidebar then hides
30
+ // entirely; a FAB at bottom-left opens a full-screen drawer.
31
+ export const isMobile = signal(false);
32
+ // Mobile drawer visibility — toggled by the FAB / nav-item taps.
33
+ export const mobileDrawerOpen = signal(false);
29
34
  export const sidebarWidth = signal(232); // px when expanded, persisted in localStorage
30
35
  export const accentColor = signal('#2f6fa3'); // user-chosen brand accent, persisted
31
36
  // Per-folder collapse state in the sidebar tree. Stored as a plain object
@@ -86,6 +91,7 @@ export const TAB_HEADINGS = {
86
91
  sessions: { title: 'Sessions', subtitle: 'Sessions you started in ccsm.' },
87
92
  launch: { title: 'Launch', subtitle: 'Spin up a new session in a fresh workspace.' },
88
93
  configure: { title: 'Configure', subtitle: 'Persisted to ~/.ccsm/config.json.' },
94
+ remote: { title: 'Remote', subtitle: 'Expose this backend to another device via tunnel + token.' },
89
95
  about: { title: 'About', subtitle: 'ccsm — Claude CLI Sessions Manager.' },
90
96
  };
91
97
 
@@ -222,12 +228,16 @@ export function selectTab(name) {
222
228
  if (!TAB_HEADINGS[name]) name = 'sessions';
223
229
  activeTab.value = name;
224
230
  if (location.hash !== `#${name}`) window.history.replaceState(null, '', `#${name}`);
231
+ // Tapping a nav item on mobile is also a "close the drawer" gesture
232
+ // — the user got what they came for, no need to keep the overlay up.
233
+ if (mobileDrawerOpen.value) mobileDrawerOpen.value = false;
225
234
  }
226
235
 
227
236
  export function selectSession(id) {
228
237
  activeSessionId.value = id;
229
238
  activeTab.value = 'sessions';
230
239
  if (location.hash !== '#sessions') window.history.replaceState(null, '', '#sessions');
240
+ if (mobileDrawerOpen.value) mobileDrawerOpen.value = false;
231
241
  }
232
242
 
233
243
  export function toggleSidebar() {
package/scripts/dev.js CHANGED
@@ -94,6 +94,17 @@ const env = {
94
94
  // `ccsm.cmd` and would replace our --watch checkout server). In dev
95
95
  // mode the server just process.exit(0)s and this script respawns it.
96
96
  CCSM_DEV: '1',
97
+ // Always opt out of the 90s heartbeat watchdog in dev. The watchdog
98
+ // only matters when ccsm is tied to its own spawned browser window —
99
+ // closing that window means ccsm should stop. In dev there's no such
100
+ // window (CCSM_NO_BROWSER above) and the contributor's browser tab
101
+ // may be closed for minutes during a long file edit. Without this,
102
+ // any ambient CCSM_LAUNCHER=1 in the parent shell would silently make
103
+ // the dev server self-terminate every 90s.
104
+ CCSM_KEEP_ALIVE: '1',
105
+ // Explicitly clear CCSM_LAUNCHER so the watchdog activation condition
106
+ // can never be true here regardless of the parent env.
107
+ CCSM_LAUNCHER: '',
97
108
  };
98
109
 
99
110
  const serverPath = path.join(__dirname, '..', 'server.js');