Skip to content
Merged
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
25 changes: 25 additions & 0 deletions packages/host-core/src/claw-binary.ts
Original file line number Diff line number Diff line change
@@ -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();
3 changes: 3 additions & 0 deletions packages/host-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
8 changes: 3 additions & 5 deletions packages/host-core/src/provision.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<void> {
const { onPhase, onStep, onLine } = callbacks;
Expand Down
71 changes: 71 additions & 0 deletions scripts/build.ts
Original file line number Diff line number Diff line change
@@ -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);
}
49 changes: 49 additions & 0 deletions tasks/2026-03-16_0018_embed-claw-binary/TASK.md
Original file line number Diff line number Diff line change
@@ -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.
Loading