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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ primo dev --author cms # CMS edits write to files; file edits revert
primo dev --author both # Bidirectional sync (beta; CMS edits often lost on conflict)
```

The port comes from `--port`, then `port:` in the workspace's `server.yaml`, then 3000. Primo also reserves the next port for reload. If the default pair is occupied, it automatically chooses the next available pair and prints the URL. If you explicitly set a port, Primo asks before using another pair for the session; noninteractive runs fail with instructions to pass another `--port`.

The selected port is saved in `.primo/dev-server.json`, leaving `server.yaml` unchanged. Status, previews (CLI and MCP), new-site handling, and local pull discovery use this session record. A second dev server for the same workspace is refused to avoid opening its database twice. `--force` explicitly stops processes on the requested ports; automatic fallback never stops them.

### `primo push`

Sync local changes to an existing hosted Primo server. Requires a server you've
Expand Down
11 changes: 9 additions & 2 deletions src/commands/add.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { read_dev_runtime, runtime_has_live_process } from '../utils/dev-runtime.js'
import { requested_dev_port } from '../utils/dev-port.js'
import fs from 'fs/promises'
import net from 'net'
import path from 'path'
Expand All @@ -14,7 +16,7 @@ import { import_site_files, site_exists, wait_for_ready, kill_process, type Impo

interface AddOptions {
dir: string
port: string
port?: string
}

const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
Expand Down Expand Up @@ -66,7 +68,12 @@ export async function add_site(target: string, options: AddOptions) {
}

let server_config = await read_server_config(base_dir)
const port = server_config.port ?? parseInt(options.port, 10)
const runtime = await read_dev_runtime(base_dir)
if (runtime && runtime_has_live_process(runtime)) {
console.log(chalk.red(`A Primo server for this workspace is running or starting on port ${runtime.port}. Stop it before running primo add.`))
process.exit(1)
}
const { port } = requested_dev_port(options.port, server_config.port)

// `primo add` is the only thing that mints a site_id for a hand-authored
// folder: `primo dev` skips a site without one rather than adopting it (see
Expand Down
121 changes: 73 additions & 48 deletions src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import path from 'path'
import { createHash, randomInt } from 'crypto'
import chalk from 'chalk'
import ora from 'ora'
import inquirer from 'inquirer'
import http, { type RequestListener } from 'http'
import { requested_dev_port, select_dev_port, is_port_in_use } from '../utils/dev-port.js'
import { claim_dev_runtime, read_dev_runtime, runtime_has_live_process, record_cms_process } from '../utils/dev-runtime.js'
import { spawn, execFileSync, type ChildProcess } from 'child_process'
import archiver from 'archiver'
import extract from 'extract-zip'
Expand All @@ -16,7 +20,7 @@ import { normalize_site } from './validate.js'

interface DevOptions {
dir: string
port: string
port?: string
force?: boolean
author?: string
}
Expand Down Expand Up @@ -734,19 +738,6 @@ async function with_site_import_lock<T>(site_dir: string, config: SiteConfig, fn
}
}

// Check if a port is in use
async function is_port_in_use(port: number): Promise<boolean> {
try {
const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(500)
})
return response.ok
} catch {
return false
}
}

// Kill processes on a specific port
async function kill_port(port: number): Promise<boolean> {
return new Promise((resolve) => {
Expand Down Expand Up @@ -855,31 +846,65 @@ export async function dev_server(options: DevOptions) {
try {
server_config = await read_server_config(base_dir)
is_server_mode = true
} catch {
// No server config, check for site config
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}

const port = server_config.port || parseInt(options.port, 10)

// Check if ports are in use
const main_in_use = await is_port_in_use(port)
const reload_in_use = await is_port_in_use(port + 1)

if (main_in_use || reload_in_use) {
if (options.force) {
spinner.text = 'Killing existing processes...'
if (main_in_use) await kill_port(port)
if (reload_in_use) await kill_port(port + 1)
// Give processes time to release ports
await new Promise(resolve => setTimeout(resolve, 500))
const requested = requested_dev_port(options.port, server_config.port)
const previous_runtime = await read_dev_runtime(base_dir)
if (previous_runtime && runtime_has_live_process(previous_runtime) && !(options.force && requested.port === previous_runtime.port)) {
throw new Error(`This workspace already has a Primo server running or starting on port ${previous_runtime.port}. Stop it before starting another.`)
}
if (options.force) {
for (const candidate of [requested.port, requested.port + 1]) {
if (await is_port_in_use(candidate)) await kill_port(candidate)
}
await new Promise(resolve => setTimeout(resolve, 500))
}
const port = await select_dev_port({
...requested,
// Force means use the requested pair, not silently fall back if killing failed.
explicit: requested.explicit || !!options.force,
confirm: !options.force && process.stdin.isTTY && process.stdout.isTTY ? async next => {
spinner.stop()
try {
const { use_next } = await inquirer.prompt([{ type: 'confirm', name: 'use_next',
message: `Port ${requested.port} or reload port ${requested.port + 1} is in use. Use ${next} for this session?`, default: true }])
return use_next
} finally { spinner.start() }
} : undefined
})
if (port !== requested.port) {
spinner.info(`Port ${requested.port} or reload port ${requested.port + 1} is in use. Using http://localhost:${port} for this session.`)
spinner.start()
}
const runtime = await claim_dev_runtime(base_dir, port)
const stop_startup_on_interrupt = () => process.exit(130)
const stop_startup_on_terminate = () => process.exit(143)
process.once('SIGINT', stop_startup_on_interrupt)
process.once('SIGTERM', stop_startup_on_terminate)
let reload_handler: RequestListener | undefined
const reload_server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/__primo/runtime') {
const ready = reload_handler && cms_process?.exitCode === null && cms_process?.signalCode === null
res.writeHead(ready ? 200 : 503, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(runtime))
} else if (reload_handler) {
reload_handler(req, res)
} else {
const ports_msg = main_in_use && reload_in_use
? `Ports ${port} and ${port + 1} are`
: `Port ${main_in_use ? port : port + 1} is`
spinner.fail(`${ports_msg} already in use. Use --force to kill existing processes.`)
process.exit(1)
res.writeHead(503)
res.end('Primo is starting')
}
}
})
// Reserve reload before starting the CMS or importing any files. A bind
// race fails startup rather than leaving a partially working session.
await new Promise<void>((resolve, reject) => {
reload_server.once('error', reject)
reload_server.listen(port + 1, '127.0.0.1', () => {
reload_server.removeListener('error', reject)
resolve()
})
})

