@pi-chamber/web 0.1.8 → 0.2.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 (82) hide show
  1. package/bin/cli.test.js +48 -0
  2. package/bin/lib/DOCUMENTATION.md +2 -2
  3. package/bin/lib/cli-args.js +38 -11
  4. package/bin/lib/cli-startup.js +60 -13
  5. package/bin/lib/commands-startup.js +20 -5
  6. package/bin/lib/commands-update.js +91 -34
  7. package/bin/lib/commands-update.test.js +57 -42
  8. package/dist/assets/CommitSection-LewzHfDH.js +1 -0
  9. package/dist/assets/DiagramEditor-BOIJVnh3.js +1 -0
  10. package/dist/assets/DiagramView-CdNHezna.js +1 -0
  11. package/dist/assets/{DiffView-BHS4m_K7.js → DiffView-Cmw-RHIH.js} +2 -2
  12. package/dist/assets/{FilesView-DmZcdwl-.js → FilesView-BODbL0Xm.js} +6 -6
  13. package/dist/assets/GitView-DqYF-n_h.js +6 -0
  14. package/dist/assets/JsonTreeView-DzEjA1ht.js +1 -0
  15. package/dist/assets/{MarkdownRendererImpl-B07lHhBl.js → MarkdownRendererImpl-Bfq0bOSM.js} +2 -2
  16. package/dist/assets/PatchDiff-CtVByTC4.js +1 -0
  17. package/dist/assets/{PierreDiffViewer-Bo0_BCT7.js → PierreDiffViewer-Cwoewmiu.js} +2 -2
  18. package/dist/assets/{useAppFontEffects-DXASDavv.js → SessionSwitcherDropdown-DNjCJjzF.js} +97 -97
  19. package/dist/assets/{SettingsView-CtsS4oDI.js → SettingsView-BtLvHlj4.js} +3 -3
  20. package/dist/assets/SettingsWindow-CPd7JmJs.js +1 -0
  21. package/dist/assets/{ToolOutputDialog-iZG2ey4T.js → ToolOutputDialog-CFFC5eI-.js} +6 -6
  22. package/dist/assets/ToolPartDiffPreview-yFq1DKSA.js +8 -0
  23. package/dist/assets/{VirtualizedFileDiff-DD5IznIe.js → VirtualizedFileDiff-CC5lTPJS.js} +4 -4
  24. package/dist/assets/collapsible-D_555Hw_.js +1 -0
  25. package/dist/assets/computeVirtualFileMetrics-BcVuV0Ic.js +3 -0
  26. package/dist/assets/contextFileOpenGuard-BygvjHuE.js +1 -0
  27. package/dist/assets/index-C9CLY-is.js +1 -0
  28. package/dist/assets/index-DQr8-8RF.js +8 -0
  29. package/dist/assets/index-DYrJXRkU.css +1 -0
  30. package/dist/assets/isDiffPlainText-Bher0UxW.js +1 -0
  31. package/dist/assets/isFilePlainText-EIViqOlI.js +1 -0
  32. package/dist/assets/main-1bIgjP48.js +2 -0
  33. package/dist/assets/main-JDtt2bQS.js +133 -0
  34. package/dist/assets/{markdownCore-BULtt6Xa.js → markdownCore-Bm4FxtK1.js} +2 -2
  35. package/dist/assets/miniChat-B7kKqKHH.js +2 -0
  36. package/dist/assets/mobile-DZPRce5z.js +2 -0
  37. package/dist/assets/renderElectronMiniChatApp-Dq0GdThJ.js +1 -0
  38. package/dist/assets/renderMobileApp-DlKnjObC.js +23 -0
  39. package/dist/assets/runtimeSurface-B6sKTjo7.js +1 -0
  40. package/dist/assets/{useInlineCommentController-KJFe98HG.js → useInlineCommentController-CSR_Ju0a.js} +2 -2
  41. package/dist/assets/usePanelSlide-BkonU4LS.js +47 -0
  42. package/dist/assets/{useWindowTitle-BcwmWC0M.js → useWindowTitle-rt_8xbd9.js} +3 -3
  43. package/dist/assets/{vendor-codemirror-language-data-CFVH0pe8.js → vendor-codemirror-language-data-Be-zDX0u.js} +2 -2
  44. package/dist/assets/{web-components-CNPQSzpa.js → web-components-DgFD1Jms.js} +4 -4
  45. package/dist/index.html +4 -4
  46. package/dist/mini-chat.html +3 -3
  47. package/dist/mobile.html +3 -3
  48. package/package.json +1 -1
  49. package/server/lib/package-manager.js +44 -65
  50. package/server/lib/package-manager.test.js +27 -0
  51. package/server/lib/pi/routes.js +6 -0
  52. package/server/lib/pi/routes.test.js +32 -0
  53. package/server/lib/pi/session-daemon/DOCUMENTATION.md +1 -1
  54. package/server/lib/pi/session-daemon/session-daemon.js +11 -4
  55. package/server/lib/pi/session-daemon/session-daemon.test.js +24 -0
  56. package/dist/assets/CommitSection-DGGArAb2.js +0 -1
  57. package/dist/assets/DiagramEditor-ig5nYV6t.js +0 -1
  58. package/dist/assets/DiagramView-8Qr6pwwI.js +0 -1
  59. package/dist/assets/GitView-gfHxmc66.js +0 -6
  60. package/dist/assets/JsonTreeView-DbFqka3P.js +0 -1
  61. package/dist/assets/PatchDiff-DD9pAs47.js +0 -1
  62. package/dist/assets/SessionSwitcherDropdown-t7WZX24f.js +0 -1
  63. package/dist/assets/SettingsWindow-DIRGn3eQ.js +0 -1
  64. package/dist/assets/TerminalView-DUwO1CCY.js +0 -46
  65. package/dist/assets/ToolPartDiffPreview-FTexlZSz.js +0 -8
  66. package/dist/assets/collapsible-ODWWxk4f.js +0 -1
  67. package/dist/assets/computeVirtualFileMetrics-DdXkLFAD.js +0 -3
  68. package/dist/assets/contextFileOpenGuard-uC4OerzL.js +0 -1
  69. package/dist/assets/index-CRB7i1S2.js +0 -8
  70. package/dist/assets/index-C_Mo87uS.css +0 -1
  71. package/dist/assets/index-MSKFS67y.js +0 -1
  72. package/dist/assets/isDiffPlainText-CeCQwHA2.js +0 -1
  73. package/dist/assets/isFilePlainText-BcfhE9FH.js +0 -1
  74. package/dist/assets/main-CPygRDd9.js +0 -134
  75. package/dist/assets/main-DZg4qzOe.js +0 -2
  76. package/dist/assets/miniChat-BFotDgpO.js +0 -2
  77. package/dist/assets/mobile-3LLzhN5Q.js +0 -2
  78. package/dist/assets/renderElectronMiniChatApp-DGmmT9Wf.js +0 -1
  79. package/dist/assets/renderMobileApp-Dd8P9hvM.js +0 -23
  80. package/dist/assets/runtimeSurface-Cnqjhb6Y.js +0 -1
  81. package/dist/assets/useOpenInAppsStore-DHAeT9zx.js +0 -1
  82. /package/dist/assets/{TerminalView-BV3KOtdA.css → usePanelSlide-BV3KOtdA.css} +0 -0
