Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>`. 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:<port>`. Your projects are stored separately from the
runtime, so updating the CLI does not replace your work.

## Why use the CLI?
Expand Down Expand Up @@ -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 <n>` 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
Expand Down
26 changes: 25 additions & 1 deletion packages/cli/scripts/smoke-packed-runtime.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -8,13 +9,18 @@ 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'),
PASCAL_NO_OPEN: '1',
}

try {
await listen(defaultPortBlocker)
const pack = await run('npm', ['pack', '--json', '--ignore-scripts'], packageDirectory)
const packResult = JSON.parse(pack.stdout) as
| Array<PackedArtifact>
Expand All @@ -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`)
Expand Down Expand Up @@ -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,
Expand All @@ -85,6 +93,22 @@ try {
await rm(smokeRoot, { recursive: true, force: true })
}

async function listen(server: http.Server): Promise<void> {
await new Promise<void>((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<void> {
if (!server.listening) return
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
)
}

interface PackedArtifact {
filename: string
size: number
Expand Down
74 changes: 72 additions & 2 deletions packages/cli/src/bin/pascal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { openBrowser } from '../browser.js'
import { collectInfo, runDoctor } from '../diagnostics.js'
import {
activateEditorRuntime,
type EditorStartProgress,
followLog,
getEditorStatus,
readLogTail,
Expand All @@ -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
Expand Down Expand Up @@ -90,14 +92,44 @@ async function runStart(args: string[], shouldOpen: boolean): Promise<void> {
})
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<ReturnType<typeof startEditor>>
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<number>((resolve) =>
Expand All @@ -107,6 +139,44 @@ async function runStart(args: string[], shouldOpen: boolean): Promise<void> {
}
}

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<void> {
const { values } = parseArgs({
args,
Expand Down
83 changes: 72 additions & 11 deletions packages/cli/src/editor-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -85,17 +96,22 @@ export async function startEditor(options: StartEditorOptions): Promise<StartEdi

async function startEditorUnlocked(options: StartEditorOptions): Promise<StartEditorResult> {
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)
} catch (error) {
if (!(error instanceof CliError) || error.code !== 'invalid_runtime') throw error
await stopEditorUnlocked(options.paths, { force: true })
await rm(options.paths.currentRuntime, { force: true })
options.onProgress?.({ step: 'runtime-installing' })
await installBundledRuntime(options.paths, options.sourceDirectory)
installedRuntime = true
currentStatus = await getEditorStatus(options.paths)
}
if (currentStatus.healthy && currentStatus.state) {
options.onProgress?.({ step: 'already-running', port: currentStatus.state.port })
return { state: currentStatus.state, alreadyRunning: true }
}
if (currentStatus.running && currentStatus.state) {
Expand All @@ -108,11 +124,20 @@ async function startEditorUnlocked(options: StartEditorOptions): Promise<StartEd

let runtime = await readActiveRuntime(options.paths)
if (!runtime) {
options.onProgress?.({ step: 'runtime-installing' })
runtime = await installBundledRuntime(options.paths, options.sourceDirectory)
installedRuntime = true
}
options.onProgress?.({
step: 'runtime-ready',
version: runtime.version,
installed: installedRuntime,
})
const manifest = await readRuntimeManifest(runtime.directory)
const serverPath = path.resolve(runtime.directory, manifest.entrypoint)
const port = await findAvailablePort(options.port ?? 3000)
const preferredPort = options.port ?? 0
const port = await findAvailablePort(preferredPort)
options.onProgress?.({ step: 'port-ready', port, preferredPort })
const instanceId = randomUUID()
const state: EditorState = {
schemaVersion: 1,
Expand Down Expand Up @@ -141,6 +166,7 @@ async function startEditorUnlocked(options: StartEditorOptions): Promise<StartEd
const logDescriptor = options.foreground
? undefined
: openSync(options.paths.editorLog, 'a', 0o600)
options.onProgress?.({ step: 'process-starting', port })
const child = spawn(nodeBinary, [serverPath], {
cwd: path.dirname(serverPath),
env: environment,
Expand All @@ -155,7 +181,9 @@ async function startEditorUnlocked(options: StartEditorOptions): Promise<StartEd
state.pid = child.pid
await writeJsonFile(options.paths.state, state)
if (!options.foreground) child.unref()
options.onProgress?.({ step: 'health-checking', port })
await waitForHealth(state, 30_000)
options.onProgress?.({ step: 'ready', port })
} catch (error) {
if (child.pid) await terminateProcess(child.pid)
await rm(options.paths.state, { force: true })
Expand Down Expand Up @@ -347,32 +375,48 @@ export async function followLog(filePath: string): Promise<never> {
}

export async function checkHealth(state: EditorState): Promise<boolean> {
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<void> {
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 <n>.`,
)
}
if (!isProcessRunning(state.pid)) {
throw new CliError('start_failed', 'The Pascal editor exited before becoming healthy.')
}
Expand All @@ -396,12 +440,29 @@ async function findAvailablePort(preferredPort: number): Promise<number> {
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<boolean> {
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<number> {
Expand Down
Loading
Loading