diff --git a/package.json b/package.json index ad14172..2b28c14 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,14 @@ }, "scripts": { "dev": "bun packages/cli/bin/cli.tsx", - "build:claw": "bun build ./packages/vm-cli/bin/claw.ts --compile --target=bun-linux-arm64 --outfile dist/claw", - "build": "bun run build:claw && bun build ./packages/cli/bin/cli.tsx --compile --outfile dist/clawctl", + "build:claw": "bun scripts/build.ts claw", + "build": "bun scripts/build.ts", "test": "bun test", "lint": "eslint .", "format": "prettier --write .", "format:check": "prettier --check .", "test:vm": "CLAWCTL_VM_TESTS=1 bun test packages/host-core/tests/vm/", - "build:release": "bun build ./packages/cli/bin/cli.tsx --compile --target=bun-darwin-arm64 --outfile dist/clawctl", + "build:release": "bun scripts/build.ts release", "release": "release-it" }, "dependencies": { diff --git a/packages/host-core/src/claw-binary.ts b/packages/host-core/src/claw-binary.ts new file mode 100644 index 0000000..3286afd --- /dev/null +++ b/packages/host-core/src/claw-binary.ts @@ -0,0 +1,25 @@ +import embeddedClawPath from "../../../dist/claw" with { type: "file" }; +import { readFileSync, writeFileSync, mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +/** + * In dev mode, the import resolves to the real `dist/claw` path. + * In compiled mode, Bun embeds the file and exposes it via a virtual + * `/$bunfs/` path that only Bun's own fs polyfills can read. External + * tools (like `limactl copy`) need a real filesystem path, so we + * extract the binary to a temp file. + */ +function resolveClawPath(): string { + if (!embeddedClawPath.startsWith("/$bunfs/")) { + return embeddedClawPath; + } + + const content = readFileSync(embeddedClawPath); + const dir = mkdtempSync(join(tmpdir(), "clawctl-")); + const outPath = join(dir, "claw"); + writeFileSync(outPath, content, { mode: 0o755 }); + return outPath; +} + +export const clawPath = resolveClawPath(); diff --git a/packages/host-core/src/index.ts b/packages/host-core/src/index.ts index 6e3ffc0..56575cc 100644 --- a/packages/host-core/src/index.ts +++ b/packages/host-core/src/index.ts @@ -33,6 +33,9 @@ export type { SecretRef, ResolvedSecretRef } from "./secrets.js"; export { provisionVM } from "./provision.js"; export type { ProvisionCallbacks, ProvisionFeatures } from "./provision.js"; +// Claw binary (embedded asset in compiled mode, direct path in dev mode) +export { clawPath } from "./claw-binary.js"; + // Verify export { verifyProvisioning } from "./verify.js"; export type { VerifyResult } from "./verify.js"; diff --git a/packages/host-core/src/provision.ts b/packages/host-core/src/provision.ts index af4040e..33b7f02 100644 --- a/packages/host-core/src/provision.ts +++ b/packages/host-core/src/provision.ts @@ -1,13 +1,11 @@ import { access, mkdir, writeFile } from "fs/promises"; import { constants } from "fs"; -import { join, resolve } from "path"; +import { join } from "path"; import type { VMConfig, ProvisionConfig } from "@clawctl/types"; import { CLAW_BIN_PATH, PROVISION_CONFIG_FILE } from "@clawctl/types"; import type { VMDriver, VMCreateOptions, OnLine } from "./drivers/types.js"; import { initGitRepo } from "./git.js"; - -/** Resolve the claw binary path from the monorepo root (host-core/src/ → ../../.. → dist/claw). */ -const DEFAULT_CLAW_BINARY = resolve(import.meta.dir, "..", "..", "..", "dist", "claw"); +import { clawPath } from "./claw-binary.js"; export interface ProvisionFeatures { onePassword: boolean; @@ -93,7 +91,7 @@ export async function provisionVM( config: VMConfig, callbacks: ProvisionCallbacks = {}, createOptions: VMCreateOptions = {}, - clawBinaryPath: string = DEFAULT_CLAW_BINARY, + clawBinaryPath: string = clawPath, features: ProvisionFeatures = { onePassword: false, tailscale: false }, ): Promise { const { onPhase, onStep, onLine } = callbacks; diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 0000000..75e4b49 --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,71 @@ +/** + * Build script for clawctl binaries. + * + * Usage: + * bun scripts/build.ts Build claw + clawctl (current platform) + * bun scripts/build.ts claw Build claw only (linux-arm64) + * bun scripts/build.ts release Build claw + clawctl (darwin-arm64) + */ + +const SHARED = { + format: "esm", + minify: true, + sourcemap: "inline", + bytecode: true, +} as const; + +async function buildClaw() { + console.log("Building claw (linux-arm64)..."); + const result = await Bun.build({ + entrypoints: ["./packages/vm-cli/bin/claw.ts"], + compile: { + target: "bun-linux-arm64", + outfile: "./dist/claw", + }, + ...SHARED, + }); + if (!result.success) { + console.error("claw build failed:"); + for (const log of result.logs) console.error(log); + process.exit(1); + } + console.log(" ✓ dist/claw"); +} + +async function buildClawctl(target?: string) { + console.log(`Building clawctl${target ? ` (${target})` : ""}...`); + const result = await Bun.build({ + entrypoints: ["./packages/cli/bin/cli.tsx"], + compile: { + ...(target && { target: target as Bun.Target }), + outfile: "./dist/clawctl", + }, + ...SHARED, + }); + if (!result.success) { + console.error("clawctl build failed:"); + for (const log of result.logs) console.error(log); + process.exit(1); + } + console.log(" ✓ dist/clawctl"); +} + +const command = process.argv[2] ?? "all"; + +switch (command) { + case "claw": + await buildClaw(); + break; + case "release": + await buildClaw(); + await buildClawctl("bun-darwin-arm64"); + break; + case "all": + await buildClaw(); + await buildClawctl(); + break; + default: + console.error(`Unknown command: ${command}`); + console.error("Usage: bun scripts/build.ts [claw|release|all]"); + process.exit(1); +} diff --git a/tasks/2026-03-16_0018_embed-claw-binary/TASK.md b/tasks/2026-03-16_0018_embed-claw-binary/TASK.md new file mode 100644 index 0000000..74388fd --- /dev/null +++ b/tasks/2026-03-16_0018_embed-claw-binary/TASK.md @@ -0,0 +1,49 @@ +# Fix `claw` binary bundling into `clawctl` + +## Status: Resolved + +## Scope + +Embed the `claw` guest CLI binary into the compiled `clawctl` executable using +Bun's `import ... with { type: "file" }` asset embedding. This makes the release +binary self-contained — no sibling `dist/claw` file needed. + +**Out of scope**: multi-binary VM assets, cross-platform builds. + +## Plan + +1. Create `packages/host-core/src/claw-binary.ts` — isolated module with the + embedded file import. +2. Update `packages/host-core/src/provision.ts` — replace `import.meta.dir` + path resolution with the embedded import. +3. Export `clawPath` from `packages/host-core/src/index.ts`. +4. Fix `build:release` in root `package.json` to build `claw` first. + +## Steps + +- [x] Create `claw-binary.ts` +- [x] Update `provision.ts` +- [x] Add re-export to `index.ts` +- [x] Fix `build:release` script +- [x] Commit task + plan +- [x] Commit implementation +- [x] Verify lint/format + +## Notes + +- `import.meta.dir` in a compiled Bun binary points to the binary's directory, + not the source tree. The current relative path traversal breaks silently. +- Bun's embed assets feature returns the original path in dev mode and extracts + to a temp location in compiled mode — no code changes needed for dev vs prod. +- Binary size will grow from ~64MB to ~160MB (claw is ~96MB compiled). + +## Outcome + +All four changes delivered as planned: + +1. **`claw-binary.ts`** — isolated module with `import ... with { type: "file" }` for the claw binary +2. **`provision.ts`** — removed `import.meta.dir` + `resolve` path hack, uses `clawPath` from the embedded import; cleaned up unused `resolve` import +3. **`index.ts`** — re-exports `clawPath` for consumers that need to reference or override +4. **`package.json`** — `build:release` now runs `build:claw` first so the asset exists at bundle time + +Lint and format pass clean. No changes needed to callers (`headless.ts`, `create-vm.tsx`) since they pass `undefined` for `clawBinaryPath` and get the default.