package/bin/cli.test.js CHANGED
@@ -289,6 +289,17 @@ describe('cli args', () => {
289
289
  expect(parsed.options.port).toBe(3002);
290
290
  });
291
291
 
292
+ it('parses startup lan bind and port flags', () => {
293
+ const parsed = parseArgs(['startup', 'enable', '--lan', '--port', '3002', '--ui-password', 'secret']);
294
+
295
+ expect(parsed.command).toBe('startup');
296
+ expect(parsed.startupAction).toBe('enable');
297
+ expect(parsed.options.lan).toBe(true);
298
+ expect(parsed.options.host).toBe('0.0.0.0');
299
+ expect(parsed.options.port).toBe(3002);
300
+ expect(parsed.options.uiPassword).toBe('secret');
301
+ });
302
+
292
303
  it('parses tunnel auto-start server options', () => {
293
304
  const parsed = parseArgs(['tunnel', 'start', '--port', '3002', '--api-only', '--lan', '--ui-password', 'secret']);
294
305
 
@@ -321,6 +332,43 @@ describe('cli args', () => {
321
332
  });
322
333
  });
323
334
 
335
+ describe('startup enable command helper', () => {
336
+ it('formats stored serve flags for a local port', async () => {
337
+ const { formatStartupServeCommand } = await import('./lib/cli-startup.js');
338
+ expect(formatStartupServeCommand({ port: 8080 })).toBe('pichamber serve --foreground --port 8080');
339
+ });
340
+
341
+ it('formats lan, port, and password flags without echoing the secret', async () => {
342
+ const { formatStartupServeCommand } = await import('./lib/cli-startup.js');
343
+ expect(formatStartupServeCommand({
344
+ lan: true,
345
+ host: '0.0.0.0',
346
+ port: 3002,
347
+ uiPassword: 'secret',
348
+ })).toBe('pichamber serve --foreground --port 3002 --lan --ui-password');
349
+ });
350
+
351
+ it('documents lan, port, and ui-password in startup help', async () => {
352
+ const { showStartupHelp } = await import('./lib/cli-args.js');
353
+ const lines = [];
354
+ const original = console.log;
355
+ console.log = (message) => {
356
+ lines.push(String(message));
357
+ };
358
+ try {
359
+ showStartupHelp();
360
+ } finally {
361
+ console.log = original;
362
+ }
363
+
364
+ const text = lines.join('\n');
365
+ expect(text).toContain('--lan');
366
+ expect(text).toContain('--port <port>');
367
+ expect(text).toContain('--ui-password');
368
+ expect(text).toContain('pichamber startup enable --lan --port 3002 --ui-password');
369
+ });
370
+ });
371
+
324
372
  describe('cli API target resolution', () => {
325
373
  it('uses an explicit port without discovery', async () => {
326
374
  await expect(resolveTargetPort(
@@ -8,9 +8,9 @@ This directory contains non-entrypoint PiChamber CLI implementation. `../cli.js`
8
8
  - `commands-lifecycle.js`: stop and restart behavior.
9
9
  - `commands-status.js`: running-instance and tunnel status presentation.
10
10
  - `commands-logs.js`: log discovery, tailing, and follow behavior.
11
- - `commands-startup.js`: native startup service management.
11
+ - `commands-startup.js`: native startup service management. `startup enable` stores serve flags (`--port`, `--lan`/`--host`, `--ui-password`, `--api-only`) in the login service. `pichamber startup --help` documents those flags. LAN binds require a UI password, matching `pichamber serve`.
12
12
  - `commands-connect-url.js`: authenticated direct/relay pairing links.
13
- - `commands-update.js`: package update and restart coordination.
13
+ - `commands-update.js`: package update and restart coordination. Updates only a global install owned by the running CLI. A Linux user systemd unit is restarted in place; the command does not spawn a replacement server on the same port.
14
14
  - `commands-tunnel.js`: tunnel lifecycle and profile management.
15
15
 
16
16
  ## Rules
@@ -522,7 +522,8 @@ EXAMPLES:
522
522
  pichamber serve --foreground # Start in foreground (for systemd Type=simple)
523
523
  pichamber connect-url --port 3000 --qr
524
524
  pichamber connect-url --server https://pichamber.example.com
525
- pichamber startup enable # Start PiChamber at user login
525
+ pichamber startup enable --lan --port 3002 --ui-password
526
+ # Login service on LAN at 0.0.0.0:3002
526
527
  pichamber tunnel help # Show tunnel lifecycle help
527
528
  pichamber logs # Follow logs for latest running instance
528
529
  `);
@@ -535,25 +536,51 @@ function showStartupHelp() {
535
536
  USAGE:
536
537
  pichamber startup <SUBCOMMAND> [OPTIONS]
537
538
 
539
+ DESCRIPTION:
540
+ Installs a user-login service that runs pichamber serve --foreground.
541
+ Flags passed to startup enable are stored in that service. Re-run enable
542
+ to replace the stored command.
543
+
538
544
  SUBCOMMANDS:
539
545
  status Show startup integration status
540
546
  enable Install and start native user startup integration
541
547
  disable Stop and remove native user startup integration
542
548
 
543
- OPTIONS:
544
- -p, --port Web server port used by startup service
545
- --host Bind address used by startup service
546
- --ui-password Protect browser UI with single password
547
- --api-only Start API routes only, without serving browser UI assets
548
- --no-env-snapshot Do not save current environment for startup service
549
- --json Output machine-readable JSON
549
+ ENABLE FLAGS:
550
+ These become the serve command the login service runs.
551
+
552
+ -p, --port <port> Listen port (default: ${DEFAULT_PORT})
553
+ --host <address> Bind address (default: 127.0.0.1, this machine only)
554
+ --hostname <address> Alias for --host
555
+ --lan Same as --host 0.0.0.0 — reachable on your LAN/Wi-Fi
556
+ --ui-password [secret] Required with --lan (omit the value to generate one)
557
+ --api-only API routes only, no browser UI
558
+ --no-env-snapshot Do not copy the current shell environment
559
+ --json Machine-readable JSON
550
560
  -q, --quiet Suppress non-essential output
551
561
 
562
+ LAN ACCESS:
563
+ Other devices on the same network need --lan and a UI password:
564
+
565
+ pichamber startup enable --lan --port 3002 --ui-password
566
+
567
+ Then open http://<this-computer-lan-ip>:3002 on those devices.
568
+
552
569
  EXAMPLES:
553
570
  pichamber startup enable
554
- pichamber startup enable --port 3000
555
- pichamber startup enable --port 3000 --api-only --host 0.0.0.0
556
- pichamber startup status --json
571
+ This machine only, port ${DEFAULT_PORT}
572
+
573
+ pichamber startup enable --port 8080
574
+ This machine only, port 8080
575
+
576
+ pichamber startup enable --lan --port 3002 --ui-password
577
+ LAN/Wi-Fi on port 3002 (password generated if omitted)
578
+
579
+ pichamber startup enable --host 0.0.0.0 --port 3002 --ui-password secret
580
+ Same as --lan, with an explicit password
581
+
582
+ pichamber startup status
583
+ pichamber startup disable
557
584
  `);
558
585
  }
559
586
 
@@ -5,7 +5,7 @@ import { spawnSync } from 'child_process';
5
5
  import { DEFAULT_PORT } from './cli-args.js';
6
6
  import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
7
7
  import { getDataDir } from './cli-paths.js';
8
- import { hasUiPasswordConfigured } from './cli-network.js';
8
+ import { assertAuthenticatedNetworkExposure, hasUiPasswordConfigured, resolveServeUiPassword } from './cli-network.js';
9
9
 
10
10
  const STARTUP_SERVICE_ID = 'dev.pichamber.web';
11
11
 
@@ -161,11 +161,24 @@ function resolveCliEntrypoint() {
161
161
  const entry = typeof process.argv[1] === 'string' && process.argv[1].trim().length > 0
162
162
  ? process.argv[1]
163
163
  : path.join(__dirname, 'cli.js');
164
- try {
165
- return fs.realpathSync(entry);
166
- } catch {
167
- return path.resolve(entry);
164
+ // Keep the invoked path, not realpath(). pnpm/npm shims point at a stable
165
+ // node_modules entry; resolving through the store pins a versioned folder
166
+ // that breaks after `pichamber update`.
167
+ return path.resolve(entry);
168
+ }
169
+
170
+ function isUserStartupServiceActive() {
171
+ if (process.platform !== 'linux') return false;
172
+ const result = runStartupCommand('systemctl', ['--user', 'is-active', 'pichamber.service'], { allowFailure: true });
173
+ return (result.stdout || '').trim() === 'active';
174
+ }
175
+
176
+ function restartUserStartupService() {
177
+ if (process.platform !== 'linux') {
178
+ throw new Error('Startup service restart is only supported on Linux systemd user units.');
168
179
  }
180
+ runStartupCommand('systemctl', ['--user', 'daemon-reload']);
181
+ runStartupCommand('systemctl', ['--user', 'restart', 'pichamber.service']);
169
182
  }
170
183
 
171
184
  function buildStartupArgs(options = {}) {
@@ -179,6 +192,22 @@ function buildStartupArgs(options = {}) {
179
192
  return args;
180
193
  }
181
194
 
195
+ function formatStartupServeCommand(options = {}) {
196
+ const parts = ['pichamber', 'serve', '--foreground', '--port', String(options.port || DEFAULT_PORT)];
197
+ if (options.lan === true && (typeof options.host !== 'string' || options.host === '0.0.0.0')) {
198
+ parts.push('--lan');
199
+ } else if (typeof options.host === 'string' && options.host.length > 0) {
200
+ parts.push('--host', options.host);
201
+ }
202
+ if (options.apiOnly === true) {
203
+ parts.push('--api-only');
204
+ }
205
+ if (options.explicitUiPassword === true || hasUiPasswordConfigured(options.uiPassword)) {
206
+ parts.push('--ui-password');
207
+ }
208
+ return parts.join(' ');
209
+ }
210
+
182
211
  function writeMacosStartupWrapper(options = {}) {
183
212
  const wrapperPath = getMacosStartupWrapperPath();
184
213
  const args = buildStartupArgs(options).map(startupShellQuote).join(' ');
@@ -299,28 +328,43 @@ function enableStartupService(options = {}) {
299
328
  throw new TunnelCliError(`Startup integration is not supported on ${paths.platform}.`, EXIT_CODE.USAGE_ERROR);
300
329
  }
301
330
 
331
+ const resolvedUiPassword = resolveServeUiPassword(options);
332
+ const serveOptions = {
333
+ ...options,
334
+ ...(resolvedUiPassword.password ? { uiPassword: resolvedUiPassword.password } : {}),
335
+ };
336
+ assertAuthenticatedNetworkExposure({
337
+ host: serveOptions.host,
338
+ uiPassword: serveOptions.uiPassword,
339
+ });
340
+ const finish = (status) => (
341
+ resolvedUiPassword.generated === true
342
+ ? { ...status, generatedUiPassword: resolvedUiPassword.password }
343
+ : status
344
+ );
345
+
302
346
  if (paths.platform === 'macos') {
303
347
  removeStartupEnvFile();
304
348
  fs.mkdirSync(path.dirname(paths.servicePath), { recursive: true, mode: 0o700 });
305
349
  fs.mkdirSync(path.join(os.homedir(), 'Library', 'Logs', 'PiChamber'), { recursive: true, mode: 0o700 });
306
- fs.writeFileSync(paths.servicePath, buildMacosLaunchAgent(options), { mode: 0o600 });
350
+ fs.writeFileSync(paths.servicePath, buildMacosLaunchAgent(serveOptions), { mode: 0o600 });
307
351
  runStartupCommand('/bin/launchctl', ['bootout', `gui/${process.getuid()}`, paths.servicePath], { allowFailure: true });
308
352
  runStartupCommand('/bin/launchctl', ['bootstrap', `gui/${process.getuid()}`, paths.servicePath]);
309
353
  runStartupCommand('/bin/launchctl', ['kickstart', '-k', `gui/${process.getuid()}/${STARTUP_SERVICE_ID}`], { allowFailure: true });
310
- return getStartupStatus();
354
+ return finish(getStartupStatus());
311
355
  }
312
356
 
313
357
  if (paths.platform === 'linux') {
314
- writeStartupEnvFile(options, { quoteValue: systemdEnvFileQuote });
358
+ writeStartupEnvFile(serveOptions, { quoteValue: systemdEnvFileQuote });
315
359
  fs.mkdirSync(path.dirname(paths.servicePath), { recursive: true, mode: 0o700 });
316
- fs.writeFileSync(paths.servicePath, buildSystemdUserService(options), { mode: 0o600 });
360
+ fs.writeFileSync(paths.servicePath, buildSystemdUserService(serveOptions), { mode: 0o600 });
317
361
  runStartupCommand('systemctl', ['--user', 'daemon-reload']);
318
362
  runStartupCommand('systemctl', ['--user', 'enable', '--now', 'pichamber.service']);
319
- return getStartupStatus();
363
+ return finish(getStartupStatus());
320
364
  }
321
365
 
322
- const envFilePath = writeStartupEnvFile(options);
323
- const startupArgs = buildStartupArgs(options).map(powershellQuote).join(', ');
366
+ const envFilePath = writeStartupEnvFile(serveOptions);
367
+ const startupArgs = buildStartupArgs(serveOptions).map(powershellQuote).join(', ');
324
368
  const powerShellCommand = [
325
369
  `$envFile=${powershellQuote(envFilePath)}`,
326
370
  `if (Test-Path $envFile) { Get-Content $envFile | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { $v=$matches[2]; if ($v.StartsWith("'") -and $v.EndsWith("'")) { $v=$v.Substring(1,$v.Length-2).Replace("'\\''","'") }; [Environment]::SetEnvironmentVariable($matches[1], $v, 'Process') } } }`,
@@ -336,7 +380,7 @@ function enableStartupService(options = {}) {
336
380
  '/TR', taskArgs,
337
381
  ]);
338
382
  runStartupCommand('schtasks.exe', ['/Run', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
339
- return getStartupStatus();
383
+ return finish(getStartupStatus());
340
384
  }
341
385
 
342
386
  function disableStartupService() {
@@ -368,4 +412,7 @@ export {
368
412
  getStartupStatus,
369
413
  enableStartupService,
370
414
  disableStartupService,
415
+ isUserStartupServiceActive,
416
+ restartUserStartupService,
417
+ formatStartupServeCommand,
371
418
  };
@@ -1,5 +1,5 @@
1
1
  import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
2
- import { getStartupStatus, enableStartupService, disableStartupService } from './cli-startup.js';
2
+ import { getStartupStatus, enableStartupService, disableStartupService, formatStartupServeCommand } from './cli-startup.js';
3
3
  import {
4
4
  intro as clackIntro,
5
5
  outro as clackOutro,
@@ -27,7 +27,18 @@ async function startupCommand(options, action = 'status') {
27
27
  status = getStartupStatus();
28
28
  }
29
29
 
30
- const result = { action: normalized, ...status };
30
+ const generatedUiPassword = typeof status.generatedUiPassword === 'string' && status.generatedUiPassword.length > 0
31
+ ? status.generatedUiPassword
32
+ : undefined;
33
+ const { generatedUiPassword: _generatedUiPassword, ...publicStatus } = status;
34
+ void _generatedUiPassword;
35
+ const serveCommand = normalized === 'enable' ? formatStartupServeCommand(options) : undefined;
36
+ const result = {
37
+ action: normalized,
38
+ ...publicStatus,
39
+ ...(serveCommand ? { serveCommand } : {}),
40
+ ...(generatedUiPassword ? { password: generatedUiPassword } : {}),
41
+ };
31
42
  if (!result.supported) {
32
43
  throw new TunnelCliError(
33
44
  `Startup integration is not supported on ${result.platform}.`,
@@ -46,7 +57,7 @@ async function startupCommand(options, action = 'status') {
46
57
  }
47
58
 
48
59
  if (isQuietMode(options)) {
49
- process.stdout.write(`startup ${result.enabled ? 'enabled' : 'disabled'} platform:${result.platform} supported:${result.supported ? 'yes' : 'no'}${result.servicePath ? ` path:${result.servicePath}` : ''}\n`);
60
+ process.stdout.write(`startup ${result.enabled ? 'enabled' : 'disabled'} platform:${result.platform} supported:${result.supported ? 'yes' : 'no'}${result.servicePath ? ` path:${result.servicePath}` : ''}${serveCommand ? ` command:${serveCommand}` : ''}${generatedUiPassword ? ` pass:${generatedUiPassword}` : ''}\n`);
50
61
  return;
51
62
  }
52
63
 
@@ -55,8 +66,12 @@ async function startupCommand(options, action = 'status') {
55
66
  if (typeof result.activeState === 'string') {
56
67
  logStatus(result.active ? 'success' : result.activeState === 'failed' ? 'error' : 'warning', `service ${result.activeState}`);
57
68
  }
58
- if (normalized === 'enable') {
59
- logStatus('info', 'service command', 'pichamber serve --foreground');
69
+ if (serveCommand) {
70
+ logStatus('info', 'service command', serveCommand);
71
+ }
72
+ if (generatedUiPassword) {
73
+ logStatus('success', 'UI password', generatedUiPassword);
74
+ logStatus('warning', 'save this password', 'it is not shown again');
60
75
  }
61
76
  clackOutro(normalized === 'status' ? 'status complete' : `${normalized} complete`);
62
77
  }
@@ -6,6 +6,10 @@ import {
6
6
  removePidFile,
7
7
  stopInstanceProcess,
8
8
  } from './cli-process.js';
9
+ import {
10
+ isUserStartupServiceActive as defaultIsUserStartupServiceActive,
11
+ restartUserStartupService as defaultRestartUserStartupService,
12
+ } from './cli-startup.js';
9
13
  import {
10
14
  intro as clackIntro,
11
15
  outro as clackOutro,
@@ -17,7 +21,23 @@ import {
17
21
  logStatus,
18
22
  } from '../cli-output.js';
19
23
 
20
- function createUpdateCommand({ importFromFilePath, packageManagerPath, serveCommand }) {
24
+ const UNOWNED_INSTALL_MESSAGE = [
25
+ 'This PiChamber copy is not a global package-manager install.',
26
+ 'Install one copy, then update that same copy:',
27
+ ' bun add -g @pi-chamber/web',
28
+ ' npm install -g @pi-chamber/web',
29
+ ' pnpm add -g @pi-chamber/web',
30
+ ' yarn global add @pi-chamber/web',
31
+ ].join('\n');
32
+
33
+ function createUpdateCommand({
34
+ importFromFilePath,
35
+ packageManagerPath,
36
+ serveCommand,
37
+ isInsideSystemdService = () => Boolean(process.env.INVOCATION_ID) || Boolean(process.env.PICHAMBER_SYSTEMD_UNIT),
38
+ isUserStartupServiceActive = defaultIsUserStartupServiceActive,
39
+ restartUserStartupService = defaultRestartUserStartupService,
40
+ }) {
21
41
  return async function updateCommand(options = {}) {
22
42
  const showOutput = shouldRenderHumanOutput(options);
23
43
  const updateSpin = createSpinner(options);
@@ -25,7 +45,7 @@ function createUpdateCommand({ importFromFilePath, packageManagerPath, serveComm
25
45
  const {
26
46
  checkForUpdates,
27
47
  executeUpdate,
28
- detectPackageManager,
48
+ resolveTrustedUpdatePackageManager,
29
49
  getCurrentVersion,
30
50
  } = await importFromFilePath(packageManagerPath);
31
51
 
@@ -94,9 +114,8 @@ function createUpdateCommand({ importFromFilePath, packageManagerPath, serveComm
94
114
  throw new Error(msg);
95
115
  }
96
116
 
97
- const isSystemdService = Boolean(process.env.INVOCATION_ID) || Boolean(process.env.PICHAMBER_SYSTEMD_UNIT);
98
- if (isSystemdService) {
99
- const msg = 'systemd deployments must be updated through package management and service restart (e.g. systemctl --user restart pichamber.service).';
117
+ if (isInsideSystemdService()) {
118
+ const msg = 'pichamber update cannot replace this process while it is running as a systemd service. Run it from a terminal instead.';
100
119
  updateSpin?.error('systemd service deployment detected');
101
120
  if (isJsonMode(options)) {
102
121
  printJson({
@@ -113,12 +132,29 @@ function createUpdateCommand({ importFromFilePath, packageManagerPath, serveComm
113
132
  throw new Error(msg);
114
133
  }
115
134
 
135
+ const pm = resolveTrustedUpdatePackageManager();
136
+ if (!pm) {
137
+ updateSpin?.error('No global package-manager install');
138
+ if (isJsonMode(options)) {
139
+ printJson({
140
+ currentVersion,
141
+ latestVersion: updateInfo.version || 'latest',
142
+ updated: false,
143
+ error: UNOWNED_INSTALL_MESSAGE,
144
+ });
145
+ return;
146
+ }
147
+ if (showOutput) {
148
+ clackOutro('update skipped');
149
+ }
150
+ throw new Error(UNOWNED_INSTALL_MESSAGE);
151
+ }
152
+
116
153
  if (showOutput && !updateSpin) {
117
- logStatus('info', `updating ${updateInfo.currentVersion || currentVersion} -> ${updateInfo.version || 'latest'}`);
154
+ logStatus('info', `updating ${updateInfo.currentVersion || currentVersion} -> ${updateInfo.version || 'latest'} with ${pm}`);
118
155
  }
119
156
  updateSpin?.message(`Updating to ${updateInfo.version || 'latest'}...`);
120
157
 
121
- const pm = detectPackageManager();
122
158
  const result = executeUpdate(pm, { silent: isJsonMode(options) || isQuietMode(options) });
123
159
  if (!result.success) {
124
160
  updateSpin?.error('Update failed');
@@ -128,34 +164,54 @@ function createUpdateCommand({ importFromFilePath, packageManagerPath, serveComm
128
164
  throw new Error(`Update failed with exit code ${result.exitCode}`);
129
165
  }
130
166
 
131
- const runningInstances = await discoverRunningInstances();
132
- if (runningInstances.length > 0) {
133
- updateSpin?.message(`Stopping ${runningInstances.length} running instance(s)...`);
134
- for (const instance of runningInstances) {
135
- try {
136
- const requested = await requestServerShutdown(instance.port, instance.host);
137
- await stopInstanceProcess(instance.pid, {
138
- shutdownWaitMs: requested ? 5000 : 0,
139
- gracefulTimeoutMs: 2500,
140
- forceTimeoutMs: 3000,
141
- });
142
- removePidFile(instance.pidFilePath);
143
- } catch {
167
+ let restartedCount = 0;
168
+ let startupServiceRestarted = false;
169
+
170
+ if (isUserStartupServiceActive()) {
171
+ updateSpin?.message('Restarting systemd user service...');
172
+ try {
173
+ restartUserStartupService();
174
+ startupServiceRestarted = true;
175
+ } catch (error) {
176
+ const detail = error instanceof Error ? error.message : String(error);
177
+ const msg = `Package updated but failed to restart pichamber.service. Run: systemctl --user restart pichamber.service (${detail})`;
178
+ updateSpin?.error('Startup service restart failed');
179
+ if (showOutput) {
180
+ clackOutro('update incomplete');
144
181
  }
182
+ throw new Error(msg);
145
183
  }
184
+ } else {
185
+ const runningInstances = await discoverRunningInstances();
186
+ if (runningInstances.length > 0) {
187
+ updateSpin?.message(`Stopping ${runningInstances.length} running instance(s)...`);
188
+ for (const instance of runningInstances) {
189
+ try {
190
+ const requested = await requestServerShutdown(instance.port, instance.host);
191
+ await stopInstanceProcess(instance.pid, {
192
+ shutdownWaitMs: requested ? 5000 : 0,
193
+ gracefulTimeoutMs: 2500,
194
+ forceTimeoutMs: 3000,
195
+ });
196
+ removePidFile(instance.pidFilePath);
197
+ } catch {
198
+ }
199
+ }
146
200
 
147
- updateSpin?.message(`Restarting ${runningInstances.length} instance(s)...`);
148
- for (const instance of runningInstances) {
149
- const storedOptions = readInstanceOptions(instance.instanceFilePath) || { port: instance.port };
150
- await serveCommand({
151
- port: storedOptions.port || instance.port,
152
- host: storedOptions.host,
153
- explicitPort: true,
154
- uiPassword: storedOptions.uiPassword,
155
- suppressStartupSummary: true,
156
- suppressUiPasswordWarning: true,
157
- quiet: true,
158
- });
201
+ updateSpin?.message(`Restarting ${runningInstances.length} instance(s)...`);
202
+ for (const instance of runningInstances) {
203
+ const storedOptions = readInstanceOptions(instance.instanceFilePath) || { port: instance.port };
204
+ await serveCommand({
205
+ port: storedOptions.port || instance.port,
206
+ host: storedOptions.host,
207
+ explicitPort: true,
208
+ uiPassword: storedOptions.uiPassword,
209
+ suppressStartupSummary: true,
210
+ suppressUiPasswordWarning: true,
211
+ quiet: true,
212
+ });
213
+ }
214
+ restartedCount = runningInstances.length;
159
215
  }
160
216
  }
161
217
 
@@ -168,7 +224,9 @@ function createUpdateCommand({ importFromFilePath, packageManagerPath, serveComm
168
224
  currentVersion,
169
225
  latestVersion: updateInfo.version || 'latest',
170
226
  updated: true,
171
- restartedCount: runningInstances.length,
227
+ packageManager: pm,
228
+ restartedCount,
229
+ startupServiceRestarted,
172
230
  });
173
231
  return;
174
232
  }
@@ -181,4 +239,3 @@ function createUpdateCommand({ importFromFilePath, packageManagerPath, serveComm
181
239
  }
182
240
 
183
241
  export { createUpdateCommand };
184
-
@@ -21,22 +21,32 @@ async function withTempPiChamberDataDir(fn) {
21
21
  }
22
22
  }
23
23
 
24
+ function createTestUpdateCommand(overrides = {}) {
25
+ const executeUpdate = overrides.executeUpdate || vi.fn(() => ({ success: true, exitCode: 0 }));
26
+ const serveCommand = overrides.serveCommand || vi.fn();
27
+ const restartUserStartupService = overrides.restartUserStartupService || vi.fn();
28
+ const updateCommand = createUpdateCommand({
29
+ packageManagerPath: '/fake/package-manager.js',
30
+ serveCommand,
31
+ isInsideSystemdService: overrides.isInsideSystemdService || (() => false),
32
+ isUserStartupServiceActive: overrides.isUserStartupServiceActive || (() => false),
33
+ restartUserStartupService,
34
+ importFromFilePath: vi.fn(async () => ({
35
+ checkForUpdates: overrides.checkForUpdates || vi.fn(async () => ({ available: true, version: '9.9.9' })),
36
+ resolveTrustedUpdatePackageManager: overrides.resolveTrustedUpdatePackageManager || vi.fn(() => 'npm'),
37
+ executeUpdate,
38
+ getCurrentVersion: vi.fn(() => '1.0.0'),
39
+ })),
40
+ });
41
+ return { updateCommand, executeUpdate, serveCommand, restartUserStartupService };
42
+ }
43
+
24
44
  describe('update command', () => {
25
45
  it('uses the package-manager helpers on the update-available path', async () => {
26
46
  await withTempPiChamberDataDir(async () => {
27
47
  const originalWrite = process.stdout.write;
28
48
  process.stdout.write = vi.fn(() => true);
29
- const executeUpdate = vi.fn(() => ({ success: true, exitCode: 0 }));
30
- const updateCommand = createUpdateCommand({
31
- packageManagerPath: '/fake/package-manager.js',
32
- serveCommand: vi.fn(),
33
- importFromFilePath: vi.fn(async () => ({
34
- checkForUpdates: vi.fn(async () => ({ available: true, version: '9.9.9' })),
35
- detectPackageManager: vi.fn(() => 'npm'),
36
- executeUpdate,
37
- getCurrentVersion: vi.fn(() => '1.0.0'),
38
- })),
39
- });
49
+ const { updateCommand, executeUpdate } = createTestUpdateCommand();
40
50
 
41
51
  try {
42
52
  await updateCommand({ json: true });
@@ -50,48 +60,53 @@ describe('update command', () => {
50
60
 
51
61
  it('handles failed update execution safely without touching instances', async () => {
52
62
  await withTempPiChamberDataDir(async () => {
53
- const executeUpdate = vi.fn(() => ({ success: false, exitCode: 1 }));
54
- const updateCommand = createUpdateCommand({
55
- packageManagerPath: '/fake/package-manager.js',
56
- serveCommand: vi.fn(),
57
- importFromFilePath: vi.fn(async () => ({
58
- checkForUpdates: vi.fn(async () => ({ available: true, version: '9.9.9' })),
59
- detectPackageManager: vi.fn(() => 'npm'),
60
- executeUpdate,
61
- getCurrentVersion: vi.fn(() => '1.0.0'),
62
- })),
63
+ const { updateCommand, executeUpdate, serveCommand, restartUserStartupService } = createTestUpdateCommand({
64
+ executeUpdate: vi.fn(() => ({ success: false, exitCode: 1 })),
63
65
  });
64
66
 
65
67
  await expect(updateCommand({ quiet: true })).rejects.toThrow('Update failed with exit code 1');
66
68
  expect(executeUpdate).toHaveBeenCalledWith('npm', { silent: true });
69
+ expect(serveCommand).not.toHaveBeenCalled();
70
+ expect(restartUserStartupService).not.toHaveBeenCalled();
67
71
  });
68
72
  });
69
73
 
70
74
  it('skips in-app update when running inside a systemd service unit', async () => {
71
75
  await withTempPiChamberDataDir(async () => {
72
- const prevInvocationId = process.env.INVOCATION_ID;
73
- process.env.INVOCATION_ID = 'test-invocation-123';
74
- try {
75
- const executeUpdate = vi.fn();
76
- const updateCommand = createUpdateCommand({
77
- packageManagerPath: '/fake/package-manager.js',
78
- serveCommand: vi.fn(),
79
- importFromFilePath: vi.fn(async () => ({
80
- checkForUpdates: vi.fn(async () => ({ available: true, version: '9.9.9' })),
81
- detectPackageManager: vi.fn(() => 'npm'),
82
- executeUpdate,
83
- getCurrentVersion: vi.fn(() => '1.0.0'),
84
- })),
85
- });
76
+ const { updateCommand, executeUpdate } = createTestUpdateCommand({
77
+ isInsideSystemdService: () => true,
78
+ });
79
+
80
+ await expect(updateCommand({ quiet: true })).rejects.toThrow('pichamber update cannot replace this process while it is running as a systemd service');
81
+ expect(executeUpdate).not.toHaveBeenCalled();
82
+ });
83
+ });
84
+
85
+ it('refuses to guess a package manager when the running copy is not a global install', async () => {
86
+ await withTempPiChamberDataDir(async () => {
87
+ const { updateCommand, executeUpdate } = createTestUpdateCommand({
88
+ resolveTrustedUpdatePackageManager: vi.fn(() => null),
89
+ });
86
90
 
87
- await expect(updateCommand({ quiet: true })).rejects.toThrow('systemd deployments must be updated through package management');
88
- expect(executeUpdate).not.toHaveBeenCalled();
91
+ await expect(updateCommand({ quiet: true })).rejects.toThrow('This PiChamber copy is not a global package-manager install.');
92
+ expect(executeUpdate).not.toHaveBeenCalled();
93
+ });
94
+ });
95
+
96
+ it('restarts a systemd user unit instead of spawning a new server', async () => {
97
+ await withTempPiChamberDataDir(async () => {
98
+ const originalWrite = process.stdout.write;
99
+ process.stdout.write = vi.fn(() => true);
100
+ const { updateCommand, serveCommand, restartUserStartupService } = createTestUpdateCommand({
101
+ isUserStartupServiceActive: () => true,
102
+ });
103
+
104
+ try {
105
+ await updateCommand({ json: true });
106
+ expect(restartUserStartupService).toHaveBeenCalledTimes(1);
107
+ expect(serveCommand).not.toHaveBeenCalled();
89
108
  } finally {
90
- if (typeof prevInvocationId === 'string') {
91
- process.env.INVOCATION_ID = prevInvocationId;
92
- } else {
93
- delete process.env.INVOCATION_ID;
94
- }
109
+ process.stdout.write = originalWrite;
95
110
  }
96
111
  });
97
112
  });
@@ -0,0 +1 @@
1
+ import{R as C,j as e}from"./vendor-react-DYyjh7Q0.js";import{f as z,T as y,y as _,B as c,I as a,z as v,aW as I}from"./SessionSwitcherDropdown-DNjCJjzF.js";import{u as H}from"./index-DQr8-8RF.js";const N=38,h=200,k=({value:t,onChange:o,placeholder:m,disabled:i=!1,hasTouchInput:r=!1,isMobile:u=!1})=>{const n=C.useRef(null),j=H(s=>s.inputSpellcheckEnabled);return C.useLayoutEffect(()=>{const s=n.current;if(!s)return;const l=document.activeElement===s,d=()=>{s.style.height=`${N}px`;const p=s.scrollHeight,x=Math.min(Math.max(p,N),h);s.style.height=`${x}px`,s.style.overflowY=p>h?"auto":"hidden",p>h&&!l&&(s.scrollTop=s.scrollHeight)};d();const f=window.requestAnimationFrame(d);return()=>{window.cancelAnimationFrame(f)}},[t]),e.jsx("textarea",{ref:n,value:t,onChange:s=>o(s.target.value),placeholder:m??"Commit message",rows:1,disabled:i,autoCorrect:r?"on":"off",autoCapitalize:r?"sentences":"off",spellCheck:u||j,className:z("w-full rounded-lg border border-border/60 bg-surface-elevated px-3 py-2 typography-ui-label text-foreground placeholder:text-muted-foreground","resize-none outline-none transition-[border-color,box-shadow] duration-150","focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]",i&&"opacity-50"),style:{minHeight:N,maxHeight:h}})},F=({highlights:t,onInsert:o})=>{if(t.length===0)return null;const m=()=>{o(t)};return e.jsxs("div",{className:"space-y-2 rounded-xl border border-border/60 bg-transparent px-3 py-2",children:[e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsx("p",{className:"typography-micro text-muted-foreground",children:"Highlights"}),e.jsxs(y,{children:[e.jsx(_,{asChild:!0,children:e.jsx(c,{variant:"ghost",size:"icon",className:"size-6",onClick:m,"aria-label":"Insert aria label",children:e.jsx(a,{name:"arrow-down",className:"size-4"})})}),e.jsx(v,{sideOffset:8,children:"Insert tooltip"})]})]}),e.jsx("ul",{className:"space-y-1",children:t.map((i,r)=>e.jsx("li",{className:"typography-meta text-foreground",children:i},`${i}-${r}`))})]})},q=({stagedCount:t,commitMessage:o,onCommitMessageChange:m,generatedHighlights:i,onInsertHighlights:r,onGenerateMessage:u,isGeneratingMessage:n,onCommit:j,onCommitAndPush:s,commitAction:l,hasPendingIndexMutation:d=!1,gitmojiEnabled:f,onOpenGitmojiPicker:p})=>{const x=t>0,b=o.trim()&&x&&l===null&&!d,{isMobile:g,hasTouchInput:w}=I();return e.jsxs("section",{className:"border-0 bg-transparent rounded-none",children:[e.jsxs("div",{className:"flex w-full items-baseline gap-2 px-0 pt-2 pb-1",children:[e.jsx("h3",{className:"typography-ui-header font-semibold text-foreground",children:"Commit"}),x?null:e.jsx("span",{className:"min-w-0 truncate typography-meta text-muted-foreground",children:"Stage files to enable commit."})]}),e.jsxs("div",{className:"flex flex-col gap-3 px-0 pt-1 pb-3",children:[e.jsx(F,{highlights:i,onInsert:r}),e.jsx(k,{value:o,onChange:m,placeholder:"Commit message",disabled:l!==null,hasTouchInput:w,isMobile:g}),f&&e.jsxs(c,{variant:"outline",size:"sm",onClick:p,className:"w-fit",type:"button",children:[e.jsx(a,{name:"emotion-happy",className:"size-4"}),"Add gitmoji"]}),e.jsxs("div",{className:"@container/commit-actions flex items-center gap-2 min-w-0",children:[e.jsxs(c,{variant:"outline",size:"sm",onClick:u,disabled:n||l!==null||d||t===0,type:"button","aria-label":"Generate commit message",className:"commit-actions__btn",children:[n?e.jsx(a,{name:"loader-4",className:"size-4 animate-spin"}):e.jsx(a,{name:"ai-generate-2",className:"size-4 text-primary"}),e.jsx("span",{className:"commit-actions__label",children:"Generate"})]}),e.jsx("div",{className:"flex-1"}),e.jsx(c,{size:"sm",variant:"outline",onClick:j,disabled:!b||n,className:"commit-actions__btn whitespace-nowrap","aria-label":"Commit aria label",children:l==="commit"?e.jsxs(e.Fragment,{children:[e.jsx(a,{name:"loader-4",className:"size-4 animate-spin"}),e.jsx("span",{className:"commit-actions__label",children:"Committing..."})]}):e.jsxs(e.Fragment,{children:[e.jsx(a,{name:"git-commit",className:"size-4"}),e.jsx("span",{className:"commit-actions__label",children:"Commit"})]})}),g?e.jsxs(y,{children:[e.jsx(_,{asChild:!0,children:e.jsx(c,{variant:"default",size:"sm",onClick:()=>s(),disabled:!b||n,className:"h-7 w-7 p-0","aria-label":"Commit and sync",children:l==="commitAndPush"?e.jsx(a,{name:"loader-4",className:"size-4 animate-spin"}):e.jsx(a,{name:"arrow-up",className:"size-3.5"})})}),e.jsx(v,{side:"top",children:e.jsx("p",{children:"Commit & sync"})})]}):e.jsx(c,{size:"sm",variant:"default",onClick:()=>s(),disabled:!b||n,className:"commit-actions__btn","aria-label":"Commit and sync",children:l==="commitAndPush"?e.jsxs(e.Fragment,{children:[e.jsx(a,{name:"loader-4",className:"size-4 animate-spin"}),e.jsx("span",{className:"commit-actions__label commit-actions__label--push",children:"Syncing..."})]}):e.jsxs(e.Fragment,{children:[e.jsx(a,{name:"arrow-up",className:"size-3.5"}),e.jsx("span",{className:"commit-actions__label commit-actions__label--push",children:"Commit & sync"})]})})]})]})]})};export{q as C};