@bakapiano/ccsm 0.18.6 → 0.19.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.
- package/lib/devices.js +19 -5
- package/lib/tunnel.js +151 -0
- package/package.json +1 -1
- package/public/css/feedback.css +72 -0
- package/public/css/responsive.css +9 -1
- package/public/css/sidebar.css +11 -5
- package/public/css/widgets.css +714 -1
- package/public/js/api.js +23 -7
- package/public/js/backend.js +26 -0
- package/public/js/components/App.js +2 -0
- package/public/js/components/OfflineBanner.js +9 -35
- package/public/js/components/PendingApprovalOverlay.js +44 -2
- package/public/js/components/RestartOverlay.js +36 -0
- package/public/js/components/Sidebar.js +1 -1
- package/public/js/icons.js +11 -6
- package/public/js/main.js +17 -0
- package/public/js/pages/ConfigurePage.js +43 -25
- package/public/js/pages/RemotePage.js +318 -152
- package/public/js/state.js +6 -0
- package/server.js +24 -2
package/lib/devices.js
CHANGED
|
@@ -89,7 +89,7 @@ async function pruneStale(map) {
|
|
|
89
89
|
|
|
90
90
|
// Upsert. Returns the (possibly newly-created) device record. Caller
|
|
91
91
|
// uses .status to decide whether to gate further work.
|
|
92
|
-
async function record(id, { userAgent, ip } = {}) {
|
|
92
|
+
async function record(id, { userAgent, ip, code } = {}) {
|
|
93
93
|
if (!id) throw new Error('device id required');
|
|
94
94
|
return withFileLock(store.filePath, async () => {
|
|
95
95
|
const map = await store.load();
|
|
@@ -100,12 +100,20 @@ async function record(id, { userAgent, ip } = {}) {
|
|
|
100
100
|
// is lastSeen and we flushed for this id within MIN_FLUSH_MS,
|
|
101
101
|
// return the in-memory copy without touching disk. Saves a
|
|
102
102
|
// disk write per request when remote pages poll at 2.5s.
|
|
103
|
-
const onlyLastSeen = (!userAgent || existing.userAgent) && (!ip || existing.ip === ip);
|
|
104
103
|
const recentlyFlushed = (now - (lastFlushAt.get(id) || 0)) < MIN_FLUSH_MS;
|
|
104
|
+
let nonLastSeenChange = false;
|
|
105
105
|
existing.lastSeen = now;
|
|
106
|
-
if (userAgent && !existing.userAgent) existing.userAgent = userAgent;
|
|
107
|
-
if (ip) existing.ip = ip;
|
|
108
|
-
|
|
106
|
+
if (userAgent && !existing.userAgent) { existing.userAgent = userAgent; nonLastSeenChange = true; }
|
|
107
|
+
if (ip && existing.ip !== ip) { existing.ip = ip; nonLastSeenChange = true; }
|
|
108
|
+
// Backfill code on existing records that pre-date the field, but
|
|
109
|
+
// never overwrite a code we've already stored — a device's code
|
|
110
|
+
// is its identifier across the approval flow and changing it
|
|
111
|
+
// mid-request would defeat the disambiguation it exists for.
|
|
112
|
+
if (code && !existing.code) {
|
|
113
|
+
existing.code = String(code).slice(0, 8);
|
|
114
|
+
nonLastSeenChange = true;
|
|
115
|
+
}
|
|
116
|
+
if (!nonLastSeenChange && recentlyFlushed) return existing;
|
|
109
117
|
await store.save(map);
|
|
110
118
|
lastFlushAt.set(id, now);
|
|
111
119
|
return existing;
|
|
@@ -115,6 +123,12 @@ async function record(id, { userAgent, ip } = {}) {
|
|
|
115
123
|
status: 'pending',
|
|
116
124
|
userAgent: userAgent || null,
|
|
117
125
|
ip: ip || null,
|
|
126
|
+
// 4-digit identification code the requesting browser generated
|
|
127
|
+
// and stashed in its own localStorage. Purely human-facing;
|
|
128
|
+
// the Remote page renders it next to each pending device so the
|
|
129
|
+
// operator can match against what the user reads off their
|
|
130
|
+
// screen before clicking Approve. Not a credential.
|
|
131
|
+
code: code ? String(code).slice(0, 8) : null,
|
|
118
132
|
firstSeen: now,
|
|
119
133
|
lastSeen: now,
|
|
120
134
|
approvedAt: null,
|
package/lib/tunnel.js
CHANGED
|
@@ -69,6 +69,10 @@ let token = null; // Remote-access bearer token. Null = no remote
|
|
|
69
69
|
// access enforced. Set via setToken() or by the
|
|
70
70
|
// start() call. Server.js middleware reads via
|
|
71
71
|
// getToken().
|
|
72
|
+
let login = null; // Pending interactive `devtunnel user login -d`
|
|
73
|
+
// flow · { child, mode, lines, url, code, status,
|
|
74
|
+
// startedAt, finishedAt, error, user }. Single
|
|
75
|
+
// flow at a time. See startDevtunnelLogin().
|
|
72
76
|
|
|
73
77
|
function getToken() { return token; }
|
|
74
78
|
function setToken(t) { token = t ? String(t) : null; return token; }
|
|
@@ -178,9 +182,152 @@ async function status() {
|
|
|
178
182
|
// pre-built share URL. The route itself is token-protected
|
|
179
183
|
// (the middleware blocks non-loopback callers without it), so
|
|
180
184
|
// anyone reaching this endpoint already knows the token.
|
|
185
|
+
login: loginSnapshot(),
|
|
181
186
|
};
|
|
182
187
|
}
|
|
183
188
|
|
|
189
|
+
// ---- devtunnel interactive login (device-code flow) ----
|
|
190
|
+
//
|
|
191
|
+
// `devtunnel user login -d` prints a Microsoft device-code line then
|
|
192
|
+
// polls Azure until the user authenticates in a browser (or until it
|
|
193
|
+
// times out). We spawn it as a child, parse the URL + code out of the
|
|
194
|
+
// first informational lines, and expose progress via /api/tunnel/status
|
|
195
|
+
// so the Remote page can render a one-click sign-in panel instead of
|
|
196
|
+
// asking the user to paste a command into a terminal.
|
|
197
|
+
//
|
|
198
|
+
// Two modes: 'microsoft' (default, -d) and 'github' (-g -d). GitHub is
|
|
199
|
+
// only worth offering if the user explicitly picks it — Microsoft
|
|
200
|
+
// device code works for Entra ID / personal MS accounts and is what
|
|
201
|
+
// most people land on.
|
|
202
|
+
//
|
|
203
|
+
// State machine:
|
|
204
|
+
// running → child alive, waiting for user
|
|
205
|
+
// done → child exited 0; probe cache is invalidated so the next
|
|
206
|
+
// providers map shows `loggedIn: true`
|
|
207
|
+
// error → child exited non-zero or crashed
|
|
208
|
+
// canceled → cancelDevtunnelLogin() killed the child
|
|
209
|
+
function loginSnapshot() {
|
|
210
|
+
if (!login) return null;
|
|
211
|
+
return {
|
|
212
|
+
mode: login.mode,
|
|
213
|
+
status: login.status,
|
|
214
|
+
url: login.url,
|
|
215
|
+
code: login.code,
|
|
216
|
+
error: login.error || null,
|
|
217
|
+
user: login.user || null,
|
|
218
|
+
startedAt: login.startedAt,
|
|
219
|
+
finishedAt: login.finishedAt || null,
|
|
220
|
+
lines: login.lines.slice(-40),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function startDevtunnelLogin({ mode = 'microsoft' } = {}) {
|
|
225
|
+
if (login && login.status === 'running') {
|
|
226
|
+
// Already in flight · return the snapshot rather than throwing so
|
|
227
|
+
// a double-click on Sign in doesn't error out.
|
|
228
|
+
return loginSnapshot();
|
|
229
|
+
}
|
|
230
|
+
const exe = await findBinary('devtunnel');
|
|
231
|
+
if (!exe) throw new Error('Microsoft Dev Tunnel is not installed');
|
|
232
|
+
// Starting a fresh login drops any existing credentials from disk
|
|
233
|
+
// before the new flow finishes — so the cached probe ("signed in as
|
|
234
|
+
// old-user") is now lying. Invalidate so the next /status round-
|
|
235
|
+
// trip re-shells `devtunnel user show` and the provider line flips
|
|
236
|
+
// to "not signed in" while the device-code panel is up.
|
|
237
|
+
invalidateProbe();
|
|
238
|
+
|
|
239
|
+
// -d picks the Microsoft device-code flow; -g switches it to GitHub.
|
|
240
|
+
// We deliberately stay on device code (no `--use-browser`) — the user
|
|
241
|
+
// may be driving the Remote page from a phone where opening a local
|
|
242
|
+
// browser on the host machine doesn't help.
|
|
243
|
+
const args = mode === 'github' ? ['user', 'login', '-g', '-d'] : ['user', 'login', '-d'];
|
|
244
|
+
const child = spawn(exe, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
245
|
+
const entry = {
|
|
246
|
+
child,
|
|
247
|
+
mode,
|
|
248
|
+
lines: [],
|
|
249
|
+
url: null,
|
|
250
|
+
code: null,
|
|
251
|
+
status: 'running',
|
|
252
|
+
error: null,
|
|
253
|
+
user: null,
|
|
254
|
+
startedAt: Date.now(),
|
|
255
|
+
finishedAt: null,
|
|
256
|
+
};
|
|
257
|
+
login = entry;
|
|
258
|
+
|
|
259
|
+
const URL_RE = /https?:\/\/\S+/i;
|
|
260
|
+
// Microsoft device-code prompt examples (have varied over CLI
|
|
261
|
+
// versions): "...enter the code ABCD-1234 to authenticate"
|
|
262
|
+
// "...code XXXXXXXXX..."
|
|
263
|
+
// GitHub device flow uses 8 chars with a dash, e.g. `XXXX-XXXX`.
|
|
264
|
+
const CODE_RE = /\b([A-Z0-9]{4,}-?[A-Z0-9]{3,})\b/;
|
|
265
|
+
const LOGGED = /Logged in as (\S+)/i;
|
|
266
|
+
|
|
267
|
+
const ingest = (line) => {
|
|
268
|
+
if (!line) return;
|
|
269
|
+
entry.lines.push(line);
|
|
270
|
+
if (entry.lines.length > 100) entry.lines.shift();
|
|
271
|
+
if (!entry.url) {
|
|
272
|
+
const m = line.match(URL_RE);
|
|
273
|
+
if (m) entry.url = m[0].replace(/[.,)]+$/, '');
|
|
274
|
+
}
|
|
275
|
+
if (!entry.code && /code/i.test(line)) {
|
|
276
|
+
// Skip URL-bearing fragments before extracting the code so we
|
|
277
|
+
// don't grab a uuid-ish segment out of the device login URL.
|
|
278
|
+
const sans = line.replace(URL_RE, '');
|
|
279
|
+
const m = sans.match(CODE_RE);
|
|
280
|
+
if (m) entry.code = m[1];
|
|
281
|
+
}
|
|
282
|
+
const u = line.match(LOGGED);
|
|
283
|
+
if (u) entry.user = u[1];
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
child.stdout.setEncoding('utf8');
|
|
287
|
+
child.stderr.setEncoding('utf8');
|
|
288
|
+
child.stdout.on('data', (c) => c.split(/\r?\n/).forEach(ingest));
|
|
289
|
+
child.stderr.on('data', (c) => c.split(/\r?\n/).forEach(ingest));
|
|
290
|
+
|
|
291
|
+
child.on('exit', (code, signal) => {
|
|
292
|
+
entry.finishedAt = Date.now();
|
|
293
|
+
if (entry.status === 'canceled') {
|
|
294
|
+
// already terminal; leave as-is
|
|
295
|
+
} else if (code === 0) {
|
|
296
|
+
entry.status = 'done';
|
|
297
|
+
// The just-completed login means the probe cache is now lying
|
|
298
|
+
// about `loggedIn: false`. Drop it so the next status() call
|
|
299
|
+
// re-shells `devtunnel user show` and the UI flips to signed-in.
|
|
300
|
+
invalidateProbe();
|
|
301
|
+
} else {
|
|
302
|
+
entry.status = 'error';
|
|
303
|
+
entry.error = `devtunnel exited code=${code}${signal ? ` signal=${signal}` : ''}`;
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
child.on('error', (err) => {
|
|
307
|
+
entry.status = 'error';
|
|
308
|
+
entry.error = String(err && err.message || err);
|
|
309
|
+
entry.finishedAt = Date.now();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
return loginSnapshot();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function cancelDevtunnelLogin() {
|
|
316
|
+
if (!login || login.status !== 'running') return loginSnapshot();
|
|
317
|
+
login.status = 'canceled';
|
|
318
|
+
login.finishedAt = Date.now();
|
|
319
|
+
try { login.child.kill(); } catch {}
|
|
320
|
+
return loginSnapshot();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function clearDevtunnelLogin() {
|
|
324
|
+
if (login && login.status === 'running') {
|
|
325
|
+
try { login.child.kill(); } catch {}
|
|
326
|
+
}
|
|
327
|
+
login = null;
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
184
331
|
// Spawn the tunnel CLI. Resolves once we've parsed the public URL out
|
|
185
332
|
// of stdout (with a timeout safety net). Throws if the CLI isn't
|
|
186
333
|
// installed, the provider is unknown, or another tunnel is running.
|
|
@@ -269,4 +416,8 @@ module.exports = {
|
|
|
269
416
|
installViaWinget,
|
|
270
417
|
getToken,
|
|
271
418
|
setToken,
|
|
419
|
+
startDevtunnelLogin,
|
|
420
|
+
cancelDevtunnelLogin,
|
|
421
|
+
clearDevtunnelLogin,
|
|
422
|
+
invalidateProbe,
|
|
272
423
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
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/feedback.css
CHANGED
|
@@ -167,6 +167,78 @@
|
|
|
167
167
|
font-size: 14px;
|
|
168
168
|
font-weight: 500;
|
|
169
169
|
}
|
|
170
|
+
/* 4-digit identification code block on the pending-approval overlay.
|
|
171
|
+
The code matches what the host operator sees in the Remote page
|
|
172
|
+
alongside this pending request — visual confirmation they're
|
|
173
|
+
approving the right device. */
|
|
174
|
+
.offline-code-block {
|
|
175
|
+
display: flex;
|
|
176
|
+
flex-direction: column;
|
|
177
|
+
align-items: center;
|
|
178
|
+
gap: 6px;
|
|
179
|
+
margin-top: var(--s-3);
|
|
180
|
+
padding: var(--s-3) var(--s-5);
|
|
181
|
+
background: var(--bg);
|
|
182
|
+
border: 1px solid var(--border);
|
|
183
|
+
border-radius: 8px;
|
|
184
|
+
}
|
|
185
|
+
.offline-code-label {
|
|
186
|
+
font-size: 10.5px;
|
|
187
|
+
letter-spacing: 0.18em;
|
|
188
|
+
text-transform: uppercase;
|
|
189
|
+
color: var(--ink-muted);
|
|
190
|
+
}
|
|
191
|
+
.offline-code {
|
|
192
|
+
font-family: var(--mono);
|
|
193
|
+
font-size: 32px;
|
|
194
|
+
font-weight: 600;
|
|
195
|
+
letter-spacing: 0.18em;
|
|
196
|
+
color: var(--ink);
|
|
197
|
+
font-variant-numeric: tabular-nums;
|
|
198
|
+
}
|
|
199
|
+
.offline-code-hint {
|
|
200
|
+
font-size: 11.5px;
|
|
201
|
+
color: var(--ink-muted);
|
|
202
|
+
text-align: center;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/* RestartOverlay — small top-of-viewport pill (not fullscreen) while
|
|
206
|
+
a user-initiated backend restart is in flight. Slides down from the
|
|
207
|
+
top, slides out on dismiss. Pinned center so it survives PWA WCO /
|
|
208
|
+
standalone window-control overlays without bumping into them. */
|
|
209
|
+
.restart-banner {
|
|
210
|
+
position: fixed;
|
|
211
|
+
top: env(titlebar-area-height, 16px);
|
|
212
|
+
left: 50%;
|
|
213
|
+
transform: translateX(-50%);
|
|
214
|
+
z-index: 1200;
|
|
215
|
+
display: inline-flex;
|
|
216
|
+
align-items: center;
|
|
217
|
+
gap: 10px;
|
|
218
|
+
padding: 8px 16px;
|
|
219
|
+
border-radius: 999px;
|
|
220
|
+
background: var(--ink);
|
|
221
|
+
color: #fff;
|
|
222
|
+
font-size: 12.5px;
|
|
223
|
+
font-weight: 500;
|
|
224
|
+
letter-spacing: -0.005em;
|
|
225
|
+
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.18);
|
|
226
|
+
animation: restart-banner-in .18s ease-out;
|
|
227
|
+
}
|
|
228
|
+
@keyframes restart-banner-in {
|
|
229
|
+
from { opacity: 0; transform: translate(-50%, -8px); }
|
|
230
|
+
to { opacity: 1; transform: translate(-50%, 0); }
|
|
231
|
+
}
|
|
232
|
+
.restart-banner-spinner {
|
|
233
|
+
width: 12px;
|
|
234
|
+
height: 12px;
|
|
235
|
+
border-radius: 50%;
|
|
236
|
+
border: 2px solid rgba(255, 255, 255, 0.25);
|
|
237
|
+
border-top-color: #fff;
|
|
238
|
+
animation: restart-spin 0.7s linear infinite;
|
|
239
|
+
}
|
|
240
|
+
.restart-banner-text { white-space: nowrap; }
|
|
241
|
+
@keyframes restart-spin { to { transform: rotate(360deg); } }
|
|
170
242
|
.offline-fallback {
|
|
171
243
|
margin-top: var(--s-3);
|
|
172
244
|
width: 100%;
|
|
@@ -120,6 +120,14 @@
|
|
|
120
120
|
pointers; touch never matches :hover. */
|
|
121
121
|
.mobile-nav-fab:hover { background: var(--bg); }
|
|
122
122
|
.mobile-nav-fab:active { cursor: grabbing; }
|
|
123
|
+
/* Suppress the browser's default focus ring + tap highlight — on touch
|
|
124
|
+
the ring lingers after every tap and reads as a stuck "selected"
|
|
125
|
+
state on the FAB. We're not removing all affordance: the icon swap
|
|
126
|
+
(hamburger ↔ X) + the is-open border change still communicate state.
|
|
127
|
+
:focus-visible is also dropped here since the FAB is touch-first. */
|
|
128
|
+
.mobile-nav-fab:focus,
|
|
129
|
+
.mobile-nav-fab:focus-visible { outline: none; }
|
|
130
|
+
.mobile-nav-fab { -webkit-tap-highlight-color: transparent; }
|
|
123
131
|
.mobile-nav-fab svg { width: 22px; height: 22px; stroke-width: 2; pointer-events: none; }
|
|
124
132
|
/* Open state stays the same paper white — only the icon swaps to X.
|
|
125
133
|
Keeping the colour consistent reads as "same button, different
|
|
@@ -127,7 +135,7 @@
|
|
|
127
135
|
.mobile-nav-fab.is-open {
|
|
128
136
|
background: var(--bg-elev);
|
|
129
137
|
color: var(--ink);
|
|
130
|
-
border-color: var(--
|
|
138
|
+
border-color: var(--border-strong);
|
|
131
139
|
}
|
|
132
140
|
|
|
133
141
|
.mobile-nav-backdrop {
|
package/public/css/sidebar.css
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
column: brand-strip → first nav item, last nav item → "Sessions"
|
|
6
6
|
header, and "Sessions" header → first folder. Bump or shrink to
|
|
7
7
|
adjust how breathy the rail feels. */
|
|
8
|
-
--sidebar-section-gap: var(--s-
|
|
8
|
+
--sidebar-section-gap: var(--s-2);
|
|
9
9
|
position: sticky;
|
|
10
10
|
top: 0;
|
|
11
11
|
height: 100vh;
|
|
@@ -400,7 +400,9 @@ body.is-resizing-sidebar * {
|
|
|
400
400
|
display: flex;
|
|
401
401
|
justify-content: space-between;
|
|
402
402
|
align-items: center;
|
|
403
|
-
padding
|
|
403
|
+
/* Right padding matches .tree-session so the +-button glyph
|
|
404
|
+
right-aligns with the per-row tree-meta timestamp below. */
|
|
405
|
+
padding: 0 8px;
|
|
404
406
|
min-height: 24px;
|
|
405
407
|
height: 24px;
|
|
406
408
|
font-size: 12px;
|
|
@@ -415,7 +417,10 @@ body.is-resizing-sidebar * {
|
|
|
415
417
|
appearance: none;
|
|
416
418
|
background: transparent;
|
|
417
419
|
border: 0;
|
|
418
|
-
padding
|
|
420
|
+
/* Zero right padding so the glyph itself lands flush against the
|
|
421
|
+
row's 8px right inset — same column as the tree-meta text on
|
|
422
|
+
each session row. */
|
|
423
|
+
padding: 2px 0 2px 4px;
|
|
419
424
|
border-radius: 4px;
|
|
420
425
|
cursor: pointer;
|
|
421
426
|
color: var(--ink);
|
|
@@ -693,9 +698,10 @@ body.is-resizing-sidebar * {
|
|
|
693
698
|
.tree-session-action:hover { opacity: 1; background: var(--sidebar-hover); }
|
|
694
699
|
.tree-session-action svg { width: 12px; height: 12px; }
|
|
695
700
|
.tree-meta {
|
|
696
|
-
font-size:
|
|
701
|
+
font-size: 12px;
|
|
697
702
|
color: var(--ink);
|
|
698
703
|
font-variant-numeric: tabular-nums;
|
|
699
704
|
flex-shrink: 0;
|
|
700
|
-
opacity: 0.
|
|
705
|
+
opacity: 0.55;
|
|
706
|
+
letter-spacing: 0.01em;
|
|
701
707
|
}
|