Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/giant-pets-take.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': minor
---

Adds the `astro preview --background` flag to start preview servers as background processes, including automatic background mode for AI coding agents
12 changes: 12 additions & 0 deletions packages/astro/src/cli/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { detectAgenticEnvironment } from 'am-i-vibing';

export function isRunByAgent(): boolean {
try {
// Only treat direct "agent" types as auto-background-worthy.
// "hybrid" environments (e.g. Warp terminal) may not actually be running
// an AI agent, so we avoid false positives by excluding them.
return detectAgenticEnvironment().type === 'agent';
} catch {
return false;
}
}
164 changes: 11 additions & 153 deletions packages/astro/src/cli/dev/background.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,22 @@
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, openSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { AstroLogger } from '../../core/logger/core.js';
import type { Flags } from '../flags.js';
import type { LockFileData } from '../../core/dev/lockfile.js';
import {
checkExistingServer,
getLogFileURL,
readLockFile,
removeLockFile,
isProcessAlive,
GRACEFUL_SHUTDOWN_TIMEOUT,
type LockFileData,
} from '../../core/dev/lockfile.js';
import { resolveRoot } from '../../core/config/config.js';
background as startBackgroundServer,
devServerCommand,
formatBackgroundOutput,
formatServerRunningMessage as formatServerMessage,
type BackgroundErrorResult,
type BackgroundResult,
} from '../server.js';

export interface BackgroundResult {
pid: number;
url: string;
existing?: boolean;
}

export interface BackgroundErrorResult {
error: string;
message: string;
}

export function formatBackgroundOutput(result: BackgroundResult | BackgroundErrorResult): string {
return JSON.stringify(result);
}
export { formatBackgroundOutput, type BackgroundErrorResult, type BackgroundResult };

/**
* Build the human-readable message shown when a background dev server is running.
* Lists every network address (when `--host` exposed any) so the output matches
* the foreground dev server, then appends the management command hints.
*/
export function formatServerRunningMessage(
data: LockFileData,
{ existing = false }: { existing?: boolean } = {},
): string {
const lines = [
`Dev server ${existing ? 'already running' : 'running'} at ${data.url} (pid ${data.pid})`,
];
if (data.urls && data.urls.network.length > 0) {
lines.push(' Network:');
for (const url of data.urls.network) {
lines.push(` ${url}`);
}
}
lines.push(' Stop: astro dev stop', ' Status: astro dev status', ' Logs: astro dev logs');
return lines.join('\n');
return formatServerMessage(data, devServerCommand, { existing });
}