if (is_server_mode) {
// Auto-discover sites in subdirectories
Expand Down Expand Up @@ -923,6 +948,11 @@ export async function dev_server(options: DevOptions) {
env: { ...process.env, PRIMO_DEV_MODE: '1', PRIMO_AUTHOR_MODE: sync_policy.mode }
})

let cms_start_error: Error | null = null
cms_process.on('error', error => { cms_start_error = error })
process.once('exit', () => { cms_process?.kill('SIGTERM') })
if (cms_process.pid) await record_cms_process(base_dir, runtime, cms_process.pid)

// Capture stderr for errors
let stderr_output = ''
cms_process.stderr?.on('data', (data) => {
Expand All @@ -931,7 +961,7 @@ export async function dev_server(options: DevOptions) {

// Wait for CMS to be ready
const ready = await wait_for_ready(`http://127.0.0.1:${port}`, 30000)
if (!ready) {
if (!ready || cms_start_error || cms_process.exitCode !== null || cms_process.signalCode !== null) {
spinner.fail('CMS failed to start')
if (stderr_output) {
console.log(chalk.red(stderr_output))
Expand Down Expand Up @@ -1346,11 +1376,10 @@ export async function dev_server(options: DevOptions) {
setup_site_watchers(site)
}

// Simple HTTP server for reload requests (only in server mode)
if (is_server_mode) {
const http = await import('http')
const reload_server = http.createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/reload') {
// Enable reload once initial imports and watchers are ready.
{
reload_handler = async (req, res) => {
if (!is_server_mode || req.method !== 'POST' || req.url !== '/reload') {
res.writeHead(404)
res.end()
return
Expand Down Expand Up @@ -1439,13 +1468,7 @@ export async function dev_server(options: DevOptions) {
})
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(body)
})
reload_server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.log(chalk.yellow(`\n Warning: Reload server port ${port + 1} in use. Hot reload disabled.`))
}
})
reload_server.listen(port + 1, '127.0.0.1')
}
}

// Start polling for CMS changes (sync back to local files)
Expand Down Expand Up @@ -1533,6 +1556,8 @@ export async function dev_server(options: DevOptions) {
process.exit(0)
}

process.removeListener('SIGINT', stop_startup_on_interrupt)
process.removeListener('SIGTERM', stop_startup_on_terminate)
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
process.on('uncaughtException', (err) => {
Expand Down
1 change: 0 additions & 1 deletion src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ export async function init_workspace(options: InitOptions) {
await fs.mkdir(path.join(base_dir, 'library'), { recursive: true })

await write_server_config(base_dir, {
port: 3000,
site_groups: [
{
id: 'default',
Expand Down
24 changes: 3 additions & 21 deletions src/commands/new.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { resolve_dev_server } from '../utils/dev-runtime.js'
import fs from 'fs/promises'
import path from 'path'
import chalk from 'chalk'
Expand Down Expand Up @@ -328,12 +329,7 @@ sections:

spinner.succeed(`Site created: ${chalk.cyan(site_dir)}`)

// Check if server is already running. Read the workspace's configured
// port (mirroring `primo dev`, which uses server_config.port) rather than
// assuming 3000 — otherwise on a custom-port workspace we'd probe the
// wrong port, miss the running server, and print links to a dead port.
const port = server_config.port ?? 3000
const server_running = await is_server_running(port)
const { port, running: server_running } = await resolve_dev_server(base_dir, server_config.port)

if (server_running) {
// A `primo dev` is already running. Ask it to reload and pick up the
Expand Down Expand Up @@ -411,7 +407,7 @@ sections:
} else if (!options.skipDev) {
// No server running, start one
console.log('')
await dev_server({ dir: base_dir, port: String(port) })
await dev_server({ dir: base_dir })
} else {
console.log('')
console.log(chalk.dim(` ${display_name} was created on disk but isn't registered yet.`))
Expand Down Expand Up @@ -454,20 +450,6 @@ function generate_id(): string {
return id
}

async function is_server_running(port: number): Promise<boolean> {
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 1000)
const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
signal: controller.signal
})
clearTimeout(timeout)
return response.ok
} catch {
return false
}
}

export function generate_agent_md(): string {
return `# Primo workspace

Expand Down
7 changes: 5 additions & 2 deletions src/commands/preview.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { resolve_dev_server } from '../utils/dev-runtime.js'
import fs from 'fs/promises'
import path from 'path'
import chalk from 'chalk'
Expand Down Expand Up @@ -68,6 +69,7 @@ export async function build_site_preview(site_dir_input: string, api_url_overrid
throw new Error(`No ${SERVER_CONFIG_FILE} found above ${site_dir}. Run \`primo preview\` from inside a workspace.`)
}
api_url = `http://127.0.0.1:${workspace.port}`
if (!workspace.running) throw new Error(`Could not reach the Primo server at ${api_url}. Start primo dev for this workspace.`)
}

const token = await dev_auth(api_url)
Expand Down Expand Up @@ -111,13 +113,14 @@ async function read_sync(site_dir: string): Promise<SyncStatus | null> {
}
}

async function find_workspace(site_dir: string): Promise<{ dir: string; port: number } | null> {
async function find_workspace(site_dir: string): Promise<{ dir: string; port: number; running: boolean } | null> {
let dir = site_dir
for (;;) {
try {
await fs.access(path.join(dir, SERVER_CONFIG_FILE))
const config = await read_server_config(dir)
return { dir, port: config.port ?? 3000 }
const server = await resolve_dev_server(dir, config.port)
return { dir, port: server.port, running: server.running }
} catch {
// keep walking up
}
Expand Down
10 changes: 9 additions & 1 deletion src/commands/pull-library.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { find_dev_workspace, resolve_dev_server } from '../utils/dev-runtime.js'
import fs from 'fs/promises'
import path from 'path'
import chalk from 'chalk'
import ora from 'ora'
import extract from 'extract-zip'
import { get_auth_token } from '../utils/auth.js'
import { normalize_server_url } from '../utils/server-config.js'
import { read_server_config, normalize_server_url } from '../utils/server-config.js'

interface PullLibraryOptions {
server?: string
Expand All @@ -13,6 +14,13 @@ interface PullLibraryOptions {
}

async function detect_server(): Promise<string | null> {
const workspace = await find_dev_workspace(process.cwd())
if (workspace) {
const config = await read_server_config(workspace)
const server = await resolve_dev_server(workspace, config.port)
if (!server.running) throw new Error(`No running Primo server for this workspace. Start primo dev, or pass --server <url>.`)
return server.url
}
const ports = [3000, 8080, 5173]

for (const port of ports) {
Expand Down
8 changes: 8 additions & 0 deletions src/commands/pull.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { find_dev_workspace, resolve_dev_server } from '../utils/dev-runtime.js'
import fs from 'fs/promises'
import path from 'path'
import chalk from 'chalk'
Expand All @@ -22,6 +23,13 @@ interface PullOptions {
const MANAGED_DIRS = ['pages', 'blocks', 'page-types', 'site']

async function detect_server(): Promise<string | null> {
const workspace = await find_dev_workspace(process.cwd())
if (workspace) {
const config = await read_server_config(workspace)
const server = await resolve_dev_server(workspace, config.port)
if (!server.running) throw new Error(`No running Primo server for this workspace. Start primo dev, or pass --server <url>.`)
return server.url
}
const ports = [3000, 8080, 5173]

for (const port of ports) {
Expand Down
16 changes: 2 additions & 14 deletions src/commands/status.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { resolve_dev_server } from '../utils/dev-runtime.js'
import fs from 'fs/promises'
import path from 'path'
import chalk from 'chalk'
Expand Down Expand Up @@ -52,9 +53,7 @@ export async function status(options: StatusOptions) {
}

const server_config = await read_server_config(base_dir)
const port = server_config.port ?? 3000
const api_url = `http://127.0.0.1:${port}`
const running = await is_server_running(port)
const { port, url: api_url, running } = await resolve_dev_server(base_dir, server_config.port)
const sites = await read_sites(base_dir)

// When the server is up, its dev-auth token unlocks the authoritative CMS
Expand Down Expand Up @@ -209,14 +208,3 @@ async function read_live_cms(api_url: string): Promise<{ sites: CmsSite[]; group
return null
}
}

async function is_server_running(port: number): Promise<boolean> {
try {
const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
signal: AbortSignal.timeout(1500)
})
return response.ok
} catch {
return false
}
}
Loading
Loading