diff --git a/bun.lock b/bun.lock index d477f58e72..d7cbe31a1b 100644 --- a/bun.lock +++ b/bun.lock @@ -41,7 +41,7 @@ }, "packages/cli": { "name": "@archon/cli", - "version": "0.2.0", + "version": "0.2.13", "bin": { "archon": "./src/cli.ts", }, @@ -114,8 +114,6 @@ "version": "0.2.0", "dependencies": { "pino": "^9", - }, - "optionalDependencies": { "pino-pretty": "^13", }, "peerDependencies": { diff --git a/packages/cli/src/commands/bundled-version.ts b/packages/cli/src/commands/bundled-version.ts deleted file mode 100644 index 17f757becd..0000000000 --- a/packages/cli/src/commands/bundled-version.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Bundled version for compiled binaries - * - * This file is updated by scripts/build-binaries.sh before compilation. - * The version is read from package.json at build time and embedded here. - * - * For development, the version command reads directly from package.json instead. - */ - -export const BUNDLED_VERSION = '0.2.0'; -export const BUNDLED_GIT_COMMIT = 'unknown'; diff --git a/packages/cli/src/commands/version.ts b/packages/cli/src/commands/version.ts index eb47c3cfbf..589a4bcd93 100644 --- a/packages/cli/src/commands/version.ts +++ b/packages/cli/src/commands/version.ts @@ -1,17 +1,21 @@ /** * Version command - displays version info * - * For compiled binaries, version and git commit are embedded via bundled-version.ts - * For development (Bun), reads from package.json and retrieves git commit at runtime + * For compiled binaries, version and git commit are embedded via `@archon/paths` + * build-time constants (rewritten by `scripts/build-binaries.sh`). + * For development (Bun), reads from package.json and retrieves git commit at runtime. */ import { readFile } from 'fs/promises'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { execFileAsync } from '@archon/git'; -import { createLogger } from '@archon/paths'; +import { + BUNDLED_GIT_COMMIT, + BUNDLED_IS_BINARY, + BUNDLED_VERSION, + createLogger, +} from '@archon/paths'; import { getDatabaseType } from '@archon/core'; -import { isBinaryBuild } from '@archon/workflows/defaults'; -import { BUNDLED_VERSION, BUNDLED_GIT_COMMIT } from './bundled-version'; const log = createLogger('cli:version'); @@ -72,7 +76,7 @@ export async function versionCommand(): Promise { let version: string; let gitCommit: string; - if (isBinaryBuild()) { + if (BUNDLED_IS_BINARY) { // Compiled binary: use embedded version and commit version = BUNDLED_VERSION; gitCommit = BUNDLED_GIT_COMMIT; @@ -86,7 +90,7 @@ export async function versionCommand(): Promise { const platform = process.platform; const arch = process.arch; const dbType = getDatabaseType(); - const buildType = isBinaryBuild() ? 'binary' : 'source (bun)'; + const buildType = BUNDLED_IS_BINARY ? 'binary' : 'source (bun)'; console.log(`Archon CLI v${version}`); console.log(` Platform: ${platform}-${arch}`); diff --git a/packages/paths/package.json b/packages/paths/package.json index 8d52e684b9..fe958ad7f4 100644 --- a/packages/paths/package.json +++ b/packages/paths/package.json @@ -12,9 +12,7 @@ "type-check": "bun x tsc --noEmit" }, "dependencies": { - "pino": "^9" - }, - "optionalDependencies": { + "pino": "^9", "pino-pretty": "^13" }, "peerDependencies": { diff --git a/packages/paths/src/bundled-build.test.ts b/packages/paths/src/bundled-build.test.ts new file mode 100644 index 0000000000..9c1ade23c6 --- /dev/null +++ b/packages/paths/src/bundled-build.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'bun:test'; +import { BUNDLED_GIT_COMMIT, BUNDLED_IS_BINARY, BUNDLED_VERSION } from './bundled-build'; + +describe('bundled-build', () => { + // In dev/test mode the placeholders must be the dev defaults. + // `scripts/build-binaries.sh` rewrites this file only during binary + // compilation and restores it afterwards via an EXIT trap. + it('BUNDLED_IS_BINARY is false in dev mode', () => { + expect(BUNDLED_IS_BINARY).toBe(false); + }); + + it('BUNDLED_VERSION is the dev placeholder', () => { + expect(BUNDLED_VERSION).toBe('dev'); + }); + + it('BUNDLED_GIT_COMMIT is the dev placeholder', () => { + expect(BUNDLED_GIT_COMMIT).toBe('unknown'); + }); +}); diff --git a/packages/paths/src/bundled-build.ts b/packages/paths/src/bundled-build.ts new file mode 100644 index 0000000000..99a21b4eb1 --- /dev/null +++ b/packages/paths/src/bundled-build.ts @@ -0,0 +1,18 @@ +/** + * Build-time constants embedded into compiled binaries. + * + * In dev/test mode, the placeholders below are used and BUNDLED_IS_BINARY + * is `false`. Compiled binaries get this file overwritten by + * `scripts/build-binaries.sh` before `bun build --compile` is invoked, + * and restored afterwards via an EXIT trap. + * + * Lives in `@archon/paths` (the bottom of the dep graph) so any package + * can import these constants without creating dependency cycles. + * + * See GitHub issue #979 for the rationale (replaces runtime detection + * heuristics that were brittle across Bun's ESM/CJS compile modes). + */ + +export const BUNDLED_IS_BINARY = false; +export const BUNDLED_VERSION = 'dev'; +export const BUNDLED_GIT_COMMIT = 'unknown'; diff --git a/packages/paths/src/index.ts b/packages/paths/src/index.ts index da33e99049..3c3fd89618 100644 --- a/packages/paths/src/index.ts +++ b/packages/paths/src/index.ts @@ -30,3 +30,6 @@ export { // Logger export { createLogger, setLogLevel, getLogLevel, rootLogger } from './logger'; export type { Logger } from './logger'; + +// Build-time constants (rewritten by scripts/build-binaries.sh) +export { BUNDLED_IS_BINARY, BUNDLED_VERSION, BUNDLED_GIT_COMMIT } from './bundled-build'; diff --git a/packages/paths/src/logger.ts b/packages/paths/src/logger.ts index 73a73b040d..414f36ee10 100644 --- a/packages/paths/src/logger.ts +++ b/packages/paths/src/logger.ts @@ -23,6 +23,7 @@ import pino from 'pino'; import type { Logger } from 'pino'; +import pretty from 'pino-pretty'; export type { Logger } from 'pino'; @@ -44,36 +45,48 @@ function getInitialLevel(): string { } /** - * Uses pino-pretty when stdout is a TTY and NODE_ENV !== 'production'; - * outputs newline-delimited JSON otherwise. + * Build the root Pino logger. + * + * Uses `pino-pretty` as a **destination stream** (not a worker-thread transport) + * when stdout is a TTY and NODE_ENV !== 'production'. Running pino-pretty as a + * destination stream keeps the formatter on the main thread, which avoids the + * `require.resolve('pino-pretty')` lookup that crashes inside Bun's `/$bunfs/` + * virtual filesystem in compiled binaries (see GitHub issue #960 / #979). + * + * The same code path runs in dev and compiled binaries — no environment + * detection required. */ -function buildLoggerOptions(): pino.LoggerOptions { +function buildLogger(): Logger { const level = getInitialLevel(); const usePretty = process.stdout.isTTY && process.env.NODE_ENV !== 'production'; if (usePretty) { - return { - level, - transport: { - target: 'pino-pretty', - options: { - colorize: true, - levelFirst: true, - translateTime: 'SYS:standard', - ignore: 'pid,hostname', - }, - }, - }; + try { + const stream = pretty({ + colorize: true, + levelFirst: true, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + }); + return pino({ level }, stream); + } catch (err) { + // pino-pretty failed to initialize (missing peer, broken TTY descriptor, + // or incompatible runtime). Fall back to plain JSON so logging keeps + // working instead of crashing the entire process at module import time. + console.warn( + `[logger] pino-pretty failed to initialize, falling back to JSON output: ${(err as Error).message}` + ); + } } - return { level }; + return pino({ level }); } /** * Root Pino logger instance. * Children inherit the root's level at creation time (not dynamically updated). */ -export const rootLogger: Logger = pino(buildLoggerOptions()); +export const rootLogger: Logger = buildLogger(); /** * Create a child logger with a module binding. diff --git a/packages/workflows/src/defaults/bundled-defaults.test.ts b/packages/workflows/src/defaults/bundled-defaults.test.ts index 00124a4ee6..e1e1cb5a30 100644 --- a/packages/workflows/src/defaults/bundled-defaults.test.ts +++ b/packages/workflows/src/defaults/bundled-defaults.test.ts @@ -1,40 +1,13 @@ import { describe, it, expect } from 'bun:test'; -import { - isBinaryBuild, - isBunVirtualFs, - BUNDLED_COMMANDS, - BUNDLED_WORKFLOWS, -} from './bundled-defaults'; +import { isBinaryBuild, BUNDLED_COMMANDS, BUNDLED_WORKFLOWS } from './bundled-defaults'; describe('bundled-defaults', () => { - describe('isBunVirtualFs', () => { - it('should detect Linux/macOS virtual filesystem paths', () => { - expect(isBunVirtualFs('/$bunfs/root/bundled-defaults')).toBe(true); - expect(isBunVirtualFs('/$bunfs/root/')).toBe(true); - }); - - it('should detect Windows virtual filesystem paths (backslash)', () => { - expect(isBunVirtualFs('B:\\~BUN\\root\\bundled-defaults')).toBe(true); - expect(isBunVirtualFs('B:\\~BUN\\root')).toBe(true); - }); - - it('should detect Windows virtual filesystem paths (forward slash)', () => { - expect(isBunVirtualFs('B:/~BUN/root/bundled-defaults')).toBe(true); - expect(isBunVirtualFs('B:/~BUN/root')).toBe(true); - }); - - it('should return false for real filesystem paths', () => { - expect(isBunVirtualFs('/home/user/project/src')).toBe(false); - expect(isBunVirtualFs('C:\\Users\\user\\project\\src')).toBe(false); - expect(isBunVirtualFs('/tmp/test')).toBe(false); - }); - }); - describe('isBinaryBuild', () => { - it('should return false when running in test environment (not compiled)', () => { - // The true path requires an actual compiled binary (import.meta.dir points to - // Bun's virtual FS only inside compiled binaries). Coverage of the true branch - // relies on isBunVirtualFs tests above + manual binary smoke testing in CI. + it('should return false in dev/test mode', () => { + // `isBinaryBuild()` reads the build-time constant `BUNDLED_IS_BINARY` from + // `@archon/paths`. In dev/test mode it is `false`. It is only rewritten to + // `true` by `scripts/build-binaries.sh` before `bun build --compile`. + // Coverage of the `true` branch is via local binary smoke testing (see #979). expect(isBinaryBuild()).toBe(false); }); }); diff --git a/packages/workflows/src/defaults/bundled-defaults.ts b/packages/workflows/src/defaults/bundled-defaults.ts index 6caf6e7ae0..a921171b9e 100644 --- a/packages/workflows/src/defaults/bundled-defaults.ts +++ b/packages/workflows/src/defaults/bundled-defaults.ts @@ -8,6 +8,8 @@ * Import syntax uses `with { type: 'text' }` to import file contents as strings. */ +import { BUNDLED_IS_BINARY } from '@archon/paths'; + // ============================================================================= // Default Commands (21 total) // ============================================================================= @@ -102,23 +104,19 @@ export const BUNDLED_WORKFLOWS: Record = { 'archon-workflow-builder': archonWorkflowBuilderWf, }; -/** - * Check if a given module directory path belongs to a compiled Bun binary. - * - * Compiled Bun binaries use a virtual filesystem for bundled modules: - * - Linux/macOS: `/$bunfs/root/` - * - Windows: `B:\~BUN\root\` or `B:/~BUN/root/` - */ -export function isBunVirtualFs(dir: string): boolean { - return dir.startsWith('/$bunfs/') || dir.startsWith('B:\\~BUN\\') || dir.startsWith('B:/~BUN/'); -} - /** * Check if the current process is running as a compiled binary (not via Bun CLI). * - * Note: `process.versions.bun` is still set in compiled binaries as of Bun 1.3.5, - * so we use the virtual filesystem path prefix for detection instead. + * Reads the build-time constant `BUNDLED_IS_BINARY` from `@archon/paths`. + * `scripts/build-binaries.sh` rewrites that file to set it to `true` before + * `bun build --compile` and restores it afterwards. See GitHub issue #979. + * + * Kept as a function (rather than a direct re-export of `BUNDLED_IS_BINARY`) + * so tests can use `spyOn(bundledDefaults, 'isBinaryBuild').mockReturnValue(...)` + * without resorting to `mock.module('@archon/paths', ...)` — which is + * process-global and irreversible in Bun and would pollute other test files. + * See `.claude/rules/dx-quirks.md` and `loader.test.ts` for context. */ export function isBinaryBuild(): boolean { - return isBunVirtualFs(import.meta.dir); + return BUNDLED_IS_BINARY; } diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index 0ddf66fb54..ddc0498394 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -9,19 +9,23 @@ VERSION="${VERSION:-$(grep '"version"' package.json | head -1 | cut -d'"' -f4)}" GIT_COMMIT="${GIT_COMMIT:-$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')}" echo "Building Archon CLI v${VERSION} (commit: ${GIT_COMMIT})" -# Update bundled version in source before compiling -BUNDLED_VERSION_FILE="packages/cli/src/commands/bundled-version.ts" -echo "Updating bundled version to ${VERSION}..." -cat > "$BUNDLED_VERSION_FILE" << EOF +# Update build-time constants in source before compiling. +# The file is restored via an EXIT trap so the dev tree is never left dirty, +# even if `bun build --compile` fails mid-way. See GitHub issue #979. +BUNDLED_BUILD_FILE="packages/paths/src/bundled-build.ts" +trap 'echo "Restoring ${BUNDLED_BUILD_FILE}..."; git checkout -- "${BUNDLED_BUILD_FILE}"' EXIT + +echo "Updating build-time constants (version=${VERSION}, is_binary=true)..." +cat > "$BUNDLED_BUILD_FILE" << EOF /** - * Bundled version for compiled binaries - * - * This file is updated by scripts/build-binaries.sh before compilation. - * The version is read from package.json at build time and embedded here. + * Build-time constants embedded into compiled binaries. * - * For development, the version command reads directly from package.json instead. + * This file is rewritten by scripts/build-binaries.sh before \`bun build --compile\` + * and restored afterwards via an EXIT trap. Do not edit these values by hand + * outside the build script — the dev defaults live in the committed copy. */ +export const BUNDLED_IS_BINARY = true; export const BUNDLED_VERSION = '${VERSION}'; export const BUNDLED_GIT_COMMIT = '${GIT_COMMIT}'; EOF