export async function background({
Expand All @@ -59,114 +26,5 @@ export async function background({
flags: Flags;
logger: AstroLogger;
}): Promise<void> {
const root = pathToFileURL(resolveRoot(flags.root) + '/');

// Check for existing server
const existing = checkExistingServer(root);
if (existing && !flags.force) {
logger.info('SKIP_FORMAT', formatServerRunningMessage(existing, { existing: true }));
return;
}

// If --force, kill the existing server first
if (existing && flags.force) {
try {
process.kill(existing.pid, 'SIGTERM');
} catch {
// Already dead
}
// Wait for graceful shutdown before escalating to SIGKILL
const deadline = Date.now() + GRACEFUL_SHUTDOWN_TIMEOUT;
while (Date.now() < deadline) {
if (!isProcessAlive(existing.pid)) break;
await new Promise((r) => setTimeout(r, 100));
}
// If still alive after timeout, force kill
if (isProcessAlive(existing.pid)) {
try {
process.kill(existing.pid, 'SIGKILL');
} catch {
// Already dead
}
}
removeLockFile(root);
}

// Build the args for the child process: plain `astro dev` (no --background)
const args: string[] = ['dev'];
if (flags.port) args.push('--port', String(flags.port));
if (flags.host != null) {
if (typeof flags.host === 'string') {
args.push('--host', flags.host);
} else {
args.push('--host');
}
}
if (flags.config) args.push('--config', String(flags.config));
if (flags.root) args.push('--root', String(flags.root));
if (flags.allowedHosts) args.push('--allowed-hosts', String(flags.allowedHosts));
if (flags.json) args.push('--json');

// Open the log file for writing, ensuring the .astro directory exists
const logFileURL = getLogFileURL(root);
const logFilePath = fileURLToPath(logFileURL);
const dotAstroDir = fileURLToPath(new URL('.astro/', root));
if (!existsSync(dotAstroDir)) {
mkdirSync(dotAstroDir, { recursive: true });
}
const logFd = openSync(logFilePath, 'w');

// Spawn node directly with astro's entry point, bypassing the .bin shim.
// On Windows, .bin shims are .cmd batch files that cannot be spawned without
// a shell, so we avoid the shim entirely for cross-platform compatibility.
const rootPath = fileURLToPath(root);
const astroBin = resolve(rootPath, 'node_modules', 'astro', 'bin', 'astro.mjs');

// Spawn the dev server as a detached child process
const child = spawn(process.execPath, [astroBin, ...args], {
detached: true,
stdio: ['ignore', logFd, logFd],
cwd: rootPath,
env: { ...process.env, ASTRO_DEV_BACKGROUND: '1' },
});

child.unref();

const childPid = child.pid;
if (!childPid) {
logger.error('SKIP_FORMAT', 'Failed to spawn background dev server process.');
process.exit(1);
}

// Poll the lock file to detect when the server is ready
const timeout = 30000;
const deadline = Date.now() + timeout;

while (Date.now() < deadline) {
// Check if child is still alive
if (!isProcessAlive(childPid)) {
logger.error('SKIP_FORMAT', 'Dev server process exited before becoming ready.');
process.exit(1);
}

// Check for the lock file (written by the child's dev server)
const lockData = readLockFile(root);
if (lockData && lockData.pid === childPid) {
logger.info('SKIP_FORMAT', formatServerRunningMessage(lockData));
return;
}

await new Promise((r) => setTimeout(r, 200));
}

// Timeout: kill the child and report failure
try {
process.kill(childPid, 'SIGTERM');
} catch {
// Already dead
}
removeLockFile(root);

logger.error('SKIP_FORMAT', `Dev server failed to start within ${timeout / 1000}s.`);
process.exit(1);
await startBackgroundServer({ flags, logger, config: devServerCommand });
}
13 changes: 1 addition & 12 deletions packages/astro/src/cli/dev/index.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,16 @@
import { detectAgenticEnvironment } from 'am-i-vibing';
import colors from 'piccolore';
import devServer from '../../core/dev/index.js';
import { pathToFileURL } from 'node:url';
import { checkExistingServer, removeLockFile, writeLockFile } from '../../core/dev/lockfile.js';
import { resolveRoot } from '../../core/config/config.js';
import { printHelp } from '../../core/messages/runtime.js';
import { isRunByAgent } from '../agent.js';
import { type Flags, createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js';

interface DevOptions {
flags: Flags;
}

function isRunByAgent(): boolean {
try {
// Only treat direct "agent" types as auto-background-worthy.
// "hybrid" environments (e.g. Warp terminal) may not actually be running
// an AI agent, so we avoid false positives by excluding them.
return detectAgenticEnvironment().type === 'agent';
} catch {
return false;
}
}

export async function dev({ flags }: DevOptions) {
if (flags.help || flags.h) {
printHelp({
Expand Down
83 changes: 2 additions & 81 deletions packages/astro/src/cli/dev/logs.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { readFileSync, existsSync, statSync, createReadStream, watch } from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { AstroLogger } from '../../core/logger/core.js';
import type { Flags } from '../flags.js';
import { checkExistingServer, getLogFileURL, isProcessAlive } from '../../core/dev/lockfile.js';
import { resolveRoot } from '../../core/config/config.js';
import { devServerCommand, logs as serverLogs } from '../server.js';

export async function logs({
flags,
Expand All @@ -12,81 +9,5 @@ export async function logs({
flags: Flags;
logger: AstroLogger;
}): Promise<void> {
const root = pathToFileURL(resolveRoot(flags.root) + '/');
const existing = checkExistingServer(root);

if (!existing) {
logger.error('SKIP_FORMAT', 'No dev server is running.');
process.exit(1);
}

if (!existing.background) {
logger.error(
'SKIP_FORMAT',
'The running dev server was not started with `astro dev --background`. View logs in the terminal where it was started.',
);
process.exit(1);
}

const logFileURL = getLogFileURL(root);
const logFilePath = fileURLToPath(logFileURL);
if (!existsSync(logFilePath)) {
logger.error('SKIP_FORMAT', 'No log file found.');
process.exit(1);
}

const follow = flags.follow || flags.f;

if (!follow) {
const content = readFileSync(logFilePath, 'utf-8');
process.stdout.write(content);
return;
}

// --follow mode: print existing content, then watch for new data
let offset = statSync(logFilePath).size;

// Print existing content
if (offset > 0) {
const content = readFileSync(logFilePath, 'utf-8');
process.stdout.write(content);
}

// Watch the file for changes and stream new bytes
const watcher = watch(logFilePath, () => {
const currentSize = statSync(logFilePath).size;
if (currentSize > offset) {
const stream = createReadStream(logFilePath, { start: offset, encoding: 'utf-8' });
stream.on('data', (chunk) => process.stdout.write(chunk));
stream.on('end', () => {
offset = currentSize;
});
}
});

// Periodically check if the server process is still alive
const aliveCheck = setInterval(() => {
if (!isProcessAlive(existing.pid)) {
// Read any final bytes
const currentSize = statSync(logFilePath).size;
if (currentSize > offset) {
const remaining = readFileSync(logFilePath, { encoding: 'utf-8' }).slice(offset);
process.stdout.write(remaining);
}
watcher.close();
clearInterval(aliveCheck);
}
}, 1000);

// Clean up on termination signals
const cleanup = () => {
watcher.close();
clearInterval(aliveCheck);
process.exit(0);
};
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);

// Keep the process alive
await new Promise(() => {});
await serverLogs({ flags, logger, config: devServerCommand });
}
45 changes: 8 additions & 37 deletions packages/astro/src/cli/dev/status.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,13 @@
import type { AstroLogger } from '../../core/logger/core.js';
import type { Flags } from '../flags.js';
import { pathToFileURL } from 'node:url';
import { checkExistingServer } from '../../core/dev/lockfile.js';
import { resolveRoot } from '../../core/config/config.js';
import {
devServerCommand,
formatStatusOutput,
status as serverStatus,
type StatusResult,
} from '../server.js';

export interface StatusResult {
running: boolean;
pid?: number;
url?: string;
port?: number;
background?: boolean;
uptime?: number;
}

export function formatStatusOutput(result: StatusResult): string {
return JSON.stringify(result);
}
export { formatStatusOutput, type StatusResult };

export async function status({
flags,
Expand All @@ -24,26 +16,5 @@ export async function status({
flags: Flags;
logger: AstroLogger;
}): Promise<void> {
const root = pathToFileURL(resolveRoot(flags.root) + '/');
const existing = checkExistingServer(root);

if (!existing) {
logger.info('SKIP_FORMAT', 'No dev server is running.');
return;
}

const startedAt = new Date(existing.startedAt).getTime();
const uptime = Math.floor((Date.now() - startedAt) / 1000);

const lines = [
`Dev server running at ${existing.url} (pid ${existing.pid}, uptime ${uptime}s${existing.background ? ', background' : ''})`,
];
if (existing.urls && existing.urls.network.length > 0) {
lines.push(' Network:');
for (const url of existing.urls.network) {
lines.push(` ${url}`);
}
}

logger.info('SKIP_FORMAT', lines.join('\n'));
await serverStatus({ flags, logger, config: devServerCommand });
}
Loading
Loading