diff --git a/packages/cli/README.md b/packages/cli/README.md index 94477cb3e..27ef8a314 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -11,9 +11,9 @@ from your terminal—without cloning or building the Pascal repository. npx @pascal-app/cli editor ``` -The first run installs a versioned editor runtime, starts it in the background on -your computer, waits for it to become healthy, and opens it at -`http://pascal.localhost:`. Your projects are stored separately from the +The first run walks through local storage, runtime installation, automatic port +selection, process startup, and a health check with live terminal feedback. It then +opens `http://pascal.localhost:`. Your projects are stored separately from the runtime, so updating the CLI does not replace your work. ## Why use the CLI? @@ -58,6 +58,9 @@ pascal editor Use `--no-open` on a headless machine. Use `--foreground` when a process supervisor should own the editor or when you want logs attached to the current terminal. +Pascal asks the operating system for an available loopback port by default, so it does +not compete with other local development servers. Pass `--port ` to request a +specific port; if it is occupied, Pascal reports that and safely selects another one. ```bash npx @pascal-app/cli editor --no-open diff --git a/packages/cli/scripts/smoke-packed-runtime.ts b/packages/cli/scripts/smoke-packed-runtime.ts index 9588e3cfd..44d8945d5 100644 --- a/packages/cli/scripts/smoke-packed-runtime.ts +++ b/packages/cli/scripts/smoke-packed-runtime.ts @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process' import { mkdtemp, rm } from 'node:fs/promises' +import http from 'node:http' import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -8,6 +9,10 @@ const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url const smokeRoot = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-smoke-')) let tarballPath: string | null = null let smokeExecutable: string | null = null +const defaultPortBlocker = http.createServer((_request, response) => { + response.setHeader('content-type', 'application/json') + response.end(JSON.stringify({ status: 'ok', app: 'foreign' })) +}) const smokeEnvironment = { ...process.env, PASCAL_HOME: path.join(smokeRoot, 'home'), @@ -15,6 +20,7 @@ const smokeEnvironment = { } try { + await listen(defaultPortBlocker) const pack = await run('npm', ['pack', '--json', '--ignore-scripts'], packageDirectory) const packResult = JSON.parse(pack.stdout) as | Array @@ -32,12 +38,13 @@ try { ( await run( process.execPath, - [smokeExecutable, 'editor', '--no-open', '--port', '0', '--json'], + [smokeExecutable, 'editor', '--no-open', '--json'], undefined, smokeEnvironment, ) ).stdout, ) as { pid: number; port: number; url: string } + if (started.port === 3000) throw new Error('editor reused the occupied default port') const rootResponse = await fetch(`http://127.0.0.1:${started.port}/`) if (!rootResponse.ok) throw new Error(`editor root returned ${rootResponse.status}`) const scenesResponse = await fetch(`${started.url}/scenes`) @@ -73,6 +80,7 @@ try { `Packed runtime smoke passed (${formatMb(artifact.size)} MB compressed, ${formatMb(artifact.unpackedSize)} MB unpacked, ${artifact.entryCount} files).`, ) } finally { + await close(defaultPortBlocker) if (smokeExecutable) { await run( process.execPath, @@ -85,6 +93,22 @@ try { await rm(smokeRoot, { recursive: true, force: true }) } +async function listen(server: http.Server): Promise { + await new Promise((resolve, reject) => { + server.once('error', (error: NodeJS.ErrnoException) => + error.code === 'EADDRINUSE' ? resolve() : reject(error), + ) + server.listen({ host: '::', port: 3000, ipv6Only: false }, resolve) + }) +} + +async function close(server: http.Server): Promise { + if (!server.listening) return + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ) +} + interface PackedArtifact { filename: string size: number diff --git a/packages/cli/src/bin/pascal.ts b/packages/cli/src/bin/pascal.ts index f27af00ca..655bd2a10 100755 --- a/packages/cli/src/bin/pascal.ts +++ b/packages/cli/src/bin/pascal.ts @@ -5,6 +5,7 @@ import { openBrowser } from '../browser.js' import { collectInfo, runDoctor } from '../diagnostics.js' import { activateEditorRuntime, + type EditorStartProgress, followLog, getEditorStatus, readLogTail, @@ -16,6 +17,7 @@ import { CliError, toCliError } from '../errors.js' import { readJsonFile } from '../json-files.js' import { resolvePascalPaths } from '../paths.js' import { installBundledRuntime } from '../runtime.js' +import { TerminalProgress } from '../terminal-progress.js' import { version } from '../version.js' const HELP = `Pascal — local 3D editor @@ -90,14 +92,44 @@ async function runStart(args: string[], shouldOpen: boolean): Promise { }) if (values.help) return print(HELP) const port = parseIntegerOption(values.port, 'port') - const result = await startEditor({ paths, port, foreground: values.foreground }) + const progress = values.json ? undefined : new TerminalProgress() + let installedRuntime = false + progress?.start('Preparing your local Pascal editor') + let result: Awaited> + try { + result = await startEditor({ + paths, + port, + foreground: values.foreground, + onProgress: progress + ? (event) => { + if (event.step === 'runtime-installing') installedRuntime = true + reportStartProgress(progress, event) + } + : undefined, + }) + } catch (error) { + progress?.stop() + throw error + } + progress?.stop() if (values.open && !values['no-open']) openBrowser(result.state.url) output( values.json, { ...result.state, alreadyRunning: result.alreadyRunning }, result.alreadyRunning ? `Pascal is already running at ${result.state.url}` - : `Pascal is running at ${result.state.url}`, + : installedRuntime + ? [ + `Pascal is ready at ${result.state.url}`, + `Projects stay in ${paths.data}`, + '', + 'Next steps:', + ' npx @pascal-app/cli status Check the local editor', + ' npx @pascal-app/cli logs --follow Follow editor logs', + ' npx @pascal-app/cli stop Stop the background process', + ].join('\n') + : `Pascal is running at ${result.state.url}`, ) if (result.child) { const exitCode = await new Promise((resolve) => @@ -107,6 +139,44 @@ async function runStart(args: string[], shouldOpen: boolean): Promise { } } +function reportStartProgress(progress: TerminalProgress, event: EditorStartProgress): void { + switch (event.step) { + case 'storage-ready': + progress.succeed(`Local data directory ready at ${event.dataDirectory}`) + return + case 'runtime-installing': + progress.start('Installing the editor runtime') + return + case 'runtime-ready': + progress.succeed( + event.installed + ? `Editor runtime ${event.version} installed` + : `Editor runtime ${event.version} ready`, + ) + return + case 'port-ready': + progress.succeed( + event.preferredPort === 0 + ? `Local port ${event.port} selected automatically` + : event.port === event.preferredPort + ? `Local port ${event.port} is available` + : `Port ${event.preferredPort} is busy; using ${event.port} instead`, + ) + return + case 'process-starting': + progress.start(`Starting Pascal on port ${event.port}`) + return + case 'health-checking': + progress.update('Checking that the editor is ready') + return + case 'ready': + progress.succeed('Pascal Editor is ready') + return + case 'already-running': + progress.succeed(`Pascal is already running on port ${event.port}`) + } +} + async function runStop(args: string[]): Promise { const { values } = parseArgs({ args, diff --git a/packages/cli/src/editor-process.ts b/packages/cli/src/editor-process.ts index 571e56722..bf861e3f0 100644 --- a/packages/cli/src/editor-process.ts +++ b/packages/cli/src/editor-process.ts @@ -41,8 +41,19 @@ export interface StartEditorOptions { port?: number foreground?: boolean sourceDirectory?: string + onProgress?: (event: EditorStartProgress) => void } +export type EditorStartProgress = + | { step: 'storage-ready'; dataDirectory: string } + | { step: 'runtime-installing' } + | { step: 'runtime-ready'; version: string; installed: boolean } + | { step: 'port-ready'; port: number; preferredPort: number } + | { step: 'process-starting'; port: number } + | { step: 'health-checking'; port: number } + | { step: 'ready'; port: number } + | { step: 'already-running'; port: number } + export interface StartEditorResult { state: EditorState alreadyRunning: boolean @@ -85,6 +96,8 @@ export async function startEditor(options: StartEditorOptions): Promise { await ensurePascalDirectories(options.paths) + options.onProgress?.({ step: 'storage-ready', dataDirectory: options.paths.data }) + let installedRuntime = false let currentStatus: EditorStatus try { currentStatus = await getEditorStatus(options.paths) @@ -92,10 +105,13 @@ async function startEditorUnlocked(options: StartEditorOptions): Promise { } export async function checkHealth(state: EditorState): Promise { + return (await probeHealth(state)) === 'healthy' +} + +async function probeHealth(state: EditorState): Promise<'healthy' | 'foreign' | 'unreachable'> { try { const response = await fetch(`http://127.0.0.1:${state.port}/api/health`, { signal: AbortSignal.timeout(1_000), }) - if (!response.ok) return false - const body = (await response.json()) as { + if (!response.ok) return 'foreign' + let body: { status?: string app?: string version?: string instanceId?: string } - return ( - body.status === 'ok' && + try { + body = (await response.json()) as typeof body + } catch { + return 'foreign' + } + return body.status === 'ok' && body.app === 'editor' && body.version === state.version && body.instanceId === state.instanceId - ) + ? 'healthy' + : 'foreign' } catch { - return false + return 'unreachable' } } export async function waitForHealth(state: EditorState, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { - if (await checkHealth(state)) return + const health = await probeHealth(state) + if (health === 'healthy') return + if (health === 'foreign') { + throw new CliError( + 'port_conflict', + `Port ${state.port} is responding as another application. Run Pascal again to choose another port, or pass --port .`, + ) + } if (!isProcessRunning(state.pid)) { throw new CliError('start_failed', 'The Pascal editor exited before becoming healthy.') } @@ -396,12 +440,29 @@ async function findAvailablePort(preferredPort: number): Promise { throw new CliError('invalid_port', `Invalid port: ${preferredPort}`) } if (preferredPort === 0) return probePort(0) - for (let port = preferredPort; port < Math.min(preferredPort + 20, 65_536); port += 1) { + if (!(await isPortAcceptingConnections(preferredPort))) { try { - return await probePort(port) + return await probePort(preferredPort) } catch {} } - throw new CliError('port_unavailable', `No available port found from ${preferredPort}.`) + return probePort(0) +} + +async function isPortAcceptingConnections(port: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ host: '127.0.0.1', port }) + let settled = false + const finish = (result: boolean) => { + if (settled) return + settled = true + socket.destroy() + resolve(result) + } + socket.setTimeout(250) + socket.once('connect', () => finish(true)) + socket.once('timeout', () => finish(false)) + socket.once('error', () => finish(false)) + }) } async function probePort(port: number): Promise { diff --git a/packages/cli/src/runtime.test.ts b/packages/cli/src/runtime.test.ts index 8de931c6b..7f506142f 100644 --- a/packages/cli/src/runtime.test.ts +++ b/packages/cli/src/runtime.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import http from 'node:http' import os from 'node:os' import path from 'node:path' import { @@ -7,6 +8,7 @@ import { getEditorStatus, startEditor, stopEditor, + waitForHealth, } from './editor-process.js' import { resolvePascalPaths } from './paths.js' import { installBundledRuntime, readActiveRuntime } from './runtime.js' @@ -37,7 +39,7 @@ describe('managed runtime', () => { await mkdir(paths.data, { recursive: true }) await writeFile(paths.database, 'persistent') - const started = await startEditor({ paths, port: 0, sourceDirectory: source }) + const started = await startEditor({ paths, sourceDirectory: source }) expect(started.alreadyRunning).toBe(false) expect((await getEditorStatus(paths)).healthy).toBe(true) expect((await startEditor({ paths, sourceDirectory: source })).alreadyRunning).toBe(true) @@ -62,6 +64,70 @@ describe('managed runtime', () => { await stopEditor(paths) }) + test('falls back to an automatic port when the requested port is occupied', async () => { + const root = await temporaryRoot() + const source = await fakeRuntime(root, '1.2.3') + const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }) + const foreignServer = http.createServer((_request, response) => response.end('foreign')) + await new Promise((resolve, reject) => { + foreignServer.once('error', reject) + foreignServer.listen({ host: '127.0.0.1', port: 0 }, resolve) + }) + const address = foreignServer.address() + if (!address || typeof address === 'string') throw new Error('foreign server has no TCP port') + + try { + const started = await startEditor({ + paths, + port: address.port, + sourceDirectory: source, + }) + + expect(started.state.port).not.toBe(address.port) + expect((await getEditorStatus(paths)).healthy).toBe(true) + await stopEditor(paths) + } finally { + await new Promise((resolve, reject) => + foreignServer.close((error) => (error ? reject(error) : resolve())), + ) + } + }) + + test('reports a foreign health responder without waiting for the timeout', async () => { + const foreignServer = http.createServer((_request, response) => response.end('not Pascal')) + await new Promise((resolve, reject) => { + foreignServer.once('error', reject) + foreignServer.listen({ host: '127.0.0.1', port: 0 }, resolve) + }) + const address = foreignServer.address() + if (!address || typeof address === 'string') throw new Error('foreign server has no TCP port') + + try { + const startedAt = Date.now() + await expect( + waitForHealth( + { + schemaVersion: 1, + pid: process.pid, + version: '1.2.3', + port: address.port, + host: '127.0.0.1', + url: `http://pascal.localhost:${address.port}`, + instanceId: 'expected-instance', + runtimeDirectory: '/tmp/pascal-test-runtime', + startedAt: new Date().toISOString(), + }, + 5_000, + ), + ).rejects.toMatchObject({ code: 'port_conflict' }) + expect(Date.now() - startedAt).toBeLessThan(1_000) + } finally { + await new Promise((resolve, reject) => + foreignServer.close((error) => (error ? reject(error) : resolve())), + ) + } + }) + test('reclaims an install lock whose owner is gone', async () => { const root = await temporaryRoot() const source = await fakeRuntime(root, '1.2.3') diff --git a/packages/cli/src/terminal-progress.test.ts b/packages/cli/src/terminal-progress.test.ts new file mode 100644 index 000000000..63269538f --- /dev/null +++ b/packages/cli/src/terminal-progress.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test' +import { TerminalProgress } from './terminal-progress.js' + +describe('terminal progress', () => { + test('prints durable stage feedback outside a TTY', () => { + const chunks: string[] = [] + const progress = new TerminalProgress({ + isTTY: false, + write(chunk) { + chunks.push(chunk) + }, + }) + + progress.start('Installing the editor runtime') + progress.update('Checking that the editor is ready') + progress.succeed('Pascal Editor is ready') + + expect(chunks.join('')).toBe( + '• Installing the editor runtime\n' + + '• Checking that the editor is ready\n' + + '✓ Pascal Editor is ready\n', + ) + }) +}) diff --git a/packages/cli/src/terminal-progress.ts b/packages/cli/src/terminal-progress.ts new file mode 100644 index 000000000..2a791ef1b --- /dev/null +++ b/packages/cli/src/terminal-progress.ts @@ -0,0 +1,67 @@ +export interface ProgressStream { + isTTY?: boolean + write(chunk: string): unknown +} + +const FRAMES = [ + '[= ]', + '[== ]', + '[ === ]', + '[ ===]', + '[ ==]', + '[ =]', + '[ ==]', + '[ ===]', +] + +export class TerminalProgress { + private frame = 0 + private message = '' + private timer: ReturnType | undefined + + constructor(private readonly stream: ProgressStream = process.stderr) {} + + start(message: string): void { + this.stopActive(false) + this.message = message + if (!this.stream.isTTY) { + this.stream.write(`• ${message}\n`) + return + } + this.render() + this.timer = setInterval(() => { + this.frame = (this.frame + 1) % FRAMES.length + this.render() + }, 90) + this.timer.unref() + } + + update(message: string): void { + if (!this.timer && !this.stream.isTTY) { + this.start(message) + return + } + this.message = message + if (this.stream.isTTY) this.render() + } + + succeed(message: string): void { + this.stopActive(true) + this.stream.write(`✓ ${message}\n`) + } + + stop(): void { + this.stopActive(true) + } + + private render(): void { + this.stream.write(`\r\u001b[2K${FRAMES[this.frame]} ${this.message}`) + } + + private stopActive(clearLine: boolean): void { + if (this.timer) clearInterval(this.timer) + this.timer = undefined + if (clearLine && this.stream.isTTY && this.message) this.stream.write('\r\u001b[2K') + this.message = '' + } +}