Skip to content
Open
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
5 changes: 5 additions & 0 deletions examples/basic/.commando/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"private": true,
"type": "module",
"dependencies": {}
}
116 changes: 116 additions & 0 deletions lib/bun-proc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* Copyright 2025 Jason Dillon
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* Bun Process Management
*
* Provides utilities for running bun commands, including the ability
* to use a compiled binary as a bun runtime via BUN_BE_BUN=1.
*
* Inspired by OpenCode's packages/opencode/src/bun/index.ts
*/

import { spawn, type SpawnOptions } from 'bun';
import { createLogger } from './logging';

const log = createLogger('bun-proc');

export interface BunRunOptions {
cwd?: string;
env?: Record<string, string>;
quiet?: boolean;
}

export interface BunRunResult {
exitCode: number;
stdout: string;
stderr: string;
}

/**
* Get the path to use as the bun executable
*
* Returns process.execPath which:
* - In development: /path/to/bun
* - In compiled binary: /path/to/cmdo (which IS bun)
*/
export function bunPath(): string {
return process.execPath;
}

/**
* Run a bun command
*
* Uses BUN_BE_BUN=1 to ensure compiled binaries act as bun runtime.
*
* @param cmd - Command and arguments (e.g., ['add', 'cowsay'])
* @param options - Spawn options
* @returns Promise with exit code, stdout, stderr
* @throws Error if command fails (non-zero exit)
*/
export async function bunRun(cmd: string[], options: BunRunOptions = {}): Promise<BunRunResult> {
const bunExe = bunPath();
const fullCmd = [bunExe, ...cmd];

log.debug({ cmd: fullCmd, cwd: options.cwd }, 'Running bun command');

const proc = spawn(fullCmd, {
cwd: options.cwd,
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
...options.env,
// Critical: Makes compiled binary act as bun runtime
BUN_BE_BUN: '1',
},
});

const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();

log.debug({ exitCode, stdout: stdout.slice(0, 200), stderr: stderr.slice(0, 200) }, 'Bun command complete');

if (exitCode !== 0 && !options.quiet) {
throw new Error(`Bun command failed with exit code ${exitCode}: ${cmd.join(' ')}\n${stderr}`);
}

return { exitCode, stdout, stderr };
}

/**
* Run `bun add` to install a package
*
* @param pkg - Package to install (e.g., 'cowsay' or 'cowsay@1.0.0')
* @param options - Additional options
*/
export async function bunAdd(pkg: string, options: BunRunOptions & { exact?: boolean } = {}): Promise<BunRunResult> {
const cmd = ['add', pkg];
if (options.exact) {
cmd.push('--exact');
}
return bunRun(cmd, options);
}

/**
* Run `bun install` to install dependencies from package.json
*
* @param options - Additional options
*/
export async function bunInstall(options: BunRunOptions = {}): Promise<BunRunResult> {
return bunRun(['install'], options);
}
67 changes: 21 additions & 46 deletions lib/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@
*/

import { Command } from 'commander';
import { pathToFileURL } from 'url';
import { StateManager } from './state';
import { die, ExitNotification } from './helpers';
import { getLoggerConfig, createLogger } from './logging';
import { rewriteModulePath, symlinkCommandoDir } from './module-symlink';
import { resolveModule } from './module-resolver';
import { autoInstallDependencies, RESTART_EXIT_CODE } from './auto-install';
import { ensureProjectDeps } from './project-deps';
import * as builtins from './builtins';
import type { Logger } from './logging';
import type {
Expand All @@ -30,6 +30,11 @@ import type {
CommandoContext,
} from './types';

// Keep for backward compatibility during transition
// TODO: Remove after confirming new approach works
import { rewriteModulePath, symlinkCommandoDir } from './module-symlink';
import { autoInstallDependencies, RESTART_EXIT_CODE } from './auto-install';

const log = createLogger('core');

// ============================================================================
Expand Down Expand Up @@ -159,20 +164,9 @@ export async function loadModule(

log.debug({ durationMs: resolveDuration, fullPath }, 'Module path resolved');

// Rewrite path to go through symlink in node_modules
// This ensures user commands import commando from the correct instance
// Requires bun --preserve-symlinks
const symlinkStart = Date.now();
const symlinkPath = await rewriteModulePath(fullPath, commandoDir);
const symlinkDuration = Date.now() - symlinkStart;

log.debug({
durationMs: symlinkDuration,
original: fullPath,
symlinked: symlinkPath
}, 'Path rewritten through symlink');

const url = import.meta.resolve(symlinkPath, import.meta.url);
// Convert to file:// URL for dynamic import
// With per-project node_modules, Bun resolves dependencies relative to the file
const url = pathToFileURL(fullPath).href;
log.debug({ url }, 'Import URL computed');

const importStart = Date.now();
Expand Down Expand Up @@ -307,36 +301,17 @@ export class Commando {
commandoDir: this.config.commandoDir
}, 'Project context available');

// 3. Setup symlink for .commando directory
log.debug('Phase 2: Setting up .commando symlink');
const symlinkStart = Date.now();
await symlinkCommandoDir(this.config.commandoDir);
const symlinkDuration = Date.now() - symlinkStart;

log.debug({ durationMs: symlinkDuration }, 'Symlink setup complete');

// 4. Auto-install dependencies if needed
if (this.config.dependencies && this.config.dependencies.length > 0) {
log.debug({ count: this.config.dependencies.length }, 'Phase 3: Checking dependencies');
const depsStart = Date.now();

const needsRestart = await autoInstallDependencies(
this.config,
this.config.commandoDir,
this.config.isRestarted,
);

const depsDuration = Date.now() - depsStart;
log.debug({ durationMs: depsDuration, needsRestart }, 'Dependency check complete');

if (needsRestart) {
log.debug('Dependencies installed, requesting restart');
// Signal restart via ExitNotification
throw new ExitNotification(RESTART_EXIT_CODE);
}
} else {
log.debug('No dependencies declared, skipping');
}
// 3. Ensure per-project dependencies (replaces symlink hack)
log.debug('Phase 2: Setting up per-project dependencies');
const depsStart = Date.now();

await ensureProjectDeps(
this.config.commandoDir,
this.config.dependencies,
);

const depsDuration = Date.now() - depsStart;
log.debug({ durationMs: depsDuration }, 'Project dependencies ready');

// 5. Load user modules
if (this.config.modules && this.config.modules.length > 0) {
Expand Down
Loading
Loading