@vantran-se/openchamber-web 2.0.6 → 2.0.8

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 (70) hide show
  1. package/bin/lib/DOCUMENTATION.md +1 -0
  2. package/bin/lib/cli-args.js +3 -0
  3. package/bin/lib/cli-lifecycle.js +1 -0
  4. package/bin/lib/cli-process.js +1 -0
  5. package/bin/lib/cli-startup.js +53 -1
  6. package/bin/lib/cli-startup.test.js +37 -0
  7. package/bin/lib/commands-lifecycle.js +29 -13
  8. package/bin/lib/commands-startup.js +5 -2
  9. package/bin/lib/commands-startup.test.js +15 -0
  10. package/bin/lib/commands-update.js +40 -8
  11. package/bin/lib/commands-update.test.js +53 -6
  12. package/dist/assets/{CodeMirrorCommentWidgets-BlzB2e0C.js → CodeMirrorCommentWidgets--j227ElB.js} +1 -1
  13. package/dist/assets/{DiffView-D7JQzQVZ.js → DiffView-IFl6EaUT.js} +1 -1
  14. package/dist/assets/{FilesView-DC2gadXM.js → FilesView-2QJJIAmr.js} +1 -1
  15. package/dist/assets/{GitView-Gzl0wbdd.js → GitView-xpLPdSXl.js} +1 -1
  16. package/dist/assets/{GuestAttachDialog-CQdDIeco.js → GuestAttachDialog-CaRAB_pV.js} +1 -1
  17. package/dist/assets/{GuestSurfacePane-CrOhlIdt.js → GuestSurfacePane-CA1Pu0hE.js} +1 -1
  18. package/dist/assets/{JsonTreeView-C3QZifYe.js → JsonTreeView-RFvqdmCZ.js} +1 -1
  19. package/dist/assets/{LinearIssuesView-X27TCI3v.js → LinearIssuesView-CYI08VW3.js} +1 -1
  20. package/dist/assets/{MarkdownImageGallery-BlTWU9oH.js → MarkdownImageGallery-D_-yFGOt.js} +1 -1
  21. package/dist/assets/{MarkdownRendererImpl-Cno7QsJO.js → MarkdownRendererImpl-Dpb32U9p.js} +1 -1
  22. package/dist/assets/{OnboardingScreen-Wr_UzNtt.js → OnboardingScreen-B3B8bi91.js} +1 -1
  23. package/dist/assets/{PatchDiff-D6pWS7cD.js → PatchDiff-CdvF5h6a.js} +1 -1
  24. package/dist/assets/{PierreDiffViewer-DDljFbJJ.js → PierreDiffViewer-DLcUtI6Q.js} +1 -1
  25. package/dist/assets/{PlanView-LcfEbBPi.js → PlanView-Bvt1PdkO.js} +1 -1
  26. package/dist/assets/{PluginPane-DPWPLc2f.js → PluginPane-CaJz1LlW.js} +1 -1
  27. package/dist/assets/{SettingsView-CFe9O3EM.js → SettingsView-DL_ucsI6.js} +1 -1
  28. package/dist/assets/{SettingsWindow-DT1xhvgi.js → SettingsWindow-ilszvARl.js} +1 -1
  29. package/dist/assets/{SubmoduleDiffSummary-CBHXaIkB.js → SubmoduleDiffSummary-CeV_WX3U.js} +1 -1
  30. package/dist/assets/{ToolOutputDialog-B_ZX8ZbZ.js → ToolOutputDialog-vc1xXXMa.js} +1 -1
  31. package/dist/assets/{ToolPartDiffPreview-D_HQzkXU.js → ToolPartDiffPreview-B4EwqT8H.js} +1 -1
  32. package/dist/assets/{VirtualizedFileDiff-BUK1ujXg.js → VirtualizedFileDiff-DDgQH7-1.js} +1 -1
  33. package/dist/assets/{WalkthroughView-H-tYOvhO.js → WalkthroughView-Cf8EdtIU.js} +1 -1
  34. package/dist/assets/{appThemeRegistry-BoJGAwCP.js → appThemeRegistry-D2_fKsMa.js} +1 -1
  35. package/dist/assets/{commitAndPush-BzZrF8bK.js → commitAndPush-P6RxBC7G.js} +1 -1
  36. package/dist/assets/{computeVirtualFileMetrics-kYyVjXLu.js → computeVirtualFileMetrics-Bz7G81bS.js} +1 -1
  37. package/dist/assets/{de-ByfQDcPj.js → de-mIryiQXH.js} +1 -1
  38. package/dist/assets/{es-DzNbjsQS.js → es-CNRZG0Xh.js} +1 -1
  39. package/dist/assets/{fr-B8AVLrnX.js → fr-TQ28DG6e.js} +1 -1
  40. package/dist/assets/{index-0-ON1L7L.js → index-Dl3ruZe_.js} +1 -1
  41. package/dist/assets/{isDiffPlainText-Bgm_aLpu.js → isDiffPlainText-D2lANTsV.js} +1 -1
  42. package/dist/assets/{isFilePlainText-D5GMgURH.js → isFilePlainText-Cbv7v9Ro.js} +1 -1
  43. package/dist/assets/{ja-1g6S0G19.js → ja-eoTHPWE5.js} +1 -1
  44. package/dist/assets/{ko-CxplvYkJ.js → ko-B6kF-W3S.js} +1 -1
  45. package/dist/assets/{main-GjXmVNpD.js → main-BCP3yPm1.js} +4 -4
  46. package/dist/assets/{main--Y98ZLsD.js → main-yjCD5A_Y.js} +2 -2
  47. package/dist/assets/{markdownCore-CVdUcVCi.js → markdownCore-Bqfm6kwc.js} +1 -1
  48. package/dist/assets/{miniChat-tTx5mpbd.js → miniChat-fs-MMZEy.js} +2 -2
  49. package/dist/assets/{mobile-BmbiDWGb.js → mobile-DsTajahd.js} +2 -2
  50. package/dist/assets/{patchFileDiff-CV4iVQ8L.js → patchFileDiff-Cxi3zs93.js} +1 -1
  51. package/dist/assets/{pl-f3QyqvIB.js → pl-Cujf3IHL.js} +1 -1
  52. package/dist/assets/{pt-BR-BsaoaiCK.js → pt-BR-kre15SPf.js} +1 -1
  53. package/dist/assets/{renderElectronMiniChatApp-BnCdnDhv.js → renderElectronMiniChatApp-Dpfh7kye.js} +2 -2
  54. package/dist/assets/{renderMobileApp-CKn8nbSW.js → renderMobileApp-BSo1S4UB.js} +3 -3
  55. package/dist/assets/{runtimeEndpointReset-sCi88eed.js → runtimeEndpointReset-3DNMw1le.js} +3 -3
  56. package/dist/assets/{shikiHighlight-O5_xLmWt.js → shikiHighlight-mGfGdiiG.js} +1 -1
  57. package/dist/assets/{textMateThemeFromAppTheme-XM4VefqN.js → textMateThemeFromAppTheme-SNTAfu1S.js} +1 -1
  58. package/dist/assets/{tr-BNFsa6nz.js → tr-DMmJYX6x.js} +1 -1
  59. package/dist/assets/{uk-CJ9y5ULA.js → uk-SB3DoBhl.js} +1 -1
  60. package/dist/assets/{useAppFontEffects-BN06mgHB.js → useAppFontEffects-DJZbUd4z.js} +10 -10
  61. package/dist/assets/{useInlineCommentController-Bb9Gjz1Q.js → useInlineCommentController-dDUqIMI2.js} +1 -1
  62. package/dist/assets/{useRootScrollLock-Chk1cFUw.js → useRootScrollLock-DAPXfqs1.js} +2 -2
  63. package/dist/assets/{vendor-codemirror-language-data-C2qMZiA7.js → vendor-codemirror-language-data-DeBrOhi1.js} +2 -2
  64. package/dist/assets/{web-components-BQWjCn_U.js → web-components-5hxxRZKN.js} +1 -1
  65. package/dist/assets/{zh-CN-U0QjVlAA.js → zh-CN-B-YkU2-B.js} +1 -1
  66. package/dist/assets/{zh-TW-J9PTJQJi.js → zh-TW-0ddAsFNT.js} +1 -1
  67. package/dist/index.html +1 -1
  68. package/dist/mini-chat.html +1 -1
  69. package/dist/mobile.html +1 -1
  70. package/package.json +1 -1
