Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
6c28459fa9 Add process manager for bun start/stop/list/logs commands
Implements a workspace-local process manager that allows running and
managing background processes via CLI commands:

- bun start SCRIPT - Start a process in the background
- bun stop NAME - Stop a running process
- bun list - List all running processes
- bun logs NAME - View logs for a process

Implementation:
- Processes are forked and exec'd as `bun run SCRIPT`
- State persists via JSON files in /tmp/bun-pm-{hash}/
- Logs are captured to /tmp/bun-logs/{hash}/
- Workspace isolation via directory hash
- Automatic cleanup of dead processes on each command

The implementation provides basic process management functionality
without complex features like auto-restart or resource limits,
making it simple for users and future developers to understand
and use.

Tests: Added comprehensive integration tests in test/cli/process-manager.test.ts
covering start, stop, list, logs, workspace isolation, and error cases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 11:32:17 +00:00
6 changed files with 1281 additions and 2 deletions

View File

@@ -91,6 +91,7 @@ pub const PackCommand = @import("./cli/pack_command.zig").PackCommand;
pub const AuditCommand = @import("./cli/audit_command.zig").AuditCommand;
pub const InitCommand = @import("./cli/init_command.zig").InitCommand;
pub const WhyCommand = @import("./cli/why_command.zig").WhyCommand;
pub const ProcessManagerCommand = @import("./cli/process_manager_command.zig").ProcessManagerCommand;
pub const Arguments = @import("./cli/Arguments.zig");
@@ -617,8 +618,11 @@ pub const Command = struct {
RootCommandMatcher.case("logout") => .ReservedCommand,
RootCommandMatcher.case("whoami") => .PackageManagerCommand,
RootCommandMatcher.case("prune") => .ReservedCommand,
RootCommandMatcher.case("list") => .ReservedCommand,
RootCommandMatcher.case("list") => .ListCommand,
RootCommandMatcher.case("why") => .WhyCommand,
RootCommandMatcher.case("start") => .StartCommand,
RootCommandMatcher.case("stop") => .StopCommand,
RootCommandMatcher.case("logs") => .LogsCommand,
RootCommandMatcher.case("-e") => .AutoCommand,
@@ -921,6 +925,26 @@ pub const Command = struct {
Output.flush();
try HelpCommand.exec(allocator);
},
.StartCommand => {
const ctx = try Command.init(allocator, log, .StartCommand);
try ProcessManagerCommand.exec(ctx);
return;
},
.StopCommand => {
const ctx = try Command.init(allocator, log, .StopCommand);
try ProcessManagerCommand.exec(ctx);
return;
},
.ListCommand => {
const ctx = try Command.init(allocator, log, .ListCommand);
try ProcessManagerCommand.exec(ctx);
return;
},
.LogsCommand => {
const ctx = try Command.init(allocator, log, .LogsCommand);
try ProcessManagerCommand.exec(ctx);
return;
},
.ExecCommand => {
const ctx = try Command.init(allocator, log, .ExecCommand);
@@ -949,6 +973,10 @@ pub const Command = struct {
RemoveCommand,
RunCommand,
RunAsNodeCommand, // arg0 == 'node'
StartCommand,
StopCommand,
ListCommand,
LogsCommand,
TestCommand,
UnlinkCommand,
UpdateCommand,
@@ -986,6 +1014,10 @@ pub const Command = struct {
.RemoveCommand => 'R',
.RunCommand => 'r',
.RunAsNodeCommand => 'n',
.StartCommand => 's',
.StopCommand => 'S',
.ListCommand => 'L',
.LogsCommand => 'K',
.TestCommand => 't',
.UnlinkCommand => 'U',
.UpdateCommand => 'u',
@@ -1312,8 +1344,12 @@ pub const Command = struct {
Output.pretty(intro_text, .{});
Output.flush();
},
.StartCommand, .StopCommand, .ListCommand, .LogsCommand => {
// These commands handle their own help
ProcessManagerCommand.printHelp();
},
else => {
HelpCommand.printWithReason(.explicit);
HelpCommand.printWithReason(.explicit, show_all_flags);
},
}
}

View File

@@ -0,0 +1,201 @@
const std = @import("std");
const bun = @import("bun");
const Environment = bun.Environment;
const Command = @import("../../cli.zig").Command;
const Protocol = @import("./protocol.zig");
const Manager = @import("./manager.zig");
const Output = bun.Output;
const Global = bun.Global;
const strings = bun.strings;
var path_buf: bun.PathBuffer = undefined;
pub fn startCommand(ctx: Command.Context) !void {
if (ctx.positionals.len < 2) {
Output.errGeneric("Usage: bun start SCRIPT", .{});
Global.exit(1);
}
const script_name = ctx.positionals[1];
const cwd = try bun.getcwd(&path_buf);
const manager = try Manager.ProcessManager.init(ctx.allocator, cwd);
defer manager.deinit();
// Clean up any dead processes first
try manager.cleanup();
manager.startProcess(script_name, script_name, cwd) catch |err| {
switch (err) {
error.ProcessAlreadyExists => {
Output.errGeneric("Process '{s}' is already running", .{script_name});
Global.exit(1);
},
else => return err,
}
};
Output.prettyln("<green>\u{2713}<r> Started: <b>{s}<r>", .{script_name});
Output.flush();
}
pub fn stopCommand(ctx: Command.Context) !void {
if (ctx.positionals.len < 2) {
Output.errGeneric("Usage: bun stop NAME", .{});
Global.exit(1);
}
const name = ctx.positionals[1];
const cwd = try bun.getcwd(&path_buf);
const manager = try Manager.ProcessManager.init(ctx.allocator, cwd);
defer manager.deinit();
// Clean up any dead processes first
try manager.cleanup();
manager.stopProcess(name) catch |err| {
switch (err) {
error.ProcessNotFound => {
Output.errGeneric("Process '{s}' not found", .{name});
Global.exit(1);
},
else => return err,
}
};
Output.prettyln("<green>\u{2713}<r> Stopped: <b>{s}<r>", .{name});
Output.flush();
}
pub fn listCommand(ctx: Command.Context) !void {
const cwd = try bun.getcwd(&path_buf);
const manager = try Manager.ProcessManager.init(ctx.allocator, cwd);
defer manager.deinit();
// Clean up any dead processes first
try manager.cleanup();
const list = try manager.listProcesses(ctx.allocator);
defer ctx.allocator.free(list);
if (list.len == 0) {
Output.prettyln("No processes running in this workspace", .{});
Output.flush();
return;
}
Output.prettyln("\n<b>NAME PID SCRIPT UPTIME<r>", .{});
for (list) |proc| {
const uptime = formatUptime(proc.uptime);
Output.prettyln("{s: <20}{d: <10}{s: <30}{s}", .{ proc.name, proc.pid, proc.script, uptime });
}
Output.prettyln("", .{});
Output.flush();
}
pub fn logsCommand(ctx: Command.Context) !void {
if (ctx.positionals.len < 2) {
Output.errGeneric("Usage: bun logs NAME [-f]", .{});
Global.exit(1);
}
const name = ctx.positionals[1];
const cwd = try bun.getcwd(&path_buf);
const manager = try Manager.ProcessManager.init(ctx.allocator, cwd);
defer manager.deinit();
// Clean up any dead processes first
try manager.cleanup();
const response = manager.getLogPaths(name) catch |err| {
switch (err) {
error.ProcessNotFound => {
Output.errGeneric("Process '{s}' not found", .{name});
Global.exit(1);
},
else => return err,
}
};
switch (response) {
.log_path => |paths| {
Output.prettyln("<b>Logs for {s}:<r>", .{name});
Output.prettyln(" stdout: {s}", .{paths.stdout});
Output.prettyln(" stderr: {s}", .{paths.stderr});
// TODO: Check for -f flag and tail the logs
// For now, just print last 50 lines of stdout
Output.prettyln("\n<b>Recent stdout:<r>", .{});
try tailFile(ctx.allocator, paths.stdout, 50);
Output.prettyln("\n<b>Recent stderr:<r>", .{});
try tailFile(ctx.allocator, paths.stderr, 50);
},
else => {},
}
Output.flush();
}
fn tailFile(allocator: std.mem.Allocator, path: []const u8, lines: usize) !void {
const file = std.fs.openFileAbsolute(path, .{}) catch |err| {
if (err == error.FileNotFound) {
Output.prettyln(" <d>(no output yet)<r>", .{});
return;
}
return err;
};
defer file.close();
const contents = try file.readToEndAlloc(allocator, 1024 * 1024);
defer allocator.free(contents);
if (contents.len == 0) {
Output.prettyln(" <d>(no output yet)<r>", .{});
return;
}
// Count newlines from the end
var line_count: usize = 0;
var i: usize = contents.len;
while (i > 0 and line_count < lines) {
i -= 1;
if (contents[i] == '\n') {
line_count += 1;
}
}
// Skip to the start of the line
if (i > 0) {
while (i < contents.len and contents[i] != '\n') {
i += 1;
}
if (i < contents.len) i += 1;
}
const tail_contents = contents[i..];
if (tail_contents.len > 0) {
Output.pretty("{s}", .{tail_contents});
} else {
Output.prettyln(" <d>(no output yet)<r>", .{});
}
}
fn formatUptime(seconds: i64) []const u8 {
var buf: [32]u8 = undefined;
if (seconds < 60) {
return std.fmt.bufPrint(&buf, "{d}s", .{seconds}) catch "?";
} else if (seconds < 3600) {
const mins = @divFloor(seconds, 60);
return std.fmt.bufPrint(&buf, "{d}m", .{mins}) catch "?";
} else if (seconds < 86400) {
const hours = @divFloor(seconds, 3600);
return std.fmt.bufPrint(&buf, "{d}h", .{hours}) catch "?";
} else {
const days = @divFloor(seconds, 86400);
return std.fmt.bufPrint(&buf, "{d}d", .{days}) catch "?";
}
}

View File

@@ -0,0 +1,406 @@
const std = @import("std");
const bun = @import("bun");
const Environment = bun.Environment;
const Output = bun.Output;
const Protocol = @import("./protocol.zig");
const Global = bun.Global;
const FileSystem = @import("../../fs.zig").FileSystem;
var path_buf: bun.PathBuffer = undefined;
var path_buf2: bun.PathBuffer = undefined;
pub fn getStateDir(allocator: std.mem.Allocator, cwd: []const u8) ![]const u8 {
const hash = std.hash.Wyhash.hash(0, cwd);
if (Environment.isLinux or Environment.isMac) {
return try std.fmt.allocPrint(allocator, "/tmp/bun-pm-{x}", .{hash});
} else if (Environment.isWindows) {
const temp = try std.process.getEnvVarOwned(allocator, "TEMP");
defer allocator.free(temp);
return try std.fmt.allocPrint(allocator, "{s}\\bun-pm-{x}", .{ temp, hash });
}
unreachable;
}
pub fn getLogDir(allocator: std.mem.Allocator, cwd: []const u8) ![]const u8 {
const hash = std.hash.Wyhash.hash(0, cwd);
if (Environment.isLinux or Environment.isMac) {
return try std.fmt.allocPrint(allocator, "/tmp/bun-logs/{x}", .{hash});
} else if (Environment.isWindows) {
const temp = try std.process.getEnvVarOwned(allocator, "TEMP");
defer allocator.free(temp);
return try std.fmt.allocPrint(allocator, "{s}\\bun-logs\\{x}", .{ temp, hash });
}
unreachable;
}
pub const ManagedProcess = struct {
name: []const u8,
script: []const u8,
pid: i32,
cwd: []const u8,
start_time: i64,
stdout_path: []const u8,
stderr_path: []const u8,
allocator: std.mem.Allocator,
pub fn deinit(this: *ManagedProcess) void {
this.allocator.free(this.name);
this.allocator.free(this.script);
this.allocator.free(this.cwd);
this.allocator.free(this.stdout_path);
this.allocator.free(this.stderr_path);
}
};
pub const ProcessManager = struct {
processes: std.StringHashMap(ManagedProcess),
allocator: std.mem.Allocator,
state_dir: []const u8,
log_dir: []const u8,
cwd: []const u8,
pub fn init(allocator: std.mem.Allocator, cwd: []const u8) !*ProcessManager {
const state_dir = try getStateDir(allocator, cwd);
const log_dir = try getLogDir(allocator, cwd);
// Create directories
std.fs.makeDirAbsolute(state_dir) catch |err| {
if (err != error.PathAlreadyExists) return err;
};
std.fs.makeDirAbsolute(log_dir) catch |err| {
if (err != error.PathAlreadyExists) return err;
};
const manager = try allocator.create(ProcessManager);
manager.* = .{
.processes = std.StringHashMap(ManagedProcess).init(allocator),
.allocator = allocator,
.state_dir = state_dir,
.log_dir = log_dir,
.cwd = try allocator.dupe(u8, cwd),
};
// Try to load existing state
manager.loadState() catch {
// Ignore errors on first load
};
return manager;
}
pub fn deinit(this: *ProcessManager) void {
var it = this.processes.valueIterator();
while (it.next()) |proc| {
proc.deinit();
}
this.processes.deinit();
this.allocator.free(this.state_dir);
this.allocator.free(this.log_dir);
this.allocator.free(this.cwd);
this.allocator.destroy(this);
}
pub fn startProcess(
this: *ProcessManager,
name: []const u8,
script: []const u8,
cwd: []const u8,
) !void {
// Check if already running
if (this.processes.get(name)) |_| {
return error.ProcessAlreadyExists;
}
// Create log files
const stdout_path = try std.fmt.allocPrint(
this.allocator,
"{s}/{s}-stdout.log",
.{ this.log_dir, name },
);
errdefer this.allocator.free(stdout_path);
const stderr_path = try std.fmt.allocPrint(
this.allocator,
"{s}/{s}-stderr.log",
.{ this.log_dir, name },
);
errdefer this.allocator.free(stderr_path);
// Create/truncate log files
const stdout_file = try std.fs.createFileAbsolute(stdout_path, .{ .truncate = true });
stdout_file.close();
const stderr_file = try std.fs.createFileAbsolute(stderr_path, .{ .truncate = true });
stderr_file.close();
// Spawn the process
const pid = try this.spawnProcess(script, cwd, stdout_path, stderr_path);
const proc = ManagedProcess{
.name = try this.allocator.dupe(u8, name),
.script = try this.allocator.dupe(u8, script),
.pid = pid,
.cwd = try this.allocator.dupe(u8, cwd),
.start_time = std.time.timestamp(),
.stdout_path = stdout_path,
.stderr_path = stderr_path,
.allocator = this.allocator,
};
try this.processes.put(proc.name, proc);
try this.saveState();
}
fn spawnProcess(
this: *ProcessManager,
script: []const u8,
cwd: []const u8,
stdout_path: []const u8,
stderr_path: []const u8,
) !i32 {
_ = this;
if (comptime Environment.isWindows) {
// Windows implementation
return error.NotImplementedOnWindows;
}
// Open log files
const stdout_fd = try std.posix.open(
stdout_path,
.{ .ACCMODE = .WRONLY, .CREAT = true, .APPEND = true },
0o644,
);
errdefer std.posix.close(stdout_fd);
const stderr_fd = try std.posix.open(
stderr_path,
.{ .ACCMODE = .WRONLY, .CREAT = true, .APPEND = true },
0o644,
);
errdefer std.posix.close(stderr_fd);
// Fork the process
const pid = try std.posix.fork();
if (pid == 0) {
// Child process
// Redirect stdout and stderr
std.posix.dup2(stdout_fd, std.posix.STDOUT_FILENO) catch std.process.exit(1);
std.posix.dup2(stderr_fd, std.posix.STDERR_FILENO) catch std.process.exit(1);
// Close original file descriptors
std.posix.close(stdout_fd);
std.posix.close(stderr_fd);
// Change directory
std.posix.chdir(cwd) catch std.process.exit(1);
// Prepare arguments for bun run
// We need a null-terminated version of script
const script_z = std.posix.toPosixPath(script) catch std.process.exit(1);
const argv = [_:null]?[*:0]const u8{
"bun",
"run",
&script_z,
null,
};
// Get bun path
const bun_path = bun.selfExePath() catch std.process.exit(1);
// Execute
_ = std.posix.execveZ(
bun_path.ptr,
@ptrCast(&argv),
@ptrCast(@extern(*[*:null]const ?[*:0]const u8, .{ .name = "environ" })),
) catch std.process.exit(1);
// If execve returns, it failed
std.process.exit(1);
}
// Parent process
std.posix.close(stdout_fd);
std.posix.close(stderr_fd);
return @intCast(pid);
}
pub fn stopProcess(this: *ProcessManager, name: []const u8) !void {
const proc = this.processes.get(name) orelse return error.ProcessNotFound;
// Send SIGTERM
if (comptime !Environment.isWindows) {
std.posix.kill(@intCast(proc.pid), std.posix.SIG.TERM) catch |err| {
if (err != error.ProcessNotFound) {
return err;
}
};
}
// Remove from map
var entry = this.processes.fetchRemove(name).?;
entry.value.deinit();
try this.saveState();
}
pub fn listProcesses(this: *ProcessManager, allocator: std.mem.Allocator) ![]Protocol.ProcessInfo {
const list = try allocator.alloc(Protocol.ProcessInfo, this.processes.count());
var it = this.processes.valueIterator();
var i: usize = 0;
while (it.next()) |proc| : (i += 1) {
const uptime = std.time.timestamp() - proc.start_time;
list[i] = .{
.name = proc.name,
.pid = proc.pid,
.script = proc.script,
.uptime = uptime,
};
}
return list;
}
pub fn getLogPaths(this: *ProcessManager, name: []const u8) !Protocol.Response {
const proc = this.processes.get(name) orelse return error.ProcessNotFound;
return Protocol.Response{
.log_path = .{
.stdout = proc.stdout_path,
.stderr = proc.stderr_path,
},
};
}
fn saveState(this: *ProcessManager) !void {
const state_path = try std.fmt.bufPrint(&path_buf, "{s}/state.json", .{this.state_dir});
var file = try std.fs.createFileAbsolute(state_path, .{ .truncate = true });
defer file.close();
var buffered_writer = std.io.bufferedWriter(file.writer());
const writer = buffered_writer.writer();
try writer.writeAll("{\n \"processes\": [\n");
var it = this.processes.valueIterator();
var first = true;
while (it.next()) |proc| {
if (!first) {
try writer.writeAll(",\n");
}
first = false;
try writer.writeAll(" {\n");
try std.json.stringify(.{
.name = proc.name,
.script = proc.script,
.pid = proc.pid,
.cwd = proc.cwd,
.start_time = proc.start_time,
.stdout_path = proc.stdout_path,
.stderr_path = proc.stderr_path,
}, .{}, writer);
try writer.writeAll("\n }");
}
try writer.writeAll("\n ]\n}\n");
try buffered_writer.flush();
}
fn loadState(this: *ProcessManager) !void {
const state_path = try std.fmt.bufPrint(&path_buf, "{s}/state.json", .{this.state_dir});
const file = std.fs.openFileAbsolute(state_path, .{}) catch |err| {
if (err == error.FileNotFound) return;
return err;
};
defer file.close();
const contents = try file.readToEndAlloc(this.allocator, 1024 * 1024);
defer this.allocator.free(contents);
const State = struct {
processes: []struct {
name: []const u8,
script: []const u8,
pid: i32,
cwd: []const u8,
start_time: i64,
stdout_path: []const u8,
stderr_path: []const u8,
},
};
const parsed = try std.json.parseFromSlice(State, this.allocator, contents, .{});
defer parsed.deinit();
// Verify each process is still running
for (parsed.value.processes) |proc_data| {
const is_running = blk: {
if (comptime Environment.isWindows) {
break :blk false;
} else {
// Check if process exists by sending signal 0
std.posix.kill(@intCast(proc_data.pid), 0) catch {
break :blk false;
};
break :blk true;
}
};
if (is_running) {
const proc = ManagedProcess{
.name = try this.allocator.dupe(u8, proc_data.name),
.script = try this.allocator.dupe(u8, proc_data.script),
.pid = proc_data.pid,
.cwd = try this.allocator.dupe(u8, proc_data.cwd),
.start_time = proc_data.start_time,
.stdout_path = try this.allocator.dupe(u8, proc_data.stdout_path),
.stderr_path = try this.allocator.dupe(u8, proc_data.stderr_path),
.allocator = this.allocator,
};
try this.processes.put(proc.name, proc);
}
}
}
pub fn cleanup(this: *ProcessManager) !void {
// Clean up dead processes
var to_remove = std.ArrayList([]const u8).init(this.allocator);
defer to_remove.deinit();
var it = this.processes.iterator();
while (it.next()) |entry| {
const is_running = blk: {
if (comptime Environment.isWindows) {
break :blk false;
} else {
std.posix.kill(@intCast(entry.value_ptr.pid), 0) catch {
break :blk false;
};
break :blk true;
}
};
if (!is_running) {
try to_remove.append(entry.key_ptr.*);
}
}
for (to_remove.items) |name| {
var entry = this.processes.fetchRemove(name).?;
entry.value.deinit();
}
if (to_remove.items.len > 0) {
try this.saveState();
}
}
};

View File

@@ -0,0 +1,38 @@
const std = @import("std");
pub const Command = union(enum) {
start: struct {
name: []const u8,
script: []const u8,
cwd: []const u8,
},
stop: struct {
name: []const u8,
},
list: void,
logs: struct {
name: []const u8,
follow: bool,
},
};
pub const Response = union(enum) {
success: struct {
message: []const u8,
},
err: struct {
message: []const u8,
},
process_list: []ProcessInfo,
log_path: struct {
stdout: []const u8,
stderr: []const u8,
},
};
pub const ProcessInfo = struct {
name: []const u8,
pid: i32,
script: []const u8,
uptime: i64,
};

View File

@@ -0,0 +1,67 @@
const std = @import("std");
const bun = @import("bun");
const Command = @import("../cli.zig").Command;
const strings = bun.strings;
const Output = bun.Output;
const Global = bun.Global;
const Manager = @import("./process_manager/manager.zig");
const Client = @import("./process_manager/client.zig");
const Protocol = @import("./process_manager/protocol.zig");
pub const ProcessManagerCommand = struct {
pub fn exec(ctx: Command.Context) !void {
const args = ctx.positionals;
if (args.len == 0) {
printHelp();
Global.exit(1);
}
const subcommand = args[0];
if (strings.eqlComptime(subcommand, "start")) {
try Client.startCommand(ctx);
} else if (strings.eqlComptime(subcommand, "stop")) {
try Client.stopCommand(ctx);
} else if (strings.eqlComptime(subcommand, "list")) {
try Client.listCommand(ctx);
} else if (strings.eqlComptime(subcommand, "logs")) {
try Client.logsCommand(ctx);
} else {
Output.errGeneric("Unknown subcommand: {s}", .{subcommand});
printHelp();
Global.exit(1);
}
}
pub fn printHelp() void {
const help_text =
\\<b>Usage:<r>
\\
\\ <b><green>bun start<r> <cyan>SCRIPT<r>
\\ Start a process in the background
\\
\\ <b><green>bun stop<r> <cyan>NAME<r>
\\ Stop a running process
\\
\\ <b><green>bun list<r>
\\ List all running processes in this workspace
\\
\\ <b><green>bun logs<r> <cyan>NAME<r>
\\ Show logs for a process
\\
\\<b>Examples:<r>
\\
\\ bun start dev # Start "dev" from package.json
\\ bun start ./server.js # Start a file directly
\\ bun list # See what's running
\\ bun logs dev # View dev logs
\\ bun stop dev # Stop dev process
\\
;
Output.pretty(help_text, .{});
Output.flush();
}
};

View File

@@ -0,0 +1,531 @@
import { spawnSync } from "bun";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import * as path from "node:path";
describe("bun process manager", () => {
let testDir: ReturnType<typeof tempDir>;
beforeEach(() => {
testDir = tempDir("process-manager", {});
});
afterEach(() => {
// Clean up any running processes
const { exitCode } = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// If there are processes, stop them
if (exitCode === 0) {
const listResult = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const output = listResult.stdout.toString();
const lines = output.split("\n");
// Find process names from the output
for (const line of lines) {
const match = line.match(/^(\S+)\s+\d+/);
if (match && match[1] && match[1] !== "NAME") {
spawnSync({
cmd: [bunExe(), "stop", match[1]],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
}
}
}
});
test("start a simple script", async () => {
await Bun.write(
path.join(String(testDir), "server.js"),
`
console.log("Server started");
setInterval(() => {
console.log("Server running...");
}, 1000);
`,
);
const { exitCode, stdout, stderr } = spawnSync({
cmd: [bunExe(), "start", "./server.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).toBe(0);
expect(stdout.toString()).toContain("Started");
expect(stdout.toString()).toContain("server.js");
// Give it a moment to start
await Bun.sleep(100);
// Verify the process is running
const listResult = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(listResult.exitCode).toBe(0);
expect(listResult.stdout.toString()).toContain("server.js");
});
test("list shows running processes", async () => {
await Bun.write(path.join(String(testDir), "worker1.js"), `setInterval(() => {}, 1000);`);
await Bun.write(path.join(String(testDir), "worker2.js"), `setInterval(() => {}, 1000);`);
// Start two processes
spawnSync({
cmd: [bunExe(), "start", "./worker1.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
spawnSync({
cmd: [bunExe(), "start", "./worker2.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
await Bun.sleep(100);
const { exitCode, stdout } = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).toBe(0);
const output = stdout.toString();
expect(output).toContain("worker1.js");
expect(output).toContain("worker2.js");
expect(output).toContain("NAME");
expect(output).toContain("PID");
expect(output).toContain("UPTIME");
});
test("stop a running process", async () => {
await Bun.write(path.join(String(testDir), "stoppable.js"), `setInterval(() => {}, 1000);`);
// Start process
spawnSync({
cmd: [bunExe(), "start", "./stoppable.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
await Bun.sleep(100);
// Stop process
const stopResult = spawnSync({
cmd: [bunExe(), "stop", "./stoppable.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(stopResult.exitCode).toBe(0);
expect(stopResult.stdout.toString()).toContain("Stopped");
// Verify it's not in the list anymore
const listResult = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(listResult.stdout.toString()).not.toContain("stoppable.js");
});
test("logs command shows log paths", async () => {
await Bun.write(
path.join(String(testDir), "logger.js"),
`
console.log("stdout message");
console.error("stderr message");
setInterval(() => {}, 1000);
`,
);
// Start process
spawnSync({
cmd: [bunExe(), "start", "./logger.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
await Bun.sleep(200);
// Get logs
const logsResult = spawnSync({
cmd: [bunExe(), "logs", "./logger.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(logsResult.exitCode).toBe(0);
const output = logsResult.stdout.toString();
expect(output).toContain("stdout");
expect(output).toContain("stderr");
expect(output).toContain("/tmp/bun-logs/");
});
test("cannot start the same process twice", async () => {
await Bun.write(path.join(String(testDir), "unique.js"), `setInterval(() => {}, 1000);`);
// Start process
const first = spawnSync({
cmd: [bunExe(), "start", "./unique.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(first.exitCode).toBe(0);
// Try to start again
const second = spawnSync({
cmd: [bunExe(), "start", "./unique.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(second.exitCode).not.toBe(0);
expect(second.stderr.toString()).toContain("already running");
});
test("stop non-existent process fails", () => {
const { exitCode, stderr } = spawnSync({
cmd: [bunExe(), "stop", "nonexistent"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).not.toBe(0);
expect(stderr.toString()).toContain("not found");
});
test("logs for non-existent process fails", () => {
const { exitCode, stderr } = spawnSync({
cmd: [bunExe(), "logs", "nonexistent"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).not.toBe(0);
expect(stderr.toString()).toContain("not found");
});
test("list with no processes", () => {
const { exitCode, stdout } = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).toBe(0);
expect(stdout.toString()).toContain("No processes running");
});
test("process output is captured to logs", async () => {
await Bun.write(
path.join(String(testDir), "output.js"),
`
console.log("test output line 1");
console.log("test output line 2");
console.error("test error line 1");
setInterval(() => {}, 1000);
`,
);
// Start process
spawnSync({
cmd: [bunExe(), "start", "./output.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// Wait for output
await Bun.sleep(300);
// Get logs
const logsResult = spawnSync({
cmd: [bunExe(), "logs", "./output.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(logsResult.exitCode).toBe(0);
const output = logsResult.stdout.toString();
expect(output).toContain("test output line 1");
expect(output).toContain("test output line 2");
expect(output).toContain("test error line 1");
});
test("start script from package.json", async () => {
await Bun.write(
path.join(String(testDir), "package.json"),
JSON.stringify({
name: "test",
scripts: {
dev: "bun run ./script.js",
},
}),
);
await Bun.write(
path.join(String(testDir), "script.js"),
`
console.log("Running from package.json script");
setInterval(() => {}, 1000);
`,
);
const { exitCode, stdout } = spawnSync({
cmd: [bunExe(), "start", "dev"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).toBe(0);
expect(stdout.toString()).toContain("Started");
await Bun.sleep(100);
const listResult = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(listResult.exitCode).toBe(0);
expect(listResult.stdout.toString()).toContain("dev");
});
test("workspace isolation - processes in different dirs don't interfere", async () => {
const dir1 = tempDir("process-manager-1", {});
const dir2 = tempDir("process-manager-2", {});
try {
await Bun.write(path.join(String(dir1), "app.js"), `setInterval(() => {}, 1000);`);
await Bun.write(path.join(String(dir2), "app.js"), `setInterval(() => {}, 1000);`);
// Start process in dir1
spawnSync({
cmd: [bunExe(), "start", "./app.js"],
cwd: String(dir1),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// Start process in dir2
spawnSync({
cmd: [bunExe(), "start", "./app.js"],
cwd: String(dir2),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
await Bun.sleep(100);
// List in dir1 should only show its process
const list1 = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(dir1),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// List in dir2 should only show its process
const list2 = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(dir2),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// Both should show exactly one process
const lines1 = list1.stdout
.toString()
.split("\n")
.filter(l => l.includes("app.js"));
const lines2 = list2.stdout
.toString()
.split("\n")
.filter(l => l.includes("app.js"));
expect(lines1.length).toBe(1);
expect(lines2.length).toBe(1);
// Cleanup
spawnSync({
cmd: [bunExe(), "stop", "./app.js"],
cwd: String(dir1),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
spawnSync({
cmd: [bunExe(), "stop", "./app.js"],
cwd: String(dir2),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
} finally {
// Cleanup dirs
}
});
test("help text is shown when no subcommand", () => {
const { exitCode, stdout } = spawnSync({
cmd: [bunExe(), "start"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).not.toBe(0);
// The help should be printed by the main command handler
});
test("uptime is tracked correctly", async () => {
await Bun.write(path.join(String(testDir), "timed.js"), `setInterval(() => {}, 1000);`);
// Start process
spawnSync({
cmd: [bunExe(), "start", "./timed.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// Wait a bit
await Bun.sleep(2000);
// Check list
const listResult = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(listResult.exitCode).toBe(0);
const output = listResult.stdout.toString();
// Should show at least 1 second of uptime
expect(output).toMatch(/\d+s/);
});
test("process manager persists state across commands", async () => {
await Bun.write(path.join(String(testDir), "persistent.js"), `setInterval(() => {}, 1000);`);
// Start process
spawnSync({
cmd: [bunExe(), "start", "./persistent.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
await Bun.sleep(100);
// Multiple list calls should all see the same process
for (let i = 0; i < 3; i++) {
const listResult = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(listResult.exitCode).toBe(0);
expect(listResult.stdout.toString()).toContain("persistent.js");
await Bun.sleep(50);
}
// Stop should work
const stopResult = spawnSync({
cmd: [bunExe(), "stop", "./persistent.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(stopResult.exitCode).toBe(0);
// And it should be gone
const finalList = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(finalList.stdout.toString()).not.toContain("persistent.js");
});
});