@bakapiano/ccsm 0.10.3 → 0.12.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.
Files changed (51) hide show
  1. package/CLAUDE.md +475 -475
  2. package/README.md +190 -190
  3. package/bin/ccsm.js +194 -194
  4. package/lib/atomicJson.js +48 -0
  5. package/lib/cliSessionWatcher.js +249 -249
  6. package/lib/config.js +188 -185
  7. package/lib/folders.js +105 -96
  8. package/lib/jsonStore.js +15 -10
  9. package/lib/localCliSessions.js +489 -177
  10. package/lib/persistedSessions.js +142 -134
  11. package/lib/webTerminal.js +208 -208
  12. package/lib/workspace.js +230 -255
  13. package/package.json +57 -57
  14. package/public/css/base.css +99 -99
  15. package/public/css/cards.css +183 -183
  16. package/public/css/feedback.css +303 -303
  17. package/public/css/forms.css +405 -405
  18. package/public/css/layout.css +160 -160
  19. package/public/css/modal.css +190 -183
  20. package/public/css/responsive.css +10 -10
  21. package/public/css/sidebar.css +608 -601
  22. package/public/css/terminals.css +294 -294
  23. package/public/css/tokens.css +81 -79
  24. package/public/css/wco.css +98 -98
  25. package/public/css/widgets.css +1596 -1375
  26. package/public/index.html +105 -103
  27. package/public/js/api.js +272 -260
  28. package/public/js/components/AdoptModal.js +343 -171
  29. package/public/js/components/App.js +35 -35
  30. package/public/js/components/DirectoryPicker.js +203 -203
  31. package/public/js/components/EntityFormModal.js +105 -105
  32. package/public/js/components/Modal.js +51 -51
  33. package/public/js/components/OfflineBanner.js +93 -93
  34. package/public/js/components/PageTitleBar.js +13 -13
  35. package/public/js/components/Picker.js +179 -179
  36. package/public/js/components/Popover.js +55 -55
  37. package/public/js/components/Sidebar.js +341 -270
  38. package/public/js/components/TerminalView.js +298 -298
  39. package/public/js/components/useDragSort.js +67 -67
  40. package/public/js/dialog.js +67 -67
  41. package/public/js/icons.js +177 -177
  42. package/public/js/main.js +132 -140
  43. package/public/js/pages/AboutPage.js +165 -165
  44. package/public/js/pages/ConfigurePage.js +475 -487
  45. package/public/js/pages/LaunchPage.js +369 -369
  46. package/public/js/pages/SessionsPage.js +97 -97
  47. package/public/js/state.js +231 -231
  48. package/public/manifest.webmanifest +15 -15
  49. package/scripts/dev.js +59 -0
  50. package/scripts/install.js +137 -137
  51. package/server.js +1147 -1117