@@ -127,6 +127,7 @@ These modules hold reusable, non-presentational logic for commands.
127
127
 
128
128
  - `cli-startup.js`
129
129
  - Native startup service detection, install/uninstall/status helpers, and platform-specific startup command execution.
130
+ - Installed startup services remain owned by their platform service manager. `startup start|stop|restart` controls that manager directly. Top-level lifecycle commands route the service's configured port through the same manager, and `update` restarts it after package installation instead of starting a duplicate daemon.
130
131
 
131
132
  - `cli-tunnel-profiles.js`
132
133
  - Tunnel profile normalization, token resolution/redaction, profile storage, migration, file-permission warnings, and managed-remote pair persistence.
@@ -674,6 +674,9 @@ SUBCOMMANDS:
674
674
  status Show startup integration status
675
675
  enable Install and start native user startup integration
676
676
  disable Stop and remove native user startup integration
677
+ start Start the installed startup service
678
+ stop Stop the installed startup service
679
+ restart Restart the installed startup service
677
680
 
678
681
  OPTIONS:
679
682
  -p, --port Web server port used by startup service
@@ -234,6 +234,7 @@ async function discoverRunningInstances(options = {}) {
234
234
  mtime,
235
235
  startedAt,
236
236
  launchMode,
237
+ startupService: storedOptions?.startupService === true,
237
238
  runtime: liveInfo.runtime,
238
239
  source: 'registry+probe',
239
240
  host: typeof confirmedHost === 'string' && confirmedHost.length > 0
@@ -60,6 +60,7 @@ function writeInstanceOptions(instanceFilePath, options, onNotice) {
60
60
  uiPassword: typeof options.uiPassword === 'string' ? options.uiPassword : undefined,
61
61
  hasUiPassword: typeof options.uiPassword === 'string',
62
62
  apiOnly: options.apiOnly === true,
63
+ startupService: process.env.OPENCHAMBER_STARTUP_SERVICE === '1',
63
64
  startedAt: Number.isFinite(options.startedAt) ? options.startedAt : Date.now(),
64
65
  };
65
66
  fs.writeFileSync(instanceFilePath, JSON.stringify(toStore, null, 2), { mode: 0o600 });
@@ -91,6 +91,7 @@ function collectStartupEnv(options = {}) {
91
91
  env.OPENCODE_BINARY = opencodeBinary.trim();
92
92
  }
93
93
  }
