diff --git a/src/commands/bar/bar-server-probe.ts b/src/commands/bar/bar-server-probe.ts index 496521e6..a400fe1f 100644 --- a/src/commands/bar/bar-server-probe.ts +++ b/src/commands/bar/bar-server-probe.ts @@ -26,8 +26,11 @@ export interface DashboardInfo { } /** - * Read the port recorded in an existing bar.json. - * Returns null when the file is absent or malformed. + * Read the port recorded in an existing bar.json, falling back to the --port + * in launch.json's args. `ccs bar stop` deletes bar.json but leaves + * launch.json, so the fallback is what keeps the sticky port (and the probe's + * ability to find a server on a non-default port) across a stop/start cycle. + * Returns null when neither file records a port. */ export function resolveBarPort(ccsDir: string): number | null { @@ -35,10 +38,25 @@ export function resolveBarPort(ccsDir: string): number | null { try { const raw = fs.readFileSync(barJsonPath, 'utf8'); const parsed = JSON.parse(raw) as Partial<{ port: number }>; - return typeof parsed.port === 'number' ? parsed.port : null; + if (typeof parsed.port === 'number') return parsed.port; } catch { - return null; + /* fall through to launch.json */ } + + const launchJsonPath = path.join(ccsDir, 'bar', 'launch.json'); + try { + const raw = fs.readFileSync(launchJsonPath, 'utf8'); + const parsed = JSON.parse(raw) as Partial<{ args: unknown[] }>; + const args = Array.isArray(parsed.args) ? parsed.args : []; + const idx = args.indexOf('--port'); + if (idx !== -1 && idx + 1 < args.length) { + const n = parseInt(String(args[idx + 1]), 10); + if (Number.isFinite(n) && n > 0 && n < 65536) return n; + } + } catch { + /* absent or malformed -> null */ + } + return null; } /** diff --git a/tests/unit/commands/bar-command.test.ts b/tests/unit/commands/bar-command.test.ts index 1c53e072..5d12cab0 100644 --- a/tests/unit/commands/bar-command.test.ts +++ b/tests/unit/commands/bar-command.test.ts @@ -3814,3 +3814,63 @@ describe('launch: --port selects the server port', () => { expect(seen.spawnPort).toBe(3777); }); }); + +describe('resolveBarPort: launch.json fallback survives `ccs bar stop`', () => { + // `ccs bar stop` deletes bar.json, so bar.json alone cannot carry the sticky + // port across a stop/start cycle. launch.json (refreshed by launch, NOT + // deleted by stop) records the port in its args and acts as the fallback. + it('falls back to the --port recorded in launch.json when bar.json is absent', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'bar', 'launch.json'), + JSON.stringify({ + schema: 1, + runtime: '/usr/bin/node', + args: ['/x/ccs.js', 'bar', 'serve', '--port', '3456'], + home: tempHome, + }) + ); + + const { resolveBarPort } = await loadLaunchSubcommand(); + expect(resolveBarPort(ccsDir)).toBe(3456); + }); + + it('bar.json port wins over launch.json when both exist', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'bar.json'), + JSON.stringify({ baseUrl: 'http://127.0.0.1:4000', port: 4000, authMode: 'loopback' }) + ); + fs.writeFileSync( + path.join(ccsDir, 'bar', 'launch.json'), + JSON.stringify({ + schema: 1, + runtime: '/usr/bin/node', + args: ['/x/ccs.js', 'bar', 'serve', '--port', '3456'], + home: tempHome, + }) + ); + + const { resolveBarPort } = await loadLaunchSubcommand(); + expect(resolveBarPort(ccsDir)).toBe(4000); + }); + + it('returns null when launch.json has no --port and bar.json is absent', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'bar', 'launch.json'), + JSON.stringify({ + schema: 1, + runtime: '/usr/bin/node', + args: ['/x/ccs.js', 'bar', 'serve'], + home: tempHome, + }) + ); + + const { resolveBarPort } = await loadLaunchSubcommand(); + expect(resolveBarPort(ccsDir)).toBeNull(); + }); +});