package/bin/ccsm.js CHANGED
@@ -1,194 +1,194 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- // ccsm launcher · entry point for `ccsm` / `npx @bakapiano/ccsm`.
5
- //
6
- // Two modes by how it's invoked:
7
- //
8
- // plain `ccsm` → start backend if not running, open a browser
9
- // window pointing at it. Terminal returns to a
10
- // prompt immediately (detached).
11
- //
12
- // `ccsm ccsm://<action>` → fired by Windows when the user clicks a
13
- // ccsm:// link (PWA offline banner). Same
14
- // backend startup as above, but DO NOT spawn
15
- // an extra browser — the PWA window that
16
- // triggered the click is already open and
17
- // will reconnect as soon as the backend
18
- // becomes reachable.
19
- //
20
- // In both modes, if a server is already running we just ping it. New
21
- // browser window opens only in the plain-`ccsm` case.
22
-
23
- const path = require('node:path');
24
- const fs = require('node:fs');
25
- const os = require('node:os');
26
- const http = require('node:http');
27
- const { spawn } = require('node:child_process');
28
-
29
- const SERVER = path.join(__dirname, '..', 'server.js');
30
- const HOME = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
31
- const LOG = path.join(HOME, 'server.log');
32
-
33
- function loadPreferredPort() {
34
- try {
35
- const cfg = JSON.parse(fs.readFileSync(path.join(HOME, 'config.json'), 'utf8'));
36
- return Number(cfg.port) || 7777;
37
- } catch {
38
- return 7777;
39
- }
40
- }
41
-
42
- function probe(port, timeoutMs = 800) {
43
- return new Promise((resolve) => {
44
- const req = http.get(`http://localhost:${port}/api/health`, { timeout: timeoutMs }, (res) => {
45
- let body = '';
46
- res.on('data', (c) => body += c);
47
- res.on('end', () => {
48
- try {
49
- const j = JSON.parse(body);
50
- resolve(j && j.name === '@bakapiano/ccsm' ? j : null);
51
- } catch { resolve(null); }
52
- });
53
- });
54
- req.on('error', () => resolve(null));
55
- req.on('timeout', () => { req.destroy(); resolve(null); });
56
- });
57
- }
58
-
59
- function post(port, pathname, timeoutMs = 2000) {
60
- return new Promise((resolve) => {
61
- const req = http.request({
62
- hostname: 'localhost', port, path: pathname, method: 'POST',
63
- headers: { 'Content-Type': 'application/json', 'Content-Length': 2 },
64
- timeout: timeoutMs,
65
- }, (res) => {
66
- res.resume();
67
- res.on('end', () => resolve(res.statusCode < 300));
68
- });
69
- req.on('error', () => resolve(false));
70
- req.on('timeout', () => { req.destroy(); resolve(false); });
71
- req.write('{}');
72
- req.end();
73
- });
74
- }
75
-
76
- // Detect ccsm:// protocol invocation. Windows runs us as
77
- // `ccsm.cmd ccsm://start` when the user clicks a protocol link.
78
- // argv layout: [node, ccsm.js, "ccsm://..."]
79
- function parseProtocolArg() {
80
- const a = process.argv[2];
81
- if (!a || !/^ccsm:\/\//i.test(a)) return null;
82
- try {
83
- // Normalise: ccsm://start or ccsm://start?foo=bar
84
- const u = new URL(a);
85
- // host is the action (`start`, `restart`, ...); empty host means
86
- // the URL was `ccsm:start` or `ccsm:///action`
87
- const action = (u.hostname || u.pathname.replace(/^\/+/, '').split('/')[0] || '').toLowerCase();
88
- return { action, raw: a };
89
- } catch {
90
- return { action: '', raw: a };
91
- }
92
- }
93
-
94
- // Compare what's running with what's installed. Returns true if they
95
- // match (or running is unknown). False means we should restart so the
96
- // new code takes over after an `npm i -g @bakapiano/ccsm@latest`.
97
- function isSameVersion(running) {
98
- try {
99
- const installed = require('../package.json').version;
100
- return running.version === installed;
101
- } catch { return true; }
102
- }
103
-
104
- (async () => {
105
- const protocol = parseProtocolArg();
106
- const SILENT = !!protocol; // ccsm:// invocations should not open a new browser
107
- const port = loadPreferredPort();
108
-
109
- // Case 1: existing instance on the preferred port
110
- let existing = await probe(port);
111
-
112
- // If an old version is running, ask it to shut down so the freshly
113
- // installed code can take over. The launcher then falls through to
114
- // Case 2 and spawns the new server itself.
115
- if (existing && !isSameVersion(existing)) {
116
- const installed = require('../package.json').version;
117
- console.log(`ccsm upgrading · running v${existing.version} → installed v${installed}`);
118
- await post(port, '/api/shutdown');
119
- // Wait for the old process to actually exit so its port frees up.
120
- for (let i = 0; i < 30; i++) {
121
- await new Promise((r) => setTimeout(r, 200));
122
- if (!(await probe(port, 200))) { existing = null; break; }
123
- }
124
- }
125
-
126
- if (existing) {
127
- if (!SILENT) {
128
- const opened = await post(port, '/api/spawn-browser');
129
- console.log(`ccsm already running · v${existing.version} · http://localhost:${port}`);
130
- if (!opened) console.log('(could not open a new window — server might be busy)');
131
- } else {
132
- console.log(`ccsm already running · ${protocol.raw}`);
133
- }
134
- return;
135
- }
136
-
137
- // Case 2: spawn detached server
138
- fs.mkdirSync(HOME, { recursive: true });
139
- const out = fs.openSync(LOG, 'a');
140
- fs.writeSync(out, `\n[${new Date().toISOString()}] ccsm starting (protocol=${protocol?.raw || '-'})...\n`);
141
-
142
- const child = spawn(process.execPath, [SERVER], {
143
- detached: true,
144
- stdio: ['ignore', out, out],
145
- windowsHide: true,
146
- env: {
147
- ...process.env,
148
- CCSM_LAUNCHER: '1',
149
- // Suppress the server's own auto-spawn of a browser when this launch
150
- // came from a ccsm:// click — the PWA window that fired it is the
151
- // browser, and a second window would just be noise.
152
- ...(SILENT ? { CCSM_NO_BROWSER: '1' } : {}),
153
- },
154
- });
155
- child.unref();
156
-
157
- // Poll /api/health for up to ~10s. Once it answers we know the server
158
- // is fully booted (port is bound, config loaded, snapshot loop running).
159
- // The actual port may differ from the preferred one if it was taken,
160
- // so on each iteration we re-probe the preferred port first, then fall
161
- // back to scanning preferred+1..preferred+9.
162
- const portsToTry = [port, ...Array.from({ length: 9 }, (_, i) => port + i + 1)];
163
- let actualPort = null;
164
- let ready = null;
165
- outer:
166
- for (let i = 0; i < 50; i++) {
167
- await new Promise((r) => setTimeout(r, 200));
168
- for (const p of portsToTry) {
169
- const r = await probe(p, 300);
170
- if (r) { ready = r; actualPort = p; break outer; }
171
- }
172
- }
173
- if (!ready) {
174
- console.error(`ccsm server did not come up in 10s. Check ${LOG}`);
175
- process.exit(1);
176
- }
177
- console.log(`ccsm started · v${ready.version}`);
178
- console.log(`backend: http://localhost:${actualPort}${actualPort !== port ? ` (preferred ${port} was taken)` : ''}`);
179
- console.log(`frontend: https://bakapiano.github.io/ccsm/v1/`);
180
- console.log(`logs: ${LOG}`);
181
-
182
- // First-run hint — printed once, then a marker file makes us quiet.
183
- const firstRunMark = path.join(HOME, '.first-run-shown');
184
- if (!fs.existsSync(firstRunMark)) {
185
- try { fs.writeFileSync(firstRunMark, new Date().toISOString()); } catch {}
186
- console.log('');
187
- console.log('First run · ccsm is now running in the background.');
188
- console.log('Open the frontend URL above, click "Install ccsm" in your browser');
189
- console.log('to install it as a PWA so the icon launches directly into the app.');
190
- }
191
- })().catch((err) => {
192
- console.error('ccsm launcher failed:', err);
193
- process.exit(1);
194
- });
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // ccsm launcher · entry point for `ccsm` / `npx @bakapiano/ccsm`.
5
+ //
6
+ // Two modes by how it's invoked:
7
+ //
8
+ // plain `ccsm` → start backend if not running, open a browser
9
+ // window pointing at it. Terminal returns to a
10
+ // prompt immediately (detached).
11
+ //
12
+ // `ccsm ccsm://<action>` → fired by Windows when the user clicks a
13
+ // ccsm:// link (PWA offline banner). Same
14
+ // backend startup as above, but DO NOT spawn
15
+ // an extra browser — the PWA window that
16
+ // triggered the click is already open and
17
+ // will reconnect as soon as the backend
18
+ // becomes reachable.
19
+ //
20
+ // In both modes, if a server is already running we just ping it. New
21
+ // browser window opens only in the plain-`ccsm` case.
22
+
23
+ const path = require('node:path');
24
+ const fs = require('node:fs');
25
+ const os = require('node:os');
26
+ const http = require('node:http');
27
+ const { spawn } = require('node:child_process');
28
+
29
+ const SERVER = path.join(__dirname, '..', 'server.js');
30
+ const HOME = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
31
+ const LOG = path.join(HOME, 'server.log');
32
+
33
+ function loadPreferredPort() {
34
+ try {
35
+ const cfg = JSON.parse(fs.readFileSync(path.join(HOME, 'config.json'), 'utf8'));
36
+ return Number(cfg.port) || 7777;
37
+ } catch {
38
+ return 7777;
39
+ }
40
+ }
41
+
42
+ function probe(port, timeoutMs = 800) {
43
+ return new Promise((resolve) => {
44
+ const req = http.get(`http://localhost:${port}/api/health`, { timeout: timeoutMs }, (res) => {
45
+ let body = '';
46
+ res.on('data', (c) => body += c);
47
+ res.on('end', () => {
48
+ try {
49
+ const j = JSON.parse(body);
50
+ resolve(j && j.name === '@bakapiano/ccsm' ? j : null);
51
+ } catch { resolve(null); }
52
+ });
53
+ });
54
+ req.on('error', () => resolve(null));
55
+ req.on('timeout', () => { req.destroy(); resolve(null); });
56
+ });
57
+ }
58
+
59
+ function post(port, pathname, timeoutMs = 2000) {
60
+ return new Promise((resolve) => {
61
+ const req = http.request({
62
+ hostname: 'localhost', port, path: pathname, method: 'POST',
63
+ headers: { 'Content-Type': 'application/json', 'Content-Length': 2 },
64
+ timeout: timeoutMs,
65
+ }, (res) => {
66
+ res.resume();
67
+ res.on('end', () => resolve(res.statusCode < 300));
68
+ });
69
+ req.on('error', () => resolve(false));
70
+ req.on('timeout', () => { req.destroy(); resolve(false); });
71
+ req.write('{}');
72
+ req.end();
73
+ });
74
+ }
75
+
76
+ // Detect ccsm:// protocol invocation. Windows runs us as
77
+ // `ccsm.cmd ccsm://start` when the user clicks a protocol link.
78
+ // argv layout: [node, ccsm.js, "ccsm://..."]
79
+ function parseProtocolArg() {
80
+ const a = process.argv[2];
81
+ if (!a || !/^ccsm:\/\//i.test(a)) return null;
82
+ try {
83
+ // Normalise: ccsm://start or ccsm://start?foo=bar
84
+ const u = new URL(a);
85
+ // host is the action (`start`, `restart`, ...); empty host means
86
+ // the URL was `ccsm:start` or `ccsm:///action`
87
+ const action = (u.hostname || u.pathname.replace(/^\/+/, '').split('/')[0] || '').toLowerCase();
88
+ return { action, raw: a };
89
+ } catch {
90
+ return { action: '', raw: a };
91
+ }
92
+ }
93
+
94
+ // Compare what's running with what's installed. Returns true if they
95
+ // match (or running is unknown). False means we should restart so the
96
+ // new code takes over after an `npm i -g @bakapiano/ccsm@latest`.
97
+ function isSameVersion(running) {
98
+ try {
99
+ const installed = require('../package.json').version;
100
+ return running.version === installed;
101
+ } catch { return true; }
102
+ }
103
+
104
+ (async () => {
105
+ const protocol = parseProtocolArg();
106
+ const SILENT = !!protocol; // ccsm:// invocations should not open a new browser
107
+ const port = loadPreferredPort();
108
+
109
+ // Case 1: existing instance on the preferred port
110
+ let existing = await probe(port);
111
+
112
+ // If an old version is running, ask it to shut down so the freshly
113
+ // installed code can take over. The launcher then falls through to
114
+ // Case 2 and spawns the new server itself.
115
+ if (existing && !isSameVersion(existing)) {
116
+ const installed = require('../package.json').version;
117
+ console.log(`ccsm upgrading · running v${existing.version} → installed v${installed}`);
118
+ await post(port, '/api/shutdown');
119
+ // Wait for the old process to actually exit so its port frees up.
120
+ for (let i = 0; i < 30; i++) {
121
+ await new Promise((r) => setTimeout(r, 200));
122
+ if (!(await probe(port, 200))) { existing = null; break; }
123
+ }
124
+ }
125
+
126
+ if (existing) {
127
+ if (!SILENT) {
128
+ const opened = await post(port, '/api/spawn-browser');
129
+ console.log(`ccsm already running · v${existing.version} · http://localhost:${port}`);
130
+ if (!opened) console.log('(could not open a new window — server might be busy)');
131
+ } else {
132
+ console.log(`ccsm already running · ${protocol.raw}`);
133
+ }
134
+ return;
135
+ }
136
+
137
+ // Case 2: spawn detached server
138
+ fs.mkdirSync(HOME, { recursive: true });
139
+ const out = fs.openSync(LOG, 'a');
140
+ fs.writeSync(out, `\n[${new Date().toISOString()}] ccsm starting (protocol=${protocol?.raw || '-'})...\n`);
141
+
142
+ const child = spawn(process.execPath, [SERVER], {
143
+ detached: true,
144
+ stdio: ['ignore', out, out],
145
+ windowsHide: true,
146
+ env: {
147
+ ...process.env,
148
+ CCSM_LAUNCHER: '1',
149
+ // Suppress the server's own auto-spawn of a browser when this launch
150
+ // came from a ccsm:// click — the PWA window that fired it is the
151
+ // browser, and a second window would just be noise.
152
+ ...(SILENT ? { CCSM_NO_BROWSER: '1' } : {}),
153
+ },
154
+ });
155
+ child.unref();
156
+
157
+ // Poll /api/health for up to ~10s. Once it answers we know the server
158
+ // is fully booted (port is bound, config loaded, snapshot loop running).
159
+ // The actual port may differ from the preferred one if it was taken,
160
+ // so on each iteration we re-probe the preferred port first, then fall
161
+ // back to scanning preferred+1..preferred+9.
162
+ const portsToTry = [port, ...Array.from({ length: 9 }, (_, i) => port + i + 1)];
163
+ let actualPort = null;
164
+ let ready = null;
165
+ outer:
166
+ for (let i = 0; i < 50; i++) {
167
+ await new Promise((r) => setTimeout(r, 200));
168
+ for (const p of portsToTry) {
169
+ const r = await probe(p, 300);
170
+ if (r) { ready = r; actualPort = p; break outer; }
171
+ }
172
+ }
173
+ if (!ready) {
174
+ console.error(`ccsm server did not come up in 10s. Check ${LOG}`);
175
+ process.exit(1);
176
+ }
177
+ console.log(`ccsm started · v${ready.version}`);
178
+ console.log(`backend: http://localhost:${actualPort}${actualPort !== port ? ` (preferred ${port} was taken)` : ''}`);
179
+ console.log(`frontend: https://bakapiano.github.io/ccsm/v1/`);
180
+ console.log(`logs: ${LOG}`);
181
+
182
+ // First-run hint — printed once, then a marker file makes us quiet.
183
+ const firstRunMark = path.join(HOME, '.first-run-shown');
184
+ if (!fs.existsSync(firstRunMark)) {
185
+ try { fs.writeFileSync(firstRunMark, new Date().toISOString()); } catch {}
186
+ console.log('');
187
+ console.log('First run · ccsm is now running in the background.');
188
+ console.log('Open the frontend URL above, click "Install ccsm" in your browser');
189
+ console.log('to install it as a PWA so the icon launches directly into the app.');
190
+ }
191
+ })().catch((err) => {
192
+ console.error('ccsm launcher failed:', err);
193
+ process.exit(1);
194
+ });
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ // Atomic JSON-file writes + per-file serialization.
4
+ //
5
+ // The naive pattern (`fs.writeFile(path, JSON.stringify(...))`) has two
6
+ // bugs under concurrent callers:
7
+ //
8
+ // 1. fs.writeFile overwrites byte-by-byte but does NOT pre-truncate.
9
+ // If writer A's serialization is longer than writer B's, and B
10
+ // finishes second, B writes only its own bytes — A's trailing
11
+ // bytes stay on disk. Result: `] }\n]` style JSON corruption.
12
+ //
13
+ // 2. Even with atomic writes, concurrent `load → mutate → save`
14
+ // sequences lose updates: A and B both read state v0, both write
15
+ // their own v1 — the later writer wins, the earlier one's edits
16
+ // vanish.
17
+ //
18
+ // Fixes:
19
+ //
20
+ // - atomicWriteJson: write to a sibling tmp file, then rename onto
21
+ // the target. rename is atomic on the same volume (NTFS / POSIX),
22
+ // so readers see either the old complete file or the new complete
23
+ // file. No truncation problem.
24
+ //
25
+ // - withFileLock: serialize all mutators of a given file through a
26
+ // per-path promise chain. Callers wrap their whole load/mutate/save
27
+ // in withFileLock(path, fn) and are guaranteed exclusivity.
28
+
29
+ const fs = require('node:fs/promises');
30
+
31
+ async function atomicWriteJson(filePath, data) {
32
+ const tmp = `${filePath}.tmp.${process.pid}.${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
33
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2));
34
+ await fs.rename(tmp, filePath);
35
+ }
36
+
37
+ const locks = new Map();
38
+ function withFileLock(filePath, fn) {
39
+ const prev = locks.get(filePath) || Promise.resolve();
40
+ const next = prev.then(fn, fn);
41
+ // Swallow rejections in the chain holder so a single failed mutator
42
+ // doesn't poison every subsequent caller. The returned `next` still
43
+ // rejects for THIS caller — only the stored chain is sanitized.
44
+ locks.set(filePath, next.catch(() => {}));
45
+ return next;
46
+ }
47
+
48
+ module.exports = { atomicWriteJson, withFileLock };