@bakapiano/ccsm 0.22.2 → 0.22.4

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 (60) 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 +274 -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 +233 -231
  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 +176 -176
  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 +592 -592
  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 +187 -15
  42. package/public/js/components/TerminalResizeDebouncer.js +126 -0
  43. package/public/js/components/XtermTerminal.js +148 -14
  44. package/public/js/components/useDragSort.js +67 -67
  45. package/public/js/dialog.js +67 -67
  46. package/public/js/icons.js +212 -212
  47. package/public/js/main.js +296 -296
  48. package/public/js/pages/AboutPage.js +90 -90
  49. package/public/js/pages/ConfigurePage.js +713 -713
  50. package/public/js/pages/LaunchPage.js +421 -421
  51. package/public/js/pages/RemotePage.js +743 -743
  52. package/public/js/pages/SessionsPage.js +100 -100
  53. package/public/js/state.js +335 -335
  54. package/public/manifest.webmanifest +25 -0
  55. package/public/setup/index.html +567 -0
  56. package/scripts/dev.js +149 -149
  57. package/scripts/install.js +153 -153
  58. package/scripts/restart-helper.js +96 -96
  59. package/scripts/upgrade-helper.js +687 -687
  60. package/server.js +1807 -1807
package/lib/devices.js CHANGED
@@ -1,229 +1,229 @@
1
- 'use strict';
2
-
3
- // Remote-device approval store. Each browser that arrives via the tunnel
4
- // generates a UUID client-side (`ccsm.deviceId` in localStorage) and sends
5
- // it as `X-Device-Id` on every API call. server.js' middleware feeds those
6
- // arrivals through record() — known devices get their lastSeen bumped,
7
- // new ones get inserted as `pending`. Until the host explicitly Approves
8
- // from the Remote page, every non-loopback request returns 403 with the
9
- // pending status.
10
- //
11
- // Stored at ~/.ccsm/devices.json:
12
- // {
13
- // "<uuid>": {
14
- // id, status: 'pending'|'approved'|'rejected',
15
- // userAgent, ip,
16
- // firstSeen, lastSeen, approvedAt,
17
- // label // user-set or auto-derived from UA
18
- // },
19
- // ...
20
- // }
21
-
22
- const { DATA_DIR } = require('./config');
23
- const { createKeyedJsonStore } = require('./jsonStore');
24
- const { withFileLock } = require('./atomicJson');
25
-
26
- const store = createKeyedJsonStore({
27
- dataDir: DATA_DIR,
28
- filename: 'devices.json',
29
- });
30
-
31
- // `record()` runs on EVERY non-loopback API request. Two side effects
32
- // without these guards:
33
- // 1. Concurrent calls each do load→mutate→save independently; the
34
- // parallel rename(tmp → target) collides on Windows and surfaces
35
- // as `EPERM: operation not permitted`.
36
- // 2. Even when serialized, writing on every request hammers the disk
37
- // for a value that only needs ~minute-grained accuracy (lastSeen
38
- // drives "seen 5m ago" labels in the UI).
39
- // Fix: serialize all mutators through the shared per-file lock, and
40
- // short-circuit lastSeen-only updates that landed within MIN_FLUSH_MS
41
- // of the last persisted write for the same id.
42
- const MIN_FLUSH_MS = 15_000;
43
- const lastFlushAt = new Map(); // id → ms timestamp of last save
44
-
45
- // Pending entries older than 24h are auto-pruned on each list() so a
46
- // drive-by scanner doesn't grow the file forever. Rejected entries kept
47
- // 1h so the host can see what got bounced and rename / un-reject if
48
- // they realize it was legit.
49
- const PENDING_TTL_MS = 24 * 60 * 60 * 1000;
50
- const REJECTED_TTL_MS = 60 * 60 * 1000;
51
-
52
- // Quick UA → human-readable label. We keep this tiny on purpose — full
53
- // UA parsing libraries are huge and the only consumer is one line in
54
- // the approval UI. Order matters: Edge UA includes "Chrome" so detect
55
- // Edge first.
56
- function describeUA(ua) {
57
- ua = String(ua || '');
58
- const device =
59
- /iPhone/.test(ua) ? 'iPhone'
60
- : /iPad/.test(ua) ? 'iPad'
61
- : /Android/.test(ua) ? 'Android'
62
- : /Mac OS X/.test(ua) ? 'Mac'
63
- : /Windows/.test(ua) ? 'Windows'
64
- : /Linux/.test(ua) ? 'Linux'
65
- : null;
66
- const browser =
67
- /Edg\//.test(ua) ? 'Edge'
68
- : /OPR\//.test(ua) ? 'Opera'
69
- : /Chrome\//.test(ua) ? 'Chrome'
70
- : /Firefox\//.test(ua) ? 'Firefox'
71
- : /Safari\//.test(ua) ? 'Safari'
72
- : null;
73
- if (device && browser) return `${device} · ${browser}`;
74
- if (device) return device;
75
- if (browser) return browser;
76
- return 'Unknown device';
77
- }
78
-
79
- async function pruneStale(map) {
80
- const now = Date.now();
81
- let dirty = false;
82
- for (const [id, d] of Object.entries(map)) {
83
- if (d.status === 'pending' && now - d.firstSeen > PENDING_TTL_MS) { delete map[id]; dirty = true; }
84
- if (d.status === 'rejected' && now - (d.rejectedAt || d.lastSeen) > REJECTED_TTL_MS) { delete map[id]; dirty = true; }
85
- }
86
- if (dirty) await store.save(map);
87
- return map;
88
- }
89
-
90
- // Upsert. Returns the (possibly newly-created) device record. Caller
91
- // uses .status to decide whether to gate further work.
92
- async function record(id, { userAgent, ip, code } = {}) {
93
- if (!id) throw new Error('device id required');
94
- return withFileLock(store.filePath, async () => {
95
- const map = await store.load();
96
- const now = Date.now();
97
- const existing = map[id];
98
- if (existing) {
99
- // Throttled lastSeen update: if the only thing that would change
100
- // is lastSeen and we flushed for this id within MIN_FLUSH_MS,
101
- // return the in-memory copy without touching disk. Saves a
102
- // disk write per request when remote pages poll at 2.5s.
103
- const recentlyFlushed = (now - (lastFlushAt.get(id) || 0)) < MIN_FLUSH_MS;
104
- let nonLastSeenChange = false;
105
- existing.lastSeen = now;
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;
117
- await store.save(map);
118
- lastFlushAt.set(id, now);
119
- return existing;
120
- }
121
- map[id] = {
122
- id,
123
- status: 'pending',
124
- userAgent: userAgent || null,
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,
132
- firstSeen: now,
133
- lastSeen: now,
134
- approvedAt: null,
135
- rejectedAt: null,
136
- label: describeUA(userAgent),
137
- };
138
- await store.save(map);
139
- lastFlushAt.set(id, now);
140
- return map[id];
141
- });
142
- }
143
-
144
- async function get(id) {
145
- const map = await store.load();
146
- return map[id] || null;
147
- }
148
-
149
- async function isApproved(id) {
150
- const d = await get(id);
151
- return !!(d && d.status === 'approved');
152
- }
153
-
154
- async function approve(id, label) {
155
- return withFileLock(store.filePath, async () => {
156
- const map = await store.load();
157
- const d = map[id];
158
- if (!d) return null;
159
- d.status = 'approved';
160
- d.approvedAt = Date.now();
161
- d.rejectedAt = null;
162
- if (label) d.label = String(label);
163
- await store.save(map);
164
- lastFlushAt.set(id, Date.now());
165
- return d;
166
- });
167
- }
168
-
169
- async function reject(id) {
170
- return withFileLock(store.filePath, async () => {
171
- const map = await store.load();
172
- const d = map[id];
173
- if (!d) return null;
174
- d.status = 'rejected';
175
- d.rejectedAt = Date.now();
176
- d.approvedAt = null;
177
- await store.save(map);
178
- lastFlushAt.set(id, Date.now());
179
- return d;
180
- });
181
- }
182
-
183
- // Identical to reject in storage terms, but separate API name so the UI
184
- // can distinguish "I'm declining a new request" from "I'm taking back
185
- // access from someone I'd previously approved". Both end up status:
186
- // 'rejected' and clear approvedAt — once cleared, the device must
187
- // request again from scratch.
188
- async function revoke(id) {
189
- return reject(id);
190
- }
191
-
192
- async function rename(id, label) {
193
- return withFileLock(store.filePath, async () => {
194
- const map = await store.load();
195
- const d = map[id];
196
- if (!d) return null;
197
- d.label = String(label || '').slice(0, 60);
198
- await store.save(map);
199
- return d;
200
- });
201
- }
202
-
203
- async function remove(id) {
204
- return store.remove(id);
205
- }
206
-
207
- async function list() {
208
- const map = await pruneStale(await store.load());
209
- return Object.values(map).sort((a, b) => {
210
- // Pending first (so the host sees them at the top), then approved
211
- // by approvedAt desc, then rejected by rejectedAt desc.
212
- const order = { pending: 0, approved: 1, rejected: 2 };
213
- if (order[a.status] !== order[b.status]) return order[a.status] - order[b.status];
214
- return (b.lastSeen || 0) - (a.lastSeen || 0);
215
- });
216
- }
217
-
218
- module.exports = {
219
- record,
220
- get,
221
- isApproved,
222
- approve,
223
- reject,
224
- revoke,
225
- rename,
226
- remove,
227
- list,
228
- describeUA,
229
- };
1
+ 'use strict';
2
+
3
+ // Remote-device approval store. Each browser that arrives via the tunnel
4
+ // generates a UUID client-side (`ccsm.deviceId` in localStorage) and sends
5
+ // it as `X-Device-Id` on every API call. server.js' middleware feeds those
6
+ // arrivals through record() — known devices get their lastSeen bumped,
7
+ // new ones get inserted as `pending`. Until the host explicitly Approves
8
+ // from the Remote page, every non-loopback request returns 403 with the
9
+ // pending status.
10
+ //
11
+ // Stored at ~/.ccsm/devices.json:
12
+ // {
13
+ // "<uuid>": {
14
+ // id, status: 'pending'|'approved'|'rejected',
15
+ // userAgent, ip,
16
+ // firstSeen, lastSeen, approvedAt,
17
+ // label // user-set or auto-derived from UA
18
+ // },
19
+ // ...
20
+ // }
21
+
22
+ const { DATA_DIR } = require('./config');
23
+ const { createKeyedJsonStore } = require('./jsonStore');
24
+ const { withFileLock } = require('./atomicJson');
25
+
26
+ const store = createKeyedJsonStore({
27
+ dataDir: DATA_DIR,
28
+ filename: 'devices.json',
29
+ });
30
+
31
+ // `record()` runs on EVERY non-loopback API request. Two side effects
32
+ // without these guards:
33
+ // 1. Concurrent calls each do load→mutate→save independently; the
34
+ // parallel rename(tmp → target) collides on Windows and surfaces
35
+ // as `EPERM: operation not permitted`.
36
+ // 2. Even when serialized, writing on every request hammers the disk
37
+ // for a value that only needs ~minute-grained accuracy (lastSeen
38
+ // drives "seen 5m ago" labels in the UI).
39
+ // Fix: serialize all mutators through the shared per-file lock, and
40
+ // short-circuit lastSeen-only updates that landed within MIN_FLUSH_MS
41
+ // of the last persisted write for the same id.
42
+ const MIN_FLUSH_MS = 15_000;
43
+ const lastFlushAt = new Map(); // id → ms timestamp of last save
44
+
45
+ // Pending entries older than 24h are auto-pruned on each list() so a
46
+ // drive-by scanner doesn't grow the file forever. Rejected entries kept
47
+ // 1h so the host can see what got bounced and rename / un-reject if
48
+ // they realize it was legit.
49
+ const PENDING_TTL_MS = 24 * 60 * 60 * 1000;
50
+ const REJECTED_TTL_MS = 60 * 60 * 1000;
51
+
52
+ // Quick UA → human-readable label. We keep this tiny on purpose — full
53
+ // UA parsing libraries are huge and the only consumer is one line in
54
+ // the approval UI. Order matters: Edge UA includes "Chrome" so detect
55
+ // Edge first.
56
+ function describeUA(ua) {
57
+ ua = String(ua || '');
58
+ const device =
59
+ /iPhone/.test(ua) ? 'iPhone'
60
+ : /iPad/.test(ua) ? 'iPad'
61
+ : /Android/.test(ua) ? 'Android'
62
+ : /Mac OS X/.test(ua) ? 'Mac'
63
+ : /Windows/.test(ua) ? 'Windows'
64
+ : /Linux/.test(ua) ? 'Linux'
65
+ : null;
66
+ const browser =
67
+ /Edg\//.test(ua) ? 'Edge'
68
+ : /OPR\//.test(ua) ? 'Opera'
69
+ : /Chrome\//.test(ua) ? 'Chrome'
70
+ : /Firefox\//.test(ua) ? 'Firefox'
71
+ : /Safari\//.test(ua) ? 'Safari'
72
+ : null;
73
+ if (device && browser) return `${device} · ${browser}`;
74
+ if (device) return device;
75
+ if (browser) return browser;
76
+ return 'Unknown device';
77
+ }
78
+
79
+ async function pruneStale(map) {
80
+ const now = Date.now();
81
+ let dirty = false;
82
+ for (const [id, d] of Object.entries(map)) {
83
+ if (d.status === 'pending' && now - d.firstSeen > PENDING_TTL_MS) { delete map[id]; dirty = true; }
84
+ if (d.status === 'rejected' && now - (d.rejectedAt || d.lastSeen) > REJECTED_TTL_MS) { delete map[id]; dirty = true; }
85
+ }
86
+ if (dirty) await store.save(map);
87
+ return map;
88
+ }
89
+
90
+ // Upsert. Returns the (possibly newly-created) device record. Caller
91
+ // uses .status to decide whether to gate further work.
92
+ async function record(id, { userAgent, ip, code } = {}) {
93
+ if (!id) throw new Error('device id required');
94
+ return withFileLock(store.filePath, async () => {
95
+ const map = await store.load();
96
+ const now = Date.now();
97
+ const existing = map[id];
98
+ if (existing) {
99
+ // Throttled lastSeen update: if the only thing that would change
100
+ // is lastSeen and we flushed for this id within MIN_FLUSH_MS,
101
+ // return the in-memory copy without touching disk. Saves a
102
+ // disk write per request when remote pages poll at 2.5s.
103
+ const recentlyFlushed = (now - (lastFlushAt.get(id) || 0)) < MIN_FLUSH_MS;
104
+ let nonLastSeenChange = false;
105
+ existing.lastSeen = now;
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;
117
+ await store.save(map);
118
+ lastFlushAt.set(id, now);
119
+ return existing;
120
+ }
121
+ map[id] = {
122
+ id,
123
+ status: 'pending',
124
+ userAgent: userAgent || null,
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,
132
+ firstSeen: now,
133
+ lastSeen: now,
134
+ approvedAt: null,
135
+ rejectedAt: null,
136
+ label: describeUA(userAgent),
137
+ };
138
+ await store.save(map);
139
+ lastFlushAt.set(id, now);
140
+ return map[id];
141
+ });
142
+ }
143
+
144
+ async function get(id) {
145
+ const map = await store.load();
146
+ return map[id] || null;
147
+ }
148
+
149
+ async function isApproved(id) {
150
+ const d = await get(id);
151
+ return !!(d && d.status === 'approved');
152
+ }
153
+
154
+ async function approve(id, label) {
155
+ return withFileLock(store.filePath, async () => {
156
+ const map = await store.load();
157
+ const d = map[id];
158
+ if (!d) return null;
159
+ d.status = 'approved';
160
+ d.approvedAt = Date.now();
161
+ d.rejectedAt = null;
162
+ if (label) d.label = String(label);
163
+ await store.save(map);
164
+ lastFlushAt.set(id, Date.now());
165
+ return d;
166
+ });
167
+ }
168
+
169
+ async function reject(id) {
170
+ return withFileLock(store.filePath, async () => {
171
+ const map = await store.load();
172
+ const d = map[id];
173
+ if (!d) return null;
174
+ d.status = 'rejected';
175
+ d.rejectedAt = Date.now();
176
+ d.approvedAt = null;
177
+ await store.save(map);
178
+ lastFlushAt.set(id, Date.now());
179
+ return d;
180
+ });
181
+ }
182
+
183
+ // Identical to reject in storage terms, but separate API name so the UI
184
+ // can distinguish "I'm declining a new request" from "I'm taking back
185
+ // access from someone I'd previously approved". Both end up status:
186
+ // 'rejected' and clear approvedAt — once cleared, the device must
187
+ // request again from scratch.
188
+ async function revoke(id) {
189
+ return reject(id);
190
+ }
191
+
192
+ async function rename(id, label) {
193
+ return withFileLock(store.filePath, async () => {
194
+ const map = await store.load();
195
+ const d = map[id];
196
+ if (!d) return null;
197
+ d.label = String(label || '').slice(0, 60);
198
+ await store.save(map);
199
+ return d;
200
+ });
201
+ }
202
+
203
+ async function remove(id) {
204
+ return store.remove(id);
205
+ }
206
+
207
+ async function list() {
208
+ const map = await pruneStale(await store.load());
209
+ return Object.values(map).sort((a, b) => {
210
+ // Pending first (so the host sees them at the top), then approved
211
+ // by approvedAt desc, then rejected by rejectedAt desc.
212
+ const order = { pending: 0, approved: 1, rejected: 2 };
213
+ if (order[a.status] !== order[b.status]) return order[a.status] - order[b.status];
214
+ return (b.lastSeen || 0) - (a.lastSeen || 0);
215
+ });
216
+ }
217
+
218
+ module.exports = {
219
+ record,
220
+ get,
221
+ isApproved,
222
+ approve,
223
+ reject,
224
+ revoke,
225
+ rename,
226
+ remove,
227
+ list,
228
+ describeUA,
229
+ };