@bakapiano/ccsm 0.17.2 → 0.17.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.17.2",
3
+ "version": "0.17.4",
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",
@@ -1,8 +1,6 @@
1
1
  import { html } from '../html.js';
2
- import { useEffect, useState } from 'preact/hooks';
3
2
  import { serverHealth, installPrompt, isInstalledPwa } from '../state.js';
4
3
  import { setToast } from '../toast.js';
5
- import { api } from '../api.js';
6
4
  import { Card } from '../components/Card.js';
7
5
  import { PageTitleBar } from '../components/PageTitleBar.js';
8
6
  import { BrandMark, IconGithub, IconExternal } from '../icons.js';
@@ -43,119 +41,11 @@ function InstallCard() {
43
41
  </${Card}>`;
44
42
  }
45
43
 
46
- function UpgradeCard() {
47
- const [info, setInfo] = useState(null); // { current, latest, updateAvailable, fetchedAt, error? }
48
- const [checking, setChecking] = useState(true);
49
- const [upgrading, setUpgrading] = useState(false);
50
-
51
- const refresh = async (force = false) => {
52
- setChecking(true);
53
- try {
54
- const r = await api('GET', '/api/version' + (force ? '?refresh=1' : ''));
55
- setInfo(r);
56
- } catch (e) {
57
- setInfo({ error: e.message });
58
- } finally {
59
- setChecking(false);
60
- }
61
- };
62
- useEffect(() => { refresh(false); }, []);
63
-
64
- const onUpgrade = async () => {
65
- if (!info?.updateAvailable) return;
66
- setUpgrading(true);
67
- try {
68
- const r = await api('POST', '/api/upgrade', { target: 'latest' });
69
- setToast(`upgrading to v${info.latest} · backend will restart`);
70
- if (r?.helperUrl) {
71
- setTimeout(() => { location.href = r.helperUrl; }, 300);
72
- } else if (r?.closeFrontend) {
73
- setTimeout(() => { try { window.close(); } catch {} }, 400);
74
- }
75
- } catch (e) {
76
- setUpgrading(false);
77
- setToast(e.message, 'error');
78
- }
79
- };
80
-
81
- // Dev-only sandbox test path: reinstall the SAME version into a
82
- // throwaway prefix under ~/.ccsm-dev/test-install. Exercises the
83
- // whole helper UI + SSE + lockfile flow without touching the user's
84
- // global install. respawn=false keeps the helper showing "done"
85
- // until it self-exits.
86
- const onTestUpgrade = async () => {
87
- if (!info?.current) return;
88
- setUpgrading(true);
89
- try {
90
- const r = await api('POST', '/api/upgrade', {
91
- target: info.current,
92
- installPrefix: 'C:\\Users\\jiannanli\\.ccsm-dev\\test-install',
93
- respawn: false,
94
- });
95
- setToast(`test upgrade · reinstalling v${info.current} to sandbox`);
96
- if (r?.helperUrl) {
97
- setTimeout(() => { location.href = r.helperUrl; }, 300);
98
- }
99
- } catch (e) {
100
- setUpgrading(false);
101
- setToast(e.message, 'error');
102
- }
103
- };
104
-
105
- const current = info?.current || serverHealth.value.version || '';
106
- const latest = info?.latest;
107
- const updateAvailable = !!info?.updateAvailable;
108
-
109
- return html`
110
- <${Card} title="Version">
111
- <div class="about-version-row">
112
- <div>
113
- <div class="about-version-line">
114
- Installed · <span class="mono">v${current || '?'}</span>
115
- </div>
116
- ${latest && !updateAvailable ? html`
117
- <div class="muted-text" style="margin-top:4px">You're on the latest release.</div>
118
- ` : null}
119
- ${updateAvailable ? html`
120
- <div class="about-update-line">
121
- Update available · <span class="mono">v${latest}</span>
122
- </div>
123
- ` : null}
124
- ${info?.error ? html`
125
- <div class="muted-text" style="margin-top:4px">Couldn't reach npm registry.</div>
126
- ` : null}
127
- </div>
128
- <div class="about-version-actions">
129
- <button class="action subtle" onClick=${() => refresh(true)} disabled=${checking || upgrading}>
130
- ${checking ? 'Checking…' : 'Check'}
131
- </button>
132
- ${updateAvailable ? html`
133
- <button class="action primary" onClick=${onUpgrade} disabled=${upgrading}>
134
- ${upgrading ? 'Upgrading…' : `Upgrade to v${latest}`}
135
- </button>
136
- ` : null}
137
- ${info?.devMode && !updateAvailable ? html`
138
- <button class="action subtle" onClick=${onTestUpgrade} disabled=${upgrading}
139
- title="Reinstall to a sandbox prefix to exercise the updater UI without touching prod">
140
- ${upgrading ? 'Testing…' : 'Test upgrade flow'}
141
- </button>
142
- ` : null}
143
- </div>
144
- </div>
145
- ${upgrading ? html`
146
- <p class="muted-text" style="margin-top:var(--s-3)">
147
- Running <code>npm i -g @bakapiano/ccsm@latest</code>. The backend will restart automatically — you'll see the "Backend not running" screen briefly.
148
- </p>
149
- ` : null}
150
- </${Card}>`;
151
- }
152
-
153
44
  export function AboutPage() {
154
45
  const version = serverHealth.value.version;
155
46
 
156
47
  return html`
157
48
  <${PageTitleBar} title="About" />
158
- <${UpgradeCard} />
159
49
  <${InstallCard} />
160
50
  <${Card} title="ccsm">
161
51
  <div class="about-block">
@@ -193,5 +83,8 @@ export function AboutPage() {
193
83
  <dd>MIT</dd>
194
84
  </dl>
195
85
  </div>
196
- </${Card}>`;
86
+ </${Card}>
87
+ <p class="muted-text" style="margin-top: var(--s-3); text-align:center;">
88
+ Looking for upgrade controls? They moved to <strong>Settings → General → Version</strong>.
89
+ </p>`;
197
90
  }
@@ -5,7 +5,7 @@
5
5
  import { html } from '../html.js';
6
6
  import { useEffect, useState } from 'preact/hooks';
7
7
  import {
8
- config, configDirty, accentColor, folders, workspaces,
8
+ config, configDirty, accentColor, folders, workspaces, serverHealth,
9
9
  setAccentColor, ACCENT_DEFAULT,
10
10
  } from '../state.js';
11
11
  import {
@@ -169,6 +169,10 @@ export function ConfigurePage() {
169
169
  <span class="label">Theme accent</span>
170
170
  <${AccentPicker} />
171
171
  </div>
172
+ <div class="field">
173
+ <span class="label">Version</span>
174
+ <${VersionField} />
175
+ </div>
172
176
  <div class="field">
173
177
  <span class="label">Backend</span>
174
178
  <${RestartButton} />
@@ -450,6 +454,67 @@ const PRESETS = [
450
454
  { name: 'Crimson', hex: '#b73f3f' },
451
455
  ];
452
456
 
457
+ function VersionField() {
458
+ const [info, setInfo] = useState(null);
459
+ const [checking, setChecking] = useState(true);
460
+ const [upgrading, setUpgrading] = useState(false);
461
+
462
+ const refresh = async (force = false) => {
463
+ setChecking(true);
464
+ try {
465
+ const r = await api('GET', '/api/version' + (force ? '?refresh=1' : ''));
466
+ setInfo(r);
467
+ } catch (e) {
468
+ setInfo({ error: e.message });
469
+ } finally {
470
+ setChecking(false);
471
+ }
472
+ };
473
+ useEffect(() => { refresh(false); }, []);
474
+
475
+ const onUpgrade = async () => {
476
+ if (!info?.updateAvailable) return;
477
+ setUpgrading(true);
478
+ try {
479
+ const r = await api('POST', '/api/upgrade', { target: 'latest' });
480
+ setToast(`upgrading to v${info.latest} · backend will restart`);
481
+ if (r?.helperUrl) {
482
+ setTimeout(() => { location.href = r.helperUrl; }, 300);
483
+ } else if (r?.closeFrontend) {
484
+ setTimeout(() => { try { window.close(); } catch {} }, 400);
485
+ }
486
+ } catch (e) {
487
+ setUpgrading(false);
488
+ setToast(e.message, 'error');
489
+ }
490
+ };
491
+
492
+ const current = info?.current || serverHealth.value.version || '';
493
+ const latest = info?.latest;
494
+ const updateAvailable = !!info?.updateAvailable;
495
+
496
+ const hint = info?.error
497
+ ? "Couldn't reach npm registry."
498
+ : updateAvailable ? `Update available · v${latest}`
499
+ : latest ? "You're on the latest release."
500
+ : 'Checks npm registry (cached 30 min).';
501
+
502
+ return html`
503
+ <div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
504
+ <span class="mono">v${current || '?'}</span>
505
+ ${updateAvailable ? html`
506
+ <button class="action primary" disabled=${upgrading} onClick=${onUpgrade}>
507
+ ${upgrading ? 'Upgrading…' : `Upgrade to v${latest}`}
508
+ </button>
509
+ ` : null}
510
+ <button class="action" disabled=${checking || upgrading} onClick=${() => refresh(true)}>
511
+ ${checking ? 'Checking…' : 'Check for updates'}
512
+ </button>
513
+ <span class="hint">${hint}</span>
514
+ </div>
515
+ `;
516
+ }
517
+
453
518
  function RestartButton() {
454
519
  const [busy, setBusy] = useState(false);
455
520
  const onClick = async () => {
@@ -537,6 +537,12 @@ httpServer.listen(HELPER_PORT, '127.0.0.1', () => {
537
537
  setPhase('installing');
538
538
  pushLine('info', `Running: npm i -g @bakapiano/ccsm@${target}${installPrefix ? ` --prefix=${installPrefix}` : ''}`);
539
539
 
540
+ // Extra settle: gracefulShutdown only waits for the server pid, but
541
+ // node-pty grandchildren (winpty-agent / conpty) need a beat longer
542
+ // to release file locks on node_modules/node-pty/build/Release/*.node.
543
+ // Without this beat, npm hits EBUSY/EPERM renaming the package dir.
544
+ await sleep(2000);
545
+
540
546
  const isWin = process.platform === 'win32';
541
547
  const arg = `@bakapiano/ccsm@${target}`;
542
548
  const npmArgs = ['i', '-g'];
@@ -555,26 +561,58 @@ httpServer.listen(HELPER_PORT, '127.0.0.1', () => {
555
561
  exeArgs = npmArgs;
556
562
  }
557
563
 
558
- const npmExit = await new Promise((resolve) => {
559
- const child = spawn(exe, exeArgs, { windowsHide: true });
560
- const pipe = (stream, label) => {
561
- let leftover = '';
562
- stream.on('data', (chunk) => {
563
- const text = leftover + chunk.toString();
564
- const lines = text.split(/\r?\n/);
565
- leftover = lines.pop() || '';
566
- for (const line of lines) if (line) pushLine(label, line);
564
+ // Postinstall opens the hosted setup guide by default — fine on a
565
+ // first npm i, but during an in-app upgrade the user is already in
566
+ // the updater UI and a fresh tab to /setup/ is just noise.
567
+ const npmEnv = { ...process.env, CCSM_NO_AUTOLAUNCH: '1' };
568
+
569
+ const LOCK_PATTERN = /\b(EBUSY|EPERM|ENOTEMPTY|EEXIST|ELOCKED|locked|in use|cannot rename|operation not permitted)\b/i;
570
+
571
+ async function runNpmOnce() {
572
+ let sawLockError = false;
573
+ const exit = await new Promise((resolve) => {
574
+ const child = spawn(exe, exeArgs, { windowsHide: true, env: npmEnv });
575
+ const pipe = (stream, label) => {
576
+ let leftover = '';
577
+ stream.on('data', (chunk) => {
578
+ const text = leftover + chunk.toString();
579
+ const lines = text.split(/\r?\n/);
580
+ leftover = lines.pop() || '';
581
+ for (const line of lines) {
582
+ if (!line) continue;
583
+ if (LOCK_PATTERN.test(line)) sawLockError = true;
584
+ pushLine(label, line);
585
+ }
586
+ });
587
+ stream.on('end', () => { if (leftover) pushLine(label, leftover); });
588
+ };
589
+ pipe(child.stdout, 'stdout');
590
+ pipe(child.stderr, 'stderr');
591
+ child.on('error', (e) => {
592
+ pushLine('stderr', `spawn error: ${e.message}`);
593
+ resolve(-1);
567
594
  });
568
- stream.on('end', () => { if (leftover) pushLine(label, leftover); });
569
- };
570
- pipe(child.stdout, 'stdout');
571
- pipe(child.stderr, 'stderr');
572
- child.on('error', (e) => {
573
- pushLine('stderr', `spawn error: ${e.message}`);
574
- resolve(-1);
595
+ child.on('exit', (code) => resolve(code));
575
596
  });
576
- child.on('exit', (code) => resolve(code));
577
- });
597
+ return { exit, sawLockError };
598
+ }
599
+
600
+ let npmExit = -1;
601
+ // Up to 3 attempts: original + 2 retries with growing backoff. Only
602
+ // retry when the failure looks like a file-lock issue from straggling
603
+ // child handles, never on a clean nonzero exit (auth, 404, etc).
604
+ const backoffs = [3000, 6000];
605
+ let attempt = 0;
606
+ while (true) {
607
+ attempt++;
608
+ const { exit, sawLockError } = await runNpmOnce();
609
+ npmExit = exit;
610
+ if (exit === 0) break;
611
+ if (!sawLockError || attempt > backoffs.length) break;
612
+ const wait = backoffs[attempt - 1];
613
+ pushLine('info', `npm failed with what looks like a file lock; retrying in ${Math.round(wait/1000)}s (attempt ${attempt + 1})…`);
614
+ await sleep(wait);
615
+ }
578
616
 
579
617
  if (npmExit !== 0) {
580
618
  errorMsg = `npm exited with code ${npmExit}`;