Skip to content
Draft
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
44 changes: 44 additions & 0 deletions packages/one/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,49 @@ const generateRoutes = defineCommand({
},
})

const daemonCommand = defineCommand({
meta: {
name: 'daemon',
version: version,
description: 'Multi-app development server proxy',
},
args: {
subcommand: {
type: 'positional',
description: 'Subcommand: start, stop, status, route (default: start)',
required: false,
},
port: {
type: 'string',
description: 'Port to listen on (default: 8081)',
},
host: {
type: 'string',
description: 'Host to bind to (default: 0.0.0.0)',
},
app: {
type: 'string',
description: 'Bundle ID for route command',
},
slot: {
type: 'string',
description: 'Slot number for route command',
},
project: {
type: 'string',
description: 'Project path for route command',
},
tui: {
type: 'boolean',
description: 'Show TUI (default: true if TTY)',
},
},
async run({ args }) {
const { daemon } = await import('./cli/daemon')
await daemon(args)
},
})

const subCommands = {
dev,
clean,
Expand All @@ -293,6 +336,7 @@ const subCommands = {
patch,
serve: serveCommand,
'generate-routes': generateRoutes,
daemon: daemonCommand,
}

// workaround for having sub-commands but also positional arg for naming in the create flow
Expand Down
176 changes: 176 additions & 0 deletions packages/one/src/cli/daemon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// one daemon CLI command

import colors from 'picocolors'
import { labelProcess } from './label-process'

export async function daemon(args: {
subcommand?: string
port?: string
host?: string
app?: string
slot?: string
project?: string
tui?: boolean
}) {
const subcommand = args.subcommand || 'run'

switch (subcommand) {
case 'run':
case 'start':
return daemonStart(args)

case 'stop':
return daemonStop()

case 'status':
return daemonStatus()

case 'route':
return daemonRoute(args)

default:
console.log(`Unknown daemon subcommand: ${subcommand}`)
console.log('Available: start, stop, status, route')
process.exit(1)
}
}

async function daemonStart(args: { port?: string; host?: string; tui?: boolean }) {
labelProcess('daemon')

const { isDaemonRunning } = await import('../daemon/ipc')

if (await isDaemonRunning()) {
console.log(colors.yellow('Daemon is already running'))
console.log("Use 'one daemon status' to see registered servers")
process.exit(1)
}

const { startDaemon } = await import('../daemon/server')

// default to TUI if running in interactive terminal
const useTUI = args.tui ?? process.stdin.isTTY

const { state } = await startDaemon({
port: args.port ? parseInt(args.port, 10) : undefined,
host: args.host,
quiet: useTUI, // suppress normal logs when TUI is active
})

if (useTUI) {
const { startTUI } = await import('../daemon/tui')
startTUI(state)
}
}

async function daemonStop() {
const { isDaemonRunning, getSocketPath, cleanupSocket } = await import('../daemon/ipc')

if (!(await isDaemonRunning())) {
console.log(colors.yellow('Daemon is not running'))
process.exit(1)
}

// send shutdown signal via IPC
// for now, just cleanup socket and let user stop the process manually
console.log(colors.yellow('Note: daemon runs in foreground. Press Ctrl+C in the daemon terminal to stop.'))
console.log(colors.dim(`Socket path: ${getSocketPath()}`))
}

async function daemonStatus() {
const { isDaemonRunning, getDaemonStatus } = await import('../daemon/ipc')

if (!(await isDaemonRunning())) {
console.log(colors.yellow('Daemon is not running'))
console.log(colors.dim("Start with 'one daemon'"))
process.exit(1)
}

try {
const status = await getDaemonStatus()

console.log(colors.cyan('\n═══════════════════════════════════════════════════'))
console.log(colors.cyan(' one daemon status'))
console.log(colors.cyan('═══════════════════════════════════════════════════\n'))

if (status.servers.length === 0) {
console.log(colors.dim(' No servers registered'))
} else {
console.log(' Registered servers:')
for (const server of status.servers) {
const shortRoot = server.root.replace(process.env.HOME || '', '~')
console.log(
` ${colors.green(server.id)} ${server.bundleId} → :${server.port} (${shortRoot})`
)
}
}

if (status.routes.length > 0) {
console.log('\n Active routes:')
for (const route of status.routes) {
console.log(` ${route.key} → ${route.serverId}`)
}
}

console.log('')
} catch (err) {
console.log(colors.red('Failed to get daemon status'))
console.error(err)
process.exit(1)
}
}

async function daemonRoute(args: { app?: string; slot?: string; project?: string }) {
const { isDaemonRunning, getDaemonStatus, setDaemonRoute, clearDaemonRoute } = await import(
'../daemon/ipc'
)

if (!(await isDaemonRunning())) {
console.log(colors.yellow('Daemon is not running'))
process.exit(1)
}

if (!args.app) {
console.log(colors.red('Missing --app parameter'))
console.log("Usage: one daemon route --app=com.example.app --slot=0")
console.log(" or: one daemon route --app=com.example.app --project=~/myapp")
process.exit(1)
}

const status = await getDaemonStatus()

// find the server to route to
let targetServer: (typeof status.servers)[0] | undefined

if (args.slot !== undefined) {
// route by slot (index in server list)
const slotIndex = parseInt(args.slot, 10)
const matchingServers = status.servers.filter((s) => s.bundleId === args.app)

if (slotIndex < 0 || slotIndex >= matchingServers.length) {
console.log(colors.red(`Invalid slot: ${args.slot}`))
console.log(`Available slots for ${args.app}: 0-${matchingServers.length - 1}`)
process.exit(1)
}

targetServer = matchingServers[slotIndex]
} else if (args.project) {
// route by project path
const normalizedProject = args.project.replace(/^~/, process.env.HOME || '')
targetServer = status.servers.find(
(s) => s.bundleId === args.app && s.root === normalizedProject
)

if (!targetServer) {
console.log(colors.red(`No server found for ${args.app} at ${args.project}`))
process.exit(1)
}
} else {
console.log(colors.red('Missing --slot or --project parameter'))
process.exit(1)
}

await setDaemonRoute(args.app, targetServer.id)
const shortRoot = targetServer.root.replace(process.env.HOME || '', '~')
console.log(colors.green(`Route set: ${args.app} → ${targetServer.id} (${shortRoot})`))
}
77 changes: 72 additions & 5 deletions packages/one/src/cli/dev.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import colors from 'picocolors'
import { setServerGlobals } from '../server/setServerGlobals'
import { virtualEntryIdNative } from '../vite/plugins/virtualEntryConstants'
import { checkNodeVersion } from './checkNodeVersion'
import { labelProcess } from './label-process'

const DEFAULT_PORT = 8081
const DAEMON_PORT = 8081

export async function dev(args: {
clean?: boolean
host?: string
Expand All @@ -16,17 +20,50 @@ export async function dev(args: {
checkNodeVersion()
setServerGlobals()

const root = process.cwd()
let daemonServerId: string | undefined
let useDaemon = false
let effectivePort = args.port ? +args.port : DEFAULT_PORT

// check if daemon is running
const { isDaemonRunning, registerWithDaemon, unregisterFromDaemon } = await import(
'../daemon/ipc'
)
const { getBundleIdFromConfig, getAvailablePort } = await import('../daemon/utils')

const daemonRunning = await isDaemonRunning()
const bundleId = getBundleIdFromConfig(root)

if (daemonRunning && !args.port) {
// daemon is running and no explicit port - register with daemon
if (bundleId) {
// find an available port that's not 8081 (daemon's port)
effectivePort = await getAvailablePort(8082, DAEMON_PORT)

console.log(colors.cyan(`[daemon] Detected running daemon on :${DAEMON_PORT}`))
console.log(colors.cyan(`[daemon] Using port :${effectivePort} for this server`))

useDaemon = true
} else {
console.log(
colors.yellow(
'[daemon] No bundleIdentifier found in app.json, running standalone on :8081'
)
)
}
}

const { dev } = await import('vxrn/dev')

const { start, stop } = await dev({
mode: args.mode,
clean: args.clean,
root: process.cwd(),
root,
debugBundle: args.debugBundle,
debug: args.debug,
server: {
host: args.host,
port: args.port ? +args.port : undefined,
port: effectivePort,
},
entries: {
native: virtualEntryIdNative,
Expand All @@ -35,21 +72,51 @@ export async function dev(args: {

const { closePromise } = await start()

// register with daemon after server starts
if (useDaemon && bundleId) {
try {
daemonServerId = await registerWithDaemon({
port: effectivePort,
bundleId,
root,
})
console.log(
colors.green(
`[daemon] Registered as ${bundleId} (${daemonServerId}) → accessible via :${DAEMON_PORT}`
)
)
} catch (err) {
console.log(colors.yellow(`[daemon] Failed to register: ${err}`))
}
}

const cleanup = async () => {
// unregister from daemon
if (daemonServerId) {
try {
await unregisterFromDaemon(daemonServerId)
} catch {
// ignore errors during cleanup
}
}
await stop()
}

process.on('beforeExit', () => {
stop()
cleanup()
})

process.on('SIGINT', async () => {
try {
await stop()
await cleanup()
} finally {
process.exit(2)
}
})

process.on('SIGTERM', async () => {
try {
await stop()
await cleanup()
} finally {
process.exit(0)
}
Expand Down
9 changes: 9 additions & 0 deletions packages/one/src/daemon/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// daemon module exports

export * from './types'
export * from './registry'
export * from './ipc'
export * from './proxy'
export * from './picker'
export * from './server'
export * from './utils'
Loading