@bakapiano/ccsm 0.22.3 → 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 +225 -225
  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 +148 -22
  42. package/public/js/components/TerminalResizeDebouncer.js +126 -0
  43. package/public/js/components/XtermTerminal.js +62 -15
  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/scripts/dev.js CHANGED
@@ -1,149 +1,149 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- // Dev launcher · fully isolates from the user's prod ccsm install.
5
- //
6
- // Why: many contributors run the published `@bakapiano/ccsm` package
7
- // for their day-to-day work (port 7777, ~/.ccsm). If `npm run dev`
8
- // reused the same data dir + port, every hot-reload would clobber the
9
- // live sessions.json. So dev gets its own:
10
- //
11
- // - CCSM_HOME → ~/.ccsm-dev/ (separate config.json, sessions.json, folders.json)
12
- // - port → 7788 (no contention with prod 7777)
13
- // - workDir → ~/ccsm-workspaces-dev (separate workspace tree)
14
- // - no browser auto-open (we're iterating in an already-open tab)
15
- //
16
- // Run via `npm run dev`. The first launch seeds a starter config; later
17
- // launches leave it alone so dev's own customisations stick.
18
-
19
- const path = require('node:path');
20
- const os = require('node:os');
21
- const fs = require('node:fs');
22
- const { spawn } = require('node:child_process');
23
-
24
- const DEV_HOME = path.join(os.homedir(), '.ccsm-dev');
25
- const DEV_PORT = '7788';
26
- const DEV_WORKDIR = path.join(os.homedir(), 'ccsm-workspaces-dev');
27
-
28
- fs.mkdirSync(DEV_HOME, { recursive: true });
29
-
30
- // Seed a fresh dev config the first time. Subsequent runs leave the
31
- // existing file alone — the dev's own UI edits persist across restarts.
32
- const configPath = path.join(DEV_HOME, 'config.json');
33
- if (!fs.existsSync(configPath)) {
34
- fs.writeFileSync(configPath, JSON.stringify({
35
- port: Number(DEV_PORT),
36
- workDir: DEV_WORKDIR,
37
- repos: [],
38
- }, null, 2));
39
- }
40
-
41
- // Mirror pages-root assets into public/ so the dev server can serve
42
- // them at the URL paths the deployed site uses (manifest at
43
- // /manifest.webmanifest, setup page at /setup/). Both mirror files
44
- // are .gitignored — they exist only for local preview.
45
- //
46
- // The manifest is rewritten with a "ccsm-dev" identity so the PWA
47
- // installed from dev shows up separately in Chrome's installed-apps
48
- // list and Start Menu, instead of conflicting with the prod CCSM
49
- // install.
50
- const REPO_ROOT = path.join(__dirname, '..');
51
- const PAGES_ROOT = path.join(REPO_ROOT, 'pages-root');
52
- const PUBLIC_DIR = path.join(REPO_ROOT, 'public');
53
-
54
- function mirrorSetup() {
55
- try {
56
- const src = path.join(PAGES_ROOT, 'setup');
57
- const dst = path.join(PUBLIC_DIR, 'setup');
58
- fs.mkdirSync(dst, { recursive: true });
59
- for (const f of fs.readdirSync(src)) {
60
- fs.copyFileSync(path.join(src, f), path.join(dst, f));
61
- }
62
- } catch (e) { console.warn('[dev] setup mirror failed:', e.message); }
63
- }
64
- function writeDevManifest() {
65
- try {
66
- const src = path.join(PAGES_ROOT, 'manifest.webmanifest');
67
- const m = JSON.parse(fs.readFileSync(src, 'utf8'));
68
- m.id = '/?ccsm-dev';
69
- m.name = 'CCSM dev';
70
- m.short_name = 'CCSM dev';
71
- // Dev runs at host root (localhost:7788/), so scope + start_url
72
- // anchor at `/` not `./` (which would resolve relative to the
73
- // manifest URL — same result here, but explicit is clearer).
74
- m.scope = '/';
75
- m.start_url = '/';
76
- // Drop related_applications self-reference — its URL points at
77
- // the prod GH Pages manifest, not this dev one. Leaving it in
78
- // would let prod's getInstalledRelatedApps() detect dev installs
79
- // as if they were prod, which is the opposite of what we want.
80
- delete m.related_applications;
81
- fs.writeFileSync(path.join(PUBLIC_DIR, 'manifest.webmanifest'), JSON.stringify(m, null, 2));
82
- } catch (e) { console.warn('[dev] manifest mirror failed:', e.message); }
83
- }
84
- mirrorSetup();
85
- writeDevManifest();
86
-
87
- const env = {
88
- ...process.env,
89
- CCSM_HOME: DEV_HOME,
90
- CCSM_PORT: DEV_PORT,
91
- CCSM_NO_BROWSER: '1',
92
- // Marks the running server as "launched by dev.js" so /api/restart can
93
- // skip the production restart-helper path (which respawns the global
94
- // `ccsm.cmd` and would replace our --watch checkout server). In dev
95
- // mode the server just process.exit(0)s and this script respawns it.
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: '',
108
- };
109
-
110
- const serverPath = path.join(__dirname, '..', 'server.js');
111
-
112
- let current = null;
113
- let stopping = false;
114
-
115
- function spawnServer() {
116
- // Don't use `node --watch` here — its restart-on-exit semantics are
117
- // "wait for a file change after a clean exit", so calling
118
- // process.exit(0) from /api/restart leaves --watch idling forever
119
- // until the user touches a file. We do our own respawn-on-exit
120
- // (below) which handles both the restart-by-exit path AND crashes,
121
- // and the dev/api SSE endpoint still gives us frontend hot-reload
122
- // without needing --watch for backend code (each restart pulls fresh
123
- // require() cache anyway since this is a new process).
124
- const child = spawn(process.execPath, [serverPath], {
125
- env,
126
- stdio: 'inherit',
127
- });
128
- child.on('exit', (code, signal) => {
129
- if (stopping) {
130
- process.exit(signal ? 1 : (code ?? 0));
131
- return;
132
- }
133
- // Server asked to restart (POST /api/restart → gracefulShutdown +
134
- // exit 0). Respawn — node --watch picks up any code changes that
135
- // landed in the meantime. A small delay lets the port fully release.
136
- console.log(`[dev] server exited (code=${code} signal=${signal || ''}) · respawning`);
137
- setTimeout(() => { current = spawnServer(); }, 500);
138
- });
139
- return child;
140
- }
141
-
142
- const stop = (sig) => () => {
143
- stopping = true;
144
- if (current) current.kill(sig);
145
- };
146
- process.on('SIGINT', stop('SIGINT'));
147
- process.on('SIGTERM', stop('SIGTERM'));
148
-
149
- current = spawnServer();
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Dev launcher · fully isolates from the user's prod ccsm install.
5
+ //
6
+ // Why: many contributors run the published `@bakapiano/ccsm` package
7
+ // for their day-to-day work (port 7777, ~/.ccsm). If `npm run dev`
8
+ // reused the same data dir + port, every hot-reload would clobber the
9
+ // live sessions.json. So dev gets its own:
10
+ //
11
+ // - CCSM_HOME → ~/.ccsm-dev/ (separate config.json, sessions.json, folders.json)
12
+ // - port → 7788 (no contention with prod 7777)
13
+ // - workDir → ~/ccsm-workspaces-dev (separate workspace tree)
14
+ // - no browser auto-open (we're iterating in an already-open tab)
15
+ //
16
+ // Run via `npm run dev`. The first launch seeds a starter config; later
17
+ // launches leave it alone so dev's own customisations stick.
18
+
19
+ const path = require('node:path');
20
+ const os = require('node:os');
21
+ const fs = require('node:fs');
22
+ const { spawn } = require('node:child_process');
23
+
24
+ const DEV_HOME = path.join(os.homedir(), '.ccsm-dev');
25
+ const DEV_PORT = '7788';
26
+ const DEV_WORKDIR = path.join(os.homedir(), 'ccsm-workspaces-dev');
27
+
28
+ fs.mkdirSync(DEV_HOME, { recursive: true });
29
+
30
+ // Seed a fresh dev config the first time. Subsequent runs leave the
31
+ // existing file alone — the dev's own UI edits persist across restarts.
32
+ const configPath = path.join(DEV_HOME, 'config.json');
33
+ if (!fs.existsSync(configPath)) {
34
+ fs.writeFileSync(configPath, JSON.stringify({
35
+ port: Number(DEV_PORT),
36
+ workDir: DEV_WORKDIR,
37
+ repos: [],
38
+ }, null, 2));
39
+ }
40
+
41
+ // Mirror pages-root assets into public/ so the dev server can serve
42
+ // them at the URL paths the deployed site uses (manifest at
43
+ // /manifest.webmanifest, setup page at /setup/). Both mirror files
44
+ // are .gitignored — they exist only for local preview.
45
+ //
46
+ // The manifest is rewritten with a "ccsm-dev" identity so the PWA
47
+ // installed from dev shows up separately in Chrome's installed-apps
48
+ // list and Start Menu, instead of conflicting with the prod CCSM
49
+ // install.
50
+ const REPO_ROOT = path.join(__dirname, '..');
51
+ const PAGES_ROOT = path.join(REPO_ROOT, 'pages-root');
52
+ const PUBLIC_DIR = path.join(REPO_ROOT, 'public');
53
+
54
+ function mirrorSetup() {
55
+ try {
56
+ const src = path.join(PAGES_ROOT, 'setup');
57
+ const dst = path.join(PUBLIC_DIR, 'setup');
58
+ fs.mkdirSync(dst, { recursive: true });
59
+ for (const f of fs.readdirSync(src)) {
60
+ fs.copyFileSync(path.join(src, f), path.join(dst, f));
61
+ }
62
+ } catch (e) { console.warn('[dev] setup mirror failed:', e.message); }
63
+ }
64
+ function writeDevManifest() {
65
+ try {
66
+ const src = path.join(PAGES_ROOT, 'manifest.webmanifest');
67
+ const m = JSON.parse(fs.readFileSync(src, 'utf8'));
68
+ m.id = '/?ccsm-dev';
69
+ m.name = 'CCSM dev';
70
+ m.short_name = 'CCSM dev';
71
+ // Dev runs at host root (localhost:7788/), so scope + start_url
72
+ // anchor at `/` not `./` (which would resolve relative to the
73
+ // manifest URL — same result here, but explicit is clearer).
74
+ m.scope = '/';
75
+ m.start_url = '/';
76
+ // Drop related_applications self-reference — its URL points at
77
+ // the prod GH Pages manifest, not this dev one. Leaving it in
78
+ // would let prod's getInstalledRelatedApps() detect dev installs
79
+ // as if they were prod, which is the opposite of what we want.
80
+ delete m.related_applications;
81
+ fs.writeFileSync(path.join(PUBLIC_DIR, 'manifest.webmanifest'), JSON.stringify(m, null, 2));
82
+ } catch (e) { console.warn('[dev] manifest mirror failed:', e.message); }
83
+ }
84
+ mirrorSetup();
85
+ writeDevManifest();
86
+
87
+ const env = {
88
+ ...process.env,
89
+ CCSM_HOME: DEV_HOME,
90
+ CCSM_PORT: DEV_PORT,
91
+ CCSM_NO_BROWSER: '1',
92
+ // Marks the running server as "launched by dev.js" so /api/restart can
93
+ // skip the production restart-helper path (which respawns the global
94
+ // `ccsm.cmd` and would replace our --watch checkout server). In dev
95
+ // mode the server just process.exit(0)s and this script respawns it.
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: '',
108
+ };
109
+
110
+ const serverPath = path.join(__dirname, '..', 'server.js');
111
+
112
+ let current = null;
113
+ let stopping = false;
114
+
115
+ function spawnServer() {
116
+ // Don't use `node --watch` here — its restart-on-exit semantics are
117
+ // "wait for a file change after a clean exit", so calling
118
+ // process.exit(0) from /api/restart leaves --watch idling forever
119
+ // until the user touches a file. We do our own respawn-on-exit
120
+ // (below) which handles both the restart-by-exit path AND crashes,
121
+ // and the dev/api SSE endpoint still gives us frontend hot-reload
122
+ // without needing --watch for backend code (each restart pulls fresh
123
+ // require() cache anyway since this is a new process).
124
+ const child = spawn(process.execPath, [serverPath], {
125
+ env,
126
+ stdio: 'inherit',
127
+ });
128
+ child.on('exit', (code, signal) => {
129
+ if (stopping) {
130
+ process.exit(signal ? 1 : (code ?? 0));
131
+ return;
132
+ }
133
+ // Server asked to restart (POST /api/restart → gracefulShutdown +
134
+ // exit 0). Respawn — node --watch picks up any code changes that
135
+ // landed in the meantime. A small delay lets the port fully release.
136
+ console.log(`[dev] server exited (code=${code} signal=${signal || ''}) · respawning`);
137
+ setTimeout(() => { current = spawnServer(); }, 500);
138
+ });
139
+ return child;
140
+ }
141
+
142
+ const stop = (sig) => () => {
143
+ stopping = true;
144
+ if (current) current.kill(sig);
145
+ };
146
+ process.on('SIGINT', stop('SIGINT'));
147
+ process.on('SIGTERM', stop('SIGTERM'));
148
+
149
+ current = spawnServer();
@@ -1,153 +1,153 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- // ccsm postinstall · Windows-only · runs after `npm install -g @bakapiano/ccsm`.
5
- // Registers the `ccsm://` URL protocol in HKCU so the hosted frontend
6
- // (https://bakapiano.github.io/ccsm/v1/) can fire `<a href="ccsm://start">`
7
- // from its OfflineBanner and have Windows spawn the backend on demand.
8
- //
9
- // Best-effort: any failure MUST NOT break npm install. Each step is in
10
- // its own try/catch; we just log and move on.
11
- //
12
- // No .lnk file, no Start Menu shortcut — just the protocol handler.
13
-
14
- const path = require('node:path');
15
- const fs = require('node:fs');
16
- const { spawnSync } = require('node:child_process');
17
-
18
- function log(msg) { process.stdout.write(`[ccsm install] ${msg}\n`); }
19
- function warn(msg) { process.stderr.write(`[ccsm install] ${msg}\n`); }
20
-
21
- if (process.platform !== 'win32') {
22
- log('non-Windows · skipping ccsm:// registration');
23
- process.exit(0);
24
- }
25
- // Note: we DO register on npx-cache installs too (not just global). The
26
- // npx cache path is stable across re-runs of the same package, and even
27
- // if the user later cleans the cache, the only consequence is the
28
- // OfflineBanner button no-ops — nothing actively broken. Registering
29
- // always means a first-time `npx @bakapiano/ccsm` gets the full "click
30
- // to wake" UX without needing a separate `npm i -g`.
31
-
32
- // Returns { ccsmCmd, isSandbox } where isSandbox=true means this install
33
- // went into a non-default prefix (e.g. `npm i -g --prefix=<tmp>` from
34
- // the in-app upgrade's test mode). For sandboxed installs we DO NOT
35
- // touch the global launcher.vbs / ccsm:// protocol registration —
36
- // otherwise we'd repoint them at a directory that gets deleted later.
37
- function findCcsmCmd() {
38
- const givenPrefix = process.env.npm_config_prefix || null;
39
- let defaultPrefix = null;
40
- try {
41
- // npm config get prefix when run WITHOUT --prefix returns the
42
- // user's default global prefix; with --prefix it echoes the flag.
43
- // We want the env-independent default so we can compare. INIT_CWD
44
- // and a clean spawn give us the user-default value.
45
- const r = spawnSync('npm', ['config', 'get', 'prefix'], {
46
- encoding: 'utf8', shell: true,
47
- env: { ...process.env, npm_config_prefix: '' },
48
- });
49
- defaultPrefix = r.stdout?.trim() || null;
50
- } catch {}
51
- const prefix = givenPrefix || defaultPrefix;
52
- if (!prefix) return { ccsmCmd: null, isSandbox: false };
53
- const candidate = path.join(prefix, 'ccsm.cmd');
54
- const isSandbox = !!(givenPrefix && defaultPrefix
55
- && path.resolve(givenPrefix).toLowerCase() !== path.resolve(defaultPrefix).toLowerCase());
56
- return {
57
- ccsmCmd: fs.existsSync(candidate) ? candidate : null,
58
- isSandbox,
59
- };
60
- }
61
-
62
- // Write a tiny VBScript wrapper that ccsm:// dispatches into. Why VBS:
63
- // wscript.exe is a Windows-subsystem host (no console window), and
64
- // `Shell.Run(..., 0, False)` launches the target completely hidden — so
65
- // when the user clicks ccsm://start, NOTHING flashes on screen, the
66
- // backend just appears in the next health probe.
67
- function writeLauncherVbs(ccsmCmd) {
68
- const home = process.env.LOCALAPPDATA || process.env.APPDATA;
69
- if (!home) throw new Error('no LOCALAPPDATA/APPDATA env var');
70
- const dir = path.join(home, 'ccsm');
71
- fs.mkdirSync(dir, { recursive: true });
72
- const vbsPath = path.join(dir, 'launcher.vbs');
73
- // Escape any double-quotes in the cmd path (rare but possible).
74
- const cmdEsc = ccsmCmd.replace(/"/g, '""');
75
- const vbs = [
76
- "' ccsm protocol launcher · invoked by wscript.exe via the registered",
77
- "' ccsm:// URL handler. Spawns ccsm.cmd with WindowStyle 0 (hidden) +",
78
- "' bWaitOnReturn=False (async), so the click leaves zero visible trace.",
79
- 'If WScript.Arguments.Count >= 1 Then',
80
- ' arg = WScript.Arguments(0)',
81
- 'Else',
82
- ' arg = ""',
83
- 'End If',
84
- 'Set sh = CreateObject("WScript.Shell")',
85
- `sh.Run """${cmdEsc}"" """ & arg & """", 0, False`,
86
- '',
87
- ].join('\r\n');
88
- fs.writeFileSync(vbsPath, vbs, { encoding: 'utf8' });
89
- return vbsPath;
90
- }
91
-
92
- function registerProtocol(vbsPath) {
93
- // wscript.exe is a no-console host. The protocol-registered command
94
- // hands the entire ccsm:// URL to launcher.vbs as argv[0]; the VBS
95
- // forwards it to ccsm.cmd "%1" with a hidden window.
96
- const command = `wscript.exe "${vbsPath}" "%1"`;
97
- const root = 'HKCU\\Software\\Classes\\ccsm';
98
- const calls = [
99
- ['add', root, '/ve', '/d', 'URL:ccsm protocol', '/f'],
100
- ['add', root, '/v', 'URL Protocol', '/d', '', '/f'],
101
- ['add', `${root}\\shell\\open\\command`, '/ve', '/d', command, '/f'],
102
- ];
103
- for (const args of calls) {
104
- const r = spawnSync('reg.exe', args, { windowsHide: true });
105
- if (r.status !== 0) {
106
- throw new Error(`reg ${args.join(' ')} → exit ${r.status}: ${r.stderr?.toString() || ''}`);
107
- }
108
- }
109
- }
110
-
111
- const { ccsmCmd, isSandbox } = (() => {
112
- try { return findCcsmCmd(); } catch { return { ccsmCmd: null, isSandbox: false }; }
113
- })();
114
- if (!ccsmCmd) {
115
- warn('could not locate ccsm.cmd · skipping protocol registration');
116
- process.exit(0);
117
- }
118
- if (isSandbox) {
119
- log(`sandbox install detected (prefix=${process.env.npm_config_prefix}) · skipping global launcher.vbs + protocol registration + auto-launch`);
120
- process.exit(0);
121
- }
122
-
123
- try {
124
- const vbsPath = writeLauncherVbs(ccsmCmd);
125
- registerProtocol(vbsPath);
126
- log(`launcher · ${vbsPath}`);
127
- log(`ccsm:// protocol registered (silent · via wscript.exe)`);
128
- } catch (e) {
129
- warn(`failed · ${e.message}`);
130
- warn('the hosted frontend\'s "Start ccsm" button will not be able to launch the backend. You can still run `ccsm` manually in a terminal.');
131
- }
132
-
133
- // Open the hosted setup guide. The page walks the user through the
134
- // remaining one-time setup (allow ccsm:// protocol, firewall, install
135
- // as PWA) and Step 1's "Try ccsm://start" button doubles as ccsm
136
- // auto-launch — so we don't need a separate spawn here. Set
137
- // CCSM_NO_AUTOLAUNCH=1 to skip (CI, headless setups).
138
- if (process.env.CCSM_NO_AUTOLAUNCH !== '1') {
139
- try {
140
- // `start` on Windows opens the default browser without attaching a
141
- // console. Run via cmd.exe /c since `start` is a cmd builtin.
142
- require('node:child_process').spawn(
143
- 'cmd.exe',
144
- ['/d', '/s', '/c', 'start', '', 'https://bakapiano.github.io/ccsm/setup/'],
145
- { detached: true, stdio: 'ignore', windowsHide: true }
146
- ).unref();
147
- log('opened setup guide · https://bakapiano.github.io/ccsm/setup/');
148
- log('(set CCSM_NO_AUTOLAUNCH=1 to skip this on future installs)');
149
- } catch (e) {
150
- warn(`setup guide open failed · ${e.message}`);
151
- warn('run `ccsm` manually to start.');
152
- }
153
- }
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // ccsm postinstall · Windows-only · runs after `npm install -g @bakapiano/ccsm`.
5
+ // Registers the `ccsm://` URL protocol in HKCU so the hosted frontend
6
+ // (https://bakapiano.github.io/ccsm/v1/) can fire `<a href="ccsm://start">`
7
+ // from its OfflineBanner and have Windows spawn the backend on demand.
8
+ //
9
+ // Best-effort: any failure MUST NOT break npm install. Each step is in
10
+ // its own try/catch; we just log and move on.
11
+ //
12
+ // No .lnk file, no Start Menu shortcut — just the protocol handler.
13
+
14
+ const path = require('node:path');
15
+ const fs = require('node:fs');
16
+ const { spawnSync } = require('node:child_process');
17
+
18
+ function log(msg) { process.stdout.write(`[ccsm install] ${msg}\n`); }
19
+ function warn(msg) { process.stderr.write(`[ccsm install] ${msg}\n`); }
20
+
21
+ if (process.platform !== 'win32') {
22
+ log('non-Windows · skipping ccsm:// registration');
23
+ process.exit(0);
24
+ }
25
+ // Note: we DO register on npx-cache installs too (not just global). The
26
+ // npx cache path is stable across re-runs of the same package, and even
27
+ // if the user later cleans the cache, the only consequence is the
28
+ // OfflineBanner button no-ops — nothing actively broken. Registering
29
+ // always means a first-time `npx @bakapiano/ccsm` gets the full "click
30
+ // to wake" UX without needing a separate `npm i -g`.
31
+
32
+ // Returns { ccsmCmd, isSandbox } where isSandbox=true means this install
33
+ // went into a non-default prefix (e.g. `npm i -g --prefix=<tmp>` from
34
+ // the in-app upgrade's test mode). For sandboxed installs we DO NOT
35
+ // touch the global launcher.vbs / ccsm:// protocol registration —
36
+ // otherwise we'd repoint them at a directory that gets deleted later.
37
+ function findCcsmCmd() {
38
+ const givenPrefix = process.env.npm_config_prefix || null;
39
+ let defaultPrefix = null;
40
+ try {
41
+ // npm config get prefix when run WITHOUT --prefix returns the
42
+ // user's default global prefix; with --prefix it echoes the flag.
43
+ // We want the env-independent default so we can compare. INIT_CWD
44
+ // and a clean spawn give us the user-default value.
45
+ const r = spawnSync('npm', ['config', 'get', 'prefix'], {
46
+ encoding: 'utf8', shell: true,
47
+ env: { ...process.env, npm_config_prefix: '' },
48
+ });
49
+ defaultPrefix = r.stdout?.trim() || null;
50
+ } catch {}
51
+ const prefix = givenPrefix || defaultPrefix;
52
+ if (!prefix) return { ccsmCmd: null, isSandbox: false };
53
+ const candidate = path.join(prefix, 'ccsm.cmd');
54
+ const isSandbox = !!(givenPrefix && defaultPrefix
55
+ && path.resolve(givenPrefix).toLowerCase() !== path.resolve(defaultPrefix).toLowerCase());
56
+ return {
57
+ ccsmCmd: fs.existsSync(candidate) ? candidate : null,
58
+ isSandbox,
59
+ };
60
+ }
61
+
62
+ // Write a tiny VBScript wrapper that ccsm:// dispatches into. Why VBS:
63
+ // wscript.exe is a Windows-subsystem host (no console window), and
64
+ // `Shell.Run(..., 0, False)` launches the target completely hidden — so
65
+ // when the user clicks ccsm://start, NOTHING flashes on screen, the
66
+ // backend just appears in the next health probe.
67
+ function writeLauncherVbs(ccsmCmd) {
68
+ const home = process.env.LOCALAPPDATA || process.env.APPDATA;
69
+ if (!home) throw new Error('no LOCALAPPDATA/APPDATA env var');
70
+ const dir = path.join(home, 'ccsm');
71
+ fs.mkdirSync(dir, { recursive: true });
72
+ const vbsPath = path.join(dir, 'launcher.vbs');
73
+ // Escape any double-quotes in the cmd path (rare but possible).
74
+ const cmdEsc = ccsmCmd.replace(/"/g, '""');
75
+ const vbs = [
76
+ "' ccsm protocol launcher · invoked by wscript.exe via the registered",
77
+ "' ccsm:// URL handler. Spawns ccsm.cmd with WindowStyle 0 (hidden) +",
78
+ "' bWaitOnReturn=False (async), so the click leaves zero visible trace.",
79
+ 'If WScript.Arguments.Count >= 1 Then',
80
+ ' arg = WScript.Arguments(0)',
81
+ 'Else',
82
+ ' arg = ""',
83
+ 'End If',
84
+ 'Set sh = CreateObject("WScript.Shell")',
85
+ `sh.Run """${cmdEsc}"" """ & arg & """", 0, False`,
86
+ '',
87
+ ].join('\r\n');
88
+ fs.writeFileSync(vbsPath, vbs, { encoding: 'utf8' });
89
+ return vbsPath;
90
+ }
91
+
92
+ function registerProtocol(vbsPath) {
93
+ // wscript.exe is a no-console host. The protocol-registered command
94
+ // hands the entire ccsm:// URL to launcher.vbs as argv[0]; the VBS
95
+ // forwards it to ccsm.cmd "%1" with a hidden window.
96
+ const command = `wscript.exe "${vbsPath}" "%1"`;
97
+ const root = 'HKCU\\Software\\Classes\\ccsm';
98
+ const calls = [
99
+ ['add', root, '/ve', '/d', 'URL:ccsm protocol', '/f'],
100
+ ['add', root, '/v', 'URL Protocol', '/d', '', '/f'],
101
+ ['add', `${root}\\shell\\open\\command`, '/ve', '/d', command, '/f'],
102
+ ];
103
+ for (const args of calls) {
104
+ const r = spawnSync('reg.exe', args, { windowsHide: true });
105
+ if (r.status !== 0) {
106
+ throw new Error(`reg ${args.join(' ')} → exit ${r.status}: ${r.stderr?.toString() || ''}`);
107
+ }
108
+ }
109
+ }
110
+
111
+ const { ccsmCmd, isSandbox } = (() => {
112
+ try { return findCcsmCmd(); } catch { return { ccsmCmd: null, isSandbox: false }; }
113
+ })();
114
+ if (!ccsmCmd) {
115
+ warn('could not locate ccsm.cmd · skipping protocol registration');
116
+ process.exit(0);
117
+ }
118
+ if (isSandbox) {
119
+ log(`sandbox install detected (prefix=${process.env.npm_config_prefix}) · skipping global launcher.vbs + protocol registration + auto-launch`);
120
+ process.exit(0);
121
+ }
122
+
123
+ try {
124
+ const vbsPath = writeLauncherVbs(ccsmCmd);
125
+ registerProtocol(vbsPath);
126
+ log(`launcher · ${vbsPath}`);
127
+ log(`ccsm:// protocol registered (silent · via wscript.exe)`);
128
+ } catch (e) {
129
+ warn(`failed · ${e.message}`);
130
+ warn('the hosted frontend\'s "Start ccsm" button will not be able to launch the backend. You can still run `ccsm` manually in a terminal.');
131
+ }
132
+
133
+ // Open the hosted setup guide. The page walks the user through the
134
+ // remaining one-time setup (allow ccsm:// protocol, firewall, install
135
+ // as PWA) and Step 1's "Try ccsm://start" button doubles as ccsm
136
+ // auto-launch — so we don't need a separate spawn here. Set
137
+ // CCSM_NO_AUTOLAUNCH=1 to skip (CI, headless setups).
138
+ if (process.env.CCSM_NO_AUTOLAUNCH !== '1') {
139
+ try {
140
+ // `start` on Windows opens the default browser without attaching a
141
+ // console. Run via cmd.exe /c since `start` is a cmd builtin.
142
+ require('node:child_process').spawn(
143
+ 'cmd.exe',
144
+ ['/d', '/s', '/c', 'start', '', 'https://bakapiano.github.io/ccsm/setup/'],
145
+ { detached: true, stdio: 'ignore', windowsHide: true }
146
+ ).unref();
147
+ log('opened setup guide · https://bakapiano.github.io/ccsm/setup/');
148
+ log('(set CCSM_NO_AUTOLAUNCH=1 to skip this on future installs)');
149
+ } catch (e) {
150
+ warn(`setup guide open failed · ${e.message}`);
151
+ warn('run `ccsm` manually to start.');
152
+ }
153
+ }