94
+ env.OPENCHAMBER_STARTUP_SERVICE = '1';
94
95
  const uiPassword = hasUiPasswordConfigured(options.uiPassword) ? options.uiPassword : undefined;
95
96
  if (uiPassword) {
96
97
  env.OPENCHAMBER_UI_PASSWORD = uiPassword;
@@ -329,6 +330,17 @@ function getUserLingerEnabled(user) {
329
330
  return parseLingerState(result.stdout);
330
331
  }
331
332
 
333
+ function startupServicePort(servicePath) {
334
+ try {
335
+ const content = fs.readFileSync(servicePath, 'utf8');
336
+ const match = content.match(/(?:--port(?:&quot;|"|')?\s+(?:&quot;|"|')?|<string>--port<\/string>\s*<string>)(\d+)/);
337
+ const port = Number.parseInt(match?.[1] || '', 10);
338
+ return Number.isInteger(port) && port > 0 ? port : DEFAULT_PORT;
339
+ } catch {
340
+ return DEFAULT_PORT;
341
+ }
342
+ }
343
+
332
344
  function getStartupStatus() {
333
345
  const paths = getStartupServicePaths();
334
346
  if (!paths.servicePath) {
@@ -336,7 +348,14 @@ function getStartupStatus() {
336
348
  }
337
349
  if (paths.platform === 'windows') {
338
350
  const result = runStartupCommand('schtasks.exe', ['/Query', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
339
- return { supported: true, platform: paths.platform, enabled: result.status === 0, active: null, servicePath: paths.servicePath };
351
+ return {
352
+ supported: true,
353
+ platform: paths.platform,
354
+ enabled: result.status === 0,
355
+ active: null,
356
+ servicePath: paths.servicePath,
357
+ port: startupServicePort(getWindowsStartupWrapperPath()),
358
+ };
340
359
  }
341
360
  if (paths.platform === 'linux') {
342
361
  const enabledResult = runStartupCommand('systemctl', ['--user', 'is-enabled', 'openchamber.service'], { allowFailure: true });
@@ -350,6 +369,7 @@ function getStartupStatus() {
350
369
  active: activeState === 'active',
351
370
  activeState,
352
371
  servicePath: paths.servicePath,
372
+ port: startupServicePort(paths.servicePath),
353
373
  lingerEnabled: getUserLingerEnabled(lingerUser),
354
374
  lingerUser: lingerUser || null,
355
375
  };
@@ -360,6 +380,7 @@ function getStartupStatus() {
360
380
  enabled: fs.existsSync(paths.servicePath),
361
381
  active: null,
362
382
  servicePath: paths.servicePath,
383
+ port: startupServicePort(paths.servicePath),
363
384
  };
364
385
  }
365
386
 
@@ -430,10 +451,41 @@ function disableStartupService() {
430
451
  return getStartupStatus();
431
452
  }
432
453
 
454
+ function controlStartupService(action) {
455
+ const status = getStartupStatus();
456
+ if (!status.supported || !status.enabled) {
457
+ throw new TunnelCliError('OpenChamber startup service is not installed.', EXIT_CODE.USAGE_ERROR);
458
+ }
459
+ if (!['start', 'stop', 'restart'].includes(action)) {
460
+ throw new TunnelCliError(`Unsupported startup service action: ${action}`, EXIT_CODE.USAGE_ERROR);
461
+ }
462
+ if (status.platform === 'linux') {
463
+ runStartupCommand('systemctl', ['--user', action, 'openchamber.service']);
464
+ } else if (status.platform === 'macos') {
465
+ const domain = `gui/${process.getuid()}`;
466
+ const target = `${domain}/${STARTUP_SERVICE_ID}`;
467
+ if (action === 'stop') {
468
+ runStartupCommand('/bin/launchctl', ['bootout', target], { allowFailure: true });
469
+ } else {
470
+ runStartupCommand('/bin/launchctl', ['bootout', target], { allowFailure: true });
471
+ runStartupCommand('/bin/launchctl', ['bootstrap', domain, status.servicePath]);
472
+ runStartupCommand('/bin/launchctl', ['kickstart', '-k', target]);
473
+ }
474
+ } else if (status.platform === 'windows') {
475
+ if (action !== 'start') runStartupCommand('schtasks.exe', ['/End', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
476
+ if (action !== 'stop') runStartupCommand('schtasks.exe', ['/Run', '/TN', STARTUP_SERVICE_ID]);
477
+ }
478
+ return getStartupStatus();
479
+ }
480
+
481
+ const restartStartupService = () => controlStartupService('restart');
433
482
 
434
483
  export {
435
484
  getStartupStatus,
485
+ startupServicePort,
436
486
  enableStartupService,
437
487
  disableStartupService,
488
+ controlStartupService,
489
+ restartStartupService,
438
490
  buildWindowsStartupTaskCommand,
439
491
  };
@@ -0,0 +1,37 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { startupServicePort } from './cli-startup.js';
7
+
8
+ function withServiceFile(content, run) {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-startup-test-'));
10
+ const servicePath = path.join(dir, 'service');
11
+ fs.writeFileSync(servicePath, content);
12
+ try {
13
+ return run(servicePath);
14
+ } finally {
15
+ fs.rmSync(dir, { recursive: true, force: true });
16
+ }
17
+ }
18
+
19
+ describe('startup service metadata', () => {
20
+ it('reads the configured port from a systemd unit', () => {
21
+ withServiceFile('ExecStart="/node" "/openchamber" "serve" "--foreground" "--port" "3069"\n', (servicePath) => {
22
+ expect(startupServicePort(servicePath)).toBe(3069);
23
+ });
24
+ });
25
+
26
+ it('reads the configured port from a launchd plist', () => {
27
+ withServiceFile('<string>--port</string>\n<string>3070</string>\n', (servicePath) => {
28
+ expect(startupServicePort(servicePath)).toBe(3070);
29
+ });
30
+ });
31
+
32
+ it('reads the configured port from the Windows startup wrapper', () => {
33
+ withServiceFile("& 'openchamber' 'serve' '--foreground' '--port' '3071'\n", (servicePath) => {
34
+ expect(startupServicePort(servicePath)).toBe(3071);
35
+ });
36
+ });
37
+ });
@@ -1,6 +1,7 @@
1
1
  import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
2
2
  import { requestServerShutdown } from './cli-http.js';
3
3
  import { isPortAvailable } from './cli-ports.js';
4
+ import { controlStartupService, getStartupStatus } from './cli-startup.js';
4
5
  import {
5
6
  discoverLifecycleInstances,
6
7
  discoverUnconfirmedRegistryInstanceOnPort,
@@ -24,6 +25,7 @@ import {
24
25
  } from '../cli-output.js';
25
26
 
26
27
  async function stopCommand(options) {
28
+ const startupStatus = getStartupStatus();
27
29
  const showOutput = shouldRenderHumanOutput(options);
28
30
  const suppressQuietOutput = options?.suppressQuietOutput === true;
29
31
  const jsonResults = [];
@@ -75,6 +77,17 @@ async function stopCommand(options) {
75
77
  }
76
78
 
77
79
  const explicitInstance = runningInstances[0];
80
+ if (startupStatus.enabled && startupStatus.port === explicitInstance.port && explicitInstance.startupService === true) {
81
+ controlStartupService('stop');
82
+ jsonResults.push({ port: explicitInstance.port, stopped: true, manager: startupStatus.platform });
83
+ if (isJsonMode(options)) printJson({ stoppedCount: 1, results: jsonResults });
84
+ else if (showOutput) {
85
+ logStatus('success', `stopped startup service on port ${explicitInstance.port}`);
86
+ finish('stop complete');
87
+ }
88
+ printQuietStopResults();
89
+ return;
90
+ }
78
91
  if (explicitInstance.runtime === 'desktop') {
79
92
  jsonResults.push({ port: options.port, runtime: 'desktop', stopped: false, reason: 'desktop-managed' });
80
93
  if (isJsonMode(options)) {
@@ -210,6 +223,11 @@ async function stopCommand(options) {
210
223
  }
211
224
 
212
225
  for (const instance of runningInstances) {
226
+ if (startupStatus.enabled && startupStatus.port === instance.port && instance.startupService === true) {
227
+ controlStartupService('stop');
228
+ jsonResults.push({ port: instance.port, stopped: true, manager: startupStatus.platform });
229
+ continue;
230
+ }
213
231
  const stopSpin = showOutput ? createSpinner(options) : null;
214
232
  if (showOutput && !stopSpin) {
215
233
  logStatus('info', `stopping port ${instance.port} (PID: ${instance.pid})`);
@@ -287,7 +305,13 @@ async function restartCommand(options, serveCommand) {
287
305
  return;
288
306
  }
289
307
 
308
+ const startupStatus = getStartupStatus();
290
309
  for (const instance of runningInstances) {
310
+ if (startupStatus.enabled && startupStatus.port === instance.port && instance.startupService === true) {
311
+ controlStartupService('restart');
312
+ restarted.push({ fromPort: instance.port, toPort: instance.port, manager: startupStatus.platform });
313
+ continue;
314
+ }
291
315
  if (instance.runtime === 'desktop') {
292
316
  const message = `Port ${instance.port} is managed by OpenChamber Desktop and cannot be restarted with this command.`;
293
317
  if (isJsonMode(options)) {
@@ -316,6 +340,11 @@ async function restartCommand(options, serveCommand) {
316
340
  const isForeground = launchMode === 'foreground';
317
341
 
318
342
  const restartPort = options.explicitPort ? options.port : instance.port;
343
+ if (isForeground) {
344
+ restarted.push({ fromPort: instance.port, toPort: restartPort, launchMode, ok: false, reason: 'manual-foreground' });
345
+ if (showOutput) logStatus('warning', `foreground OpenChamber on port ${instance.port} requires its owning terminal or process manager to restart it`);
346
+ continue;
347
+ }
319
348
 
320
349
  const restartSpin = showOutput ? createSpinner(options) : null;
321
350
  if (showOutput && !restartSpin) {
@@ -331,19 +360,6 @@ async function restartCommand(options, serveCommand) {
331
360
  suppressQuietOutput: true,
332
361
  });
333
362
 
334
- // Foreground instances are managed by a process manager (systemd,
335
- // Docker, etc.) that will restart them automatically after stop.
336
- // Do not call serve() here — just record the stop as a successful
337
- // restart and let the process manager handle the actual restart.
338
- if (isForeground) {
339
- restarted.push({ fromPort: instance.port, toPort: restartPort, launchMode, ok: true });
340
- restartSpin?.stop(`Stopped foreground instance on port ${instance.port} (process manager will restart)`);
341
- if (showOutput && !restartSpin) {
342
- logStatus('success', `port ${instance.port} stopped`, 'process manager will restart');
343
- }
344
- continue;
345
- }
346
-
347
363
  await new Promise((resolve) => setTimeout(resolve, 500));
348
364
 
349
365
  const restartedPort = await runServe({
@@ -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, controlStartupService } from './cli-startup.js';
3
3
  import {
4
4
  intro as clackIntro,
5
5
  outro as clackOutro,
@@ -13,8 +13,9 @@ async function startupCommand(options, action = 'status', dependencies = {}) {
13
13
  const getStatus = dependencies.getStartupStatus || getStartupStatus;
14
14
  const enableService = dependencies.enableStartupService || enableStartupService;
15
15
  const disableService = dependencies.disableStartupService || disableStartupService;
16
+ const controlService = dependencies.controlStartupService || controlStartupService;
16
17
  const normalized = typeof action === 'string' ? action.trim().toLowerCase() : 'status';
17
- if (!['status', 'enable', 'disable'].includes(normalized)) {
18
+ if (!['status', 'enable', 'disable', 'start', 'stop', 'restart'].includes(normalized)) {
18
19
  throw new TunnelCliError(
19
20
  `Unknown startup subcommand '${action}'. Use 'openchamber startup --help'.`,
20
21
  EXIT_CODE.USAGE_ERROR
@@ -26,6 +27,8 @@ async function startupCommand(options, action = 'status', dependencies = {}) {
26
27
  status = enableService(options);
27
28
  } else if (normalized === 'disable') {
28
29
  status = disableService();
30
+ } else if (normalized === 'start' || normalized === 'stop' || normalized === 'restart') {
31
+ status = controlService(normalized);
29
32
  } else {
30
33
  status = getStatus();
31
34
  }
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { startupCommand } from './commands-startup.js';
4
+
5
+ const quiet = { quiet: true };
6
+
7
+ describe('startup command service controls', () => {
8
+ for (const action of ['start', 'stop', 'restart']) {
9
+ it(`${action}s the installed startup service without changing installation`, async () => {
10
+ const controlStartupService = vi.fn(() => ({ supported: true, enabled: true, active: action !== 'stop', platform: 'linux' }));
11
+ await startupCommand(quiet, action, { controlStartupService });
12
+ expect(controlStartupService).toHaveBeenCalledWith(action);
13
+ });
14
+ }
15
+ });
@@ -1,5 +1,6 @@
1
1
  import { requestServerShutdown } from './cli-http.js';
2
- import { discoverRunningInstances } from './cli-lifecycle.js';
2
+ import { discoverRunningInstances as discoverRunningInstancesDefault } from './cli-lifecycle.js';
3
+ import { getStartupStatus as getStartupStatusDefault, restartStartupService as restartStartupServiceDefault } from './cli-startup.js';
3
4
  import {
4
5
  readInstanceOptions,
5
6
  removePidFile,
@@ -16,7 +17,27 @@ import {
16
17
  logStatus,
17
18
  } from '../cli-output.js';
18
19
 
19
- function createUpdateCommand({ importFromFilePath, packageManagerPath, serveCommand }) {
20
+ export function partitionUpdateInstances(instances, readOptions = readInstanceOptions, startupStatus = null) {
21
+ const managed = [];
22
+ const startup = [];
23
+ const foreground = [];
24
+ for (const instance of instances) {
25
+ const options = readOptions(instance.instanceFilePath) || {};
26
+ if (options.launchMode !== 'foreground') managed.push(instance);
27
+ else if (startupStatus?.enabled && startupStatus.port === instance.port && instance.startupService === true) startup.push(instance);
28
+ else foreground.push(instance);
29
+ }
30
+ return { managed, startup, foreground };
31
+ }
32
+
33
+ function createUpdateCommand({
34
+ importFromFilePath,
35
+ packageManagerPath,
36
+ serveCommand,
37
+ discoverRunningInstances = discoverRunningInstancesDefault,
38
+ getStartupStatus = getStartupStatusDefault,
39
+ restartStartupService = restartStartupServiceDefault,
40
+ }) {
20
41
  return async function updateCommand(options = {}) {
21
42
  const showOutput = shouldRenderHumanOutput(options);
22
43
  const updateSpin = createSpinner(options);
@@ -29,6 +50,12 @@ function createUpdateCommand({ importFromFilePath, packageManagerPath, serveComm
29
50
  } = await importFromFilePath(packageManagerPath);
30
51
 
31
52
  const runningInstances = await discoverRunningInstances();
53
+ const startupStatus = getStartupStatus();
54
+ const { managed: managedInstances, startup: startupInstances } = partitionUpdateInstances(
55
+ runningInstances,
56
+ readInstanceOptions,
57
+ startupStatus,
58
+ );
32
59
  const currentVersion = getCurrentVersion();
33
60
 
34
61
  if (showOutput) {
@@ -75,9 +102,9 @@ function createUpdateCommand({ importFromFilePath, packageManagerPath, serveComm
75
102
  }
76
103
  updateSpin?.message(`Updating to ${updateInfo.version || 'latest'}...`);
77
104
 
78
- if (runningInstances.length > 0) {
79
- updateSpin?.message(`Stopping ${runningInstances.length} running instance(s)...`);
80
- for (const instance of runningInstances) {
105
+ if (managedInstances.length > 0) {
106
+ updateSpin?.message(`Stopping ${managedInstances.length} running instance(s)...`);
107
+ for (const instance of managedInstances) {
81
108
  try {
82
109
  const requested = await requestServerShutdown(instance.port, instance.host);
83
110
  await stopInstanceProcess(instance.pid, {
@@ -101,9 +128,14 @@ function createUpdateCommand({ importFromFilePath, packageManagerPath, serveComm
101
128
  throw new Error(`Update failed with exit code ${result.exitCode}`);
102
129
  }
103
130
 
104
- if (runningInstances.length > 0) {
105
- updateSpin?.message(`Restarting ${runningInstances.length} instance(s)...`);
106
- for (const instance of runningInstances) {
131
+ if (startupInstances.length > 0) {
132
+ updateSpin?.message('Restarting startup service...');
133
+ restartStartupService();
134
+ }
135
+
136
+ if (managedInstances.length > 0) {
137
+ updateSpin?.message(`Restarting ${managedInstances.length} instance(s)...`);
138
+ for (const instance of managedInstances) {
107
139
  const storedOptions = readInstanceOptions(instance.instanceFilePath) || { port: instance.port };
108
140
  await serveCommand({
109
141
  port: storedOptions.port || instance.port,
@@ -3,7 +3,7 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import { describe, expect, it, vi } from 'vitest';
5
5
 
6
- import { createUpdateCommand } from './commands-update.js';
6
+ import { createUpdateCommand, partitionUpdateInstances } from './commands-update.js';
7
7
 
8
8
  async function withTempOpenChamberDataDir(fn) {
9
9
  const previous = process.env.OPENCHAMBER_DATA_DIR;
@@ -12,16 +12,63 @@ async function withTempOpenChamberDataDir(fn) {
12
12
  try {
13
13
  return await fn(dir);
14
14
  } finally {
15
- if (typeof previous === 'string') {
16
- process.env.OPENCHAMBER_DATA_DIR = previous;
17
- } else {
18
- delete process.env.OPENCHAMBER_DATA_DIR;
19
- }
15
+ if (previous === undefined) delete process.env.OPENCHAMBER_DATA_DIR;
16
+ else process.env.OPENCHAMBER_DATA_DIR = previous;
20
17
  fs.rmSync(dir, { recursive: true, force: true });
21
18
  }
22
19
  }
23
20
 
24
21
  describe('update command', () => {
22
+ it('separates CLI daemons from the installed startup service', () => {
23
+ const daemon = { port: 3000, instanceFilePath: '/daemon.json' };
24
+ const startup = { port: 3069, instanceFilePath: '/startup.json', startupService: true };
25
+ const manualForeground = { port: 3070, instanceFilePath: '/manual.json' };
26
+ const readOptions = vi.fn((filePath) => ({
27
+ launchMode: filePath === daemon.instanceFilePath ? 'daemon' : 'foreground',
28
+ }));
29
+
30
+ expect(partitionUpdateInstances(
31
+ [daemon, startup, manualForeground],
32
+ readOptions,
33
+ { enabled: true, active: true, port: 3069 },
34
+ )).toEqual({
35
+ managed: [daemon],
36
+ startup: [startup],
37
+ foreground: [manualForeground],
38
+ });
39
+ });
40
+
41
+ it('restarts the installed startup service after updating', async () => {
42
+ await withTempOpenChamberDataDir(async (dir) => {
43
+ const originalWrite = process.stdout.write;
44
+ process.stdout.write = vi.fn(() => true);
45
+ const instanceFilePath = path.join(dir, 'run', 'openchamber-3069.json');
46
+ fs.mkdirSync(path.dirname(instanceFilePath), { recursive: true });
47
+ fs.writeFileSync(instanceFilePath, JSON.stringify({ port: 3069, launchMode: 'foreground' }));
48
+ const restartStartupService = vi.fn();
49
+ const updateCommand = createUpdateCommand({
50
+ packageManagerPath: '/fake/package-manager.js',
51
+ serveCommand: vi.fn(),
52
+ discoverRunningInstances: vi.fn(async () => [{ port: 3069, instanceFilePath, startupService: true }]),
53
+ getStartupStatus: vi.fn(() => ({ supported: true, enabled: true, active: true, port: 3069 })),
54
+ restartStartupService,
55
+ importFromFilePath: vi.fn(async () => ({
56
+ checkForUpdates: vi.fn(async () => ({ available: true, version: '9.9.9' })),
57
+ detectPackageManager: vi.fn(() => 'npm'),
58
+ executeUpdate: vi.fn(() => ({ success: true, exitCode: 0 })),
59
+ getCurrentVersion: vi.fn(() => '1.0.0'),
60
+ })),
61
+ });
62
+
63
+ try {
64
+ await updateCommand({ json: true });
65
+ expect(restartStartupService).toHaveBeenCalledTimes(1);
66
+ } finally {
67
+ process.stdout.write = originalWrite;
68
+ }
69
+ });
70
+ });
71
+
25
72
  it('uses the package-manager helpers on the update-available path', async () => {
26
73
  await withTempOpenChamberDataDir(async () => {
27
74
  const originalWrite = process.stdout.write;
@@ -1 +1 @@
1
- import{j as a}from"./vendor-react-BUCujlKS.js";import{I as l,a as I}from"./useInlineCommentController-Bb9Gjz1Q.js";function E(g){const{drafts:x,editingDraftId:d,commentText:o,onTextChange:r,selection:n,isDragging:u,fileLabel:s,newWidgetId:f,mapDraftToRange:p,onSave:m,onCancel:c,onEdit:C,onDelete:h}=g,t=[];for(const e of x){const i=p(e);if(e.id===d){t.push({afterLine:i.end,id:`edit-${e.id}`,content:a.jsx(l,{initialText:o,onTextChange:r,fileLabel:s,lineRange:i,isEditing:!0,onSave:m,onCancel:c},`edit-${e.id}`)});continue}t.push({afterLine:i.end,id:`card-${e.id}`,content:a.jsx(I,{draft:e,onEdit:()=>C(e),onDelete:()=>h(e)},`card-${e.id}`)})}if(n&&!d&&!u){const e={...n,start:Math.min(n.start,n.end),end:Math.max(n.start,n.end)};t.push({afterLine:e.end,id:f,content:a.jsx(l,{initialText:o,onTextChange:r,fileLabel:s,lineRange:e,isEditing:!1,onSave:m,onCancel:c},f)})}return t}export{E as b};
1
+ import{j as a}from"./vendor-react-BUCujlKS.js";import{I as l,a as I}from"./useInlineCommentController-dDUqIMI2.js";function E(g){const{drafts:x,editingDraftId:d,commentText:o,onTextChange:r,selection:n,isDragging:u,fileLabel:s,newWidgetId:f,mapDraftToRange:p,onSave:m,onCancel:c,onEdit:C,onDelete:h}=g,t=[];for(const e of x){const i=p(e);if(e.id===d){t.push({afterLine:i.end,id:`edit-${e.id}`,content:a.jsx(l,{initialText:o,onTextChange:r,fileLabel:s,lineRange:i,isEditing:!0,onSave:m,onCancel:c},`edit-${e.id}`)});continue}t.push({afterLine:i.end,id:`card-${e.id}`,content:a.jsx(I,{draft:e,onEdit:()=>C(e),onDelete:()=>h(e)},`card-${e.id}`)})}if(n&&!d&&!u){const e={...n,start:Math.min(n.start,n.end),end:Math.max(n.start,n.end)};t.push({afterLine:e.end,id:f,content:a.jsx(l,{initialText:o,onTextChange:r,fileLabel:s,lineRange:e,isEditing:!1,onSave:m,onCancel:c},f)})}return t}export{E as b};