diff --git a/examples/basic/.commando/package.json b/examples/basic/.commando/package.json new file mode 100644 index 0000000..7ee9e34 --- /dev/null +++ b/examples/basic/.commando/package.json @@ -0,0 +1,5 @@ +{ + "private": true, + "type": "module", + "dependencies": {} +} diff --git a/lib/bun-proc.ts b/lib/bun-proc.ts new file mode 100644 index 0000000..8161eb6 --- /dev/null +++ b/lib/bun-proc.ts @@ -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; + 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 { + 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 { + 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 { + return bunRun(['install'], options); +} diff --git a/lib/core.ts b/lib/core.ts index 3188407..abf0b84 100644 --- a/lib/core.ts +++ b/lib/core.ts @@ -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 { @@ -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'); // ============================================================================ @@ -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(); @@ -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) { diff --git a/lib/project-deps.ts b/lib/project-deps.ts new file mode 100644 index 0000000..d2f56ed --- /dev/null +++ b/lib/project-deps.ts @@ -0,0 +1,275 @@ +/* + * 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. + */ + +/** + * Per-Project Dependency Management + * + * Manages per-project node_modules in .commando/ directories, + * similar to OpenCode's .opencode/ pattern. + * + * Key features: + * - Auto-creates package.json and .gitignore + * - Ensures @planet57/commando SDK is available (installed from npm) + * - Installs project-specific dependencies + * - Works with both compiled binary and dev mode + */ + +import { join } from 'path'; +import { existsSync } from 'fs'; +import { writeFile, mkdir, readFile, symlink, readlink, rm } from 'fs/promises'; +import { bunAdd, bunInstall } from './bun-proc'; +import { createLogger } from './logging'; +import pkg from '../package.json'; + +const log = createLogger('project-deps'); + +/** + * Files to exclude from git in .commando/ + */ +const GITIGNORE_CONTENT = `# Auto-generated by commando +node_modules/ +bun.lock +package.json +`; + +/** + * Detect if running from a compiled Bun binary + * + * Compiled binaries have import.meta.url starting with /$bunfs/ + */ +export function isCompiledBinary(): boolean { + return import.meta.url.startsWith('file:///$bunfs/'); +} + +/** + * Ensure a project's .commando/ directory has proper dependency setup + * + * This function: + * 1. Creates package.json if missing + * 2. Creates .gitignore if missing + * 3. Ensures @planet57/commando is available in node_modules + * 4. Installs any declared dependencies + * + * @param commandoDir - Path to .commando/ directory + * @param dependencies - Optional list of dependencies to install + */ +export async function ensureProjectDeps( + commandoDir: string, + dependencies?: string[], +): Promise { + log.debug({ commandoDir, depCount: dependencies?.length, isCompiled: isCompiledBinary() }, 'Ensuring project dependencies'); + + // 1. Ensure package.json exists + await ensurePackageJson(commandoDir); + + // 2. Ensure .gitignore exists + await ensureGitignore(commandoDir); + + // 3. Ensure commando SDK is available + await ensureCommandoSdk(commandoDir); + + // 4. Install declared dependencies + if (dependencies && dependencies.length > 0) { + await installDependencies(commandoDir, dependencies); + } +} + +/** + * Create package.json if it doesn't exist + */ +async function ensurePackageJson(commandoDir: string): Promise { + const pkgPath = join(commandoDir, 'package.json'); + + if (existsSync(pkgPath)) { + log.debug({ pkgPath }, 'package.json already exists'); + return; + } + + // Ensure the .commando directory exists + if (!existsSync(commandoDir)) { + log.debug({ commandoDir }, 'Creating .commando directory'); + await mkdir(commandoDir, { recursive: true }); + } + + log.debug({ pkgPath }, 'Creating package.json'); + + const content = { + private: true, + type: 'module', + dependencies: {}, + }; + + await writeFile(pkgPath, JSON.stringify(content, null, 2) + '\n'); +} + +/** + * Create .gitignore if it doesn't exist + */ +async function ensureGitignore(commandoDir: string): Promise { + const gitignorePath = join(commandoDir, '.gitignore'); + + if (existsSync(gitignorePath)) { + log.debug({ gitignorePath }, '.gitignore already exists'); + return; + } + + log.debug({ gitignorePath }, 'Creating .gitignore'); + await writeFile(gitignorePath, GITIGNORE_CONTENT); +} + +/** + * Ensure @planet57/commando is available in the project's node_modules + * + * Strategy depends on runtime mode: + * - Compiled binary: Install from npm (can't symlink to embedded code) + * - Dev mode: Symlink to source for fast iteration + */ +async function ensureCommandoSdk(commandoDir: string): Promise { + const targetDir = join(commandoDir, 'node_modules', '@planet57'); + const targetPath = join(targetDir, 'commando'); + + if (isCompiledBinary()) { + // Compiled mode: install from npm + await ensureCommandoSdkFromNpm(commandoDir, targetPath); + } else { + // Dev mode: symlink to source + await ensureCommandoSdkSymlink(commandoDir, targetDir, targetPath); + } +} + +/** + * Install @planet57/commando from npm + * Used when running as compiled binary + */ +async function ensureCommandoSdkFromNpm(commandoDir: string, targetPath: string): Promise { + // Check if already installed with correct version + const pkgJsonPath = join(targetPath, 'package.json'); + if (existsSync(pkgJsonPath)) { + try { + const installedPkg = JSON.parse(await readFile(pkgJsonPath, 'utf-8')); + if (installedPkg.version === pkg.version) { + log.debug({ version: pkg.version }, 'Commando SDK already installed at correct version'); + return; + } + log.debug({ installed: installedPkg.version, required: pkg.version }, 'Commando SDK version mismatch, updating'); + } catch (err) { + log.debug({ error: err }, 'Could not read installed commando version'); + } + } + + // Install from npm + log.info({ version: pkg.version }, 'Installing @planet57/commando from npm'); + try { + await bunAdd(`@planet57/commando@${pkg.version}`, { cwd: commandoDir, exact: true }); + } catch (err: any) { + // If package not published yet, warn but continue + if (err.message?.includes('404')) { + log.warn({ version: pkg.version }, '@planet57/commando not found on npm - package may not be published yet'); + log.warn('Project commands may fail to import from @planet57/commando'); + return; + } + throw err; + } +} + +/** + * Symlink to commando source + * Used in dev mode for fast iteration + */ +async function ensureCommandoSdkSymlink(commandoDir: string, targetDir: string, targetPath: string): Promise { + // Find where commando source is (the package containing this file) + const thisFile = new URL(import.meta.url).pathname; + const libDir = join(thisFile, '..'); + const commandoRoot = join(libDir, '..'); + + // Check if symlink exists and points to the right place + if (existsSync(targetPath)) { + try { + const currentTarget = await readlink(targetPath); + if (currentTarget === commandoRoot) { + log.debug({ targetPath }, 'Commando SDK symlink correct'); + return; + } + // Wrong target - remove and recreate + log.debug({ targetPath, currentTarget, expectedTarget: commandoRoot }, 'Symlink points to wrong location, recreating'); + await rm(targetPath, { recursive: true }); + } catch (err: any) { + // Not a symlink or can't read - remove and recreate + log.debug({ targetPath, error: err.code }, 'Cannot verify symlink, recreating'); + await rm(targetPath, { recursive: true, force: true }); + } + } + + log.debug({ commandoRoot, targetPath }, 'Creating symlink to commando source'); + + // Create @planet57 directory + await mkdir(targetDir, { recursive: true }); + + // Create symlink + await symlink(commandoRoot, targetPath, 'dir'); + log.debug({ from: commandoRoot, to: targetPath }, 'Symlink created'); +} + +/** + * Install dependencies to the project's node_modules + */ +async function installDependencies(commandoDir: string, dependencies: string[]): Promise { + log.debug({ commandoDir, dependencies }, 'Checking dependencies'); + + // Read current package.json to check what's already installed + const pkgPath = join(commandoDir, 'package.json'); + const pkgContent = await readFile(pkgPath, 'utf-8'); + const pkgJson = JSON.parse(pkgContent); + const installed = pkgJson.dependencies || {}; + + // Find missing dependencies + const missing = dependencies.filter(dep => { + const name = parseDependencyName(dep); + return !installed[name]; + }); + + if (missing.length === 0) { + log.debug('All dependencies already installed'); + return; + } + + log.info({ missing }, 'Installing missing dependencies'); + + // Install each missing dependency + for (const dep of missing) { + await bunAdd(dep, { cwd: commandoDir }); + } +} + +/** + * Parse dependency string to extract package name + * Examples: + * "lodash@^4.0.0" → "lodash" + * "@aws-sdk/client-s3@^3.0.0" → "@aws-sdk/client-s3" + */ +function parseDependencyName(dep: string): string { + // Handle scoped packages + if (dep.startsWith('@')) { + const parts = dep.split('/'); + if (parts.length >= 2) { + const scopeAndName = parts[0] + '/' + parts[1].split('@')[0]; + return scopeAndName; + } + } + + // Regular packages + return dep.split('@')[0]; +} diff --git a/sandbox/opencode-plugin-system.md b/sandbox/opencode-plugin-system.md new file mode 100644 index 0000000..3a4ed04 --- /dev/null +++ b/sandbox/opencode-plugin-system.md @@ -0,0 +1,317 @@ +# OpenCode-Style Plugin System for CommanDO + +**Date**: 2025-01-04 +**Status**: Ready for review +**Branch**: `feature/per-project-deps` + +--- + +## Overview + +Adopt OpenCode's architecture pattern for CommanDO: + +1. **Compiled binary** - Single executable bundles CommanDO + Bun runtime +2. **Per-directory plugins** - Each `.commando/` has its own `node_modules/` +3. **User-global + project** - `~/.commando/` for user commands, `.commando/` for project commands +4. **No npm publishing** - Private ecosystem using git refs + +--- + +## Target Architecture + +``` +~/.commando/ # User-global plugins +├── config.yml # User's modules, settings +├── package.json # User's dependencies +├── node_modules/ +│ ├── some-shared-tool/ +│ └── @planet57/commando/ # SDK via git ref +└── my-global-cmd.ts + +project/.commando/ # Project-specific plugins +├── config.yml # Project modules, settings +├── package.json # Project dependencies (auto-managed) +├── .gitignore # Excludes node_modules, bun.lock +├── node_modules/ +│ ├── lodash/ +│ └── @planet57/commando/ # SDK (symlink in dev, git ref in compiled) +└── my-project-cmd.ts +``` + +### Key Principles + +| Aspect | Approach | +|--------|----------| +| **Config format** | `config.yml` for modules/settings, `package.json` for dependencies | +| **SDK distribution** | Git refs (private), no npm publishing | +| **Plugin priority** | Project > User-global (project overrides) | +| **First run** | Auto-install dependencies, "just works" | + +--- + +## Compiled Binary + +CommanDO compiles to a standalone executable using Bun's compile feature: + +```bash +bun build --compile --outfile build/cmdo lib/cli.ts +``` + +The binary: +- **IS the Bun runtime** - No separate Bun installation needed +- **Self-invokes with `BUN_BE_BUN=1`** - Can run `bun add`, `bun install` using itself +- **Embeds all CommanDO code** - Single ~66MB file + +### Self-as-Runtime + +```typescript +// The compiled binary can use itself to run bun commands +const proc = Bun.spawn([process.execPath, 'add', 'cowsay'], { + env: { ...process.env, BUN_BE_BUN: '1' } +}); +``` + +--- + +## SDK Resolution + +How project commands get `@planet57/commando`: + +| Mode | SDK Source | +|------|------------| +| Dev (source) | Symlink to repo root | +| Dev (packaged) | Symlink to dev-home | +| Compiled (release) | `github:jdillon/commando#v2.1.0` | +| Compiled (branch) | `github:jdillon/commando#main` | + +### Version Detection + +Build-time `version.json` determines git ref: + +```json +{ + "version": "2.1.0", + "branch": "main", + "tag": "v2.1.0", + "dirty": false +} +``` + +```typescript +function getSdkGitRef(): string { + if (versionInfo.tag) { + return `github:jdillon/commando#${versionInfo.tag}`; + } + return `github:jdillon/commando#${versionInfo.branch}`; +} +``` + +--- + +## Module Loading + +### Priority Order + +1. **Project modules** (`.commando/config.yml` → `modules:`) +2. **User-global modules** (`~/.commando/config.yml` → `modules:`) + +Project modules with the same group name override user-global. + +### Dependency Resolution + +Modules resolve dependencies from their local `node_modules/`: + +``` +.commando/ +├── node_modules/ +│ └── cowsay/ +└── moo.ts # import cowsay from 'cowsay' → resolves locally +``` + +--- + +## Implementation Plan + +### Phase 1: Module Resolution (Project-Local) + +**Goal**: Dependencies resolve from `commandoDir/node_modules` + +```typescript +// lib/module-resolver.ts - Line 67 +// BEFORE: +const nodeModules = getNodeModulesPath(); // ~/.commando/node_modules + +// AFTER: +const nodeModules = join(commandoDir, 'node_modules'); +``` + +### Phase 2: SDK Git Refs + +**Goal**: Compiled binary installs SDK via git ref + +```typescript +// lib/project-deps.ts +async function ensureCommandoSdkFromGit(commandoDir: string): Promise { + const gitRef = getSdkGitRef(); // github:jdillon/commando#v2.1.0 + await bunAdd(gitRef, { cwd: commandoDir }); +} +``` + +### Phase 3: User-Global Config + +**Goal**: Load `~/.commando/config.yml` and merge with project + +```typescript +// lib/config-resolver.ts +async function loadUserGlobalConfig(): Promise> { + const configPath = join(homedir(), '.commando', 'config.yml'); + if (!existsSync(configPath)) return {}; + // Load with cosmiconfig... +} + +// Merge order: defaults → user-global → project +``` + +```typescript +// lib/core.ts - initialize() +// 1. Load user-global modules +// 2. Load project modules (override by group name) +``` + +### Phase 4: Remove Legacy Code + +**Delete**: +- `lib/module-symlink.ts` +- `lib/auto-install.ts` + +**Simplify**: +- `lib/commando-home.ts` - Remove shared deps logic, keep path utilities + +### Phase 5: Update Tests & Examples + +- Update test fixtures to use per-project `node_modules/` +- Update `examples/deps/` to use `package.json` for deps +- Ensure `bun test` passes + +### Phase 6: Install Scripts + +Update `bin/install.sh` to set up user-global structure: + +```bash +mkdir -p ~/.commando +cat > ~/.commando/package.json << 'EOF' +{ + "private": true, + "type": "module", + "dependencies": {} +} +EOF +``` + +--- + +## Files to Change + +### Core Changes + +| File | Action | Description | +|------|--------|-------------| +| `lib/module-resolver.ts` | Modify | Resolve from `commandoDir/node_modules` | +| `lib/project-deps.ts` | Modify | Use git ref for SDK | +| `lib/config-resolver.ts` | Modify | Load user-global config | +| `lib/core.ts` | Modify | Merge user-global + project modules | +| `lib/types.ts` | Modify | Add `userConfigDir` | +| `lib/version.ts` | Modify | Add `tag` field for git ref resolution | + +### Remove + +| File | Reason | +|------|--------| +| `lib/module-symlink.ts` | Legacy symlink hack | +| `lib/auto-install.ts` | Legacy restart-based install | + +### Simplify + +| File | Action | +|------|--------| +| `lib/commando-home.ts` | Remove shared deps logic | + +### Scripts & Tests + +| File | Action | +|------|--------| +| `bin/install.sh` | Update for new structure | +| `bin/cmdo-dev` | Review (may need updates) | +| `tests/install.test.ts` | Update expectations | +| `tests/fixtures/` | Update to new pattern | +| `examples/deps/` | Move deps to package.json | + +--- + +## Testing Strategy + +### Dev Mode (No Binary Needed) + +```bash +# Direct source +bun run lib/cli.ts --root examples/basic basic greet + +# Packaged dev +./bin/cmdo-dev --root examples/basic basic greet + +# Test suite +bun test +``` + +### Compiled Mode (Integration) + +```bash +# Build +bun build --compile --outfile build/cmdo lib/cli.ts + +# Test +./build/cmdo --root examples/basic basic greet +``` + +### Test Matrix + +| Scenario | Dev | Packaged | Compiled | +|----------|-----|----------|----------| +| Project with local modules | ✓ | ✓ | ✓ | +| Project with dependencies | ✓ | ✓ | ✓ | +| User-global modules | ✓ | ✓ | ✓ | +| Project overrides user-global | ✓ | ✓ | ✓ | +| SDK import in commands | ✓ | ✓ | ✓ | + +--- + +## Verification Checklist + +- [ ] `bun test` passes +- [ ] `examples/basic` works in dev mode +- [ ] `examples/deps` works with local `node_modules/` +- [ ] User-global config loads from `~/.commando/config.yml` +- [ ] Project modules override user-global +- [ ] Compiled binary installs SDK via git ref +- [ ] `bin/cmdo-dev` still works +- [ ] `bin/install.sh` sets up new structure +- [ ] No references to removed files + +--- + +## Deferred + +### Offline Mode + +Not in scope. Future enhancement: `config.offline: true` to skip network operations. + +### Multi-Directory Merge + +OpenCode merges all `.opencode/` dirs walking up the tree. CommanDO uses nearest-only for now. + +--- + +## References + +- `sandbox/opencode-runtime-research.md` - How OpenCode implements this pattern +- OpenCode source: `packages/opencode/src/config/config.ts` diff --git a/sandbox/opencode-runtime-research.md b/sandbox/opencode-runtime-research.md new file mode 100644 index 0000000..37f73d9 --- /dev/null +++ b/sandbox/opencode-runtime-research.md @@ -0,0 +1,300 @@ +# OpenCode Runtime Architecture Research + +**Date**: 2025-01-03 +**Purpose**: Document how OpenCode achieves "global install + per-project dependencies" pattern + +--- + +## Overview + +OpenCode is a CLI tool that: +- Installs globally (npm, brew, curl) +- Loads plugins/commands from multiple directories +- Each directory can have its own `node_modules/` +- No symlink tricks needed + +This document explains the key architectural patterns that make this work. + +--- + +## Compiled Bun Binary + +### How It's Built + +OpenCode uses Bun's compile feature to create standalone executables: + +```typescript +// packages/opencode/script/build.ts:125-149 +await Bun.build({ + compile: { + target: "bun-darwin-arm64", // platform-specific + outfile: `dist/${name}/bin/opencode`, + }, + entrypoints: ["./src/index.ts"], + // ... +}) +``` + +This produces a single binary that embeds: +- The Bun runtime +- All of OpenCode's code +- Static assets + +### The Launcher Script + +The `bin/opencode` file distributed via npm is just a Node.js launcher: + +```javascript +// packages/opencode/bin/opencode +#!/usr/bin/env node + +// Finds platform-specific binary in node_modules +const base = "opencode-" + platform + "-" + arch +const binary = path.join(modules, entry, "bin", "opencode") + +// Spawns the real binary +childProcess.spawnSync(resolved, process.argv.slice(2), { stdio: "inherit" }) +``` + +### Self-Referential Bun + +The compiled binary IS a Bun runtime. When OpenCode needs to run `bun add`: + +```typescript +// packages/opencode/src/bun/index.ts:51-53 +export function which() { + return process.execPath // Returns path to the compiled OpenCode binary +} + +// Line 19-27: Uses itself to run bun commands +const result = Bun.spawn([which(), ...cmd], { + env: { + ...process.env, + BUN_BE_BUN: "1", // CRITICAL: Makes compiled binary act as bun runtime + }, +}) +``` + +**Key insight**: Users don't need Bun installed. The OpenCode binary IS Bun. + +### The `BUN_BE_BUN` Environment Variable + +**Critical discovery**: When a Bun-compiled binary spawns itself, it will try to run the embedded program again (infinite loop). Setting `BUN_BE_BUN=1` tells Bun "act like the bun CLI, not like the compiled program." + +Without `BUN_BE_BUN=1`: +```bash +./compiled-binary --version # Hangs - tries to run embedded program +``` + +With `BUN_BE_BUN=1`: +```bash +BUN_BE_BUN=1 ./compiled-binary --version # Returns "1.3.5" - acts as bun +``` + +This is how OpenCode uses itself to run `bun add`, `bun install`, etc. + +**Verified experimentally** in `tmp/compile-test/` - a compiled binary can: +1. Use `process.execPath` to get its own path +2. Spawn itself with `BUN_BE_BUN=1` to run bun commands +3. Successfully execute `bun add` to install packages + +--- + +## Per-Directory Dependencies + +### Directory Scanning + +OpenCode walks up the directory tree looking for `.opencode/` directories: + +```typescript +// packages/opencode/src/config/config.ts:71-87 +const directories = [ + Global.Path.config, // ~/.config/opencode + ...(await Array.fromAsync( + Filesystem.up({ + targets: [".opencode"], // Walk up from cwd + start: Instance.directory, + stop: Instance.worktree, + }), + )), + // ~/.opencode if exists +] +``` + +### Auto-Install per Directory + +For each config directory, dependencies are auto-installed: + +```typescript +// packages/opencode/src/config/config.ts:164-187 +async function installDependencies(dir: string) { + if (Installation.isLocal()) return // Skip in dev mode + + const pkg = path.join(dir, "package.json") + + // Auto-create package.json if missing + if (!(await Bun.file(pkg).exists())) { + await Bun.write(pkg, "{}") + } + + // Auto-create .gitignore + const gitignore = path.join(dir, ".gitignore") + if (!hasGitIgnore) { + await Bun.write(gitignore, [ + "node_modules", + "package.json", + "bun.lock", + ".gitignore" + ].join("\n")) + } + + // Always install the plugin SDK + await BunProc.run( + ["add", "@opencode-ai/plugin@" + Installation.VERSION, "--exact"], + { cwd: dir } + ) + + // Install user dependencies + await BunProc.run(["install"], { cwd: dir }) +} +``` + +### Why No Symlinks Needed + +When Bun imports a file, it resolves dependencies relative to that file's location: + +``` +.opencode/ +├── package.json # { "dependencies": { "cowsay": "^1.0.0" } } +├── node_modules/ +│ └── cowsay/ +└── plugin/ + └── my-plugin.ts # import cowsay from 'cowsay' → resolves to ../node_modules/cowsay +``` + +The file `.opencode/plugin/my-plugin.ts` naturally finds `cowsay` in `.opencode/node_modules/` because that's Bun's standard module resolution. + +--- + +## Dependency Locations + +| Location | Purpose | Contents | +|----------|---------|----------| +| `~/.config/opencode/node_modules/` | Global user plugins | `@opencode-ai/plugin` + user deps | +| `~/.cache/opencode/node_modules/` | NPM plugin cache | Version-locked npm plugins | +| `~/.local/share/opencode/bin/node_modules/` | Runtime tools | LSP servers, etc. | +| `.opencode/node_modules/` | Project plugins | `@opencode-ai/plugin` + project deps | + +### XDG Base Directories + +OpenCode follows the XDG specification: + +```typescript +// packages/opencode/src/global/index.ts:8-11 +const data = path.join(xdgData!, "opencode") // ~/.local/share/opencode +const cache = path.join(xdgCache!, "opencode") // ~/.cache/opencode +const config = path.join(xdgConfig!, "opencode") // ~/.config/opencode +const state = path.join(xdgState!, "opencode") // ~/.local/state/opencode +``` + +--- + +## Plugin Loading + +### Discovery + +Plugins are discovered via glob patterns in each config directory: + +```typescript +// packages/opencode/src/config/config.ts:299-312 +const PLUGIN_GLOB = new Bun.Glob("{plugin,plugins}/*.{ts,js}") + +async function loadPlugin(dir: string) { + const plugins: string[] = [] + for await (const item of PLUGIN_GLOB.scan({ + cwd: dir, + absolute: true, + })) { + plugins.push(pathToFileURL(item).href) // file:// URL + } + return plugins +} +``` + +### Loading + +Plugins are dynamically imported: + +```typescript +// packages/opencode/src/plugin/index.ts:36-53 +for (let plugin of plugins) { + if (!plugin.startsWith("file://")) { + // NPM package - install to cache + plugin = await BunProc.install(pkg, version) + } + const mod = await import(plugin) + // Initialize plugin... +} +``` + +--- + +## NPM Plugin Cache + +For plugins specified as npm packages (not local files): + +```typescript +// packages/opencode/src/bun/index.ts:63-129 +export async function install(pkg: string, version = "latest") { + const mod = path.join(Global.Path.cache, "node_modules", pkg) + + // Check if already installed at this version + const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json")) + if (parsed.dependencies[pkg] === version) return mod + + // Install to central cache + await BunProc.run([ + "add", "--force", "--exact", + "--cwd", Global.Path.cache, + pkg + "@" + version, + ]) + + return mod // Return path for import() +} +``` + +This provides: +- Version tracking in `~/.cache/opencode/package.json` +- Deduplication across projects +- Fast subsequent loads (skip install if version matches) + +--- + +## Key Patterns for CommanDO + +### What CommanDO Can Adopt + +1. **Per-directory `node_modules/`**: Each `.commando/` gets its own dependencies +2. **Auto-managed `package.json`**: Create if missing, add SDK automatically +3. **Auto `.gitignore`**: Exclude generated files from version control +4. **Direct file imports**: Load `.commando/*.ts` directly, not through symlinks +5. **Self-as-runtime**: If CommanDO compiles with Bun, it can use itself for `bun add` + +### What's Different + +| OpenCode | CommanDO (Current) | +|----------|-------------------| +| Compiled Bun binary | Runs via `bun` command | +| XDG directories | `~/.commando/` | +| Multiple config dirs merged | Single project config | +| Symlink-free | Uses symlink hack | + +--- + +## References + +- `packages/opencode/src/config/config.ts` - Config loading and dependency installation +- `packages/opencode/src/bun/index.ts` - Bun process management and npm cache +- `packages/opencode/src/plugin/index.ts` - Plugin loading +- `packages/opencode/src/global/index.ts` - XDG path definitions +- `packages/opencode/script/build.ts` - Compiled binary build process diff --git a/tests/fixtures/test-project/.commando/.gitignore b/tests/fixtures/test-project/.commando/.gitignore new file mode 100644 index 0000000..669bb51 --- /dev/null +++ b/tests/fixtures/test-project/.commando/.gitignore @@ -0,0 +1,3 @@ +# Auto-generated by commando +node_modules/ +bun.lock diff --git a/tests/fixtures/test-project/.commando/package.json b/tests/fixtures/test-project/.commando/package.json new file mode 100644 index 0000000..7ee9e34 --- /dev/null +++ b/tests/fixtures/test-project/.commando/package.json @@ -0,0 +1,5 @@ +{ + "private": true, + "type": "module", + "dependencies": {} +}