task: US-006 — delete packages/sandbox + build.yml workflow

Per SPEC v0.7 §"oh CLI" deprecation. Atomic file-deletion mass:
- Removed packages/sandbox/ workspace (CLI + harness/onboard/lib/tools + tests)
- Removed packages/sandbox entry from pnpm-workspace.yaml
- Removed link:cli script from root package.json
- Removed .github/workflows/build.yml (was duplicating release.yml's image
  build, pushing to ghcr.io/ryaneggz/open-harness — drifted from canonical
  ghcr.io/ryaneggz/openharness produced by release.yml)
- Removed .github/workflows/ci.yml (sandbox-only CI now stale)
- Removed CLI build/symlink block from .devcontainer/entrypoint.sh that
  wired /usr/local/bin/openharness and /usr/local/bin/oh
- Rewrote .husky/pre-commit to run root-level lint + test (was cd'ing into
  the now-deleted packages/sandbox/)
- Regenerated pnpm-lock.yaml (-49 packages)

release.yml is now the sole image producer pushing
ghcr.io/ryaneggz/openharness:<tag> + :latest.

Refs #210
This commit is contained in:
OpenHarness Bot committed 2026-05-02 12:23:00 -06:00
1 parent b9c490bbbf
commit d6c4886d6e
85 files changed
+61 -7206

No files matched your search

-28
View File
@@ -188,34 +188,6 @@ if echo "$@" | grep -q sshd; then
fi
fi
# Build and link openharness CLI (from bind-mounted repo)
# Runs on every boot: install deps + build only if dist is missing or stale,
# always re-symlink so container recreation (fresh /usr/local/bin/) re-establishes PATH entries.
CLI_TARGET="$HARNESS/packages/sandbox/dist/src/cli/index.js"
if [ -f "$HARNESS/packages/sandbox/package.json" ]; then
if [ ! -f "$CLI_TARGET" ] || [ "$HARNESS/packages/sandbox/package.json" -nt "$CLI_TARGET" ]; then
echo "[entrypoint] building openharness CLI..."
(
cd "$HARNESS"
gosu sandbox pnpm install --frozen-lockfile \
|| gosu sandbox pnpm install \
|| echo "[entrypoint] WARN: pnpm install failed"
gosu sandbox pnpm --filter @openharness/sandbox run build \
|| echo "[entrypoint] WARN: pnpm build failed"
)
fi
if [ -f "$CLI_TARGET" ]; then
ln -sf "$CLI_TARGET" /usr/local/bin/openharness
ln -sf "$CLI_TARGET" /usr/local/bin/oh
chmod +x /usr/local/bin/openharness /usr/local/bin/oh
echo "[entrypoint] openharness CLI (alias: oh) installed"
else
echo "[entrypoint] ERROR: $CLI_TARGET not found after build — CLI not installed"
fi
fi
# ─── Start cron runtime in tmux session ────────────────────────────
# Per SPEC v0.7 §"Croner runtime" + .claude/rules/sandbox-processes.md.
# Replaces the legacy heartbeat-daemon watchdog. Runs as sandbox user
-50
View File
@@ -1,50 +0,0 @@
name: Build Open Harness
permissions:
contents: read
packages: write
on:
push:
tags:
- "[0-9][0-9][0-9][0-9].[0-9]*.[0-9]*"
jobs:
build:
runs-on: ubuntu-latest
environment: Dev
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Parse tag
id: parse
run: |
# Expected tag format: YYYY.M.D or YYYY.M.D-N
# e.g. 2026.4.4, 2026.4.4-1
VERSION=${GITHUB_REF#refs/tags/}
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USERNAME || github.actor }}
password: ${{ secrets.GHCR_PAT }}
- name: Build and push
run: |
IMAGE=ghcr.io/ryaneggz/open-harness:${{ steps.parse.outputs.version }}
LATEST=ghcr.io/ryaneggz/open-harness:latest
echo "Building: $IMAGE and $LATEST"
docker build -f .devcontainer/Dockerfile -t $IMAGE -t $LATEST .
docker push $IMAGE
docker push $LATEST
-45
View File
@@ -1,45 +0,0 @@
name: "CI: openharness"
on:
push:
branches:
- development
- agent/orchestrator
paths:
- "packages/sandbox/src/**"
- "packages/sandbox/package.json"
- "packages/sandbox/pnpm-lock.yaml"
- "packages/sandbox/tsconfig.json"
- ".github/workflows/ci.yml"
permissions:
contents: read
jobs:
test-sandbox:
name: "Test: packages/sandbox"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 22.x
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install dependencies
working-directory: packages/sandbox
run: pnpm install --frozen-lockfile
- name: Build
working-directory: packages/sandbox
run: pnpm run build
- name: Test
working-directory: packages/sandbox
run: pnpm test
+1 -1
View File
@@ -1 +1 @@
cd packages/sandbox && pnpm exec lint-staged && pnpm run build && pnpm run test
pnpm run lint && pnpm run test
+1
View File
@@ -9,6 +9,7 @@ Update policy and release automation live in [`.claude/rules/git.md`](.claude/ru
## [Unreleased]
### Removed
- **BREAKING**: `@openharness/sandbox` CLI removed; `docker compose` is the canonical substrate. Deleted the `packages/sandbox/` workspace package (CLI binary + harness/onboard/lib/tools source + tests), the entry from `pnpm-workspace.yaml`, the `link:cli` script from root `package.json`, the now-stale `.github/workflows/ci.yml` (sandbox-only CI), and the duplicate `.github/workflows/build.yml` (which had been pushing a drifted `ghcr.io/ryaneggz/open-harness` image alongside the canonical `ghcr.io/ryaneggz/openharness` produced by `release.yml`). The CLI build/symlink block in `.devcontainer/entrypoint.sh` that wired `/usr/local/bin/openharness` and `/usr/local/bin/oh` is gone. `release.yml` is now the sole image producer. Per SPEC v0.7 §"oh CLI". ([#210](https://github.com/ryaneggz/open-harness/issues/210))
- **BREAKING**: heartbeat daemon removed. Deleted `packages/sandbox/src/lib/heartbeat/` (~1,752 LOC), `packages/sandbox/src/cli/heartbeat-daemon.ts`, `packages/sandbox/src/cli/actions/heartbeat.ts`, `packages/sandbox/src/tools/heartbeat.ts`, the `oh heartbeat {start,stop,status}` command group, the `heartbeat-daemon` package bin, the seven `heartbeat-*.test.ts` suites, and `.claude/skills/heartbeat/`. Heartbeat env vars (`HEARTBEAT_ACTIVE_START`/`_END`/`_AGENT`) stripped from `.devcontainer/docker-compose.yml`; the legacy daemon-startup blocks in `install/entrypoint.sh` and the `/usr/local/bin/heartbeat-daemon` symlink in `.devcontainer/entrypoint.sh` are gone; `install/banner.sh` no longer advertises `heartbeat-daemon status`. The croner runtime (`scripts/cron-runtime.ts`, US-002/US-003/US-004) is the canonical replacement per SPEC v0.7 §"Croner runtime". ([#210](https://github.com/ryaneggz/open-harness/issues/210))
- **BREAKING**: `@mariozechner/pi-coding-agent` is no longer a dependency. The pi agent CLI and Mom Slack bot have been extracted to a separate harness pack at [`ryaneggz/mifune`](https://github.com/ryaneggz/mifune) (npm: `@ryaneggz/mifune`). Install with `oh harness add @ryaneggz/mifune`. ([#208](https://github.com/ryaneggz/open-harness/pull/208))
-1
View File
@@ -6,7 +6,6 @@
"packageManager": "pnpm@10.33.0",
"scripts": {
"setup": "pnpm install && pnpm -r --if-present run build",
"link:cli": "pnpm --dir packages/sandbox link --global",
"build": "pnpm -r --if-present run build",
"test": "pnpm -r --if-present run test && vitest run",
"test:scripts": "vitest run",
-7
View File
@@ -1,7 +0,0 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}
-28
View File
@@ -1,28 +0,0 @@
import eslint from "@typescript-eslint/eslint-plugin";
import parser from "@typescript-eslint/parser";
export default [
{
files: ["src/**/*.ts", "extensions/**/*.ts"],
ignores: ["src/__tests__/**"],
languageOptions: {
parser,
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
},
},
plugins: {
"@typescript-eslint": eslint,
},
rules: {
...eslint.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_" },
],
"@typescript-eslint/no-explicit-any": "warn",
"no-console": "off",
},
},
];
-54
View File
@@ -1,54 +0,0 @@
{
"name": "@openharness/sandbox",
"version": "0.1.0",
"description": "Open Harness CLI and sandbox tools — Docker containers, git worktrees",
"type": "module",
"bin": {
"openharness": "./dist/src/cli/index.js",
"oh": "./dist/src/cli/index.js"
},
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
"exports": {
".": "./dist/src/index.js"
},
"files": [
"dist/",
"README.md"
],
"scripts": {
"build": "tsc && chmod +x dist/src/cli/index.js",
"dev": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write 'src/**/*.ts'",
"format:check": "prettier --check 'src/**/*.ts'",
"ci": "eslint src/ && prettier --check 'src/**/*.ts' && vitest run"
},
"dependencies": {
"commander": "^12.0.0",
"croner": "^9.1.0",
"typebox": "^1.0.0"
},
"devDependencies": {
"@types/node": "^24.3.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint": "^9.0.0",
"lint-staged": "^16.4.0",
"prettier": "^3.0.0",
"typescript": "^5.5.0",
"vitest": "^3.0.0"
},
"lint-staged": {
"src/**/*.ts": [
"eslint --fix",
"prettier --write"
]
},
"engines": {
"node": ">=20.0.0"
}
}
@@ -1,187 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// In-memory fs mock so we can simulate .devcontainer/.env for detectMode.
const fs = new Map<string, string>();
vi.mock("node:fs", () => ({
existsSync: (p: string) => fs.has(p),
readFileSync: (p: string) => fs.get(p) ?? "",
writeFileSync: (p: string, data: string) => {
fs.set(p, data);
},
renameSync: (a: string, b: string) => {
const v = fs.get(a);
fs.delete(a);
if (v !== undefined) fs.set(b, v);
},
mkdirSync: () => {},
}));
beforeEach(() => {
fs.clear();
delete process.env.PUBLIC_DOMAIN;
delete process.env.ACME_EMAIL;
});
afterEach(() => {
delete process.env.PUBLIC_DOMAIN;
delete process.env.ACME_EMAIL;
});
const { detectMode, hostFor, renderCaddyfile, validateRouteName, writeCaddyfile } =
await import("../lib/caddy.js");
describe("detectMode", () => {
it("returns local when PUBLIC_DOMAIN is unset", () => {
expect(detectMode()).toEqual({ mode: "local" });
});
it("returns remote when PUBLIC_DOMAIN is in process.env", () => {
process.env.PUBLIC_DOMAIN = "harness.example.com";
expect(detectMode()).toEqual({
mode: "remote",
publicDomain: "harness.example.com",
acmeEmail: undefined,
});
});
it("reads PUBLIC_DOMAIN from .devcontainer/.env when process.env unset", async () => {
const { resolve } = await import("node:path");
fs.set(resolve(".devcontainer/.env"), "PUBLIC_DOMAIN=harness.example.com\n");
expect(detectMode()).toEqual({
mode: "remote",
publicDomain: "harness.example.com",
acmeEmail: undefined,
});
});
it("reads ACME_EMAIL from .env when set", async () => {
const { resolve } = await import("node:path");
fs.set(
resolve(".devcontainer/.env"),
"PUBLIC_DOMAIN=harness.example.com\nACME_EMAIL=you@example.com\n",
);
expect(detectMode()).toEqual({
mode: "remote",
publicDomain: "harness.example.com",
acmeEmail: "you@example.com",
});
});
it("prefers process.env over .env file", async () => {
const { resolve } = await import("node:path");
fs.set(resolve(".devcontainer/.env"), "PUBLIC_DOMAIN=old.example.com\n");
process.env.PUBLIC_DOMAIN = "new.example.com";
expect(detectMode().publicDomain).toBe("new.example.com");
});
});
describe("validateRouteName", () => {
it("accepts simple names", () => {
expect(() => validateRouteName("docs")).not.toThrow();
expect(() => validateRouteName("my-app")).not.toThrow();
expect(() => validateRouteName("a1b2c3")).not.toThrow();
});
it("rejects names starting with digit", () => {
expect(() => validateRouteName("1docs")).toThrow(/Invalid route name/);
});
it("rejects uppercase", () => {
expect(() => validateRouteName("Docs")).toThrow(/Invalid route name/);
});
it("rejects overlong names", () => {
expect(() => validateRouteName("a".repeat(40))).toThrow(/Invalid route name/);
});
it("rejects reserved names", () => {
expect(() => validateRouteName("admin")).toThrow(/reserved/);
expect(() => validateRouteName("www")).toThrow(/reserved/);
expect(() => validateRouteName("gateway")).toThrow(/reserved/);
expect(() => validateRouteName("api-internal")).toThrow(/reserved/);
});
});
describe("hostFor", () => {
it("local mode → <name>.<sandbox>.localhost", () => {
const host = hostFor({ name: "docs", port: 8080, sandbox: "oh-local" }, { mode: "local" });
expect(host).toBe("docs.oh-local.localhost");
});
it("remote mode → <name>.<sandbox>.<publicDomain>", () => {
const host = hostFor(
{ name: "docs", port: 8080, sandbox: "oh-local" },
{ mode: "remote", publicDomain: "harness.example.com" },
);
expect(host).toBe("docs.oh-local.harness.example.com");
});
it("throws when remote mode has no publicDomain", () => {
expect(() =>
hostFor({ name: "docs", port: 8080, sandbox: "oh-local" }, { mode: "remote" }),
).toThrow(/publicDomain/);
});
});
describe("renderCaddyfile", () => {
it("local mode: emits tls internal and auto_https disable_redirects", () => {
const out = renderCaddyfile([{ name: "docs", port: 8080, sandbox: "oh-local" }], {
mode: "local",
});
expect(out).toContain("admin 0.0.0.0:2019");
expect(out).toContain("auto_https disable_redirects");
expect(out).toContain("docs.oh-local.localhost:443 {");
expect(out).toContain("tls internal");
expect(out).toContain("reverse_proxy oh-local:8080");
});
it("remote mode: emits email and no tls internal", () => {
const out = renderCaddyfile([{ name: "docs", port: 8080, sandbox: "oh-local" }], {
mode: "remote",
publicDomain: "harness.example.com",
acmeEmail: "you@example.com",
});
expect(out).toContain("email you@example.com");
expect(out).not.toContain("tls internal");
expect(out).not.toContain("auto_https disable_redirects");
expect(out).toContain("docs.oh-local.harness.example.com {");
expect(out).toContain("reverse_proxy oh-local:8080");
});
it("empty routes → still emits global block only", () => {
const out = renderCaddyfile([], { mode: "local" });
expect(out).toContain("admin 0.0.0.0:2019");
expect(out).not.toContain("reverse_proxy");
});
it("multiple routes → each gets its own site block", () => {
const out = renderCaddyfile(
[
{ name: "docs", port: 8080, sandbox: "oh-local" },
{ name: "api", port: 3000, sandbox: "oh-local" },
],
{ mode: "local" },
);
expect(out).toMatch(/docs\.oh-local\.localhost:443 \{/);
expect(out).toMatch(/api\.oh-local\.localhost:443 \{/);
expect((out.match(/reverse_proxy/g) ?? []).length).toBe(2);
});
it("includes header warning that it is generated", () => {
const out = renderCaddyfile([], { mode: "local" });
expect(out).toMatch(/^# Generated/);
});
});
describe("writeCaddyfile", () => {
it("writes via tmp + rename to .openharness/Caddyfile", async () => {
writeCaddyfile("content");
const { resolve } = await import("node:path");
const path = resolve(".openharness/Caddyfile");
expect(fs.has(path)).toBe(true);
expect(fs.get(path)).toBe("content");
// tmp should be gone
expect(fs.has(`${path}.tmp`)).toBe(false);
});
});
-188
View File
@@ -1,188 +0,0 @@
import { describe, it, expect } from "vitest";
import { buildProgram, HOST_ONLY_COMMANDS } from "../cli/index.js";
describe("Commander program structure", () => {
it("declares the program name and description", () => {
const program = buildProgram();
expect(program.name()).toBe("oh");
expect(program.description()).toContain("openharness");
});
it("registers all expected top-level commands", () => {
const program = buildProgram();
const names = program.commands.map((c) => c.name());
const expected = [
"sandbox",
"run",
"stop",
"clean",
"shell",
"list",
"onboard",
"worktree",
"ports",
"expose",
"unexpose",
"open",
"harness",
];
for (const cmd of expected) {
expect(names).toContain(cmd);
}
});
it("does not contain removed commands", () => {
const program = buildProgram();
const names = program.commands.map((c) => c.name());
expect(names).not.toContain("build");
expect(names).not.toContain("rebuild");
expect(names).not.toContain("push");
expect(names).not.toContain("install");
});
});
describe("heartbeat subcommand group is gone", () => {
it("does not register a top-level heartbeat command", () => {
const program = buildProgram();
const names = program.commands.map((c) => c.name());
expect(names).not.toContain("heartbeat");
});
});
describe("harness subcommand group", () => {
it("registers add, list, remove placeholders", () => {
const program = buildProgram();
const harness = program.commands.find((c) => c.name() === "harness");
expect(harness).toBeDefined();
const subnames = harness!.commands.map((c) => c.name());
expect(subnames.sort()).toEqual(["add", "list", "remove"]);
});
it("`harness add` requires a <spec> arg", () => {
const program = buildProgram();
const harness = program.commands.find((c) => c.name() === "harness");
const add = harness!.commands.find((c) => c.name() === "add");
expect(add).toBeDefined();
const args = add!.registeredArguments;
expect(args[0].name()).toBe("spec");
expect(args[0].required).toBe(true);
});
it("`harness remove` requires a <name> arg", () => {
const program = buildProgram();
const harness = program.commands.find((c) => c.name() === "harness");
const remove = harness!.commands.find((c) => c.name() === "remove");
expect(remove).toBeDefined();
expect(remove!.registeredArguments[0].required).toBe(true);
});
it("`harness list` takes no args", () => {
const program = buildProgram();
const harness = program.commands.find((c) => c.name() === "harness");
const list = harness!.commands.find((c) => c.name() === "list");
expect(list).toBeDefined();
expect(list!.registeredArguments.length).toBe(0);
});
});
describe("optional-name commands", () => {
const optional = ["sandbox", "run", "stop", "clean", "ports"];
for (const cmd of optional) {
it(`${cmd} accepts an optional [name]`, () => {
const program = buildProgram();
const c = program.commands.find((x) => x.name() === cmd);
expect(c).toBeDefined();
const arg = c!.registeredArguments[0];
expect(arg).toBeDefined();
expect(arg.required).toBe(false);
});
}
});
describe("required-name commands", () => {
const required = ["shell", "worktree", "expose", "unexpose"];
for (const cmd of required) {
it(`${cmd} requires <name>`, () => {
const program = buildProgram();
const c = program.commands.find((x) => x.name() === cmd);
expect(c).toBeDefined();
const arg = c!.registeredArguments[0];
expect(arg).toBeDefined();
expect(arg.required).toBe(true);
});
}
});
describe("expose command", () => {
it("requires <name> and <port>", () => {
const program = buildProgram();
const expose = program.commands.find((c) => c.name() === "expose");
expect(expose!.registeredArguments).toHaveLength(2);
expect(expose!.registeredArguments.every((a) => a.required)).toBe(true);
});
});
describe("worktree command", () => {
it("declares a --base-branch option", () => {
const program = buildProgram();
const worktree = program.commands.find((c) => c.name() === "worktree");
const flags = worktree!.options.map((o) => o.long);
expect(flags).toContain("--base-branch");
});
});
describe("onboard command", () => {
it("declares --force and --only flags", () => {
const program = buildProgram();
const onboard = program.commands.find((c) => c.name() === "onboard");
const flags = onboard!.options.map((o) => o.long);
expect(flags).toContain("--force");
expect(flags).toContain("--only");
});
it("accepts an optional [target] arg", () => {
const program = buildProgram();
const onboard = program.commands.find((c) => c.name() === "onboard");
const arg = onboard!.registeredArguments[0];
expect(arg.name()).toBe("target");
expect(arg.required).toBe(false);
});
});
describe("--version", () => {
it("returns the package.json version (no pi-coding-agent suffix)", async () => {
const program = buildProgram();
// Commander's exitOverride lets us catch the exit instead of process.exit().
program.exitOverride();
let captured = "";
program.configureOutput({
writeOut: (s) => {
captured = s;
},
});
try {
await program.parseAsync(["node", "oh", "--version"]);
} catch {
// exitOverride throws CommanderError on --version
}
expect(captured.trim()).toMatch(/^\d+\.\d+\.\d+/);
expect(captured).not.toContain("pi");
});
});
describe("HOST_ONLY_COMMANDS", () => {
it("contains all container-lifecycle commands", () => {
const expected = ["sandbox", "run", "stop", "clean", "shell", "list"];
for (const cmd of expected) {
expect(HOST_ONLY_COMMANDS.has(cmd)).toBe(true);
}
});
it("does not include sandbox-side commands", () => {
expect(HOST_ONLY_COMMANDS.has("onboard")).toBe(false);
expect(HOST_ONLY_COMMANDS.has("expose")).toBe(false);
expect(HOST_ONLY_COMMANDS.has("harness")).toBe(false);
});
});
@@ -1,113 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { execSync } from "node:child_process";
// Mock fs and child_process before importing config
vi.mock("node:fs", () => ({
existsSync: vi.fn(),
readFileSync: vi.fn(),
}));
vi.mock("node:child_process", () => ({
execSync: vi.fn(),
}));
const mockExistsSync = vi.mocked(existsSync);
const mockReadFileSync = vi.mocked(readFileSync);
const mockExecSync = vi.mocked(execSync);
// Import after mocks are set up
const { SandboxConfig } = await import("../lib/config.js");
describe("SandboxConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: no files exist
mockExistsSync.mockReturnValue(false);
});
it("uses explicit name when provided", () => {
const config = new SandboxConfig({ name: "my-sandbox" });
expect(config.name).toBe("my-sandbox");
});
it("falls back to 'sandbox' when no name can be resolved", () => {
const config = new SandboxConfig();
expect(config.name).toBe("sandbox");
});
it("reads SANDBOX_NAME from .env when init-env.sh exists", () => {
mockExistsSync.mockImplementation((path) => {
if (String(path) === ".devcontainer/init-env.sh") return true;
if (String(path) === ".devcontainer/.env") return true;
return false;
});
mockReadFileSync.mockReturnValue("SANDBOX_NAME=open-harness\n");
const config = new SandboxConfig();
expect(config.name).toBe("open-harness");
expect(mockExecSync).toHaveBeenCalledWith("bash .devcontainer/init-env.sh", { stdio: "pipe" });
});
it("always includes base compose file", () => {
const config = new SandboxConfig({ name: "test" });
expect(config.composeFiles[0]).toBe(".devcontainer/docker-compose.yml");
});
it("appends overlays from config.json when they exist", () => {
mockExistsSync.mockImplementation((path) => {
const p = String(path);
if (p === ".openharness/config.json") return true;
if (p === ".devcontainer/docker-compose.slack.yml") return true;
if (p === ".devcontainer/docker-compose.cloudflared.yml") return true;
return false;
});
mockReadFileSync.mockReturnValue(
JSON.stringify({
composeOverrides: [
".devcontainer/docker-compose.slack.yml",
".devcontainer/docker-compose.cloudflared.yml",
".devcontainer/docker-compose.missing.yml",
],
}),
);
const config = new SandboxConfig({ name: "test" });
expect(config.composeFiles).toEqual([
".devcontainer/docker-compose.yml",
".devcontainer/docker-compose.slack.yml",
".devcontainer/docker-compose.cloudflared.yml",
]);
});
it("skips overlays that do not exist on disk", () => {
mockExistsSync.mockImplementation((path) => {
if (String(path) === ".openharness/config.json") return true;
return false;
});
mockReadFileSync.mockReturnValue(
JSON.stringify({
composeOverrides: [".devcontainer/docker-compose.missing.yml"],
}),
);
const config = new SandboxConfig({ name: "test" });
expect(config.composeFiles).toEqual([".devcontainer/docker-compose.yml"]);
});
it("handles malformed config.json gracefully", () => {
mockExistsSync.mockImplementation((path) => {
if (String(path) === ".openharness/config.json") return true;
return false;
});
mockReadFileSync.mockReturnValue("not valid json");
const config = new SandboxConfig({ name: "test" });
expect(config.composeFiles).toEqual([".devcontainer/docker-compose.yml"]);
});
it("sets envFile to .devcontainer/.env", () => {
const config = new SandboxConfig({ name: "test" });
expect(config.envFile).toBe(".devcontainer/.env");
});
});
@@ -1,140 +0,0 @@
import { describe, it, expect, vi } from "vitest";
// Mock fs and child_process before importing anything that uses config
vi.mock("node:fs", () => ({
existsSync: vi.fn(() => false),
readFileSync: vi.fn(() => ""),
}));
vi.mock("node:child_process", () => ({
execSync: vi.fn(),
}));
const { SandboxConfig } = await import("../lib/config.js");
const { composeCmd, composeEnv, composeUp, composeDown, execCmd, psCmd } =
await import("../lib/docker.js");
function makeConfig(name = "test-agent") {
return new SandboxConfig({ name });
}
describe("docker command builders", () => {
describe("composeCmd", () => {
it("builds base compose command with env-file and project name", () => {
const config = makeConfig();
const cmd = composeCmd(config);
expect(cmd[0]).toBe("docker");
expect(cmd[1]).toBe("compose");
expect(cmd).toContain("--env-file");
expect(cmd).toContain(".devcontainer/.env");
expect(cmd).toContain("-f");
expect(cmd).toContain(".devcontainer/docker-compose.yml");
expect(cmd).toContain("-p");
expect(cmd).toContain("test-agent");
});
it("includes all compose files from config", () => {
// Config with explicit name always has at least the base compose file
const config = makeConfig();
const cmd = composeCmd(config);
const fIndices = cmd.reduce<number[]>((acc, v, i) => (v === "-f" ? [...acc, i] : acc), []);
expect(fIndices.length).toBeGreaterThanOrEqual(1);
expect(cmd[fIndices[0] + 1]).toBe(".devcontainer/docker-compose.yml");
});
});
describe("composeEnv", () => {
it("includes SANDBOX_NAME", () => {
const config = makeConfig();
expect(composeEnv(config)).toEqual({ SANDBOX_NAME: "test-agent" });
});
});
describe("composeUp", () => {
it("appends up -d --build", () => {
const config = makeConfig();
const cmd = composeUp(config);
expect(cmd.slice(-3)).toEqual(["up", "-d", "--build"]);
});
it("omits --build when build: false", () => {
const config = makeConfig();
const cmd = composeUp(config, { build: false });
expect(cmd.slice(-2)).toEqual(["up", "-d"]);
expect(cmd).not.toContain("--build");
});
it("appends --force-recreate when forceRecreate: true", () => {
const config = makeConfig();
const cmd = composeUp(config, { build: false, forceRecreate: true });
expect(cmd.slice(-3)).toEqual(["up", "-d", "--force-recreate"]);
expect(cmd).not.toContain("--build");
});
it("combines --build and --force-recreate", () => {
const config = makeConfig();
const cmd = composeUp(config, { build: true, forceRecreate: true });
expect(cmd.slice(-4)).toEqual(["up", "-d", "--build", "--force-recreate"]);
});
});
describe("composeDown", () => {
it("appends down", () => {
const config = makeConfig();
const cmd = composeDown(config);
expect(cmd[cmd.length - 1]).toBe("down");
});
it("adds -v flag for volume removal", () => {
const config = makeConfig();
const cmd = composeDown(config, true);
expect(cmd).toContain("-v");
});
it("does not add -v flag by default", () => {
const config = makeConfig();
const cmd = composeDown(config);
expect(cmd).not.toContain("-v");
});
});
describe("execCmd", () => {
it("builds basic exec command", () => {
const cmd = execCmd("my-sandbox", ["bash", "--login"]);
expect(cmd).toEqual(["docker", "exec", "my-sandbox", "bash", "--login"]);
});
it("adds --user flag", () => {
const cmd = execCmd("my-sandbox", ["bash"], { user: "sandbox" });
expect(cmd).toContain("--user");
expect(cmd).toContain("sandbox");
});
it("adds -it flags for interactive", () => {
const cmd = execCmd("my-sandbox", ["bash"], { interactive: true });
expect(cmd).toContain("-it");
});
it("adds -w workdir flag", () => {
const cmd = execCmd("my-sandbox", ["ls"], { workdir: "/home/sandbox/workspace" });
expect(cmd).toContain("-w");
expect(cmd).toContain("/home/sandbox/workspace");
});
it("adds -e env flags", () => {
const cmd = execCmd("my-sandbox", ["bash"], { env: { HOME: "/home/sandbox" } });
expect(cmd).toContain("-e");
expect(cmd).toContain("HOME=/home/sandbox");
});
});
describe("psCmd", () => {
it("builds docker ps with sandbox filter", () => {
const cmd = psCmd();
expect(cmd[0]).toBe("docker");
expect(cmd[1]).toBe("ps");
expect(cmd).toContain("--filter");
expect(cmd).toContain("label=com.docker.compose.service=sandbox");
});
});
});
@@ -1,163 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
// In-memory filesystem state — keyed by resolved path string
const fs = new Map<string, string>();
vi.mock("node:fs", () => ({
existsSync: (p: string) => fs.has(p),
readFileSync: (p: string) => fs.get(p) ?? "",
writeFileSync: (p: string, data: string) => {
fs.set(p, data);
},
renameSync: (a: string, b: string) => {
const v = fs.get(a);
fs.delete(a);
if (v !== undefined) fs.set(b, v);
},
mkdirSync: () => {},
}));
beforeEach(() => fs.clear());
const {
readExposures,
writeExposures,
upsertExposure,
removeRoute,
findRouteByName,
findRouteByPort,
listRoutes,
} = await import("../lib/exposures.js");
function mkRoute(routeName: string, port: number, sandbox = "oh-local") {
return {
routeName,
port,
sandbox,
url: `https://${routeName}.${sandbox}.localhost:8443`,
createdAt: "2026-04-22T00:00:00.000Z",
};
}
describe("readExposures", () => {
it("returns empty file when path does not exist", () => {
expect(readExposures()).toEqual({ version: 1, exposures: [] });
});
it("returns empty and warns on malformed JSON", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { resolve } = await import("node:path");
fs.set(resolve(".openharness/exposures.json"), "not valid json {{");
expect(readExposures()).toEqual({ version: 1, exposures: [] });
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("failed to parse"));
warnSpy.mockRestore();
});
it("returns empty and warns on unknown version", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { resolve } = await import("node:path");
fs.set(resolve(".openharness/exposures.json"), JSON.stringify({ version: 99, exposures: [] }));
expect(readExposures()).toEqual({ version: 1, exposures: [] });
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("unknown version"));
warnSpy.mockRestore();
});
it("silently drops entries that do not match the current schema", async () => {
const { resolve } = await import("node:path");
// Legacy scope-union entries (no routeName) must be filtered out.
const legacy = { port: 3000, scope: "local", hostPort: 3000, createdAt: "x" };
const ok = mkRoute("docs", 8080);
fs.set(
resolve(".openharness/exposures.json"),
JSON.stringify({ version: 1, exposures: [legacy, ok] }),
);
expect(readExposures().exposures).toEqual([ok]);
});
});
describe("writeExposures / readExposures round-trip", () => {
it("persists and reads back a route correctly", () => {
const r = mkRoute("docs", 8080);
writeExposures({ version: 1, exposures: [r] });
expect(readExposures()).toEqual({ version: 1, exposures: [r] });
});
it("writes via tmp file then renames atomically", async () => {
const { resolve } = await import("node:path");
const finalPath = resolve(".openharness/exposures.json");
writeExposures({ version: 1, exposures: [] });
expect(fs.has(finalPath)).toBe(true);
expect(fs.has(`${finalPath}.tmp`)).toBe(false);
});
});
describe("upsertExposure", () => {
it("adds a new route when none exists", () => {
const r = mkRoute("docs", 8080);
upsertExposure(r);
expect(readExposures().exposures).toEqual([r]);
});
it("replaces an existing entry with the same routeName", () => {
upsertExposure(mkRoute("docs", 8080));
const updated = { ...mkRoute("docs", 9000), createdAt: "2026-06-01T00:00:00.000Z" };
upsertExposure(updated);
expect(readExposures().exposures).toEqual([updated]);
});
it("does not replace entries with different routeNames", () => {
upsertExposure(mkRoute("docs", 8080));
upsertExposure(mkRoute("api", 3000));
expect(readExposures().exposures).toHaveLength(2);
});
});
describe("removeRoute", () => {
it("removes an existing route and returns true", () => {
upsertExposure(mkRoute("docs", 8080));
expect(removeRoute("docs")).toBe(true);
expect(readExposures().exposures).toHaveLength(0);
});
it("returns false when route does not exist", () => {
upsertExposure(mkRoute("docs", 8080));
expect(removeRoute("api")).toBe(false);
expect(readExposures().exposures).toHaveLength(1);
});
it("leaves unrelated routes intact", () => {
upsertExposure(mkRoute("docs", 8080));
upsertExposure(mkRoute("api", 3000));
removeRoute("docs");
expect(readExposures().exposures.map((e) => e.routeName)).toEqual(["api"]);
});
});
describe("findRouteByName / findRouteByPort / listRoutes", () => {
beforeEach(() => {
upsertExposure(mkRoute("docs", 8080));
upsertExposure(mkRoute("api", 3000));
});
it("finds by name", () => {
expect(findRouteByName("docs")?.port).toBe(8080);
});
it("finds by port", () => {
expect(findRouteByPort(3000)?.routeName).toBe("api");
});
it("returns undefined for unknown name", () => {
expect(findRouteByName("nope")).toBeUndefined();
});
it("returns undefined for unknown port", () => {
expect(findRouteByPort(9999)).toBeUndefined();
});
it("lists all routes", () => {
expect(listRoutes()).toHaveLength(2);
});
});
@@ -1,360 +0,0 @@
import { describe, expect, it } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
installPack,
resolveSpec,
uninstallPack,
validateManifest,
type PackEnv,
} from "../harness/pack.js";
import {
addInstalled,
parseHarnessRegistry,
readHarnessRegistry,
removeInstalled,
writeHarnessRegistry,
type InstalledHarness,
} from "../harness/registry.js";
import type { HarnessPack } from "../onboard/types.js";
function makeTempDir(): string {
return mkdtempSync(join(tmpdir(), "oh-harness-pack-"));
}
function writeManifest(dir: string, manifest: HarnessPack): void {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "harness.json"), JSON.stringify(manifest, null, 2));
}
function fakeManifest(over: Partial<HarnessPack> = {}): HarnessPack {
return {
name: "fake-pack",
version: "1.0.0",
description: "fixture",
openharness: ">=2026.5.2",
agents: [],
compose_overlays: [],
onboard_steps: [],
install_hook: "install-hook.sh",
entrypoint_hook: "entrypoint-hook.sh",
workspace_seed: "workspace-seed/",
...over,
};
}
// ─── resolveSpec ────────────────────────────────────────────────────────────
describe("resolveSpec", () => {
it("classifies @scope/name as npm", () => {
expect(resolveSpec("@ryaneggz/mifune")).toEqual({
mode: "npm",
spec: "@ryaneggz/mifune",
identifier: "@ryaneggz/mifune",
});
});
it("classifies bare package-name as npm", () => {
expect(resolveSpec("mifune")).toEqual({
mode: "npm",
spec: "mifune",
identifier: "mifune",
});
});
it("classifies owner/repo as git (GitHub HTTPS)", () => {
expect(resolveSpec("ryaneggz/mifune")).toEqual({
mode: "git",
spec: "ryaneggz/mifune",
identifier: "https://github.com/ryaneggz/mifune.git",
});
});
it("classifies https URL as git", () => {
const r = resolveSpec("https://github.com/ryaneggz/mifune.git");
expect(r.mode).toBe("git");
expect(r.identifier).toBe("https://github.com/ryaneggz/mifune.git");
});
it("classifies git@ URL as git", () => {
const r = resolveSpec("git@github.com:ryaneggz/mifune.git");
expect(r.mode).toBe("git");
});
it("classifies ./relative as local", () => {
const r = resolveSpec("./local/path");
expect(r.mode).toBe("local");
expect(r.identifier).toMatch(/local\/path$/);
});
it("classifies absolute path as local", () => {
const r = resolveSpec("/abs/path");
expect(r.mode).toBe("local");
expect(r.identifier).toBe("/abs/path");
});
it("trims whitespace before classifying", () => {
expect(resolveSpec(" @ryaneggz/mifune ").mode).toBe("npm");
});
it("throws on empty spec", () => {
expect(() => resolveSpec(" ")).toThrow();
});
});
// ─── validateManifest ───────────────────────────────────────────────────────
describe("validateManifest", () => {
it("accepts a complete manifest", () => {
expect(validateManifest(fakeManifest())).not.toBeNull();
});
it("rejects null/undefined/non-object input", () => {
expect(validateManifest(null)).toBeNull();
expect(validateManifest(undefined)).toBeNull();
expect(validateManifest("string")).toBeNull();
expect(validateManifest(42)).toBeNull();
});
it("rejects manifests missing required keys", () => {
const m = fakeManifest() as unknown as Record<string, unknown>;
delete m.compose_overlays;
expect(validateManifest(m)).toBeNull();
});
it("rejects wrong-typed fields", () => {
expect(validateManifest({ ...fakeManifest(), agents: "should be array" })).toBeNull();
expect(validateManifest({ ...fakeManifest(), version: 42 })).toBeNull();
});
});
// ─── registry round-trip ───────────────────────────────────────────────────
describe("registry round-trip", () => {
it("readHarnessRegistry returns empty for missing file", () => {
expect(readHarnessRegistry(join(makeTempDir(), "missing.json"))).toEqual({ installed: [] });
});
it("write + read round-trips", () => {
const tmp = makeTempDir();
const path = join(tmp, "harnesses.json");
const entry: InstalledHarness = {
name: "fake-pack",
path: "/some/where",
version: "1.0.0",
source: "npm",
spec: "@ryaneggz/mifune",
manifest: fakeManifest(),
};
writeHarnessRegistry({ installed: [entry] }, path);
const parsed = readHarnessRegistry(path);
expect(parsed.installed).toHaveLength(1);
expect(parsed.installed[0]).toMatchObject({
name: "fake-pack",
source: "npm",
spec: "@ryaneggz/mifune",
});
rmSync(tmp, { recursive: true, force: true });
});
it("addInstalled replaces an existing entry by name", () => {
const tmp = makeTempDir();
const path = join(tmp, "harnesses.json");
const entry: InstalledHarness = {
name: "fake-pack",
path: "/v1",
version: "1.0.0",
source: "git",
spec: "ryaneggz/mifune",
manifest: fakeManifest(),
};
addInstalled(entry, path);
addInstalled({ ...entry, version: "2.0.0", path: "/v2" }, path);
const parsed = readHarnessRegistry(path);
expect(parsed.installed).toHaveLength(1);
expect(parsed.installed[0].version).toBe("2.0.0");
rmSync(tmp, { recursive: true, force: true });
});
it("removeInstalled deletes by name and returns the removed entry", () => {
const tmp = makeTempDir();
const path = join(tmp, "harnesses.json");
const entry: InstalledHarness = {
name: "fake-pack",
path: "/x",
version: "1.0.0",
source: "local",
spec: "/x",
manifest: fakeManifest(),
};
addInstalled(entry, path);
const removed = removeInstalled("fake-pack", path);
expect(removed?.name).toBe("fake-pack");
expect(readHarnessRegistry(path).installed).toEqual([]);
rmSync(tmp, { recursive: true, force: true });
});
it("removeInstalled returns null when the pack is absent", () => {
const tmp = makeTempDir();
const path = join(tmp, "harnesses.json");
writeHarnessRegistry({ installed: [] }, path);
expect(removeInstalled("never", path)).toBeNull();
rmSync(tmp, { recursive: true, force: true });
});
it("parseHarnessRegistry upgrades pre-T5 entries (no source/spec)", () => {
const json = JSON.stringify({
installed: [
{
name: "old",
path: "/p",
version: "0.1.0",
manifest: fakeManifest(),
},
],
});
const r = parseHarnessRegistry(json);
expect(r.installed[0].source).toBe("local");
expect(r.installed[0].spec).toBe("/p");
});
});
// ─── installPack / uninstallPack ────────────────────────────────────────────
describe("installPack (local mode)", () => {
it("registers a local pack with valid harness.json", async () => {
const tmp = makeTempDir();
const packDir = join(tmp, "fake-pack");
writeManifest(packDir, fakeManifest({ name: "test-pack", agents: ["pi"] }));
// Stub install_hook + entrypoint_hook so they exist
writeFileSync(join(packDir, "install-hook.sh"), "#!/bin/sh\nexit 0\n");
writeFileSync(join(packDir, "entrypoint-hook.sh"), "#!/bin/sh\nexit 0\n");
const registryPath = join(tmp, "harnesses.json");
const binDir = join(tmp, "bin");
mkdirSync(binDir);
const env: PackEnv = {
exec: () => undefined, // pretend install_hook runs cleanly
capture: () => "",
registryPath,
entrypointBinDir: binDir,
};
const result = await installPack(packDir, env);
expect(result.entry.name).toBe("test-pack");
expect(result.entry.source).toBe("local");
const registry = readHarnessRegistry(registryPath);
expect(registry.installed).toHaveLength(1);
// Verify entrypoint hook was symlinked
expect(existsSync(join(binDir, "test-pack-entrypoint-hook.sh"))).toBe(true);
rmSync(tmp, { recursive: true, force: true });
});
it("warns when install_hook is missing but still registers the pack", async () => {
const tmp = makeTempDir();
const packDir = join(tmp, "fake-pack");
writeManifest(packDir, fakeManifest({ name: "no-hook" }));
// Deliberately omit install-hook.sh
const registryPath = join(tmp, "harnesses.json");
const env: PackEnv = {
exec: () => undefined,
capture: () => "",
registryPath,
entrypointBinDir: join(tmp, "bin"),
};
const result = await installPack(packDir, env);
expect(result.entry.name).toBe("no-hook");
expect(result.warnings.some((w) => w.includes("install_hook"))).toBe(true);
rmSync(tmp, { recursive: true, force: true });
});
it("uninstallPack removes the registry entry and the entrypoint symlink", async () => {
const tmp = makeTempDir();
const packDir = join(tmp, "fake-pack");
writeManifest(packDir, fakeManifest({ name: "uninstall-me" }));
writeFileSync(join(packDir, "install-hook.sh"), "#!/bin/sh\nexit 0\n");
writeFileSync(join(packDir, "entrypoint-hook.sh"), "#!/bin/sh\nexit 0\n");
const registryPath = join(tmp, "harnesses.json");
const binDir = join(tmp, "bin");
mkdirSync(binDir);
const env: PackEnv = {
exec: () => undefined,
capture: () => "",
registryPath,
entrypointBinDir: binDir,
};
await installPack(packDir, env);
expect(existsSync(join(binDir, "uninstall-me-entrypoint-hook.sh"))).toBe(true);
const result = await uninstallPack("uninstall-me", env);
expect(result.removed?.name).toBe("uninstall-me");
expect(readHarnessRegistry(registryPath).installed).toEqual([]);
expect(existsSync(join(binDir, "uninstall-me-entrypoint-hook.sh"))).toBe(false);
rmSync(tmp, { recursive: true, force: true });
});
it("uninstallPack on absent name returns removed=null", async () => {
const tmp = makeTempDir();
const env: PackEnv = {
exec: () => undefined,
capture: () => "",
registryPath: join(tmp, "harnesses.json"),
entrypointBinDir: join(tmp, "bin"),
};
const result = await uninstallPack("never", env);
expect(result.removed).toBeNull();
rmSync(tmp, { recursive: true, force: true });
});
it("throws on a pack directory with no harness.json or package.json#harness", async () => {
const tmp = makeTempDir();
const packDir = join(tmp, "empty");
mkdirSync(packDir);
const env: PackEnv = {
exec: () => undefined,
capture: () => "",
registryPath: join(tmp, "harnesses.json"),
};
await expect(installPack(packDir, env)).rejects.toThrow(/harness\.json/);
rmSync(tmp, { recursive: true, force: true });
});
it("reads manifest from package.json#harness when harness.json is absent", async () => {
const tmp = makeTempDir();
const packDir = join(tmp, "npm-style");
mkdirSync(packDir);
const manifest = fakeManifest({ name: "npm-style-pack" });
writeFileSync(
join(packDir, "package.json"),
JSON.stringify({ name: "@scope/npm-style-pack", harness: manifest }),
);
writeFileSync(join(packDir, "install-hook.sh"), "#!/bin/sh\nexit 0\n");
writeFileSync(join(packDir, "entrypoint-hook.sh"), "#!/bin/sh\nexit 0\n");
const registryPath = join(tmp, "harnesses.json");
const env: PackEnv = {
exec: () => undefined,
capture: () => "",
registryPath,
entrypointBinDir: join(tmp, "bin"),
};
mkdirSync(join(tmp, "bin"));
const result = await installPack(packDir, env);
expect(result.entry.name).toBe("npm-style-pack");
rmSync(tmp, { recursive: true, force: true });
});
});
@@ -1,45 +0,0 @@
import { describe, it, expect } from "vitest";
import { parseArgs } from "../onboard/args.js";
import { UnknownStepError } from "../onboard/types.js";
describe("onboard parseArgs", () => {
it("empty argv -> force false, no only", () => {
expect(parseArgs([])).toEqual({ force: false, only: undefined });
});
it("--force flips force", () => {
expect(parseArgs(["--force"])).toEqual({ force: true, only: undefined });
});
it("--only <step> sets only", () => {
expect(parseArgs(["--only", "slack"])).toEqual({ force: false, only: "slack" });
});
it("--only=<step> sets only", () => {
expect(parseArgs(["--only=cloudflare"])).toEqual({ force: false, only: "cloudflare" });
});
it("positional step alias sets only", () => {
expect(parseArgs(["claude"])).toEqual({ force: false, only: "claude" });
});
it("--force and --only can combine", () => {
expect(parseArgs(["--force", "--only", "slack"])).toEqual({ force: true, only: "slack" });
});
it("unknown --only throws UnknownStepError", () => {
expect(() => parseArgs(["--only", "whatever"])).toThrow(UnknownStepError);
});
it("unknown --only=value throws UnknownStepError", () => {
expect(() => parseArgs(["--only=bogus"])).toThrow(UnknownStepError);
});
it("ignores unrecognized flags (bash script did too)", () => {
expect(parseArgs(["--weird-flag", "--force"])).toEqual({ force: true, only: undefined });
});
it("positional non-step args are ignored (no crash)", () => {
expect(parseArgs(["not-a-step"])).toEqual({ force: false, only: undefined });
});
});
@@ -1,56 +0,0 @@
import { describe, it, expect } from "vitest";
import { claudeStep } from "../onboard/steps/claude.js";
import { execWasCalled, ioMessages, makeFakeDeps } from "../onboard/testing/fake-deps.js";
describe("claude step", () => {
it("returns done when ~/.claude/.credentials.json exists", async () => {
const deps = makeFakeDeps({
files: { "/home/sandbox/.claude/.credentials.json": "{}" },
});
const result = await claudeStep.run(deps, { force: false });
expect(result).toEqual({ id: "claude", status: "done" });
expect(ioMessages(deps, "ok")).toContain("Claude Code already authenticated");
expect(execWasCalled(deps, /claude/)).toBe(false);
});
it("accepts the alternate credentials.json path", async () => {
const deps = makeFakeDeps({
files: { "/home/sandbox/.claude/credentials.json": "{}" },
});
const result = await claudeStep.run(deps, { force: false });
expect(result.status).toBe("done");
});
it("prompts; 'n' answer returns skipped without running claude", async () => {
const deps = makeFakeDeps({ askAnswers: ["n"] });
const result = await claudeStep.run(deps, { force: false });
expect(result).toEqual({ id: "claude", status: "skipped" });
expect(execWasCalled(deps, /claude/)).toBe(false);
});
it("prompts; blank answer (default yes) runs claude --version", async () => {
const deps = makeFakeDeps({
askAnswers: [""],
execStubs: [{ match: /^claude --version$/, result: { status: 0, stdout: "", stderr: "" } }],
});
const result = await claudeStep.run(deps, { force: false });
expect(result).toEqual({ id: "claude", status: "done" });
expect(execWasCalled(deps, /^claude --version$/)).toBe(true);
expect(ioMessages(deps, "ok")).toContain("Claude Code ready");
});
it("claude --version failing still records done but warns", async () => {
const deps = makeFakeDeps({
askAnswers: ["y"],
execStubs: [
{
match: /^claude --version$/,
result: { status: 1, stdout: "", stderr: "boom" },
},
],
});
const result = await claudeStep.run(deps, { force: false });
expect(result.status).toBe("done");
expect(ioMessages(deps, "warn")).toContain("Run 'claude' manually to complete auth");
});
});
@@ -1,41 +0,0 @@
import { describe, it, expect } from "vitest";
import { normalizeOnboardOpts } from "../cli/onboard.js";
describe("normalizeOnboardOpts", () => {
it("rewrites a step-id name into only", () => {
expect(normalizeOnboardOpts({ name: "slack" })).toEqual({
name: undefined,
only: "slack",
});
});
it("leaves a non-step name alone (host mode against a real container)", () => {
expect(normalizeOnboardOpts({ name: "my-sandbox" })).toEqual({ name: "my-sandbox" });
});
it("preserves only when both name and only are set (host mode + step)", () => {
expect(normalizeOnboardOpts({ name: "my-sandbox", only: "slack" })).toEqual({
name: "my-sandbox",
only: "slack",
});
});
it("does not overwrite explicit only with a step-id name", () => {
expect(normalizeOnboardOpts({ name: "slack", only: "llm" })).toEqual({
name: "slack",
only: "llm",
});
});
it("passes through force", () => {
expect(normalizeOnboardOpts({ name: "slack", force: true })).toEqual({
name: undefined,
only: "slack",
force: true,
});
});
it("no-ops on empty opts", () => {
expect(normalizeOnboardOpts({})).toEqual({});
});
});
@@ -1,109 +0,0 @@
import { describe, it, expect } from "vitest";
import { cloudflareStep } from "../onboard/steps/cloudflare.js";
import { execWasCalled, ioMessages, makeFakeDeps } from "../onboard/testing/fake-deps.js";
describe("cloudflare step", () => {
it("no cloudflared binary → skipped", async () => {
const deps = makeFakeDeps({ which: { cloudflared: null } });
const result = await cloudflareStep.run(deps, { force: false });
expect(result.status).toBe("skipped");
expect(ioMessages(deps, "skip")).toContain("cloudflared not installed — skipping");
});
it("existing tunnel config + !force → done, no prompts", async () => {
const deps = makeFakeDeps({
which: { cloudflared: "/usr/bin/cloudflared" },
files: { "/home/sandbox/.cloudflared": "dir" },
execStubs: [
{
match: (cmd) => cmd[0] === "sh" && cmd[2].includes("config-*.yml"),
result: {
status: 0,
stdout: "/home/sandbox/.cloudflared/config-open-harness.yml\n",
stderr: "",
},
},
],
});
const result = await cloudflareStep.run(deps, { force: false });
expect(result.status).toBe("done");
expect(ioMessages(deps, "ok").some((m) => m.includes("Tunnel 'open-harness'"))).toBe(true);
});
it("no cert.pem + user declines → skipped", async () => {
const deps = makeFakeDeps({
which: { cloudflared: "/usr/bin/cloudflared" },
askAnswers: ["n"],
execStubs: [
{
match: (cmd) => cmd[0] === "sh" && cmd[2].includes("config-*.yml"),
result: { status: 1, stdout: "", stderr: "" },
},
],
});
const result = await cloudflareStep.run(deps, { force: false });
expect(result.status).toBe("skipped");
});
it("no cert.pem + user accepts + login still leaves no cert → failed", async () => {
const deps = makeFakeDeps({
which: { cloudflared: "/usr/bin/cloudflared" },
askAnswers: ["y"],
execStubs: [
{
match: (cmd) => cmd[0] === "sh" && cmd[2].includes("config-*.yml"),
result: { status: 1, stdout: "", stderr: "" },
},
{ match: /^cloudflared login$/, result: { status: 0, stdout: "", stderr: "" } },
],
});
const result = await cloudflareStep.run(deps, { force: false });
expect(result.status).toBe("failed");
expect(execWasCalled(deps, /^cloudflared login$/)).toBe(true);
});
it("existing cert.pem + prompts → delegates to cloudflared-tunnel.sh", async () => {
const deps = makeFakeDeps({
which: { cloudflared: "/usr/bin/cloudflared" },
files: { "/home/sandbox/.cloudflared/cert.pem": "pem" },
askAnswers: ["my-tun", "my-tun.example.com", ""],
execStubs: [
{
match: (cmd) => cmd[0] === "sh" && cmd[2].includes("config-*.yml"),
result: { status: 1, stdout: "", stderr: "" },
},
{ match: /cloudflared-tunnel\.sh/, result: { status: 0, stdout: "", stderr: "" } },
],
});
const result = await cloudflareStep.run(deps, { force: false });
expect(result.status).toBe("done");
expect(
execWasCalled(
deps,
(cmd) =>
cmd[0] === "bash" &&
cmd[1].endsWith("/install/cloudflared-tunnel.sh") &&
cmd[2] === "my-tun" &&
cmd[3] === "my-tun.example.com" &&
cmd[4] === "3000",
),
).toBe(true);
});
it("empty hostname → failed", async () => {
const deps = makeFakeDeps({
which: { cloudflared: "/usr/bin/cloudflared" },
files: { "/home/sandbox/.cloudflared/cert.pem": "pem" },
askAnswers: ["my-tun", ""],
execStubs: [
{
match: (cmd) => cmd[0] === "sh" && cmd[2].includes("config-*.yml"),
result: { status: 1, stdout: "", stderr: "" },
},
],
});
const result = await cloudflareStep.run(deps, { force: false });
expect(result.status).toBe("failed");
expect(ioMessages(deps, "fail").some((m) => m.includes("hostname"))).toBe(true);
});
});
@@ -1,114 +0,0 @@
import { describe, it, expect } from "vitest";
import { loadEnvInto, parseEnvFile, upsertEnvFile } from "../onboard/env.js";
import { makeFakeDeps } from "../onboard/testing/fake-deps.js";
describe("parseEnvFile", () => {
it("parses KEY=VALUE lines", () => {
const parsed = parseEnvFile("SLACK_APP_TOKEN=xapp-1\nSLACK_BOT_TOKEN=xoxb-1\n");
expect(parsed.SLACK_APP_TOKEN).toBe("xapp-1");
expect(parsed.SLACK_BOT_TOKEN).toBe("xoxb-1");
});
it("ignores blank lines and comments", () => {
const parsed = parseEnvFile("# header\n\nFOO=bar\n");
expect(parsed).toEqual({ FOO: "bar" });
});
it("strips surrounding quotes", () => {
expect(parseEnvFile('FOO="bar"').FOO).toBe("bar");
expect(parseEnvFile("FOO='bar'").FOO).toBe("bar");
});
it("last occurrence wins for duplicate keys", () => {
expect(parseEnvFile("FOO=a\nFOO=b\n").FOO).toBe("b");
});
});
describe("loadEnvInto", () => {
it("adds missing keys, preserves existing keys", () => {
const deps = makeFakeDeps({
files: { "/home/sandbox/harness/.devcontainer/.env": "FOO=a\nBAR=b\n" },
});
const target: Record<string, string | undefined> = { FOO: "preexisting" };
loadEnvInto(deps.fs, "/home/sandbox/harness/.devcontainer/.env", target);
expect(target.FOO).toBe("preexisting");
expect(target.BAR).toBe("b");
});
it("ignores commented-out prior values and loads only the live one", () => {
const deps = makeFakeDeps({
files: {
"/env": "# FOO=old1\n# FOO=old2\nFOO=current\n",
},
});
const target: Record<string, string | undefined> = {};
loadEnvInto(deps.fs, "/env", target);
expect(target.FOO).toBe("current");
});
it("empty value counts as missing and gets overwritten", () => {
const deps = makeFakeDeps({ files: { "/env": "FOO=real\n" } });
const target: Record<string, string | undefined> = { FOO: "" };
loadEnvInto(deps.fs, "/env", target);
expect(target.FOO).toBe("real");
});
it("no-op when file does not exist", () => {
const deps = makeFakeDeps();
const target: Record<string, string | undefined> = {};
loadEnvInto(deps.fs, "/missing", target);
expect(target).toEqual({});
});
});
describe("upsertEnvFile", () => {
it("creates the file when absent", () => {
const deps = makeFakeDeps();
upsertEnvFile(deps.fs, "/env", { FOO: "bar" });
expect(deps.files.get("/env")).toBe("FOO=bar\n");
});
it("comments out existing key in place and inserts new value after it", () => {
const deps = makeFakeDeps({
files: { "/env": "KEEP=me\nSLACK_APP_TOKEN=old\nTRAILING=yes\n" },
});
upsertEnvFile(deps.fs, "/env", { SLACK_APP_TOKEN: "new" });
expect(deps.files.get("/env")).toBe(
"KEEP=me\n# SLACK_APP_TOKEN=old\nSLACK_APP_TOKEN=new\nTRAILING=yes\n",
);
});
it("appends at end when the key has no prior occurrence", () => {
const deps = makeFakeDeps({ files: { "/env": "A=1\n" } });
upsertEnvFile(deps.fs, "/env", { B: "2" });
expect(deps.files.get("/env")).toBe("A=1\nB=2\n");
});
it("multiple existing occurrences: comments each, inserts new after the last", () => {
const deps = makeFakeDeps({
files: { "/env": "FOO=a\nMID=x\nFOO=b\nEND=y\n" },
});
upsertEnvFile(deps.fs, "/env", { FOO: "c" });
expect(deps.files.get("/env")).toBe("# FOO=a\nMID=x\n# FOO=b\nFOO=c\nEND=y\n");
});
it("leaves already-commented matching lines untouched", () => {
const deps = makeFakeDeps({
files: { "/env": "# FOO=old-disabled\nFOO=active\n" },
});
upsertEnvFile(deps.fs, "/env", { FOO: "new" });
expect(deps.files.get("/env")).toBe("# FOO=old-disabled\n# FOO=active\nFOO=new\n");
});
it("handles multi-key upsert on empty file", () => {
const deps = makeFakeDeps();
upsertEnvFile(deps.fs, "/env", { A: "1", B: "2" });
expect(deps.files.get("/env")).toBe("A=1\nB=2\n");
});
it("multi-key upsert where one key matches, one is new", () => {
const deps = makeFakeDeps({ files: { "/env": "A=1\n" } });
upsertEnvFile(deps.fs, "/env", { A: "2", B: "3" });
expect(deps.files.get("/env")).toBe("# A=1\nA=2\nB=3\n");
});
});
@@ -1,44 +0,0 @@
import { describe, it, expect } from "vitest";
import { githubStep } from "../onboard/steps/github.js";
import { execWasCalled, ioMessages, makeFakeDeps } from "../onboard/testing/fake-deps.js";
describe("github step", () => {
it("already authed → setup-git runs, returns done", async () => {
const deps = makeFakeDeps({
execStubs: [
{ match: /^gh auth status$/, result: { status: 0, stdout: "", stderr: "" } },
{ match: /^gh auth setup-git$/, result: { status: 0, stdout: "", stderr: "" } },
],
});
const result = await githubStep.run(deps, { force: false });
expect(result.status).toBe("done");
expect(execWasCalled(deps, /^gh auth setup-git$/)).toBe(true);
expect(ioMessages(deps, "ok")).toContain("GitHub CLI already authenticated");
});
it("not authed → runs gh auth login; success → done", async () => {
const deps = makeFakeDeps({
execStubs: [
{ match: /^gh auth status$/, result: { status: 1, stdout: "", stderr: "" } },
{ match: /^gh auth login$/, result: { status: 0, stdout: "", stderr: "" } },
{ match: /^gh auth setup-git$/, result: { status: 0, stdout: "", stderr: "" } },
],
});
const result = await githubStep.run(deps, { force: false });
expect(result.status).toBe("done");
expect(execWasCalled(deps, /^gh auth login$/)).toBe(true);
expect(ioMessages(deps, "ok")).toContain("GitHub CLI authenticated");
});
it("gh auth login failure → failed", async () => {
const deps = makeFakeDeps({
execStubs: [
{ match: /^gh auth status$/, result: { status: 1, stdout: "", stderr: "" } },
{ match: /^gh auth login$/, result: { status: 1, stdout: "", stderr: "" } },
],
});
const result = await githubStep.run(deps, { force: false });
expect(result.status).toBe("failed");
expect(ioMessages(deps, "fail")).toContain("GitHub CLI authentication failed");
});
});
@@ -1,68 +0,0 @@
import { describe, it, expect } from "vitest";
import { llmStep } from "../onboard/steps/llm.js";
import { execWasCalled, ioMessages, makeFakeDeps } from "../onboard/testing/fake-deps.js";
describe("llm step", () => {
it("populated auth.json → done, no prompt", async () => {
const deps = makeFakeDeps({
files: { "/home/sandbox/.pi/agent/auth.json": JSON.stringify({ openai: { key: "x" } }) },
});
const result = await llmStep.run(deps, { force: false });
expect(result.status).toBe("done");
expect(ioMessages(deps, "ok").some((m) => m.startsWith("Already authenticated"))).toBe(true);
expect(execWasCalled(deps, /^openharness$/)).toBe(false);
});
it("empty '{}' auth.json is treated as missing", async () => {
const deps = makeFakeDeps({
env: { OPENAI_API_KEY: "sk-1234" },
files: { "/home/sandbox/.pi/agent/auth.json": "{}" },
});
const result = await llmStep.run(deps, { force: false });
expect(result.status).toBe("done");
expect(ioMessages(deps, "ok")).toContain("OPENAI_API_KEY set via environment");
});
it("no auth, no env, user declines → skipped", async () => {
const deps = makeFakeDeps({ askAnswers: ["n"] });
const result = await llmStep.run(deps, { force: false });
expect(result.status).toBe("skipped");
expect(execWasCalled(deps, /^openharness$/)).toBe(false);
});
it("no auth, user accepts, openharness populates auth.json → done + slack symlink", async () => {
const deps = makeFakeDeps({
askAnswers: [""],
execStubs: [
{
match: /^openharness$/,
sideEffect: () => {
deps.files.set(
"/home/sandbox/.pi/agent/auth.json",
JSON.stringify({ openai: { key: "x" } }),
);
},
result: { status: 0, stdout: "", stderr: "" },
},
],
});
const result = await llmStep.run(deps, { force: false });
expect(result.status).toBe("done");
expect(deps.recorder.symlinks).toContainEqual({
target: "/home/sandbox/.pi/agent/auth.json",
link: "/home/sandbox/.pi/slack/auth.json",
});
});
it("no auth, user accepts, openharness leaves auth unset → skipped + warn", async () => {
const deps = makeFakeDeps({
askAnswers: ["y"],
execStubs: [{ match: /^openharness$/, result: { status: 0, stdout: "", stderr: "" } }],
});
const result = await llmStep.run(deps, { force: false });
expect(result.status).toBe("skipped");
expect(ioMessages(deps, "warn")).toContain(
"Auth not detected — run 'openharness' and '/login' later",
);
});
});
@@ -1,65 +0,0 @@
import { describe, it, expect } from "vitest";
import * as marker from "../onboard/marker.js";
import { makeFakeDeps } from "../onboard/testing/fake-deps.js";
describe("onboard marker", () => {
it("markerPath is ~/.claude/.onboarded", () => {
expect(marker.markerPath("/home/sandbox")).toBe("/home/sandbox/.claude/.onboarded");
});
it("exists returns false when file is absent", () => {
const deps = makeFakeDeps();
expect(marker.exists(deps.fs, deps.home)).toBe(false);
});
it("read returns parsed marker when file exists", () => {
const path = "/home/sandbox/.claude/.onboarded";
const deps = makeFakeDeps({
files: {
[path]: JSON.stringify({
version: 1,
completedAt: "2026-04-21T12:00:00Z",
steps: { llm: { status: "done" } },
}),
},
});
const parsed = marker.read(deps.fs, deps.home);
expect(parsed?.version).toBe(1);
expect(parsed?.completedAt).toBe("2026-04-21T12:00:00Z");
expect(parsed?.steps.llm.status).toBe("done");
});
it("read returns null on invalid JSON", () => {
const path = "/home/sandbox/.claude/.onboarded";
const deps = makeFakeDeps({ files: { [path]: "not json" } });
expect(marker.read(deps.fs, deps.home)).toBeNull();
});
it("write creates a file with the full schema", () => {
const deps = makeFakeDeps();
marker.write(deps.fs, deps.home, "2026-04-21T00:00:00Z", {
llm: "done",
slack: "skipped",
ssh: "done",
github: "done",
cloudflare: "skipped",
claude: "done",
});
const path = "/home/sandbox/.claude/.onboarded";
expect(deps.files.has(path)).toBe(true);
const parsed = JSON.parse(deps.files.get(path)!);
expect(parsed.version).toBe(1);
expect(parsed.completedAt).toBe("2026-04-21T00:00:00Z");
expect(parsed.steps.llm.status).toBe("done");
expect(parsed.steps.slack.status).toBe("skipped");
expect(parsed.steps.cloudflare.status).toBe("skipped");
});
it("round-trip: write then read yields the same object", () => {
const deps = makeFakeDeps();
marker.write(deps.fs, deps.home, "2026-04-21T00:00:00Z", { llm: "done" });
const back = marker.read(deps.fs, deps.home);
expect(back?.steps.llm.status).toBe("done");
});
});
@@ -1,130 +0,0 @@
import { describe, it, expect } from "vitest";
import { runOnboarding } from "../onboard/orchestrator.js";
import { makeFakeDeps } from "../onboard/testing/fake-deps.js";
import type { Step, StepId, StepResult, StepStatus } from "../onboard/types.js";
function stubStep(id: StepId, status: StepStatus, label?: string): Step {
return {
id,
label: label ?? `Step — ${id}`,
run: async (): Promise<StepResult> => ({ id, status }),
};
}
const allSteps: Step[] = [
stubStep("llm", "done", "Step 1/6 — LLM Provider (OpenAI)"),
stubStep("github", "done", "Step 2/6 — GitHub CLI"),
stubStep("slack", "skipped", "Step 3/6 — Slack (Mom Bot)"),
stubStep("ssh", "done", "Step 4/6 — SSH Key"),
stubStep("cloudflare", "skipped", "Step 5/6 — Cloudflare Tunnel"),
stubStep("claude", "done", "Step 6/6 — Claude Code"),
];
describe("runOnboarding", () => {
it("runs every step in order and writes the marker", async () => {
const deps = makeFakeDeps({ now: "2026-04-21T00:00:00Z" });
const { results, exitCode } = await runOnboarding(allSteps, deps, { force: false });
expect(exitCode).toBe(0);
expect(results).toEqual({
llm: "done",
slack: "skipped",
ssh: "done",
github: "done",
cloudflare: "skipped",
claude: "done",
});
const markerPath = "/home/sandbox/.claude/.onboarded";
expect(deps.files.has(markerPath)).toBe(true);
const saved = JSON.parse(deps.files.get(markerPath)!);
expect(saved.version).toBe(1);
expect(saved.completedAt).toBe("2026-04-21T00:00:00Z");
expect(saved.steps.llm.status).toBe("done");
expect(saved.steps.slack.status).toBe("skipped");
});
it("--only short-circuits to a single step with done → exit 0", async () => {
const deps = makeFakeDeps();
const { results, exitCode } = await runOnboarding(allSteps, deps, {
force: false,
only: "slack",
});
// Stub 'slack' was 'skipped' → still exit 0.
expect(exitCode).toBe(0);
expect(results).toEqual({ slack: "skipped" });
// No marker on --only.
expect(deps.files.has("/home/sandbox/.claude/.onboarded")).toBe(false);
});
it("--only with failed step → exit 1", async () => {
const failing: Step[] = [stubStep("github", "failed", "Step 2/6 — GitHub CLI")];
const deps = makeFakeDeps();
const { exitCode } = await runOnboarding(failing, deps, {
force: false,
only: "github",
});
expect(exitCode).toBe(1);
});
it("marker present + !force + !only → short-circuits without running steps", async () => {
const deps = makeFakeDeps({
files: {
"/home/sandbox/.claude/.onboarded": JSON.stringify({
version: 1,
completedAt: "2026-04-20T00:00:00Z",
steps: { llm: { status: "done" } },
}),
},
});
// Any step that ran would throw because the stubs don't record anything,
// so the assertion is implicit: no results collected = no runs.
const { results, exitCode } = await runOnboarding(allSteps, deps, { force: false });
expect(exitCode).toBe(0);
expect(results).toEqual({});
});
it("marker present + --force → re-runs every step", async () => {
const deps = makeFakeDeps({
now: "2026-04-21T05:00:00Z",
files: {
"/home/sandbox/.claude/.onboarded": JSON.stringify({
version: 1,
completedAt: "2026-04-20T00:00:00Z",
steps: { llm: { status: "done" } },
}),
},
});
const { results } = await runOnboarding(allSteps, deps, { force: true });
expect(Object.keys(results).length).toBe(6);
const saved = JSON.parse(deps.files.get("/home/sandbox/.claude/.onboarded")!);
expect(saved.completedAt).toBe("2026-04-21T05:00:00Z");
});
it("a step that throws is caught and recorded as failed", async () => {
const throwing: Step = {
id: "ssh",
label: "Step 4/6 — SSH Key",
run: async () => {
throw new Error("boom");
},
};
const deps = makeFakeDeps();
const { results, exitCode } = await runOnboarding([throwing], deps, {
force: false,
only: "ssh",
});
expect(results.ssh).toBe("failed");
expect(exitCode).toBe(1);
});
it("--only with unknown step id returns exit 2", async () => {
const deps = makeFakeDeps();
const { exitCode } = await runOnboarding(allSteps, deps, {
force: false,
// Pretend an invalid step slipped past parseArgs.
only: "nope" as StepId,
});
expect(exitCode).toBe(2);
});
});
@@ -1,163 +0,0 @@
import { describe, expect, it } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getAllSteps, loadPackSteps, orderSteps } from "../onboard/steps/index.js";
import type { Step, HarnessPack } from "../onboard/types.js";
function makeTempDir(): string {
return mkdtempSync(join(tmpdir(), "oh-pack-steps-"));
}
function writeRegistry(tmp: string, packs: Array<{ path: string; manifest: HarnessPack }>): string {
const file = join(tmp, "harnesses.json");
writeFileSync(
file,
JSON.stringify(
{
installed: packs.map((p) => ({
name: p.manifest.name,
path: p.path,
version: p.manifest.version,
manifest: p.manifest,
})),
},
null,
2,
),
);
return file;
}
function fakeManifest(over: Partial<HarnessPack> = {}): HarnessPack {
return {
name: "fake-pack",
version: "0.0.0",
description: "fixture",
openharness: ">=2026.5.2",
agents: [],
compose_overlays: [],
onboard_steps: [],
install_hook: "install-hook.sh",
entrypoint_hook: "entrypoint-hook.sh",
workspace_seed: "workspace-seed/",
...over,
};
}
function makeFakeStep(id: string, label: string): Step {
return {
id: id as Step["id"],
label,
run: async () => ({ id: id as Step["id"], status: "skipped" as const }),
};
}
describe("loadPackSteps", () => {
it("returns [] when the registry file is missing", async () => {
const steps = await loadPackSteps({
registryPath: join(makeTempDir(), "does-not-exist.json"),
});
expect(steps).toEqual([]);
});
it("returns [] when the registry has no installed packs", async () => {
const tmp = makeTempDir();
const path = writeRegistry(tmp, []);
try {
const steps = await loadPackSteps({ registryPath: path });
expect(steps).toEqual([]);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it("loads steps from a fake pack via the importer override", async () => {
const tmp = makeTempDir();
const packDir = join(tmp, "fake-pack");
mkdirSync(packDir, { recursive: true });
const manifest = fakeManifest({
name: "fake-pack",
onboard_steps: [{ id: "fake", file: "onboard-steps/fake.js" }],
});
const path = writeRegistry(tmp, [{ path: packDir, manifest }]);
try {
const fakeStep = makeFakeStep("fake", "Fake — test step");
const steps = await loadPackSteps({
registryPath: path,
importer: async (specifier: string) => {
expect(specifier).toContain("fake-pack");
expect(specifier).toContain("onboard-steps/fake.js");
return { fakeStep };
},
});
expect(steps).toHaveLength(1);
expect(steps[0].id).toBe("fake");
expect(steps[0].label).toBe("Fake — test step");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it("skips entries when the import throws", async () => {
const tmp = makeTempDir();
const path = writeRegistry(tmp, [
{
path: tmp,
manifest: fakeManifest({
onboard_steps: [{ id: "broken", file: "missing.js" }],
}),
},
]);
try {
const steps = await loadPackSteps({
registryPath: path,
importer: async () => {
throw new Error("module not found");
},
});
expect(steps).toEqual([]);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
describe("orderSteps", () => {
const a = makeFakeStep("llm", "a");
const b = makeFakeStep("github", "b");
const c = makeFakeStep("slack", "c");
it("preserves insertion order when no `after` constraints exist", () => {
const out = orderSteps([a, b, c], {});
expect(out.map((s) => s.id)).toEqual(["llm", "github", "slack"]);
});
it("respects `after` constraints when satisfiable", () => {
const out = orderSteps([a, b, c], { slack: "github" });
expect(out.map((s) => s.id)).toEqual(["llm", "github", "slack"]);
});
it("flushes the rest in insertion order on a cycle", () => {
const out = orderSteps([a, b, c], { llm: "github", github: "llm" });
expect(out).toHaveLength(3);
});
});
describe("getAllSteps", () => {
it("returns the core steps unchanged when no packs are installed", async () => {
const steps = await getAllSteps({
registryPath: join(makeTempDir(), "missing.json"),
});
const ids = steps.map((s) => s.id);
expect(ids).toContain("llm");
expect(ids).toContain("github");
expect(ids).toContain("ssh");
expect(ids).toContain("cloudflare");
expect(ids).toContain("claude");
// slack moved to @ryaneggz/mifune harness pack — not a core step
expect(ids).not.toContain("slack");
});
});
@@ -1,91 +0,0 @@
import { describe, it, expect } from "vitest";
import { sshStep } from "../onboard/steps/ssh.js";
import { execWasCalled, ioMessages, makeFakeDeps } from "../onboard/testing/fake-deps.js";
describe("ssh step", () => {
it("existing key + verified github → done", async () => {
const deps = makeFakeDeps({
files: { "/home/sandbox/.ssh/id_ed25519.pub": "ssh-ed25519 AAAA sandbox@host\n" },
execStubs: [
{
match: /^ssh -T git@github\.com$/,
result: {
status: 1,
stdout: "",
stderr: "Hi sandbox! You've successfully authenticated, but GitHub does not…",
},
},
],
});
const result = await sshStep.run(deps, { force: false });
expect(result.status).toBe("done");
expect(ioMessages(deps, "ok")).toContain("GitHub SSH access verified");
});
it("existing key but first verify fails → prompts, second try succeeds", async () => {
let calls = 0;
const deps = makeFakeDeps({
files: { "/home/sandbox/.ssh/id_ed25519.pub": "ssh-ed25519 AAAA sandbox@host" },
execStubs: [
{
match: /^ssh -T git@github\.com$/,
sideEffect: () => {
calls += 1;
},
// This stub is used for the return value only; we swap it below.
},
],
});
// Replace the exec.capture with a two-phase counter.
const realCapture = deps.exec.capture;
deps.exec.capture = (cmd, opts) => {
if (cmd.join(" ") === "ssh -T git@github.com") {
calls += 1;
return calls === 1
? { status: 1, stdout: "", stderr: "Permission denied" }
: { status: 1, stdout: "", stderr: "You've successfully authenticated" };
}
return realCapture(cmd, opts);
};
const result = await sshStep.run(deps, { force: false });
expect(result.status).toBe("done");
expect(deps.recorder.waitForEnterCount).toBe(1);
expect(calls).toBe(2);
});
it("existing key + both verifies fail → unverified", async () => {
const deps = makeFakeDeps({
files: { "/home/sandbox/.ssh/id_ed25519.pub": "ssh-ed25519 AAAA" },
execStubs: [
{
match: /^ssh -T git@github\.com$/,
result: { status: 1, stdout: "", stderr: "Permission denied" },
},
],
});
const result = await sshStep.run(deps, { force: false });
expect(result.status).toBe("unverified");
});
it("no key → generates one, waits for enter, returns done", async () => {
const deps = makeFakeDeps({
execStubs: [
{ match: /^hostname$/, result: { status: 0, stdout: "test-host\n", stderr: "" } },
{
match: /^ssh-keygen /,
result: { status: 0, stdout: "", stderr: "" },
sideEffect: () => {
deps.files.set(
"/home/sandbox/.ssh/id_ed25519.pub",
"ssh-ed25519 GEN sandbox@test-host",
);
},
},
],
});
const result = await sshStep.run(deps, { force: false });
expect(result.status).toBe("done");
expect(execWasCalled(deps, /^ssh-keygen /)).toBe(true);
expect(deps.recorder.waitForEnterCount).toBe(1);
});
});
@@ -1,57 +0,0 @@
import { describe, it, expect } from "vitest";
import { sandboxTools } from "../tools/index.js";
describe("sandboxTools", () => {
it("exports all 12 tools", () => {
expect(sandboxTools).toHaveLength(12);
});
it("each tool has required fields", () => {
for (const tool of sandboxTools) {
expect(tool.name).toBeTruthy();
expect(tool.label).toBeTruthy();
expect(tool.description).toBeTruthy();
expect(tool.parameters).toBeTruthy();
expect(typeof tool.execute).toBe("function");
}
});
it("tool names are unique", () => {
const names = sandboxTools.map((t) => t.name);
expect(new Set(names).size).toBe(names.length);
});
it("tool names follow sandbox_ prefix convention", () => {
for (const tool of sandboxTools) {
expect(tool.name).toMatch(/^sandbox_/);
}
});
const expectedTools = [
"sandbox_list",
"sandbox_sandbox",
"sandbox_run",
"sandbox_shell",
"sandbox_stop",
"sandbox_clean",
"sandbox_worktree",
"sandbox_onboard",
"sandbox_ports",
"sandbox_expose",
"sandbox_unexpose",
"sandbox_open",
];
for (const name of expectedTools) {
it(`includes ${name}`, () => {
expect(sandboxTools.find((t) => t.name === name)).toBeTruthy();
});
}
it("does not include removed tools", () => {
const names = sandboxTools.map((t) => t.name);
expect(names).not.toContain("sandbox_build");
expect(names).not.toContain("sandbox_rebuild");
expect(names).not.toContain("sandbox_push");
});
});
-17
View File
@@ -1,17 +0,0 @@
/**
* `oh clean [name]` — full cleanup (containers + volumes).
*/
import { SandboxConfig } from "../../lib/config.js";
import { composeDown, composeEnv } from "../../lib/docker.js";
import { runSafe } from "../../lib/exec.js";
export async function cleanAction(name?: string): Promise<void> {
const config = new SandboxConfig({ name });
const stopped = runSafe(composeDown(config, true), { env: composeEnv(config) });
console.log(
stopped
? `Sandbox '${config.name}' cleaned (containers stopped, volumes removed).`
: `No running sandbox '${config.name}' found.`,
);
}
@@ -1,35 +0,0 @@
/**
* `oh expose <name> <port>` — expose a sandbox app via a Caddy route.
*
* Validation rules carried over from the legacy `resolveSubcommand`:
* - the first positional must be a route name (kebab-case), not a port;
* - the second positional must be a numeric port.
*/
export async function exposeAction(name: string, portArg: string): Promise<void> {
if (!Number.isNaN(Number(name))) {
console.error(
`Usage: openharness expose <name> <port>\n` +
`(The first positional is the route name, not a port. Example: openharness expose docs 8080)`,
);
process.exit(1);
}
const port = Number(portArg);
if (!Number.isFinite(port)) {
console.error(`Port must be a number, got '${portArg}'.`);
process.exit(1);
}
const { exposeTool } = await import("../../tools/index.js");
const result = await exposeTool.execute(
"cli",
{ routeName: name, port },
undefined,
undefined,
undefined as never,
);
for (const item of result.content) {
if (item.type === "text" && "text" in item) console.log(item.text);
}
}
@@ -1,65 +0,0 @@
/**
* `oh harness <add|list|remove>` — manage harness packs.
*
* Thin CLI shell over `src/harness/pack.ts`. Every action prints
* human-readable status and exits non-zero on failure so CI scripts
* can wire `oh harness` calls into provisioning steps.
*/
import { installPack, uninstallPack } from "../../harness/pack.js";
import { readHarnessRegistry } from "../../harness/registry.js";
export async function harnessAddAction(spec: string): Promise<void> {
console.log(`Installing harness pack '${spec}' ...`);
try {
const { entry, warnings } = await installPack(spec);
console.log(`✓ Installed ${entry.manifest.name}@${entry.version} (${entry.source})`);
console.log(` Path: ${entry.path}`);
if (entry.manifest.compose_overlays.length > 0) {
console.log(` Compose overlays: ${entry.manifest.compose_overlays.length} registered`);
}
if (entry.manifest.onboard_steps.length > 0) {
console.log(` Onboard steps: ${entry.manifest.onboard_steps.map((s) => s.id).join(", ")}`);
}
for (const w of warnings) console.warn(` warn: ${w}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`✗ Install failed: ${msg}`);
process.exit(1);
}
}
export async function harnessListAction(): Promise<void> {
const registry = readHarnessRegistry();
if (registry.installed.length === 0) {
console.log("No harness packs installed.");
console.log(" Try: oh harness add @ryaneggz/mifune");
return;
}
console.log(`${registry.installed.length} harness pack(s) installed:\n`);
for (const entry of registry.installed) {
console.log(` ${entry.manifest.name}@${entry.version}`);
console.log(` source: ${entry.source} (${entry.spec})`);
console.log(` path: ${entry.path}`);
if (entry.manifest.agents.length > 0) {
console.log(` agents: ${entry.manifest.agents.join(", ")}`);
}
}
}
export async function harnessRemoveAction(name: string): Promise<void> {
console.log(`Removing harness pack '${name}' ...`);
try {
const { removed, warnings } = await uninstallPack(name);
if (!removed) {
console.log(`No harness pack named '${name}' is installed.`);
return;
}
console.log(`✓ Removed ${removed.manifest.name}@${removed.version} (${removed.source})`);
for (const w of warnings) console.warn(` warn: ${w}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`✗ Remove failed: ${msg}`);
process.exit(1);
}
}
-16
View File
@@ -1,16 +0,0 @@
/**
* `oh list` — list running sandbox containers.
*/
import { execSync } from "node:child_process";
import { psCmd } from "../../lib/docker.js";
export async function listAction(): Promise<void> {
console.log("\n Running containers:");
try {
const ps = execSync(psCmd().join(" "), { encoding: "utf-8" }).trim();
console.log(ps || " (none)");
} catch {
console.log(" (docker not available or no containers running)");
}
}
@@ -1,36 +0,0 @@
/**
* `oh onboard [name|step]` — interactive onboarding wizard.
*
* Routing rules carried over from the legacy `resolveSubcommand`:
* a positional matching a known step id (e.g. `oh onboard slack`) is a
* step selector, not a container name.
*/
import { runOnboardCommand } from "../onboard.js";
import { STEP_IDS } from "../../onboard/types.js";
export interface OnboardActionOpts {
force?: boolean;
only?: string;
}
export async function onboardAction(
target: string | undefined,
opts: OnboardActionOpts,
): Promise<void> {
let name: string | undefined = target;
let only: string | undefined = opts.only;
// A bare positional matching a step id rewrites to `--only`.
if (!only && name && (STEP_IDS as readonly string[]).includes(name)) {
only = name;
name = undefined;
}
const exitCode = await runOnboardCommand({
name,
force: opts.force,
only,
});
if (exitCode !== 0) process.exit(exitCode);
}
-19
View File
@@ -1,19 +0,0 @@
/**
* `oh open <name|port>` — open a route's URL in the user's browser.
*
* If the positional parses as a number it is treated as a port; otherwise
* it is treated as a route name (matches the legacy `resolveSubcommand`).
*/
export async function openAction(target: string): Promise<void> {
const { openTool } = await import("../../tools/index.js");
const maybePort = Number(target);
const params: Record<string, unknown> = Number.isFinite(maybePort)
? { port: maybePort }
: { routeName: target };
const result = await openTool.execute("cli", params, undefined, undefined, undefined as never);
for (const item of result.content) {
if (item.type === "text" && "text" in item) console.log(item.text);
}
}
-11
View File
@@ -1,11 +0,0 @@
/**
* `oh ports [name]` — inspect listeners and routes for a sandbox.
*/
export async function portsAction(name?: string): Promise<void> {
const { portsTool } = await import("../../tools/index.js");
const result = await portsTool.execute("cli", { name }, undefined, undefined, undefined as never);
for (const item of result.content) {
if (item.type === "text" && "text" in item) console.log(item.text);
}
}
-13
View File
@@ -1,13 +0,0 @@
/**
* `oh run [name]` — start an existing sandbox container.
*/
import { SandboxConfig } from "../../lib/config.js";
import { composeUp, composeEnv } from "../../lib/docker.js";
import { run } from "../../lib/exec.js";
export async function runAction(name?: string): Promise<void> {
const config = new SandboxConfig({ name });
run(composeUp(config), { env: composeEnv(config) });
console.log(`Sandbox '${config.name}' started.`);
}
@@ -1,66 +0,0 @@
/**
* `oh sandbox [name]` — provision (build + start) a sandbox.
*
* Pulled out of the legacy `runSubcommand` switch so each Commander action
* can be lazy-loaded via dynamic `import()`.
*/
import { execSync } from "node:child_process";
import { SandboxConfig } from "../../lib/config.js";
import { composeUp, composeEnv } from "../../lib/docker.js";
import { run } from "../../lib/exec.js";
export async function sandboxAction(name?: string): Promise<void> {
const config = new SandboxConfig({ name });
const env = composeEnv(config);
console.log(`Starting sandbox '${config.name}'...`);
console.log(`Compose files: ${config.composeFiles.join(", ")}`);
run(composeUp(config), { env });
// Validate container is running
let running = false;
try {
const status = execSync(`docker inspect -f '{{.State.Running}}' ${config.name}`, {
encoding: "utf-8",
stdio: "pipe",
}).trim();
running = status === "true";
} catch {
// container not found
}
if (!running) {
console.error(`\nError: container '${config.name}' is not running.`);
console.error("Check logs: docker logs " + config.name);
process.exit(1);
}
// Get port mappings (only show what's actually mapped)
let sshPort: string | null = null;
let appPort: string | null = null;
try {
const ports = execSync(`docker port ${config.name}`, {
encoding: "utf-8",
stdio: "pipe",
}).trim();
const sshMatch = ports.match(/22\/tcp -> [\d.]+:(\d+)/);
const appMatch = ports.match(/3000\/tcp -> [\d.]+:(\d+)/);
if (sshMatch) sshPort = sshMatch[1];
if (appMatch) appPort = appMatch[1];
} catch {
// no ports mapped
}
console.log(`\n Sandbox '${config.name}' is running!\n`);
console.log(" Connect:");
if (sshPort) {
console.log(` SSH: ssh sandbox@localhost -p ${sshPort}`);
}
console.log(` Shell: openharness shell ${config.name}`);
if (appPort) {
console.log(` App: http://localhost:${appPort}`);
}
console.log("");
console.log(" Next steps:");
console.log(` openharness onboard ${config.name} # one-time auth setup`);
}
-15
View File
@@ -1,15 +0,0 @@
/**
* `oh shell <name>` — open an interactive bash shell inside a sandbox.
*/
import { spawnSync } from "node:child_process";
import { execCmd } from "../../lib/docker.js";
export async function shellAction(name: string): Promise<void> {
const cmd = execCmd(name, ["bash", "--login"], {
user: "sandbox",
interactive: true,
workdir: "/home/sandbox/harness",
});
spawnSync(cmd[0], cmd.slice(1), { stdio: "inherit" });
}
-18
View File
@@ -1,18 +0,0 @@
/**
* `oh stop [name]` — stop and remove a sandbox container.
*/
import { SandboxConfig } from "../../lib/config.js";
import { composeDown, composeEnv } from "../../lib/docker.js";
import { run } from "../../lib/exec.js";
export async function stopAction(name?: string): Promise<void> {
const config = new SandboxConfig({ name });
try {
run(composeDown(config), { env: composeEnv(config) });
console.log(`Sandbox '${config.name}' stopped.`);
} catch {
console.error(`Error: no sandbox '${config.name}' found to stop.`);
process.exit(1);
}
}
@@ -1,17 +0,0 @@
/**
* `oh unexpose <name>` — remove a Caddy route for a sandbox app.
*/
export async function unexposeAction(name: string): Promise<void> {
const { unexposeTool } = await import("../../tools/index.js");
const result = await unexposeTool.execute(
"cli",
{ routeName: name },
undefined,
undefined,
undefined as never,
);
for (const item of result.content) {
if (item.type === "text" && "text" in item) console.log(item.text);
}
}
@@ -1,25 +0,0 @@
/**
* `oh worktree <name> [--base-branch <branch>]` — create a git worktree
* for branch isolation. Delegates to the `sandbox_worktree` tool.
*/
export interface WorktreeActionOpts {
baseBranch?: string;
}
export async function worktreeAction(name: string, opts: WorktreeActionOpts): Promise<void> {
const { worktreeTool } = await import("../../tools/index.js");
const params: Record<string, unknown> = { name };
if (opts.baseBranch) params.baseBranch = opts.baseBranch;
const result = await worktreeTool.execute(
"cli",
params,
undefined,
undefined,
undefined as never,
);
for (const item of result.content) {
if (item.type === "text" && "text" in item) console.log(item.text);
}
}
-217
View File
@@ -1,217 +0,0 @@
#!/usr/bin/env node
/**
* openharness — Open Harness CLI
*
* Commander.js-based subcommand router. Each action lives in
* `./actions/*.ts` and is lazy-loaded via dynamic import so cold-start
* stays fast for `--help` / `--version`.
*/
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { Command } from "commander";
function readVersion(): string {
try {
const here = dirname(fileURLToPath(import.meta.url));
// dist/src/cli/index.js → ../../../package.json
// src/cli/index.ts (tsx) → ../../package.json
const candidates = [
join(here, "..", "..", "..", "package.json"),
join(here, "..", "..", "package.json"),
];
for (const path of candidates) {
if (existsSync(path)) {
const pkg = JSON.parse(readFileSync(path, "utf-8")) as { version?: string };
if (typeof pkg.version === "string") return pkg.version;
}
}
} catch {
// fallthrough
}
return "0.0.0";
}
export function isInsideContainer(): boolean {
return existsSync("/.dockerenv");
}
export const HOST_ONLY_COMMANDS = new Set(["sandbox", "run", "stop", "clean", "shell", "list"]);
/**
* Build the Commander program. Exported for tests so they can introspect
* registered subcommands and option flags without spawning a process.
*/
export function buildProgram(): Command {
const program = new Command();
program
.name("oh")
.description("openharness — sandbox-for-harnesses platform")
.version(readVersion(), "-v, --version");
// ─── Container lifecycle (host-only) ────────────────────────────────
program
.command("sandbox [name]")
.description("provision (build + start) a sandbox")
.action(async (name?: string) => {
const { sandboxAction } = await import("./actions/sandbox.js");
await sandboxAction(name);
});
program
.command("run [name]")
.description("start the sandbox container")
.action(async (name?: string) => {
const { runAction } = await import("./actions/run.js");
await runAction(name);
});
program
.command("stop [name]")
.description("stop and remove the sandbox container")
.action(async (name?: string) => {
const { stopAction } = await import("./actions/stop.js");
await stopAction(name);
});
program
.command("clean [name]")
.description("full cleanup (containers + volumes)")
.action(async (name?: string) => {
const { cleanAction } = await import("./actions/clean.js");
await cleanAction(name);
});
program
.command("shell <name>")
.description("open an interactive shell in the sandbox")
.action(async (name: string) => {
const { shellAction } = await import("./actions/shell.js");
await shellAction(name);
});
program
.command("list")
.description("list running sandboxes")
.action(async () => {
const { listAction } = await import("./actions/list.js");
await listAction();
});
// ─── Onboarding ─────────────────────────────────────────────────────
program
.command("onboard [target]")
.description(
"setup wizard — sandbox name OR a single step (slack, llm, ssh, github, cloudflare, claude)",
)
.option("--force", "re-run completed steps")
.option("--only <step>", "run only the named step")
.action(async (target: string | undefined, opts: { force?: boolean; only?: string }) => {
const { onboardAction } = await import("./actions/onboard.js");
await onboardAction(target, { force: opts.force, only: opts.only });
});
// ─── Worktree ───────────────────────────────────────────────────────
program
.command("worktree <name>")
.description("create a git worktree for branch isolation")
.option("--base-branch <branch>", "base branch (default: development)")
.action(async (name: string, opts: { baseBranch?: string }) => {
const { worktreeAction } = await import("./actions/worktree.js");
await worktreeAction(name, { baseBranch: opts.baseBranch });
});
// ─── Gateway / expose ───────────────────────────────────────────────
program
.command("ports [name]")
.description("inspect listeners and exposed routes")
.action(async (name?: string) => {
const { portsAction } = await import("./actions/ports.js");
await portsAction(name);
});
program
.command("expose <name> <port>")
.description("expose a sandbox app via a Caddy route")
.action(async (name: string, port: string) => {
const { exposeAction } = await import("./actions/expose.js");
await exposeAction(name, port);
});
program
.command("unexpose <name>")
.description("remove a Caddy route")
.action(async (name: string) => {
const { unexposeAction } = await import("./actions/unexpose.js");
await unexposeAction(name);
});
program
.command("open <target>")
.description("open a route's URL (by name or port)")
.action(async (target: string) => {
const { openAction } = await import("./actions/open.js");
await openAction(target);
});
// ─── Harness packs (NEW from openharness/mifune split) ──────────────
// Placeholder action handlers — real implementation lands in T5
// alongside src/harness/pack.ts and src/harness/registry.ts.
const harness = program.command("harness").description("manage harness packs");
harness
.command("add <spec>")
.description("install a harness pack (npm package, owner/repo, git URL, or local path)")
.action(async (spec: string) => {
const { harnessAddAction } = await import("./actions/harness.js");
await harnessAddAction(spec);
});
harness
.command("list")
.description("list installed harness packs")
.action(async () => {
const { harnessListAction } = await import("./actions/harness.js");
await harnessListAction();
});
harness
.command("remove <name>")
.description("uninstall a harness pack")
.action(async (name: string) => {
const { harnessRemoveAction } = await import("./actions/harness.js");
await harnessRemoveAction(name);
});
// ─── Host-only enforcement (replaces ad-hoc HOST_ONLY_COMMANDS check)
program.hook("preAction", (thisCommand) => {
if (HOST_ONLY_COMMANDS.has(thisCommand.name()) && isInsideContainer()) {
console.error(`Error: 'oh ${thisCommand.name()}' is a host-only command.`);
console.error("You are inside the sandbox. Run this from the host instead.");
process.exit(1);
}
});
return program;
}
const program = buildProgram();
const isMainModule = (() => {
if (typeof process.argv[1] !== "string") return false;
try {
const here = fileURLToPath(import.meta.url);
return here === process.argv[1] || process.argv[1].endsWith("cli/index.js");
} catch {
return false;
}
})();
if (isMainModule) {
program.parseAsync(process.argv).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
console.error(msg);
process.exit(1);
});
}
export { program };
-84
View File
@@ -1,84 +0,0 @@
/**
* Interactive onboarding wizard — entry point for the `openharness onboard`
* subcommand. When invoked without a sandbox name, runs the TS orchestrator
* in-process. When invoked with a name, shells out via `docker exec` so the
* wizard executes inside the named container.
*/
import { spawnSync } from "node:child_process";
import { execCmd } from "../lib/docker.js";
import { makeRealDeps } from "../onboard/deps.js";
import { runOnboarding } from "../onboard/orchestrator.js";
import { parseArgs } from "../onboard/args.js";
import { getAllSteps } from "../onboard/steps/index.js";
import { STEP_IDS, UnknownStepError } from "../onboard/types.js";
export interface OnboardInvocationOptions {
/** Sandbox container name. When set, runs host-mode (docker exec). */
name?: string;
force?: boolean;
/** Positional step id ("slack" | "llm" | …) or `--only <id>` argument. */
only?: string;
}
/**
* A bare positional matching a step id (e.g. `oh onboard slack`) is a
* step selector, not a container name. Without this rewrite the CLI
* would docker-exec into a sandbox literally called "slack".
*/
export function normalizeOnboardOpts(opts: OnboardInvocationOptions): OnboardInvocationOptions {
if (opts.name && !opts.only && (STEP_IDS as readonly string[]).includes(opts.name)) {
return { ...opts, only: opts.name, name: undefined };
}
return opts;
}
export async function runOnboardCommand(opts: OnboardInvocationOptions): Promise<number> {
const normalized = normalizeOnboardOpts(opts);
if (normalized.name) {
return runHostMode(normalized);
}
return runInContainerMode(normalized);
}
function runHostMode(opts: OnboardInvocationOptions): number {
const args = ["openharness", "onboard"];
if (opts.force) args.push("--force");
if (opts.only) args.push("--only", opts.only);
const cmd = execCmd(opts.name!, args, {
user: "sandbox",
interactive: true,
env: { HOME: "/home/sandbox" },
});
const result = spawnSync(cmd[0], cmd.slice(1), { stdio: "inherit" });
if (result.status !== 0) {
console.error(
`Error: container '${opts.name}' is not running. Start it first: openharness run ${opts.name}`,
);
return 1;
}
return 0;
}
async function runInContainerMode(opts: OnboardInvocationOptions): Promise<number> {
const argv: string[] = [];
if (opts.force) argv.push("--force");
if (opts.only) argv.push("--only", opts.only);
let parsed;
try {
parsed = parseArgs(argv);
} catch (err) {
if (err instanceof UnknownStepError) {
console.error(err.message);
return 2;
}
throw err;
}
const deps = makeRealDeps();
const steps = await getAllSteps();
const { exitCode } = await runOnboarding(steps, deps, parsed);
return exitCode;
}
-399
View File
@@ -1,399 +0,0 @@
/**
* Harness pack installer + uninstaller.
*
* A "harness pack" is a directory (or installable package) that contributes
* onboarding steps, compose overlays, workspace seeds, and runtime hooks
* to an openharness sandbox. Packs are consumable from three source modes:
*
* - `npm` — published npm package (`@scope/name` or plain `name`).
* Installed via `npm install -g`. Pack lives at
* `$(npm root -g)/<package>/`.
*
* - `git` — git repo. `<owner>/<repo>` resolves against GitHub;
* `https://`, `git@`, or `ssh://` URLs go as-is. Cloned to
* `~/openharness/harnesses/<name>/`.
*
* - `local` — already-on-disk directory (`./path` or absolute path).
* Recorded by absolute path; not copied.
*
* After source resolution, every pack follows the same install pipeline:
* 1. Validate `harness.json` against the schema.
* 2. Append compose overlays to `.openharness/config.json`.
* 3. Run `install_hook` (e.g., `npm install -g <agent-cli>`).
* 4. Symlink `entrypoint_hook` into `/usr/local/bin/<name>-entrypoint-hook.sh`.
* 5. Register the pack in `.openharness/harnesses.json`.
*
* Side effects (npm/git/exec, fs writes outside the registry) are routed
* through the {@link PackEnv} injection bag so unit tests can swap fakes.
*/
import { execSync } from "node:child_process";
import {
existsSync,
mkdirSync,
readFileSync,
symlinkSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, join, resolve as resolvePath } from "node:path";
import type { HarnessPack } from "../onboard/types.js";
import {
addInstalled,
HARNESS_REGISTRY_PATH,
readHarnessRegistry,
removeInstalled,
type InstalledHarness,
type SourceMode,
} from "./registry.js";
// ─── Source resolution ──────────────────────────────────────────────────────
export interface ResolvedSource {
mode: SourceMode;
spec: string;
/** Source-specific identifier:
* - npm: package name (e.g. `@ryaneggz/mifune`)
* - git: clone URL (e.g. `https://github.com/ryaneggz/mifune.git`)
* - local: absolute path
*/
identifier: string;
}
/**
* Classify a `oh harness add <spec>` argument into one of the three source
* modes. Pure function — no side effects.
*
* Heuristics:
* - Starts with `@` → npm scoped package.
* - Starts with `./` or `/` → local path.
* - Contains `://` or `git@` → git URL.
* - Plain `<owner>/<repo>` shape (one slash, no spaces) → GitHub git.
* - Otherwise (single token, no slash) → npm unscoped package.
*/
export function resolveSpec(spec: string): ResolvedSource {
const s = spec.trim();
if (s.length === 0) throw new Error("Empty harness pack spec");
if (s.startsWith("@")) {
return { mode: "npm", spec: s, identifier: s };
}
if (s.startsWith("./") || s.startsWith("../") || s.startsWith("/") || isAbsolute(s)) {
return { mode: "local", spec: s, identifier: resolvePath(s) };
}
if (s.includes("://") || s.startsWith("git@")) {
return { mode: "git", spec: s, identifier: s };
}
// owner/repo shape: exactly one slash, no whitespace, no protocol
const parts = s.split("/");
if (parts.length === 2 && parts.every((p) => p.length > 0 && !/\s/.test(p))) {
return {
mode: "git",
spec: s,
identifier: `https://github.com/${parts[0]}/${parts[1]}.git`,
};
}
// Bare package name, no scope, no slash
return { mode: "npm", spec: s, identifier: s };
}
// ─── Manifest validation ────────────────────────────────────────────────────
const REQUIRED_KEYS: ReadonlyArray<keyof HarnessPack> = [
"name",
"version",
"description",
"openharness",
"agents",
"compose_overlays",
"onboard_steps",
"install_hook",
"entrypoint_hook",
"workspace_seed",
];
/**
* Parse + validate a `harness.json` (or its package.json `harness` key).
* Returns the parsed manifest on success, `null` on any structural failure.
* Callers that want a thrown error can wrap with {@link assertManifest}.
*/
export function validateManifest(input: unknown): HarnessPack | null {
if (!input || typeof input !== "object") return null;
const obj = input as Record<string, unknown>;
for (const key of REQUIRED_KEYS) {
if (!(key in obj)) return null;
}
if (typeof obj.name !== "string") return null;
if (typeof obj.version !== "string") return null;
if (typeof obj.description !== "string") return null;
if (typeof obj.openharness !== "string") return null;
if (!Array.isArray(obj.agents)) return null;
if (!Array.isArray(obj.compose_overlays)) return null;
if (!Array.isArray(obj.onboard_steps)) return null;
if (typeof obj.install_hook !== "string") return null;
if (typeof obj.entrypoint_hook !== "string") return null;
if (typeof obj.workspace_seed !== "string") return null;
return obj as unknown as HarnessPack;
}
export function assertManifest(input: unknown): HarnessPack {
const m = validateManifest(input);
if (!m) throw new Error("Invalid harness.json — missing required fields");
return m;
}
/**
* Read `harness.json` from a pack directory, falling back to the
* `harness` key inside `package.json` (the npm packaging shape).
*/
export function readManifest(packDir: string): HarnessPack {
const harnessJson = join(packDir, "harness.json");
if (existsSync(harnessJson)) {
return assertManifest(JSON.parse(readFileSync(harnessJson, "utf-8")));
}
const pkgJson = join(packDir, "package.json");
if (existsSync(pkgJson)) {
const pkg = JSON.parse(readFileSync(pkgJson, "utf-8")) as Record<string, unknown>;
if (pkg.harness) return assertManifest(pkg.harness);
}
throw new Error(`No harness.json found in ${packDir}`);
}
// ─── Install / uninstall pipeline ───────────────────────────────────────────
export interface PackEnv {
/** Run a shell command. Throws on non-zero exit. */
exec(cmd: string[], opts?: { cwd?: string }): void;
/** Capture stdout of a command (utf-8). Throws on non-zero exit. */
capture(cmd: string[], opts?: { cwd?: string }): string;
/** Optional override for the registry path (tests). */
registryPath?: string;
/** Override for the harness install root. Defaults to ~/openharness/harnesses. */
harnessRoot?: string;
/** Override for npm's global root. Defaults to `npm root -g`. */
npmGlobalRoot?: string;
/** Override for the entrypoint hook bin dir. Defaults to /usr/local/bin. */
entrypointBinDir?: string;
}
export const realPackEnv: PackEnv = {
exec(cmd, opts) {
execSync(cmd.join(" "), { stdio: "inherit", cwd: opts?.cwd });
},
capture(cmd, opts) {
return execSync(cmd.join(" "), { encoding: "utf-8", cwd: opts?.cwd }).toString();
},
};
export interface InstallResult {
entry: InstalledHarness;
warnings: string[];
}
/**
* Install a harness pack from a source spec. Idempotent — re-installing
* the same pack updates the registry entry rather than duplicating it.
*/
export async function installPack(
spec: string,
env: PackEnv = realPackEnv,
): Promise<InstallResult> {
const source = resolveSpec(spec);
const warnings: string[] = [];
const packDir = await fetchPackDir(source, env);
const manifest = readManifest(packDir);
registerComposeOverlays(packDir, manifest, warnings);
runInstallHook(packDir, manifest, env, warnings);
installEntrypointHook(packDir, manifest, env, warnings);
const entry: InstalledHarness = {
name: manifest.name,
path: packDir,
version: manifest.version,
source: source.mode,
spec: source.spec,
manifest,
};
addInstalled(entry, env.registryPath ?? HARNESS_REGISTRY_PATH);
return { entry, warnings };
}
/**
* Uninstall a harness pack by name. Reverses install side effects in
* the inverse order. Idempotent: removing an already-absent pack is a
* no-op (returns null).
*/
export async function uninstallPack(
name: string,
env: PackEnv = realPackEnv,
): Promise<{ removed: InstalledHarness | null; warnings: string[] }> {
const warnings: string[] = [];
const registry = readHarnessRegistry(env.registryPath ?? HARNESS_REGISTRY_PATH);
const entry = registry.installed.find((p) => p.name === name) ?? null;
if (!entry) return { removed: null, warnings };
uninstallEntrypointHook(entry, env, warnings);
unregisterComposeOverlays(entry, warnings);
if (entry.source === "npm") {
try {
env.exec(["npm", "uninstall", "-g", entry.spec]);
} catch (err) {
warnings.push(`npm uninstall failed: ${(err as Error).message}`);
}
}
// git/local: leave the on-disk dir alone (user may have other work in it).
const removed = removeInstalled(name, env.registryPath ?? HARNESS_REGISTRY_PATH);
return { removed, warnings };
}
// ─── Source-mode-specific fetchers ──────────────────────────────────────────
async function fetchPackDir(source: ResolvedSource, env: PackEnv): Promise<string> {
switch (source.mode) {
case "npm":
return fetchNpmPack(source.identifier, env);
case "git":
return fetchGitPack(source.spec, source.identifier, env);
case "local":
return fetchLocalPack(source.identifier);
}
}
function fetchNpmPack(pkgName: string, env: PackEnv): string {
env.exec(["npm", "install", "-g", pkgName]);
const root = env.npmGlobalRoot ?? env.capture(["npm", "root", "-g"]).trim();
const dir = join(root, pkgName);
if (!existsSync(dir)) {
throw new Error(`npm install reported success but ${dir} does not exist`);
}
return dir;
}
function fetchGitPack(spec: string, cloneUrl: string, env: PackEnv): string {
// Pack name from the URL: take the last segment, drop a trailing `.git`.
const last = cloneUrl.split("/").pop() ?? spec;
const name = last.replace(/\.git$/, "");
const root = env.harnessRoot ?? join(homedir(), "openharness", "harnesses");
mkdirSync(root, { recursive: true });
const dir = join(root, name);
if (existsSync(dir)) {
env.exec(["git", "-C", dir, "fetch", "--all", "--tags"]);
env.exec(["git", "-C", dir, "reset", "--hard", "origin/HEAD"]);
} else {
env.exec(["git", "clone", cloneUrl, dir]);
}
return dir;
}
function fetchLocalPack(absPath: string): string {
if (!existsSync(absPath)) {
throw new Error(`Local pack path does not exist: ${absPath}`);
}
return absPath;
}
// ─── Side-effect steps (compose overlays, hooks) ────────────────────────────
const COMPOSE_CONFIG_PATH = ".openharness/config.json";
interface ComposeConfig {
composeOverrides?: string[];
[key: string]: unknown;
}
function readComposeConfig(): ComposeConfig {
if (!existsSync(COMPOSE_CONFIG_PATH)) return {};
try {
return JSON.parse(readFileSync(COMPOSE_CONFIG_PATH, "utf-8")) as ComposeConfig;
} catch {
return {};
}
}
function writeComposeConfig(cfg: ComposeConfig): void {
mkdirSync(".openharness", { recursive: true });
writeFileSync(COMPOSE_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
}
function registerComposeOverlays(packDir: string, manifest: HarnessPack, warnings: string[]): void {
if (manifest.compose_overlays.length === 0) return;
const cfg = readComposeConfig();
const overrides = new Set(cfg.composeOverrides ?? []);
for (const rel of manifest.compose_overlays) {
const abs = resolvePath(packDir, rel);
if (!existsSync(abs)) {
warnings.push(`compose overlay missing: ${abs}`);
continue;
}
overrides.add(abs);
}
cfg.composeOverrides = [...overrides];
writeComposeConfig(cfg);
}
function unregisterComposeOverlays(entry: InstalledHarness, warnings: string[]): void {
const cfg = readComposeConfig();
if (!cfg.composeOverrides) return;
const before = cfg.composeOverrides.length;
cfg.composeOverrides = cfg.composeOverrides.filter((p) => !p.startsWith(entry.path));
if (cfg.composeOverrides.length !== before) writeComposeConfig(cfg);
if (entry.manifest.compose_overlays.length > 0 && cfg.composeOverrides.length === before) {
warnings.push(`no compose overlays were removed for pack '${entry.name}'`);
}
}
function runInstallHook(
packDir: string,
manifest: HarnessPack,
env: PackEnv,
warnings: string[],
): void {
const hook = join(packDir, manifest.install_hook);
if (!existsSync(hook)) {
warnings.push(`install_hook not found: ${manifest.install_hook}`);
return;
}
try {
env.exec(["bash", hook], { cwd: packDir });
} catch (err) {
warnings.push(`install_hook failed: ${(err as Error).message}`);
}
}
function installEntrypointHook(
packDir: string,
manifest: HarnessPack,
env: PackEnv,
warnings: string[],
): void {
const src = join(packDir, manifest.entrypoint_hook);
if (!existsSync(src)) {
warnings.push(`entrypoint_hook not found: ${manifest.entrypoint_hook}`);
return;
}
const binDir = env.entrypointBinDir ?? "/usr/local/bin";
const dst = join(binDir, `${manifest.name}-entrypoint-hook.sh`);
try {
if (existsSync(dst)) unlinkSync(dst);
symlinkSync(src, dst);
} catch (err) {
warnings.push(`entrypoint_hook symlink failed: ${(err as Error).message}`);
}
}
function uninstallEntrypointHook(entry: InstalledHarness, env: PackEnv, warnings: string[]): void {
const binDir = env.entrypointBinDir ?? "/usr/local/bin";
const dst = join(binDir, `${entry.name}-entrypoint-hook.sh`);
if (!existsSync(dst)) return;
try {
unlinkSync(dst);
} catch (err) {
warnings.push(`entrypoint_hook removal failed: ${(err as Error).message}`);
}
}
-150
View File
@@ -1,150 +0,0 @@
/**
* Harness-pack registry — read/write helpers for `.openharness/harnesses.json`.
*
* The registry is the on-disk source of truth for which harness packs are
* installed in a sandbox. Each entry records the pack name, the absolute
* path to its installed location, the version, the source mode ("npm" /
* "git" / "local"), and a snapshot of its parsed manifest.
*
* The reader half is consumed by the onboard step loader to discover
* pack-contributed onboarding steps. The writer half is consumed by
* `oh harness add|remove` to install and uninstall packs.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import type { HarnessPack } from "../onboard/types.js";
/** Default location, relative to the project root. */
export const HARNESS_REGISTRY_PATH = ".openharness/harnesses.json";
export type SourceMode = "npm" | "git" | "local";
export interface InstalledHarness {
name: string;
/** Absolute path to the pack directory on disk. */
path: string;
version: string;
/** How the pack was installed — drives the uninstall path. */
source: SourceMode;
/** Original spec the user passed (`@scope/pkg`, `owner/repo`, `./path`). */
spec: string;
/** Parsed `harness.json` for the pack. */
manifest: HarnessPack;
}
export interface HarnessRegistry {
installed: InstalledHarness[];
}
const EMPTY: HarnessRegistry = { installed: [] };
/**
* Read and parse `.openharness/harnesses.json`. Returns an empty registry
* (`{ installed: [] }`) when the file is missing or unparseable, so callers
* never have to handle null. Callers that need to distinguish "missing" from
* "empty" can stat the path themselves first.
*/
export function readHarnessRegistry(path: string = HARNESS_REGISTRY_PATH): HarnessRegistry {
if (!existsSync(path)) return { installed: [] };
let raw: string;
try {
raw = readFileSync(path, "utf-8");
} catch {
return { installed: [] };
}
return parseHarnessRegistry(raw);
}
/**
* Parse a registry JSON string. Defensive: non-array `installed` is coerced
* to `[]`, and individual entries missing required fields are dropped.
*
* Older registry formats (without `source` or `spec` fields) are upgraded
* in-place: missing `source` defaults to `"local"`, missing `spec` defaults
* to the path. This is forward-compatible with the T4-era registry shape.
*/
export function parseHarnessRegistry(raw: string): HarnessRegistry {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { installed: [] };
}
if (!parsed || typeof parsed !== "object") return { installed: [] };
const obj = parsed as { installed?: unknown };
if (!Array.isArray(obj.installed)) return { installed: [] };
const installed: InstalledHarness[] = [];
for (const entry of obj.installed) {
if (!entry || typeof entry !== "object") continue;
const e = entry as Partial<InstalledHarness>;
if (
typeof e.name === "string" &&
typeof e.path === "string" &&
typeof e.version === "string" &&
e.manifest &&
typeof e.manifest === "object"
) {
installed.push({
name: e.name,
path: e.path,
version: e.version,
source: isSourceMode(e.source) ? e.source : "local",
spec: typeof e.spec === "string" ? e.spec : e.path,
manifest: e.manifest as HarnessPack,
});
}
}
return { installed };
}
function isSourceMode(v: unknown): v is SourceMode {
return v === "npm" || v === "git" || v === "local";
}
/**
* Write the registry to disk atomically (write-then-rename via Node's
* synchronous writeFile). Creates the parent directory if missing.
*/
export function writeHarnessRegistry(
registry: HarnessRegistry,
path: string = HARNESS_REGISTRY_PATH,
): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(registry, null, 2) + "\n", "utf-8");
}
/**
* Append (or replace) an entry in the registry. Re-installing a pack with
* the same `name` updates the existing entry rather than duplicating it.
*/
export function addInstalled(
entry: InstalledHarness,
path: string = HARNESS_REGISTRY_PATH,
): HarnessRegistry {
const registry = readHarnessRegistry(path);
const others = registry.installed.filter((p) => p.name !== entry.name);
const next: HarnessRegistry = { installed: [...others, entry] };
writeHarnessRegistry(next, path);
return next;
}
/**
* Remove an entry by name. No-op when the pack is not registered. Returns
* the entry that was removed (or null) so callers can drive the uninstall
* path off the recorded source mode.
*/
export function removeInstalled(
name: string,
path: string = HARNESS_REGISTRY_PATH,
): InstalledHarness | null {
const registry = readHarnessRegistry(path);
const entry = registry.installed.find((p) => p.name === name) ?? null;
if (!entry) return null;
const next: HarnessRegistry = { installed: registry.installed.filter((p) => p.name !== name) };
writeHarnessRegistry(next, path);
return entry;
}
export { EMPTY as EMPTY_HARNESS_REGISTRY };
-14
View File
@@ -1,14 +0,0 @@
export { sandboxTools } from "./tools/index.js";
export {
listTool,
runTool,
shellTool,
stopTool,
cleanTool,
sandboxTool,
worktreeTool,
onboardTool,
} from "./tools/index.js";
export { SandboxConfig, type SandboxOptions } from "./lib/config.js";
export { composeCmd, composeEnv, composeUp, composeDown, execCmd, psCmd } from "./lib/docker.js";
export { run, runSafe, capture } from "./lib/exec.js";
-146
View File
@@ -1,146 +0,0 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { runSafe } from "./exec.js";
import { execCmd } from "./docker.js";
const ENV_FILE = ".devcontainer/.env";
const CADDYFILE_PATH = ".openharness/Caddyfile";
const RESERVED_ROUTE_NAMES = new Set(["admin", "www", "gateway", "api-internal"]);
const ROUTE_NAME_RE = /^[a-z][a-z0-9-]{0,30}$/;
export type Mode = "local" | "remote";
export interface GatewayMode {
mode: Mode;
publicDomain?: string;
acmeEmail?: string;
}
export interface Route {
name: string;
port: number;
sandbox: string;
}
function readEnvVar(envPath: string, key: string): string | undefined {
if (!existsSync(envPath)) return undefined;
try {
const content = readFileSync(envPath, "utf-8");
const match = content.match(new RegExp(`^${key}=(.+)$`, "m"));
return match?.[1]?.trim() || undefined;
} catch {
return undefined;
}
}
export function detectMode(): GatewayMode {
const publicDomain = process.env.PUBLIC_DOMAIN ?? readEnvVar(resolve(ENV_FILE), "PUBLIC_DOMAIN");
const acmeEmail = process.env.ACME_EMAIL ?? readEnvVar(resolve(ENV_FILE), "ACME_EMAIL");
return publicDomain ? { mode: "remote", publicDomain, acmeEmail } : { mode: "local" };
}
export function validateRouteName(name: string): void {
if (!ROUTE_NAME_RE.test(name)) {
throw new Error(`Invalid route name '${name}'. Must match /^[a-z][a-z0-9-]{0,30}$/.`);
}
if (RESERVED_ROUTE_NAMES.has(name)) {
throw new Error(
`Route name '${name}' is reserved. Reserved: ${[...RESERVED_ROUTE_NAMES].join(", ")}.`,
);
}
}
export function hostFor(route: Route, mode: GatewayMode): string {
const base = `${route.name}.${route.sandbox}`;
if (mode.mode === "remote") {
if (!mode.publicDomain) {
throw new Error("Remote mode requires publicDomain.");
}
return `${base}.${mode.publicDomain}`;
}
return `${base}.localhost`;
}
export function renderCaddyfile(routes: Route[], mode: GatewayMode): string {
const lines: string[] = [];
lines.push("# Generated by `openharness expose` — do not hand-edit.");
lines.push("{");
lines.push(" admin 0.0.0.0:2019");
if (mode.mode === "remote") {
if (mode.acmeEmail) lines.push(` email ${mode.acmeEmail}`);
} else {
lines.push(" auto_https disable_redirects");
}
lines.push("}");
lines.push("");
for (const route of routes) {
const host = hostFor(route, mode);
const suffix = mode.mode === "remote" ? "" : ":443";
lines.push(`${host}${suffix} {`);
if (mode.mode === "local") {
lines.push(" tls internal");
}
lines.push(` reverse_proxy ${route.sandbox}:${route.port}`);
lines.push("}");
lines.push("");
}
return lines.join("\n");
}
export function writeCaddyfile(content: string): void {
const path = resolve(CADDYFILE_PATH);
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp`;
writeFileSync(tmp, content, "utf-8");
renameSync(tmp, path);
}
/**
* Reload Caddy configuration. The Caddyfile is bind-mounted into the gateway
* container at /etc/caddy/Caddyfile, so we just ask Caddy to reload from disk.
* Atomic in-process swap — no dropped connections.
*/
export function reloadCaddy(sandboxName: string): boolean {
const gatewayName = `${sandboxName}-gateway`;
return runSafe(execCmd(gatewayName, ["caddy", "reload", "--config", "/etc/caddy/Caddyfile"]), {
stdio: "pipe",
});
}
/**
* HEAD-probe a URL to check liveness. Accepts self-signed certs so that
* local-mode `tls internal` routes are probeable. Returns within `timeoutMs`.
*/
export async function httpProbe(
url: string,
timeoutMs = 2000,
): Promise<{ ok: boolean; status?: number }> {
const isHttps = url.startsWith("https:");
const mod = isHttps ? await import("node:https") : await import("node:http");
return new Promise((resolvePromise) => {
const opts: Record<string, unknown> = { method: "HEAD" };
if (isHttps) opts.rejectUnauthorized = false;
const req = mod.request(url, opts as never, (res) => {
clearTimeout(timer);
const status = res.statusCode ?? 0;
resolvePromise({ ok: status > 0 && status < 500, status });
res.resume();
});
const timer = setTimeout(() => {
req.destroy();
resolvePromise({ ok: false });
}, timeoutMs);
req.on("error", () => {
clearTimeout(timer);
resolvePromise({ ok: false });
});
req.end();
});
}
-170
View File
@@ -1,170 +0,0 @@
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { execSync } from "node:child_process";
const BASE_COMPOSE = ".devcontainer/docker-compose.yml";
const CONFIG_PATH = ".openharness/config.json";
const ENV_FILE = ".devcontainer/.env";
const INIT_ENV = ".devcontainer/init-env.sh";
export interface SandboxOptions {
name?: string;
}
export class SandboxConfig {
readonly name: string;
readonly composeFiles: string[];
readonly envFile: string;
constructor(opts: SandboxOptions = {}) {
this.envFile = ENV_FILE;
// Resolve name: explicit > .env file > init-env.sh fallback
if (opts.name) {
this.name = opts.name;
} else {
// Try running init-env.sh to generate .env, then read SANDBOX_NAME
if (existsSync(INIT_ENV)) {
try {
execSync(`bash ${INIT_ENV}`, { stdio: "pipe" });
} catch {
// Ignore — .env may already exist
}
}
this.name = readEnvName() ?? "sandbox";
}
// Build compose file list: base + overlays from config.json
const files: string[] = [BASE_COMPOSE];
if (existsSync(CONFIG_PATH)) {
try {
const raw = readFileSync(CONFIG_PATH, "utf-8");
const config = JSON.parse(raw) as { composeOverrides?: string[] };
if (Array.isArray(config.composeOverrides)) {
for (const override of config.composeOverrides) {
if (existsSync(override)) {
files.push(override);
}
}
}
} catch {
// Ignore parse errors — use base only
}
}
// Auto-enable SSH overlay when HOST_SSH_DIR is set
const sshOverlay = ".devcontainer/docker-compose.ssh.yml";
if (!files.includes(sshOverlay) && existsSync(sshOverlay)) {
const hostSshDir = process.env.HOST_SSH_DIR ?? readEnvVar(ENV_FILE, "HOST_SSH_DIR");
if (hostSshDir) {
files.push(sshOverlay);
}
}
this.composeFiles = files;
}
/**
* Add a compose override path to .openharness/config.json (idempotent).
* Creates the file (and parent directory) if they do not exist.
*/
static addOverride(path: string): void {
let config: { composeOverrides: string[] } = { composeOverrides: [] };
if (existsSync(CONFIG_PATH)) {
try {
const raw = readFileSync(CONFIG_PATH, "utf-8");
const parsed = JSON.parse(raw) as { composeOverrides?: string[] };
config = {
composeOverrides: Array.isArray(parsed.composeOverrides) ? parsed.composeOverrides : [],
};
} catch {
// Ignore parse errors — start fresh
}
}
if (config.composeOverrides.includes(path)) {
return; // already present — idempotent no-op
}
config.composeOverrides.push(path);
SandboxConfig._writeConfig(config);
}
/**
* Remove a compose override path from .openharness/config.json (idempotent).
* No-op if the path is not present or the file does not exist.
*/
static removeOverride(path: string): void {
if (!existsSync(CONFIG_PATH)) return;
let config: { composeOverrides: string[] } = { composeOverrides: [] };
try {
const raw = readFileSync(CONFIG_PATH, "utf-8");
const parsed = JSON.parse(raw) as { composeOverrides?: string[] };
config = {
composeOverrides: Array.isArray(parsed.composeOverrides) ? parsed.composeOverrides : [],
};
} catch {
return; // Unparseable — nothing to remove
}
const filtered = config.composeOverrides.filter((p) => p !== path);
if (filtered.length === config.composeOverrides.length) {
return; // not present — idempotent no-op
}
SandboxConfig._writeConfig({ composeOverrides: filtered });
}
/**
* Write or replace a KEY=value line in .devcontainer/.env (idempotent).
* Creates the file if it does not exist.
*/
static upsertEnvVar(key: string, value: string): void {
const envDir = dirname(ENV_FILE);
if (!existsSync(envDir)) {
mkdirSync(envDir, { recursive: true });
}
let content = "";
if (existsSync(ENV_FILE)) {
content = readFileSync(ENV_FILE, "utf-8");
}
// Strip any existing KEY=... line (handles trailing \r)
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
content = content.replace(new RegExp(`^${escaped}=.*\r?\n?`, "m"), "");
// Normalise: ensure content ends with exactly one newline (or is empty)
if (content.length > 0 && !content.endsWith("\n")) {
content += "\n";
}
content += `${key}=${value}\n`;
writeFileSync(ENV_FILE, content, "utf-8");
}
/**
* Atomically write config to CONFIG_PATH via a tmp file + rename.
* Creates parent directory if needed.
*/
private static _writeConfig(config: { composeOverrides: string[] }): void {
const dir = dirname(CONFIG_PATH);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const tmp = `${CONFIG_PATH}.tmp`;
writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", "utf-8");
renameSync(tmp, CONFIG_PATH);
}
}
/**
* Read a variable from a .env file.
*/
function readEnvVar(envPath: string, key: string): string | undefined {
if (!existsSync(envPath)) return undefined;
try {
const content = readFileSync(envPath, "utf-8");
const match = content.match(new RegExp(`^${key}=(.+)$`, "m"));
return match?.[1]?.trim() || undefined;
} catch {
return undefined;
}
}
/**
* Read SANDBOX_NAME from .devcontainer/.env
*/
function readEnvName(): string | undefined {
return readEnvVar(ENV_FILE, "SANDBOX_NAME");
}
-101
View File
@@ -1,101 +0,0 @@
import { SandboxConfig } from "./config.js";
/**
* Build the base `docker compose` command with env-file, project, and compose file flags.
*/
export function composeCmd(config: SandboxConfig): string[] {
const cmd = ["docker", "compose", "--env-file", config.envFile];
for (const file of config.composeFiles) {
cmd.push("-f", file);
}
cmd.push("-p", config.name);
return cmd;
}
/**
* Environment variables to pass to docker compose.
*/
export function composeEnv(config: SandboxConfig): Record<string, string> {
return { SANDBOX_NAME: config.name };
}
/**
* Build the `docker compose up` command.
*
* @param config Sandbox configuration.
* @param opts.build Append `--build` (default: true).
* @param opts.forceRecreate Append `--force-recreate` (default: false).
*/
export function composeUp(
config: SandboxConfig,
opts: { build?: boolean; forceRecreate?: boolean } = {},
): string[] {
const cmd = [...composeCmd(config), "up", "-d"];
if (opts.build ?? true) cmd.push("--build");
if (opts.forceRecreate) cmd.push("--force-recreate");
return cmd;
}
/**
* Build the `docker compose down` command.
*/
export function composeDown(config: SandboxConfig, volumes = false): string[] {
const cmd = [...composeCmd(config), "down"];
if (volumes) {
cmd.push("-v");
}
return cmd;
}
/**
* Build a `docker exec` command.
*/
export function execCmd(
name: string,
command: string[],
opts: {
user?: string;
interactive?: boolean;
workdir?: string;
env?: Record<string, string>;
} = {},
): string[] {
const cmd = ["docker", "exec"];
if (opts.user) {
cmd.push("--user", opts.user);
}
if (opts.interactive) {
cmd.push("-it");
}
if (opts.workdir) {
cmd.push("-w", opts.workdir);
}
if (opts.env) {
for (const [key, value] of Object.entries(opts.env)) {
cmd.push("-e", `${key}=${value}`);
}
}
cmd.push(name, ...command);
return cmd;
}
/**
* Build a `docker ps` command filtered by compose service label.
*/
export function psCmd(): string[] {
return [
"docker",
"ps",
"--filter",
"label=com.docker.compose.service=sandbox",
"--format",
"table {{.Names}}\t{{.Status}}\t{{.Image}}",
];
}
-87
View File
@@ -1,87 +0,0 @@
import { spawnSync, execSync, type SpawnSyncOptions } from "node:child_process";
export interface RunOptions {
cwd?: string;
env?: Record<string, string>;
stdio?: "inherit" | "pipe";
}
/**
* Run a command with stdio inherited (output streams to terminal).
* Throws on non-zero exit code.
*/
export function run(cmd: string[], opts: RunOptions = {}): void {
const [bin, ...args] = cmd;
const spawnOpts: SpawnSyncOptions = {
stdio: opts.stdio ?? "inherit",
cwd: opts.cwd,
env: { ...process.env, ...opts.env },
};
const result = spawnSync(bin, args, spawnOpts);
if (result.error) {
throw new Error(`Failed to execute ${bin}: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(`Command failed with exit code ${result.status}: ${cmd.join(" ")}`);
}
}
/**
* Run a command but ignore errors (for cleanup operations).
* Returns true if the command succeeded.
*/
export function runSafe(cmd: string[], opts: RunOptions = {}): boolean {
const [bin, ...args] = cmd;
const spawnOpts: SpawnSyncOptions = {
stdio: opts.stdio ?? "inherit",
cwd: opts.cwd,
env: { ...process.env, ...opts.env },
};
const result = spawnSync(bin, args, spawnOpts);
return result.status === 0;
}
/**
* Run a command and capture stdout.
* Throws on non-zero exit code.
*/
export function capture(cmd: string[], opts: Omit<RunOptions, "stdio"> = {}): string {
const command = cmd.join(" ");
try {
return execSync(command, {
cwd: opts.cwd,
env: { ...process.env, ...opts.env },
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch (err) {
const error = err as { status?: number; stderr?: string };
throw new Error(
`Command failed with exit code ${error.status}: ${command}\n${error.stderr ?? ""}`,
);
}
}
/**
* Run a command and capture stdout.
* Returns undefined on failure (non-zero exit or spawn error) instead of throwing.
*/
export function captureSafe(
cmd: string[],
opts: Omit<RunOptions, "stdio"> = {},
): string | undefined {
const command = cmd.join(" ");
try {
return execSync(command, {
cwd: opts.cwd,
env: { ...process.env, ...opts.env },
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch {
return undefined;
}
}
-94
View File
@@ -1,94 +0,0 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
const EXPOSURES_PATH = ".openharness/exposures.json";
/**
* A single named Caddy route from the gateway to a sandbox port.
* This is the only exposure shape — legacy `--local` host-port publishes and
* `--public` quick-tunnels were removed in favour of the gateway.
*/
export interface Exposure {
routeName: string;
port: number;
sandbox: string;
url: string;
createdAt: string;
}
export interface ExposuresFile {
version: 1;
exposures: Exposure[];
}
const EMPTY: ExposuresFile = { version: 1, exposures: [] };
/**
* Type guard that also filters out any pre-gateway entries left from the
* deprecated scope-union era. Safe to apply to raw parsed JSON.
*/
function isExposure(x: unknown): x is Exposure {
if (!x || typeof x !== "object") return false;
const e = x as Record<string, unknown>;
return (
typeof e.routeName === "string" &&
typeof e.port === "number" &&
typeof e.sandbox === "string" &&
typeof e.url === "string" &&
typeof e.createdAt === "string"
);
}
export function readExposures(): ExposuresFile {
const path = resolve(EXPOSURES_PATH);
if (!existsSync(path)) return { ...EMPTY, exposures: [] };
try {
const raw = readFileSync(path, "utf-8");
const parsed = JSON.parse(raw) as Partial<ExposuresFile>;
if (parsed.version !== 1) {
console.warn(`[exposures] unknown version ${parsed.version} — treating as empty`);
return { ...EMPTY, exposures: [] };
}
const exposures = (parsed.exposures ?? []).filter(isExposure);
return { version: 1, exposures };
} catch (err) {
console.warn(`[exposures] failed to parse ${EXPOSURES_PATH}: ${(err as Error).message}`);
return { ...EMPTY, exposures: [] };
}
}
export function writeExposures(file: ExposuresFile): void {
const path = resolve(EXPOSURES_PATH);
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp`;
writeFileSync(tmp, JSON.stringify(file, null, 2) + "\n", "utf-8");
renameSync(tmp, path);
}
/** Upsert by routeName — one route per name. */
export function upsertExposure(e: Exposure): void {
const file = readExposures();
const others = file.exposures.filter((x) => x.routeName !== e.routeName);
writeExposures({ version: 1, exposures: [...others, e] });
}
/** Remove a route by name. Returns true if something was removed. */
export function removeRoute(routeName: string): boolean {
const file = readExposures();
const next = file.exposures.filter((x) => x.routeName !== routeName);
if (next.length === file.exposures.length) return false;
writeExposures({ version: 1, exposures: next });
return true;
}
export function findRouteByName(routeName: string): Exposure | undefined {
return readExposures().exposures.find((x) => x.routeName === routeName);
}
export function findRouteByPort(port: number): Exposure | undefined {
return readExposures().exposures.find((x) => x.port === port);
}
export function listRoutes(): Exposure[] {
return readExposures().exposures;
}
-49
View File
@@ -1,49 +0,0 @@
/**
* argv parser for the onboarding CLI. Accepts the same shapes the bash
* script did:
* onboard → { force: false }
* onboard --force → { force: true }
* onboard --only slack → { only: "slack", force: false }
* onboard --only=slack → { only: "slack", force: false }
* onboard slack → { only: "slack", force: false } (positional)
*/
import { STEP_IDS, UnknownStepError, type ParsedArgs, type StepId } from "./types.js";
export function parseArgs(argv: readonly string[]): ParsedArgs {
let only: StepId | undefined;
let force = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--force") {
force = true;
continue;
}
if (arg === "--only") {
const next = argv[i + 1];
if (next === undefined) continue;
only = ensureStepId(next);
i++;
continue;
}
if (arg.startsWith("--only=")) {
only = ensureStepId(arg.slice("--only=".length));
continue;
}
if ((STEP_IDS as readonly string[]).includes(arg)) {
only = arg as StepId;
continue;
}
// Unknown flags are ignored (bash did the same: `*) shift ;;`)
}
return { only, force };
}
function ensureStepId(value: string): StepId {
if (!(STEP_IDS as readonly string[]).includes(value)) {
throw new UnknownStepError(value);
}
return value as StepId;
}
-112
View File
@@ -1,112 +0,0 @@
/**
* Wire up a real {@link Deps} bag from node APIs for production use.
* Tests substitute fakes directly; this factory is only used by the CLI
* entrypoint.
*/
import {
appendFileSync,
chmodSync,
existsSync,
mkdirSync,
readFileSync,
statSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { spawnSync } from "node:child_process";
import { realIo } from "./io.js";
import type { Deps, ExecOptions, ExecResult, FsDeps } from "./types.js";
const fs: FsDeps = {
exists: (path) => existsSync(path),
readFile: (path) => readFileSync(path, "utf-8"),
writeFile: (path, contents) => writeFileSync(path, contents),
appendFile: (path, contents) => appendFileSync(path, contents),
mkdirp: (path) => {
mkdirSync(path, { recursive: true });
},
symlink: (target, link) => symlinkSync(target, link),
chmod: (path, mode) => chmodSync(path, mode),
stat: (path) => {
try {
const s = statSync(path);
return { size: s.size };
} catch {
return null;
}
},
};
function mergedEnv(opts?: ExecOptions): Record<string, string> {
const base: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) {
if (v !== undefined) base[k] = v;
}
return { ...base, ...(opts?.env ?? {}) };
}
function run(cmd: string[], opts?: ExecOptions): void {
const [bin, ...args] = cmd;
const result = spawnSync(bin, args, {
stdio: "inherit",
cwd: opts?.cwd,
env: mergedEnv(opts),
});
if (result.error) throw new Error(`Failed to execute ${bin}: ${result.error.message}`);
if (result.status !== 0) {
throw new Error(`Command failed with exit code ${result.status}: ${cmd.join(" ")}`);
}
}
function runSafe(cmd: string[], opts?: ExecOptions): boolean {
const [bin, ...args] = cmd;
const result = spawnSync(bin, args, {
stdio: "inherit",
cwd: opts?.cwd,
env: mergedEnv(opts),
});
return result.status === 0;
}
function capture(cmd: string[], opts?: ExecOptions): ExecResult {
const [bin, ...args] = cmd;
const result = spawnSync(bin, args, {
cwd: opts?.cwd,
env: mergedEnv(opts),
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
});
return {
status: result.status ?? -1,
stdout: (result.stdout ?? "").toString(),
stderr: (result.stderr ?? "").toString(),
};
}
function which(bin: string): string | null {
const result = spawnSync("sh", ["-c", `command -v ${bin}`], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) return null;
const path = (result.stdout ?? "").toString().trim();
return path.length > 0 ? path : null;
}
export function makeRealDeps(): Deps {
const envRecord: Record<string, string | undefined> = {};
for (const [k, v] of Object.entries(process.env)) envRecord[k] = v;
return {
env: envRecord,
home: process.env.HOME ?? "/home/sandbox",
fs,
exec: { run, runSafe, capture, which },
io: realIo(),
clock: {
nowUtcIso: () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
},
};
}
-114
View File
@@ -1,114 +0,0 @@
/**
* Host `.env` file helpers — read Slack tokens into process env and persist
* user-provided tokens back to the host `.env` without leaving duplicates.
*
* Mirrors the bash script's `set -a && . .env` loading and the
* `sed -i '/^KEY=/d' + echo KEY=val >> .env` upsert pattern.
*/
import type { FsDeps } from "./types.js";
/**
* Parse a `.env` file. Returns a map of KEY → VALUE. Strips surrounding
* single or double quotes. Ignores blank lines and lines beginning with `#`.
* Preserves the last occurrence when the same key appears twice.
*/
export function parseEnvFile(contents: string): Record<string, string> {
const out: Record<string, string> = {};
for (const rawLine of contents.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
out[key] = value;
}
return out;
}
/**
* Load any keys from the host `.env` into {@link target} that aren't already
* set there. Matches the bash behavior `set -a && . .env` but without the
* shell subprocess.
*/
export function loadEnvInto(
fs: FsDeps,
envPath: string,
target: Record<string, string | undefined>,
): void {
if (!fs.exists(envPath)) return;
const stat = fs.stat(envPath);
if (!stat || stat.size === 0) return;
const parsed = parseEnvFile(fs.readFile(envPath));
for (const [k, v] of Object.entries(parsed)) {
if (target[k] === undefined || target[k] === "") {
target[k] = v;
}
}
}
/**
* Upsert a set of KEY=VALUE pairs into `.env`.
*
* For each key, any existing uncommented `KEY=…` line is **commented out in
* place** (prefixed with `# `) and the new value is inserted on the line
* directly after the last such occurrence. Keys with no existing match are
* appended at the end.
*
* Rationale: onboarding often runs against a `.devcontainer/.env` that the
* user has pre-populated with defaults. Stripping the old line hides the
* fact that the value changed; leaving it commented preserves diff context
* and makes reverts obvious. Already-commented lines are left untouched.
*
* Creates the file if it doesn't exist. Returns the new file contents.
*/
export function upsertEnvFile(
fs: FsDeps,
envPath: string,
updates: Record<string, string>,
): string {
const existing = fs.exists(envPath) ? fs.readFile(envPath) : "";
const lines = existing.split("\n");
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
const lastMatchIdx: Record<string, number> = {};
const rewritten = lines.map((line, idx) => {
const trimmed = line.trimStart();
if (!trimmed || trimmed.startsWith("#")) return line;
const eq = line.indexOf("=");
if (eq === -1) return line;
const key = line.slice(0, eq).trim();
if (Object.prototype.hasOwnProperty.call(updates, key)) {
lastMatchIdx[key] = idx;
return `# ${line}`;
}
return line;
});
const insertionsAt: Record<number, string[]> = {};
const unmatched = new Set(Object.keys(updates));
for (const [key, idx] of Object.entries(lastMatchIdx)) {
(insertionsAt[idx] ||= []).push(`${key}=${updates[key]}`);
unmatched.delete(key);
}
const out: string[] = [];
for (let i = 0; i < rewritten.length; i++) {
out.push(rewritten[i]);
if (insertionsAt[i]) out.push(...insertionsAt[i]);
}
for (const key of unmatched) {
out.push(`${key}=${updates[key]}`);
}
const next = out.join("\n") + "\n";
fs.writeFile(envPath, next);
return next;
}
-24
View File
@@ -1,24 +0,0 @@
export { parseArgs } from "./args.js";
export {
runOnboarding,
type OrchestratorOptions,
type OrchestratorResult,
} from "./orchestrator.js";
export { makeRealDeps } from "./deps.js";
export {
ALL_STEPS,
getAllSteps,
loadPackSteps,
orderSteps,
type LoadPackStepsOptions,
type PackStepImporter,
} from "./steps/index.js";
export {
HARNESS_REGISTRY_PATH,
readHarnessRegistry,
parseHarnessRegistry,
type HarnessRegistry,
type InstalledHarness,
} from "../harness/registry.js";
export type { Deps, HarnessPack, PackStep, Step, StepId, StepResult, StepStatus } from "./types.js";
export { UnknownStepError, STEP_IDS } from "./types.js";
-74
View File
@@ -1,74 +0,0 @@
/**
* Real terminal IO for the onboarding wizard.
*
* Emits the same ANSI-colored banner/ok/skip/warn/fail/ask lines that the
* original `install/onboard.sh` produced, so a diff of stdout between the
* bash and TS paths should be near-empty (modulo a few bash-only colors
* already absent from some messages).
*/
import readline from "node:readline";
import type { IoDeps } from "./types.js";
const RED = "\x1b[0;31m";
const GREEN = "\x1b[0;32m";
const CYAN = "\x1b[0;36m";
const YELLOW = "\x1b[0;33m";
const NC = "\x1b[0m";
const B = "\x1b[1m";
export function banner(message: string): void {
process.stdout.write(`\n${CYAN}==> ${message}${NC}\n`);
}
export function ok(message: string): void {
process.stdout.write(` ${GREEN}✓${NC} ${message}\n`);
}
export function skip(message: string): void {
process.stdout.write(` ${YELLOW}⊘${NC} ${message}\n`);
}
export function warn(message: string): void {
process.stdout.write(` ${YELLOW}!${NC} ${message}\n`);
}
export function fail(message: string): void {
process.stdout.write(` ${RED}✗${NC} ${message}\n`);
}
export function info(message: string): void {
process.stdout.write(`${message}\n`);
}
export function raw(message: string): void {
process.stdout.write(message);
}
export async function ask(question: string): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
try {
return await new Promise<string>((resolve) => {
rl.question(`\n ${B}${question}${NC} `, (answer) => resolve(answer.trim()));
});
} finally {
rl.close();
}
}
export async function waitForEnter(): Promise<void> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
try {
await new Promise<void>((resolve) => {
rl.question("", () => resolve());
});
} finally {
rl.close();
}
}
export function realIo(): IoDeps {
return { banner, ok, skip, warn, fail, info, ask, waitForEnter, raw };
}
export const COLORS = { RED, GREEN, CYAN, YELLOW, NC, B } as const;
-53
View File
@@ -1,53 +0,0 @@
/**
* Read / write the onboarding marker file (`~/.claude/.onboarded`).
*
* Preserves the exact JSON schema the bash script wrote so downstream
* consumers (`jq -r '.completedAt'`) keep working unchanged.
*/
import { dirname } from "node:path";
import type { FsDeps, StepStatus } from "./types.js";
export const MARKER_VERSION = 1;
export interface MarkerFile {
version: number;
completedAt: string;
steps: Record<string, { status: StepStatus }>;
}
export function markerPath(home: string): string {
return `${home}/.claude/.onboarded`;
}
export function exists(fs: FsDeps, home: string): boolean {
return fs.exists(markerPath(home));
}
export function read(fs: FsDeps, home: string): MarkerFile | null {
const path = markerPath(home);
if (!fs.exists(path)) return null;
try {
const parsed = JSON.parse(fs.readFile(path));
if (typeof parsed !== "object" || parsed === null) return null;
return parsed as MarkerFile;
} catch {
return null;
}
}
export function write(
fs: FsDeps,
home: string,
completedAt: string,
steps: Record<string, StepStatus>,
): void {
const path = markerPath(home);
fs.mkdirp(dirname(path));
const payload: MarkerFile = {
version: MARKER_VERSION,
completedAt,
steps: Object.fromEntries(Object.entries(steps).map(([k, v]) => [k, { status: v }])),
};
fs.writeFile(path, JSON.stringify(payload, null, 2) + "\n");
}
@@ -1,127 +0,0 @@
/**
* Runs the onboarding step sequence, writes the marker, prints summaries.
*
* Mirrors the top-level control flow of `install/onboard.sh`:
* 1. In --only mode: run one step, report + exit (no marker).
* 2. Else if marker exists and --force is not set: print short-circuit.
* 3. Else: run every step in order, collect results, write marker.
*
* The step list, dependency bag, and "did anything fail" mapping are
* injectable so tests can exercise each branch without touching a shell.
*/
import * as marker from "./marker.js";
import type { Deps, Step, StepId, StepResult, StepStatus } from "./types.js";
export interface OrchestratorOptions {
only?: StepId;
force: boolean;
}
export interface OrchestratorResult {
results: Record<string, StepStatus>;
exitCode: number;
}
export async function runOnboarding(
steps: readonly Step[],
deps: Deps,
opts: OrchestratorOptions,
): Promise<OrchestratorResult> {
const { io, home, fs, clock } = deps;
const results: Record<string, StepStatus> = {};
if (opts.only) {
const step = steps.find((s) => s.id === opts.only);
if (!step) {
io.fail(`Unknown step: ${opts.only}`);
return { results, exitCode: 2 };
}
io.raw(`\n \x1b[1m\x1b[0;36mOpen Harness — onboarding step: ${opts.only}\x1b[0m\n\n`);
io.banner(step.label);
const res = await safeRun(step, deps, { force: opts.force });
results[res.id] = res.status;
io.raw(`\n \x1b[1m${res.id}\x1b[0m: ${res.status}\n\n`);
return { results, exitCode: onlyExitCode(res.status) };
}
if (marker.exists(fs, home) && !opts.force) {
io.banner("Already onboarded");
const current = marker.read(fs, home);
const completedAt = current?.completedAt ?? "unknown";
io.raw(` Completed: ${completedAt}\n`);
io.raw(`\n Run with \x1b[1m--force\x1b[0m to re-verify all steps,\n`);
io.raw(
` or \x1b[1m--only <step>\x1b[0m (llm|github|slack|ssh|cloudflare|claude) to re-run one.\n\n`,
);
return { results, exitCode: 0 };
}
printWelcome(deps);
for (const step of steps) {
io.banner(step.label);
const res = await safeRun(step, deps, { force: opts.force });
results[res.id] = res.status;
}
marker.write(fs, home, clock.nowUtcIso(), results);
printSummary(deps, results);
return { results, exitCode: 0 };
}
async function safeRun(step: Step, deps: Deps, opts: { force: boolean }): Promise<StepResult> {
try {
return await step.run(deps, opts);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
deps.io.fail(`step '${step.id}' threw: ${message}`);
return { id: step.id, status: "failed", message };
}
}
function onlyExitCode(status: StepStatus): number {
switch (status) {
case "done":
case "skipped":
return 0;
default:
return 1;
}
}
function printWelcome(deps: Deps): void {
const { io } = deps;
io.raw("\n");
io.raw(" \x1b[1m\x1b[0;36mOpen Harness — First-Time Setup\x1b[0m\n");
io.raw(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
io.raw("\n");
io.raw(" This wizard walks you through one-time authentication\n");
io.raw(" for all services used by this sandbox.\n");
io.raw("\n");
}
function printSummary(deps: Deps, results: Record<string, StepStatus>): void {
const { io } = deps;
io.banner("Onboarding Complete");
io.raw("\n");
const rows: [string, StepId][] = [
["LLM", "llm"],
["GitHub", "github"],
["Slack", "slack"],
["SSH", "ssh"],
["Cloudflare", "cloudflare"],
["Claude", "claude"],
];
for (const [label, id] of rows) {
io.raw(` \x1b[0;32m${label}\x1b[0m: ${results[id] ?? "unknown"}\n`);
}
io.raw("\n");
io.raw(" The dev server is now running. On future restarts,\n");
io.raw(" the application will start automatically.\n");
io.raw("\n");
io.raw(" \x1b[0;36mVerify\x1b[0m: pnpm run test:setup\n");
io.raw(" \x1b[0;36mRe-run\x1b[0m: openharness onboard --force\n");
io.raw("\n");
}
@@ -1,43 +0,0 @@
/**
* Step 6/6 — Claude Code. Port of `install/onboard.sh:418-438`.
*
* Behavior:
* - If `~/.claude/.credentials.json` OR `~/.claude/credentials.json` exists,
* record "done" and return.
* - Else prompt; a "n" answer → "skipped"; anything else → run
* `claude --version` (best-effort) and record "done".
*/
import type { Step } from "../types.js";
export const claudeStep: Step = {
id: "claude",
label: "Step 6/6 — Claude Code",
async run(deps): Promise<{ id: "claude"; status: "done" | "skipped" }> {
const { fs, home, io, exec } = deps;
const cred1 = `${home}/.claude/.credentials.json`;
const cred2 = `${home}/.claude/credentials.json`;
if (fs.exists(cred1) || fs.exists(cred2)) {
io.ok("Claude Code already authenticated");
return { id: "claude", status: "done" };
}
io.raw(" Claude Code requires authentication on first run.\n");
io.raw(" This will open a browser for Anthropic OAuth.\n\n");
const answer = await io.ask("Authenticate now? [Y/n]:");
if (/^[Nn]$/.test(answer)) {
io.skip("Skipped — run 'claude' later to authenticate");
return { id: "claude", status: "skipped" };
}
io.raw("\n Running: \x1b[0;36mclaude --version\x1b[0m (triggers auth check)\n\n");
const versionOk = exec.runSafe(["claude", "--version"]);
if (versionOk) {
io.ok("Claude Code ready");
} else {
io.warn("Run 'claude' manually to complete auth");
}
return { id: "claude", status: "done" };
},
};
@@ -1,85 +0,0 @@
/**
* Step 5/6 — Cloudflare Tunnel. Port of `install/onboard.sh:359-413`.
*
* - `cloudflared` not on PATH → skipped.
* - `~/.cloudflared/config-*.yml` exists and !force → done.
* - No `cert.pem`: prompt; "n" → skipped; "y" → run `cloudflared login`.
* - With cert.pem: collect tunnel name / hostname / local port, delegate to
* `install/cloudflared-tunnel.sh`, record done.
*/
import type { Step, StepResult } from "../types.js";
const DEFAULT_PORT = "3000";
export const cloudflareStep: Step = {
id: "cloudflare",
label: "Step 5/6 — Cloudflare Tunnel",
async run(deps, opts): Promise<StepResult> {
const { io, fs, exec, home } = deps;
if (!exec.which("cloudflared")) {
io.skip("cloudflared not installed — skipping");
return { id: "cloudflare", status: "skipped" };
}
const configDir = `${home}/.cloudflared`;
const existingConfig = findFirstConfig(deps, configDir);
if (existingConfig && !opts.force) {
const tunnelName = existingConfig.replace(/^.*\/config-/, "").replace(/\.yml$/, "");
io.ok(`Tunnel '${tunnelName}' already configured`);
return { id: "cloudflare", status: "done" };
}
const certPath = `${configDir}/cert.pem`;
if (!fs.exists(certPath)) {
const answer = await io.ask("Set up Cloudflare tunnel now? [y/N]:");
if (!/^[Yy]$/.test(answer)) {
io.skip("Skipped — to set up later, run:");
io.raw(" cloudflared login\n");
io.raw(" /cloudflared-tunnel <name> <hostname> 3000\n");
return { id: "cloudflare", status: "skipped" };
}
io.raw("\n Running: \x1b[0;36mcloudflared login\x1b[0m\n");
io.raw(" (Copy the URL below and open it in a browser)\n\n");
exec.runSafe(["cloudflared", "login"]);
}
if (!fs.exists(certPath)) {
io.fail("Cloudflare login failed");
return { id: "cloudflare", status: "failed" };
}
io.ok("Cloudflare authenticated");
const tunnelName = (await io.ask("Tunnel name (default: open-harness):")) || "open-harness";
const tunnelHost = (await io.ask("Public hostname (e.g., my-tunnel.example.com):")).trim();
if (!tunnelHost) {
io.fail("Public hostname is required");
return { id: "cloudflare", status: "failed" };
}
const tunnelPort = (await io.ask("Local port (default: 3000):")) || DEFAULT_PORT;
io.raw("\n");
exec.run(["bash", `${home}/install/cloudflared-tunnel.sh`, tunnelName, tunnelHost, tunnelPort]);
return { id: "cloudflare", status: "done" };
},
};
/**
* List `config-*.yml` files under {@link configDir} using the in-memory fs.
* Real deps don't implement `readdir`, so we use `ls` via exec.capture.
*/
function findFirstConfig(
deps: {
fs: { exists(p: string): boolean };
exec: { capture(cmd: string[]): { status: number; stdout: string } };
},
configDir: string,
): string | null {
if (!deps.fs.exists(configDir)) return null;
const res = deps.exec.capture(["sh", "-c", `ls ${configDir}/config-*.yml 2>/dev/null | head -1`]);
const first = res.stdout.trim().split("\n")[0] ?? "";
return first.length > 0 ? first : null;
}
@@ -1,43 +0,0 @@
/**
* Step 2/6 — GitHub CLI.
*
* - If `gh auth status` succeeds → run `gh auth setup-git` best-effort, done.
* - Else run `gh auth login` interactively; success → done, failure → failed.
*
* Runs before the SSH step so that the interactive `gh auth login` flow (which
* offers to generate + upload an SSH key for the user) can satisfy both this
* step and the next one.
*/
import type { Step, StepResult } from "../types.js";
export const githubStep: Step = {
id: "github",
label: "Step 2/6 — GitHub CLI",
async run(deps): Promise<StepResult> {
const { io, exec } = deps;
const statusOk = exec.runSafe(["gh", "auth", "status"]);
if (statusOk) {
io.ok("GitHub CLI already authenticated");
exec.runSafe(["gh", "auth", "setup-git"]);
io.ok("Git credential helper configured");
return { id: "github", status: "done" };
}
io.raw(" Running: \x1b[0;36mgh auth login\x1b[0m\n");
io.raw(
" \x1b[0;90mTip: choose SSH when prompted — gh can generate and upload the key for you.\x1b[0m\n\n",
);
const loginOk = exec.runSafe(["gh", "auth", "login"]);
if (loginOk) {
io.ok("GitHub CLI authenticated");
exec.runSafe(["gh", "auth", "setup-git"]);
io.ok("Git credential helper configured");
return { id: "github", status: "done" };
}
io.fail("GitHub CLI authentication failed");
return { id: "github", status: "failed" };
},
};
-181
View File
@@ -1,181 +0,0 @@
import { resolve as resolvePath } from "node:path";
import { pathToFileURL } from "node:url";
import { claudeStep } from "./claude.js";
import { cloudflareStep } from "./cloudflare.js";
import { githubStep } from "./github.js";
import { llmStep } from "./llm.js";
import { sshStep } from "./ssh.js";
import {
HARNESS_REGISTRY_PATH,
readHarnessRegistry,
type InstalledHarness,
} from "../../harness/registry.js";
import type { PackStep, Step, StepId } from "../types.js";
/**
* Execution order: llm → github → ssh → cloudflare → claude.
*
* GitHub runs second (right after LLM) so that its credentials are in place
* before anything else, and — crucially — before SSH. `gh auth login` can
* generate and upload an SSH key itself, which lets the ssh step fast-path
* in the common case. See .claude/specs/onboard-github-before-ssh.md.
*
* Pack-contributed steps (e.g. the `slack` step from `@ryaneggz/mifune`)
* are loaded via `loadPackSteps()` and merged into the execution order at
* runtime based on each pack's `onboard_steps[].after` declaration.
*/
export const ALL_STEPS: readonly Step[] = [
llmStep,
githubStep,
sshStep,
cloudflareStep,
claudeStep,
] as const;
export { llmStep, sshStep, githubStep, cloudflareStep, claudeStep };
/**
* Optional injection point for `loadPackSteps` — tests can replace the module
* loader to avoid touching the disk. Production passes `import(specifier)`.
*/
export type PackStepImporter = (specifier: string) => Promise<unknown>;
export interface LoadPackStepsOptions {
/** Path to `.openharness/harnesses.json`. Defaults to {@link HARNESS_REGISTRY_PATH}. */
registryPath?: string;
/** Override the dynamic-import used to load step modules. Mostly for tests. */
importer?: PackStepImporter;
}
/**
* Load all onboard steps contributed by installed harness packs. Returns
* `[]` when the registry is missing or empty. Each pack's
* `onboard_steps[]` entry is resolved against the pack directory and
* dynamic-imported; the imported module must expose either a `Step`-shaped
* default export or a single named export matching `<id>Step` (e.g.
* `slackStep` for id `"slack"`).
*/
export async function loadPackSteps(opts: LoadPackStepsOptions = {}): Promise<Step[]> {
const registry = readHarnessRegistry(opts.registryPath ?? HARNESS_REGISTRY_PATH);
if (registry.installed.length === 0) return [];
const importer: PackStepImporter = opts.importer ?? ((s) => import(s));
const steps: Step[] = [];
for (const pack of registry.installed) {
const packSteps = pack.manifest.onboard_steps;
if (!Array.isArray(packSteps)) continue;
for (const ps of packSteps) {
const step = await loadOneStep(pack, ps, importer);
if (step) steps.push(step);
}
}
return steps;
}
async function loadOneStep(
pack: InstalledHarness,
ps: PackStep,
importer: PackStepImporter,
): Promise<Step | null> {
if (!ps || typeof ps.id !== "string" || typeof ps.file !== "string") return null;
const abs = resolvePath(pack.path, ps.file);
const specifier = pathToFileURL(abs).href;
let mod: unknown;
try {
mod = await importer(specifier);
} catch {
return null;
}
const exported = pickStepExport(mod, ps.id);
if (!exported) return null;
// Stamp the registered id onto the resolved step. The pack-supplied id is
// authoritative — the inner module's id field is treated as a hint only.
return { ...exported, id: exported.id ?? (ps.id as StepId) };
}
function pickStepExport(mod: unknown, id: string): Step | null {
if (!mod || typeof mod !== "object") return null;
const m = mod as Record<string, unknown>;
// Common patterns, in priority order:
// 1. default export (ESM `export default`)
// 2. named export `<id>Step` (matches the convention used in core)
// 3. named export `step`
const candidates = [m.default, m[`${id}Step`], m.step];
for (const c of candidates) {
if (isStepShape(c)) return c;
}
return null;
}
function isStepShape(v: unknown): v is Step {
if (!v || typeof v !== "object") return false;
const s = v as Partial<Step>;
return typeof s.label === "string" && typeof s.run === "function";
}
/**
* Topologically order steps using {@link PackStep.after}. Steps without an
* `after` constraint preserve the input order (so core ordering — llm,
* github, slack, … — is stable). Cycles or unresolved `after` references
* fall back to insertion order for the offending step. Note: only pack
* steps carry `after` metadata at the moment, but this helper accepts a
* parallel `after` map so the same logic can be reused once core steps
* grow explicit dependencies.
*/
export function orderSteps(
steps: readonly Step[],
afterById: Readonly<Record<string, string | undefined>> = {},
): Step[] {
const remaining = new Map<string, Step>();
for (const s of steps) remaining.set(s.id, s);
const out: Step[] = [];
const placed = new Set<string>();
// Keep iterating while we make progress; bail out if a pass adds nothing.
while (remaining.size > 0) {
let progressed = false;
for (const s of steps) {
if (placed.has(s.id) || !remaining.has(s.id)) continue;
const dep = afterById[s.id];
if (!dep || placed.has(dep) || !remaining.has(dep)) {
out.push(s);
placed.add(s.id);
remaining.delete(s.id);
progressed = true;
}
}
if (!progressed) {
// Cycle or unsatisfiable dependency — flush the rest in insertion order.
for (const s of steps) {
if (!placed.has(s.id) && remaining.has(s.id)) {
out.push(s);
placed.add(s.id);
remaining.delete(s.id);
}
}
break;
}
}
return out;
}
/**
* Return the merged ordered list of core steps plus any steps contributed by
* installed harness packs. Used by the onboard CLI entrypoint so that pack
* authors can extend onboarding without forking the core package.
*/
export async function getAllSteps(opts: LoadPackStepsOptions = {}): Promise<Step[]> {
const packSteps = await loadPackSteps(opts);
if (packSteps.length === 0) return [...ALL_STEPS];
// Build the after-map keyed by step id. We only know `after` for pack
// steps right now; core steps default to undefined (no constraint).
const afterById: Record<string, string | undefined> = {};
const registry = readHarnessRegistry(opts.registryPath ?? HARNESS_REGISTRY_PATH);
for (const pack of registry.installed) {
for (const ps of pack.manifest.onboard_steps ?? []) {
if (ps && typeof ps.id === "string") afterById[ps.id] = ps.after;
}
}
return orderSteps([...ALL_STEPS, ...packSteps], afterById);
}
-98
View File
@@ -1,98 +0,0 @@
/**
* Step 1/6 — LLM Provider (OpenAI). Port of `install/onboard.sh:74-116`.
*
* - `~/.pi/agent/auth.json` populated (non-empty + not `"{}"`) → done.
* - Else `OPENAI_API_KEY` env set → done.
* - Else prompt user; "n" → skipped.
* any other answer → run `openharness` (interactive), re-check
* auth.json. If present, symlink it to `~/.pi/slack/auth.json` and
* return done. If still missing, warn + skipped.
*/
import type { Step, StepResult } from "../types.js";
export const llmStep: Step = {
id: "llm",
label: "Step 1/6 — LLM Provider (OpenAI)",
async run(deps): Promise<StepResult> {
const { io, fs, exec, home, env } = deps;
const piAuth = `${home}/.pi/agent/auth.json`;
io.raw(" This sandbox uses \x1b[1mopenharness\x1b[0m (Pi agent) for AI tasks.\n");
io.raw(" You need to authenticate with an LLM provider first.\n\n");
if (authPopulated(fs, piAuth)) {
const provider = detectProvider(fs, piAuth);
io.ok(`Already authenticated (provider: ${provider})`);
return { id: "llm", status: "done" };
}
if (env.OPENAI_API_KEY && env.OPENAI_API_KEY.length > 0) {
io.ok("OPENAI_API_KEY set via environment");
return { id: "llm", status: "done" };
}
io.raw(" \x1b[1mInside the sandbox, run:\x1b[0m\n\n");
io.raw(" \x1b[0;36mopenharness\x1b[0m # launches the agent CLI\n");
io.raw(" \x1b[0;36m/login\x1b[0m # authenticate with OpenAI\n");
io.raw(" \x1b[0;36m/model\x1b[0m # select \x1b[1mgpt-5.4\x1b[0m\n");
io.raw(" \x1b[0;36mCtrl+C\x1b[0m # exit back to onboarding\n\n");
const answer = await io.ask("Authenticate now? [Y/n]:");
if (/^[Nn]$/.test(answer)) {
io.skip("Skipped — run 'openharness' then '/login' later");
return { id: "llm", status: "skipped" };
}
io.raw(
"\n Launching openharness — run \x1b[1m/login\x1b[0m then \x1b[1m/model\x1b[0m to set up.\n",
);
io.raw(" Press \x1b[1mCtrl+C\x1b[0m when done to continue onboarding.\n\n");
exec.runSafe(["openharness"]);
if (authPopulated(fs, piAuth)) {
io.ok("LLM provider authenticated");
fs.mkdirp(`${home}/.pi/slack`);
const slackLink = `${home}/.pi/slack/auth.json`;
if (!fs.exists(slackLink)) {
fs.symlink(piAuth, slackLink);
}
io.ok("Auth shared with Mom (Slack bot)");
return { id: "llm", status: "done" };
}
io.warn("Auth not detected — run 'openharness' and '/login' later");
return { id: "llm", status: "skipped" };
},
};
function authPopulated(
fs: {
exists(p: string): boolean;
readFile(p: string): string;
stat(p: string): { size: number } | null;
},
path: string,
): boolean {
if (!fs.exists(path)) return false;
const stat = fs.stat(path);
if (!stat || stat.size === 0) return false;
try {
const contents = fs.readFile(path).trim();
return contents !== "" && contents !== "{}";
} catch {
return false;
}
}
function detectProvider(fs: { readFile(p: string): string }, path: string): string {
try {
const parsed = JSON.parse(fs.readFile(path));
if (parsed && typeof parsed === "object") {
const keys = Object.keys(parsed);
if (keys.length > 0) return keys[0];
}
} catch {
/* ignore */
}
return "unknown";
}
-71
View File
@@ -1,71 +0,0 @@
/**
* Step 4/6 — SSH Key.
*
* Runs after GitHub CLI so that `gh auth login` has had the chance to
* generate and upload a key. In the common path the key is already on disk
* and registered with GitHub, so this step is pure verification.
*
* - If `~/.ssh/id_ed25519.pub` exists, print it and run `ssh -T git@github.com`
* to verify GitHub access:
* - "successfully authenticated" in output → done
* - otherwise → prompt user to add the key, wait for Enter, re-verify.
* - Else (user declined SSH at the `gh` prompt), generate a new key with
* `ssh-keygen -t ed25519 -N ""`, print it, instruct the user to add it
* to GitHub, wait for Enter, return done.
*/
import type { Step, StepResult } from "../types.js";
const GH_MARKER = "successfully authenticated";
function combine(result: { stdout: string; stderr: string }): string {
return `${result.stdout}\n${result.stderr}`;
}
export const sshStep: Step = {
id: "ssh",
label: "Step 4/6 — SSH Key",
async run(deps): Promise<StepResult> {
const { io, fs, exec, home } = deps;
const keyPath = `${home}/.ssh/id_ed25519`;
const pubPath = `${keyPath}.pub`;
const printKey = (): void => {
const pub = fs.readFile(pubPath).trim();
io.raw(`\n Public key:\n \x1b[0;36m${pub}\x1b[0m\n`);
};
const verifyGithub = (): boolean => {
const res = exec.capture(["ssh", "-T", "git@github.com"]);
return combine(res).includes(GH_MARKER);
};
if (fs.exists(pubPath)) {
io.ok("SSH key exists");
printKey();
if (verifyGithub()) {
io.ok("GitHub SSH access verified");
return { id: "ssh", status: "done" };
}
io.warn("SSH key exists but GitHub access not verified");
io.raw("\n Add this key to GitHub → Settings → SSH and GPG keys\n");
io.raw(" Then press Enter to continue...\n");
await io.waitForEnter();
if (verifyGithub()) {
io.ok("GitHub SSH access verified");
return { id: "ssh", status: "done" };
}
io.warn("Could not verify GitHub SSH access — continuing anyway");
return { id: "ssh", status: "unverified" };
}
io.warn("No SSH key found — generating one");
const hostname = exec.capture(["hostname"]).stdout.trim() || "sandbox";
exec.run(["ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-C", `sandbox@${hostname}`]);
printKey();
io.raw("\n Add this key to GitHub → Settings → SSH and GPG keys\n");
io.raw(" Then press Enter to continue...\n");
await io.waitForEnter();
return { id: "ssh", status: "done" };
},
};
@@ -1,229 +0,0 @@
/**
* In-memory test double for {@link Deps}. Records every exec invocation and
* IO line, exposes a scripted answer queue for `ask()`, and emulates fs on
* a plain `Map<string, string>`.
*
* Tests import `makeFakeDeps({...})`, seed any files, env keys, or scripted
* answers, then call `step.run(deps, opts)` and assert against the recorder.
*/
import type { Deps, ExecOptions, ExecResult, FsDeps, IoDeps, StepId } from "../types.js";
export interface FakeFileState {
[path: string]: string;
}
export interface FakeExecCall {
cmd: string[];
opts?: ExecOptions;
mode: "run" | "runSafe" | "capture";
}
export interface FakeExecStub {
match: RegExp | ((cmd: string[]) => boolean);
/** For `run` + `runSafe` this controls success; for `capture`, stdout/stderr. */
result?: ExecResult;
/** Thrown from `run()` when status is non-zero. runSafe returns false instead. */
throwFromRun?: boolean;
/** Invoked for side-effects (e.g. "tmux starts" touches a file). */
sideEffect?: (cmd: string[], opts?: ExecOptions) => void;
}
export interface FakeWhichStub {
[bin: string]: string | null;
}
export interface FakeIoRecord {
level: keyof IoDeps;
message: string;
}
export interface FakeDepsOptions {
env?: Record<string, string | undefined>;
home?: string;
files?: FakeFileState;
askAnswers?: string[];
execStubs?: FakeExecStub[];
which?: FakeWhichStub;
/** Fixed "now" for the marker completedAt field. */
now?: string;
}
export interface FakeDeps extends Deps {
recorder: {
execCalls: FakeExecCall[];
io: FakeIoRecord[];
askQuestions: string[];
waitForEnterCount: number;
writtenFiles: Record<string, string[]>;
appendedFiles: Record<string, string[]>;
symlinks: Array<{ target: string; link: string }>;
chmods: Array<{ path: string; mode: number }>;
sleeps: number[];
};
files: Map<string, string>;
/** Enqueue additional scripted answers at runtime. */
queueAnswer(answer: string): void;
}
export function makeFakeDeps(opts: FakeDepsOptions = {}): FakeDeps {
const files = new Map<string, string>();
for (const [k, v] of Object.entries(opts.files ?? {})) files.set(k, v);
const askAnswers = [...(opts.askAnswers ?? [])];
const execStubs = opts.execStubs ?? [];
const whichStubs = opts.which ?? {};
const env: Record<string, string | undefined> = { ...(opts.env ?? {}) };
const recorder = {
execCalls: [] as FakeExecCall[],
io: [] as FakeIoRecord[],
askQuestions: [] as string[],
waitForEnterCount: 0,
writtenFiles: {} as Record<string, string[]>,
appendedFiles: {} as Record<string, string[]>,
symlinks: [] as Array<{ target: string; link: string }>,
chmods: [] as Array<{ path: string; mode: number }>,
sleeps: [] as number[],
};
const fs: FsDeps = {
exists: (p) => files.has(p),
readFile: (p) => {
const v = files.get(p);
if (v === undefined) throw new Error(`fake fs: ENOENT ${p}`);
return v;
},
writeFile: (p, contents) => {
files.set(p, contents);
(recorder.writtenFiles[p] ??= []).push(contents);
},
appendFile: (p, contents) => {
const prev = files.get(p) ?? "";
files.set(p, prev + contents);
(recorder.appendedFiles[p] ??= []).push(contents);
},
mkdirp: (_p) => {
/* no-op in the fake fs */
},
symlink: (target, link) => {
// Follow the target content if we can — tests can symlink auth files.
const existing = files.get(target);
if (existing !== undefined && !files.has(link)) {
files.set(link, existing);
}
recorder.symlinks.push({ target, link });
},
chmod: (path, mode) => {
recorder.chmods.push({ path, mode });
},
stat: (p) => {
const v = files.get(p);
if (v === undefined) return null;
return { size: v.length };
},
};
function findStub(cmd: string[]): FakeExecStub | undefined {
for (const stub of execStubs) {
const joined = cmd.join(" ");
if (typeof stub.match === "function") {
if (stub.match(cmd)) return stub;
} else if (stub.match.test(joined)) {
return stub;
}
}
return undefined;
}
const exec: Deps["exec"] = {
run: (cmd, opts) => {
recorder.execCalls.push({ cmd, opts, mode: "run" });
const stub = findStub(cmd);
stub?.sideEffect?.(cmd, opts);
const status = stub?.result?.status ?? 0;
if (status !== 0 && stub?.throwFromRun !== false) {
throw new Error(`fake exec: status ${status}: ${cmd.join(" ")}`);
}
},
runSafe: (cmd, opts) => {
recorder.execCalls.push({ cmd, opts, mode: "runSafe" });
const stub = findStub(cmd);
stub?.sideEffect?.(cmd, opts);
const status = stub?.result?.status ?? 0;
return status === 0;
},
capture: (cmd, opts) => {
recorder.execCalls.push({ cmd, opts, mode: "capture" });
const stub = findStub(cmd);
stub?.sideEffect?.(cmd, opts);
return stub?.result ?? { status: 0, stdout: "", stderr: "" };
},
which: (bin) => {
if (bin in whichStubs) return whichStubs[bin];
return null;
},
};
const io: IoDeps = {
banner: (m) => recorder.io.push({ level: "banner", message: m }),
ok: (m) => recorder.io.push({ level: "ok", message: m }),
skip: (m) => recorder.io.push({ level: "skip", message: m }),
warn: (m) => recorder.io.push({ level: "warn", message: m }),
fail: (m) => recorder.io.push({ level: "fail", message: m }),
info: (m) => recorder.io.push({ level: "info", message: m }),
raw: (m) => recorder.io.push({ level: "raw", message: m }),
ask: async (q) => {
recorder.askQuestions.push(q);
if (askAnswers.length === 0) {
throw new Error(`fake io: no scripted answer for ask(${JSON.stringify(q)})`);
}
return askAnswers.shift()!;
},
waitForEnter: async () => {
recorder.waitForEnterCount += 1;
},
};
const deps: FakeDeps = {
env,
home: opts.home ?? "/home/sandbox",
fs,
exec,
io,
clock: {
nowUtcIso: () => opts.now ?? "2026-04-21T00:00:00Z",
sleep: async (ms) => {
recorder.sleeps.push(ms);
},
},
recorder,
files,
queueAnswer: (answer) => askAnswers.push(answer),
};
return deps;
}
export function ioMessages(deps: FakeDeps, level: keyof IoDeps): string[] {
return deps.recorder.io.filter((r) => r.level === level).map((r) => r.message);
}
export function execWasCalled(
deps: FakeDeps,
matcher: RegExp | ((cmd: string[]) => boolean),
): boolean {
for (const call of deps.recorder.execCalls) {
const cmd = call.cmd.join(" ");
if (typeof matcher === "function") {
if (matcher(call.cmd)) return true;
} else if (matcher.test(cmd)) {
return true;
}
}
return false;
}
export function resultFor(id: StepId, results: Record<string, string>): string {
return results[id];
}
-152
View File
@@ -1,152 +0,0 @@
/**
* Interactive onboarding wizard — shared types.
*
* Each step is a pure function that takes an injected {@link Deps} bag and
* returns a {@link StepResult}. Tests substitute fakes via the dependency
* bag; production wires up real fs / exec / readline.
*/
export type StepId = "llm" | "slack" | "ssh" | "github" | "cloudflare" | "claude";
export type StepStatus = "done" | "skipped" | "failed" | "unverified" | "unknown";
export const STEP_IDS: readonly StepId[] = [
"llm",
"slack",
"ssh",
"github",
"cloudflare",
"claude",
] as const;
export interface StepResult {
id: StepId;
status: StepStatus;
message?: string;
}
export interface ExecOptions {
cwd?: string;
env?: Record<string, string>;
}
export interface ExecResult {
status: number;
stdout: string;
stderr: string;
}
export interface FsDeps {
exists(path: string): boolean;
readFile(path: string): string;
writeFile(path: string, contents: string): void;
appendFile(path: string, contents: string): void;
mkdirp(path: string): void;
symlink(target: string, link: string): void;
chmod(path: string, mode: number): void;
stat(path: string): { size: number } | null;
}
export interface ExecDeps {
/** Run a command with inherited stdio. Throws on non-zero exit. */
run(cmd: string[], opts?: ExecOptions): void;
/** Run a command, ignore the exit code. Returns true iff status === 0. */
runSafe(cmd: string[], opts?: ExecOptions): boolean;
/** Run a command, capture stdout + stderr + status. Never throws. */
capture(cmd: string[], opts?: ExecOptions): ExecResult;
/** `which`-style lookup. Returns the absolute path or null. */
which(bin: string): string | null;
}
export interface IoDeps {
banner(message: string): void;
ok(message: string): void;
skip(message: string): void;
warn(message: string): void;
fail(message: string): void;
info(message: string): void;
ask(question: string): Promise<string>;
/** Wait for the user to press Enter (used after "Add key to GitHub"). */
waitForEnter(): Promise<void>;
/** Emit a raw line without any prefix or color. */
raw(message: string): void;
}
export interface ClockDeps {
nowUtcIso(): string;
/** Sleep N milliseconds. Mockable in tests. */
sleep(ms: number): Promise<void>;
}
export interface Deps {
env: Record<string, string | undefined>;
home: string;
fs: FsDeps;
exec: ExecDeps;
io: IoDeps;
clock: ClockDeps;
}
export interface StepRunOptions {
force: boolean;
}
export interface Step {
id: StepId;
/** Human-readable step header, e.g. "Step 1/6 — LLM Provider (OpenAI)". */
label: string;
run(deps: Deps, opts: StepRunOptions): Promise<StepResult>;
}
export class UnknownStepError extends Error {
constructor(public readonly name: string) {
super(`Unknown step: ${name} (valid: ${STEP_IDS.join(" ")})`);
this.name = "UnknownStepError";
}
}
export interface ParsedArgs {
only?: StepId;
force: boolean;
}
/**
* A single onboard step contributed by a harness pack.
*
* `id` is the runtime step identifier (e.g. `"slack"`). `after` declares an
* ordering prerequisite — the loader resolves a topological order across all
* core + pack steps. `file` is a path *within the pack directory* that points
* at the compiled step module; the loader dynamic-imports it and expects a
* default or named export of shape {@link Step}.
*/
export interface PackStep {
id: string;
after?: string;
file: string;
}
/**
* The shape of a pack's `harness.json` manifest. A "harness pack" bundles
* agents, compose overlays, install + entrypoint hooks, workspace seed
* content, and (optionally) a prebuilt OCI image into a single installable
* unit. Onboard steps are one extension point among several; this contract
* only consumes `onboard_steps` but the full manifest is typed here so the
* pack registry can store and round-trip it without loss.
*
* See `.claude/plans/we-need-to-seperate-serialized-clover.md`
* (harness-pack contract) for the full pack architecture.
*/
export interface HarnessPack {
name: string;
version: string;
description: string;
/** Minimum compatible openharness CLI version. */
openharness: string;
agents: string[];
compose_overlays: string[];
onboard_steps: PackStep[];
install_hook: string;
entrypoint_hook: string;
workspace_seed: string;
prebuilt_image?: string;
}
-31
View File
@@ -1,31 +0,0 @@
import { Type } from "typebox";
import type { ToolDefinition } from "../types.js";
import { SandboxConfig, type SandboxOptions } from "../lib/config.js";
import { composeDown, composeEnv } from "../lib/docker.js";
import { runSafe } from "../lib/exec.js";
export const cleanTool: ToolDefinition = {
name: "sandbox_clean",
label: "Clean Sandbox",
description: "Full cleanup: stop containers and remove volumes.",
promptSnippet: "sandbox_clean — stop containers and remove volumes",
parameters: Type.Object({
name: Type.Optional(Type.String({ description: "Sandbox name (auto-resolved if omitted)" })),
}),
async execute(_toolCallId, params: Record<string, unknown>) {
const config = new SandboxConfig(params as unknown as SandboxOptions);
const env = composeEnv(config);
const stopped = runSafe(composeDown(config, true), { env });
const msg = stopped
? `Sandbox '${config.name}' cleaned (containers stopped, volumes removed).`
: `No running sandbox '${config.name}' found.`;
return {
content: [{ type: "text" as const, text: msg }],
details: undefined,
};
},
};
-89
View File
@@ -1,89 +0,0 @@
import { Type } from "typebox";
import type { ToolDefinition } from "../types.js";
import { SandboxConfig } from "../lib/config.js";
import { execCmd } from "../lib/docker.js";
import { captureSafe } from "../lib/exec.js";
import { listRoutes, upsertExposure, type Exposure } from "../lib/exposures.js";
import {
detectMode,
hostFor,
reloadCaddy,
renderCaddyfile,
validateRouteName,
writeCaddyfile,
type Route,
} from "../lib/caddy.js";
const GATEWAY_OVERLAY_PATH = ".devcontainer/docker-compose.gateway.yml";
export const exposeTool: ToolDefinition = {
name: "sandbox_expose",
label: "Expose App",
description:
"Expose a sandbox app via a Caddy route. Laptop: https://<name>.<sandbox>.localhost:8443 (tls internal). Remote (PUBLIC_DOMAIN set): https://<name>.<sandbox>.<PUBLIC_DOMAIN>.",
promptSnippet: "sandbox_expose — route a sandbox app through the Caddy gateway",
parameters: Type.Object({
name: Type.Optional(Type.String({ description: "Sandbox name (auto-resolved if omitted)" })),
routeName: Type.String({
description: "Route name — forms the hostname prefix (kebab-case, 1–30 chars)",
}),
port: Type.Number({ description: "Container port the app is listening on" }),
}),
async execute(_toolCallId, params: Record<string, unknown>) {
const port = params.port as number;
const routeName = params.routeName as string;
validateRouteName(routeName);
const config = new SandboxConfig({ name: params.name as string | undefined });
const lines: string[] = [];
// Listening pre-check — warning only, non-fatal.
const ssOut = captureSafe(execCmd(config.name, ["ss", "-tln"], { user: "sandbox" }));
if (ssOut === undefined) {
lines.push(`Warning: sandbox '${config.name}' is not running; routing anyway.`);
} else if (!new RegExp(`:${port}\\b`).test(ssOut)) {
lines.push(
`Warning: nothing is listening on port ${port} inside '${config.name}'. Route added — start your app when ready.`,
);
}
const mode = detectMode();
const route: Route = { name: routeName, port, sandbox: config.name };
const host = hostFor(route, mode);
const suffix = mode.mode === "remote" ? "" : ":8443";
const url = `https://${host}${suffix}`;
// Activate gateway overlay on first route (idempotent).
SandboxConfig.addOverride(GATEWAY_OVERLAY_PATH);
const exposure: Exposure = {
routeName,
port,
sandbox: config.name,
url,
createdAt: new Date().toISOString(),
};
upsertExposure(exposure);
const routes: Route[] = listRoutes().map((e) => ({
name: e.routeName,
port: e.port,
sandbox: e.sandbox,
}));
writeCaddyfile(renderCaddyfile(routes, mode));
const reloaded = reloadCaddy(config.name);
if (!reloaded) {
lines.push(
`Warning: caddy reload failed. If the gateway isn't running yet, run 'openharness run' to start it.`,
);
}
lines.push(`Route: ${url}`);
return {
content: [{ type: "text" as const, text: lines.join("\n") }],
details: undefined,
};
},
};
-43
View File
@@ -1,43 +0,0 @@
import type { ToolDefinition } from "../types.js";
import { listTool } from "./list.js";
import { runTool } from "./run.js";
import { shellTool } from "./shell.js";
import { stopTool } from "./stop.js";
import { cleanTool } from "./clean.js";
import { sandboxTool } from "./sandbox.js";
import { worktreeTool } from "./worktree.js";
import { onboardTool } from "./onboard.js";
import { portsTool } from "./ports.js";
import { exposeTool } from "./expose.js";
import { unexposeTool } from "./unexpose.js";
import { openTool } from "./open.js";
export const sandboxTools: ToolDefinition[] = [
listTool,
sandboxTool,
runTool,
shellTool,
stopTool,
cleanTool,
worktreeTool,
onboardTool,
portsTool,
exposeTool,
unexposeTool,
openTool,
];
export {
listTool,
runTool,
shellTool,
stopTool,
cleanTool,
sandboxTool,
worktreeTool,
onboardTool,
portsTool,
exposeTool,
unexposeTool,
openTool,
};
-31
View File
@@ -1,31 +0,0 @@
import { Type } from "typebox";
import { execSync } from "node:child_process";
import type { ToolDefinition } from "../types.js";
export const listTool: ToolDefinition = {
name: "sandbox_list",
label: "List Sandboxes",
description: "List all running sandbox containers. No parameters required.",
promptSnippet: "sandbox_list — list running sandbox containers",
parameters: Type.Object({}),
async execute() {
const lines: string[] = [];
lines.push("\n Running containers:");
try {
const ps = execSync(
'docker ps --filter "label=com.docker.compose.service=sandbox" --format "table {{.Names}}\\t{{.Status}}\\t{{.Image}}"',
{ encoding: "utf-8" },
).trim();
lines.push(ps || " (none)");
} catch {
lines.push(" (docker not available or no containers running)");
}
return {
content: [{ type: "text" as const, text: lines.join("\n") }],
details: undefined,
};
},
};
-45
View File
@@ -1,45 +0,0 @@
import { Type } from "typebox";
import type { ToolDefinition } from "../types.js";
import { runOnboardCommand } from "../cli/onboard.js";
export const onboardTool: ToolDefinition = {
name: "sandbox_onboard",
label: "Onboard Sandbox",
description:
"Interactive first-time setup wizard. Configures SSH, GitHub CLI, Cloudflare tunnel, and Claude Code auth, then starts the dev server.",
promptSnippet: "sandbox_onboard — interactive onboarding wizard for sandbox setup",
parameters: Type.Object({
name: Type.Optional(
Type.String({ description: "Sandbox name (omit if already inside container)" }),
),
force: Type.Optional(
Type.Boolean({ description: "Re-verify all steps even if already onboarded" }),
),
only: Type.Optional(
Type.String({
description:
"Run only one step (llm|github|slack|ssh|cloudflare|claude); skips the marker short-circuit",
}),
),
}),
async execute(_toolCallId, params: Record<string, unknown>) {
const exitCode = await runOnboardCommand({
name: params.name as string | undefined,
force: params.force as boolean | undefined,
only: params.only as string | undefined,
});
if (exitCode !== 0) {
return {
content: [{ type: "text" as const, text: "Onboarding did not complete successfully." }],
details: undefined,
};
}
return {
content: [{ type: "text" as const, text: "Onboarding complete." }],
details: undefined,
};
},
};
-48
View File
@@ -1,48 +0,0 @@
import { Type } from "typebox";
import type { ToolDefinition } from "../types.js";
import { findRouteByName, findRouteByPort } from "../lib/exposures.js";
import { runSafe } from "../lib/exec.js";
export const openTool: ToolDefinition = {
name: "sandbox_open",
label: "Open Exposure",
description: "Open an active route's URL in the default browser.",
promptSnippet: "sandbox_open — open a route URL",
parameters: Type.Object({
routeName: Type.Optional(Type.String({ description: "Route name" })),
port: Type.Optional(Type.Number({ description: "Container port (alternative to routeName)" })),
}),
async execute(_toolCallId, params: Record<string, unknown>) {
const routeName = params.routeName as string | undefined;
const port = params.port as number | undefined;
const lines: string[] = [];
const route = routeName
? findRouteByName(routeName)
: typeof port === "number"
? findRouteByPort(port)
: undefined;
if (!route) {
const key = routeName ?? (port !== undefined ? `port ${port}` : "(no key given)");
lines.push(`No route for ${key}.`);
lines.push(`Run: openharness expose <name> <port>`);
return {
content: [{ type: "text" as const, text: lines.join("\n") }],
details: undefined,
};
}
lines.push(`Route: ${route.url}`);
const opened =
runSafe(["xdg-open", route.url], { stdio: "pipe" }) ||
runSafe(["open", route.url], { stdio: "pipe" });
if (!opened) lines.push(`(could not auto-open; copy the URL above)`);
return {
content: [{ type: "text" as const, text: lines.join("\n") }],
details: undefined,
};
},
};
-117
View File
@@ -1,117 +0,0 @@
import { Type } from "typebox";
import type { ToolDefinition } from "../types.js";
import { SandboxConfig } from "../lib/config.js";
import { execCmd } from "../lib/docker.js";
import { captureSafe } from "../lib/exec.js";
import { readExposures } from "../lib/exposures.js";
interface Row {
port: number;
listening: boolean;
label?: string;
url?: string;
/** e.g. `next-server (pid 35374)` — best-effort. */
process?: string;
}
/** Parse `ss -tlnp` output into port→"command (pid N)" map. Root-owned sockets yield no entry. */
function parseListenerProcesses(ssOut: string): Map<number, string> {
const out = new Map<number, string>();
const lineRe = /LISTEN[^\n]*:(\d+)\s+[^\n]*?users:\(\("([^"]+)",pid=(\d+)/g;
for (const m of ssOut.matchAll(lineRe)) {
const port = Number(m[1]);
const cmd = m[2];
const pid = m[3];
if (!out.has(port)) out.set(port, `${cmd} (pid ${pid})`);
}
return out;
}
export const portsTool: ToolDefinition = {
name: "sandbox_ports",
label: "Inspect Ports",
description: "Show container listeners (+ processes) and Caddy routes for a sandbox.",
promptSnippet: "sandbox_ports — inspect listeners and routes",
parameters: Type.Object({
name: Type.Optional(Type.String({ description: "Sandbox name (auto-resolved if omitted)" })),
}),
async execute(_toolCallId: string, params: Record<string, unknown>) {
const config = new SandboxConfig({
name: params.name as string | undefined,
});
const lines: string[] = [`\nPorts for sandbox '${config.name}':`];
// Probe docker port — undefined if container is not running.
const portOut = captureSafe(["docker", "port", config.name]);
if (portOut === undefined) {
lines.push(` Sandbox '${config.name}' is not running.`);
return {
content: [{ type: "text" as const, text: lines.join("\n") }],
details: undefined,
};
}
const rows = new Map<number, Row>();
// Source 1: container-internal listeners from `ss -tlnp` (captures PID+command).
// Falls back to `ss -tln` on older images.
const ssOut =
captureSafe(execCmd(config.name, ["ss", "-tlnp"], { user: "sandbox" })) ??
captureSafe(execCmd(config.name, ["ss", "-tln"], { user: "sandbox" })) ??
"";
const procs = parseListenerProcesses(ssOut);
const listenRe = /LISTEN\s+\d+\s+\d+\s+[^\s]*?:(\d+)\b/g;
for (const m of ssOut.matchAll(listenRe)) {
const port = Number(m[1]);
const r: Row = rows.get(port) ?? { port, listening: false };
r.listening = true;
const p = procs.get(port);
if (p && !r.process) r.process = p;
rows.set(port, r);
}
// Source 2: recorded routes (exposures registry).
for (const e of readExposures().exposures) {
const r: Row = rows.get(e.port) ?? { port: e.port, listening: false };
r.label = e.routeName;
r.url = e.url;
rows.set(e.port, r);
}
if (rows.size === 0) {
lines.push(` (no listeners or routes)`);
return {
content: [{ type: "text" as const, text: lines.join("\n") }],
details: undefined,
};
}
// Truncate helper for fixed-width columns.
const trunc = (s: string | undefined, n: number): string => {
const v = s ?? "—";
return v.length > n ? v.slice(0, n - 1) + "…" : v;
};
lines.push(
` PORT LISTENING LABEL URL PROCESS`,
);
lines.push(
` ──── ───────── ─────────── ──────────────────────────────────────── ────────────────────────`,
);
const sorted = [...rows.values()].sort((a, b) => a.port - b.port);
for (const r of sorted) {
const port = String(r.port).padEnd(5);
const listening = (r.listening ? "yes" : "no").padStart(6).padEnd(9);
const label = trunc(r.label, 11).padEnd(11);
const url = trunc(r.url, 40).padEnd(40);
const proc = trunc(r.process, 24);
lines.push(` ${port} ${listening} ${label} ${url} ${proc}`);
}
return {
content: [{ type: "text" as const, text: lines.join("\n") }],
details: undefined,
};
},
};
-201
View File
@@ -1,201 +0,0 @@
import { Type } from "typebox";
import type { ToolDefinition } from "../types.js";
import { execCmd } from "../lib/docker.js";
import { run } from "../lib/exec.js";
export const RALPH_ACTIONS = ["prd", "setup", "run", "status", "reflect", "cleanup", "pr"] as const;
export const ralphTool: ToolDefinition = {
name: "sandbox_ralph",
label: "Ralph",
description:
"Ralph autonomous agent workflow. Actions: prd (generate PRD from plan), setup (convert PRD to prd.json + draft PR), run (start Ralph loop in tmux), status (show progress), reflect (update memory from session), cleanup (lint/format/test), pr (archive + undraft PR).",
promptSnippet: "sandbox_ralph — Ralph workflow (prd|setup|run|status|reflect|cleanup|pr)",
parameters: Type.Object({
name: Type.String({ description: "Sandbox name" }),
action: Type.Union(
[
Type.Literal("prd"),
Type.Literal("setup"),
Type.Literal("run"),
Type.Literal("status"),
Type.Literal("reflect"),
Type.Literal("cleanup"),
Type.Literal("pr"),
],
{ description: "Ralph action" },
),
iterations: Type.Optional(
Type.Number({
default: 200,
description: "Max iterations for ralph run (default: 200)",
}),
),
}),
async execute(_toolCallId, params: Record<string, unknown>) {
const name = params.name as string;
const action = params.action as string;
const iterations = (params.iterations as number) ?? 200;
const steps: string[] = [];
try {
switch (action) {
case "prd": {
steps.push("Generating PRD from plan...");
const cmd = execCmd(
name,
[
"bash",
"-c",
'cd ~/harness/workspace && claude --dangerously-skip-permissions -p "Read the plan files in .claude/plans/ and generate a PRD using the /prd skill. Save to tasks/"',
],
{ user: "sandbox", workdir: "/home/sandbox/harness/workspace" },
);
run(cmd);
steps.push("PRD generated. Check tasks/ for the output.");
break;
}
case "setup": {
steps.push("Converting PRD to prd.json...");
const convertCmd = execCmd(
name,
[
"bash",
"-c",
'cd ~/harness/workspace && claude --dangerously-skip-permissions -p "Convert the latest PRD in tasks/ to .ralph/prd.json using the /ralph skill"',
],
{ user: "sandbox", workdir: "/home/sandbox/harness/workspace" },
);
run(convertCmd);
steps.push("Committing PRD + prd.json and creating draft PR...");
const prCmd = execCmd(
name,
[
"bash",
"-c",
`cd ~/harness/workspace && git add tasks/ .ralph/prd.json .ralph/progress.txt 2>/dev/null; git commit -m "task: add PRD and prd.json for ralph" && git push -u origin HEAD && gh pr create --draft --base development --title "feat: $(jq -r .description .ralph/prd.json | head -c 60)" --body "PRD and prd.json ready for Ralph execution" 2>&1 || echo "PR may already exist"`,
],
{ user: "sandbox", workdir: "/home/sandbox/harness/workspace" },
);
run(prCmd);
steps.push("Draft PR created. Ready for ralph run.");
break;
}
case "run": {
steps.push(`Starting Ralph loop in tmux (${iterations} iterations)...`);
const cmd = execCmd(
name,
[
"bash",
"-c",
`tmux new-session -d -s ralph "cd ~/harness/workspace && .ralph/ralph.sh --tool claude ${iterations}"`,
],
{ user: "sandbox", workdir: "/home/sandbox/harness/workspace" },
);
run(cmd);
steps.push("Ralph is running in tmux session 'ralph'.");
steps.push(` Attach: docker exec -it ${name} tmux attach -t ralph`);
break;
}
case "status": {
const cmd = execCmd(
name,
[
"bash",
"-c",
`cd ~/harness/workspace && echo "=== Stories ===" && jq ".userStories[] | {id, title, passes}" .ralph/prd.json 2>/dev/null || echo "No prd.json found" && echo "" && echo "=== Progress (last 20 lines) ===" && tail -20 .ralph/progress.txt 2>/dev/null || echo "No progress.txt found" && echo "" && echo "=== tmux ===" && tmux has-session -t ralph 2>/dev/null && echo "Ralph tmux session: RUNNING" || echo "Ralph tmux session: NOT RUNNING"`,
],
{ user: "sandbox", workdir: "/home/sandbox/harness/workspace" },
);
run(cmd);
break;
}
case "reflect": {
steps.push("Reflecting on session — updating memory...");
const cmd = execCmd(
name,
[
"bash",
"-c",
`cd ~/harness/workspace && claude --dangerously-skip-permissions -p "Read .ralph/progress.txt and .ralph/prd.json. Focus on: what would make the NEXT session better? Update MEMORY.md with actionable patterns, approaches to reuse, mistakes to avoid, and codebase insights. Append a session log to memory/$(date +%Y-%m-%d).md. Commit memory updates."`,
],
{ user: "sandbox", workdir: "/home/sandbox/harness/workspace" },
);
run(cmd);
steps.push("Memory updated with session reflections.");
break;
}
case "cleanup": {
steps.push("Running cleanup: stage and commit any pending changes...");
const cmd = execCmd(
name,
[
"bash",
"-c",
`cd ~/harness/workspace && git add -A && git diff --cached --quiet || git commit -m "task: cleanup before PR submission"`,
],
{ user: "sandbox", workdir: "/home/sandbox/harness/workspace" },
);
run(cmd);
steps.push("Cleanup complete. (Project-specific lint/test should be added per PRD.)");
break;
}
case "pr": {
steps.push("Archiving ralph run...");
const archiveCmd = execCmd(
name,
[
"bash",
"-c",
`cd ~/harness/workspace && BRANCH=$(jq -r .branchName .ralph/prd.json 2>/dev/null | sed 's|ralph/||') && mkdir -p .ralph/archive/$BRANCH && cp .ralph/prd.json .ralph/progress.txt .ralph/archive/$BRANCH/ 2>/dev/null && git add .ralph/archive/ && git commit -m "task: archive ralph run for $BRANCH" 2>/dev/null || true`,
],
{ user: "sandbox", workdir: "/home/sandbox/harness/workspace" },
);
run(archiveCmd);
steps.push("Validating all stories pass...");
const prCmd = execCmd(
name,
[
"bash",
"-c",
`cd ~/harness/workspace && ALL_PASS=$(jq "[.userStories[].passes] | all" .ralph/prd.json 2>/dev/null) && if [ "$ALL_PASS" = "true" ]; then git push && gh pr ready && echo "PR is now ready for review!"; else echo "Not all stories pass yet. Run 'openharness ralph status ${name}' to check."; exit 1; fi`,
],
{ user: "sandbox", workdir: "/home/sandbox/harness/workspace" },
);
run(prCmd);
steps.push("PR taken out of draft.");
break;
}
}
} catch {
return {
content: [
{
type: "text" as const,
text: `Error: ralph ${action} failed for '${name}'. Is the container running?`,
},
],
details: undefined,
};
}
return {
content: [
{
type: "text" as const,
text: steps.length > 0 ? steps.join("\n") : `Ralph ${action} completed for '${name}'.`,
},
],
details: undefined,
};
},
};
-27
View File
@@ -1,27 +0,0 @@
import { Type } from "typebox";
import type { ToolDefinition } from "../types.js";
import { SandboxConfig, type SandboxOptions } from "../lib/config.js";
import { composeUp, composeEnv } from "../lib/docker.js";
import { run } from "../lib/exec.js";
export const runTool: ToolDefinition = {
name: "sandbox_run",
label: "Run Sandbox",
description: "Start the sandbox container (docker compose up -d --build).",
promptSnippet: "sandbox_run — start the sandbox container",
parameters: Type.Object({
name: Type.Optional(Type.String({ description: "Sandbox name (auto-resolved if omitted)" })),
}),
async execute(_toolCallId, params: Record<string, unknown>) {
const config = new SandboxConfig(params as unknown as SandboxOptions);
const env = composeEnv(config);
run(composeUp(config), { env });
return {
content: [{ type: "text" as const, text: `Sandbox '${config.name}' started.` }],
details: undefined,
};
},
};
-42
View File
@@ -1,42 +0,0 @@
import { Type } from "typebox";
import type { ToolDefinition } from "../types.js";
import { SandboxConfig, type SandboxOptions } from "../lib/config.js";
import { composeUp, composeEnv } from "../lib/docker.js";
import { run } from "../lib/exec.js";
export const sandboxTool: ToolDefinition = {
name: "sandbox_sandbox",
label: "Start Sandbox",
description:
"Start the .devcontainer sandbox. Runs docker compose up with overlays from .openharness/config.json.",
promptSnippet: "sandbox_sandbox — start the .devcontainer sandbox",
parameters: Type.Object({
name: Type.Optional(
Type.String({ description: "Sandbox name (auto-resolved from git remote if omitted)" }),
),
}),
async execute(_toolCallId, params: Record<string, unknown>) {
const opts = params as unknown as SandboxOptions;
const config = new SandboxConfig(opts);
const env = composeEnv(config);
const steps: string[] = [];
// Build and start container
steps.push(`Starting sandbox '${config.name}'...`);
steps.push(`Compose files: ${config.composeFiles.join(", ")}`);
run(composeUp(config), { env });
steps.push("");
steps.push(`Sandbox '${config.name}' is running!`);
steps.push("");
steps.push(" Next steps:");
steps.push(` openharness onboard ${config.name} # one-time auth setup`);
steps.push(` openharness shell ${config.name} # enter the sandbox`);
return {
content: [{ type: "text" as const, text: steps.join("\n") }],
details: undefined,
};
},
};
-44
View File
@@ -1,44 +0,0 @@
import { Type } from "typebox";
import type { ToolDefinition } from "../types.js";
import { execCmd } from "../lib/docker.js";
import { run } from "../lib/exec.js";
export const shellTool: ToolDefinition = {
name: "sandbox_shell",
label: "Shell into Sandbox",
description:
"Open an interactive login shell inside a running sandbox container. Uses the user's $SHELL (zsh by default), falling back to bash.",
promptSnippet: "sandbox_shell — open interactive login shell ($SHELL, default zsh) in a sandbox",
parameters: Type.Object({
name: Type.String({ description: "Sandbox name" }),
}),
async execute(_toolCallId, params: Record<string, unknown>) {
const name = params.name as string;
const cmd = execCmd(name, ["/bin/sh", "-lc", 'exec "${SHELL:-/bin/zsh}" -l'], {
user: "sandbox",
interactive: true,
workdir: "/home/sandbox/workspace",
env: { HOME: "/home/sandbox" },
});
try {
run(cmd);
} catch {
return {
content: [
{
type: "text" as const,
text: `Error: container '${name}' is not running. Start it with: openharness (then /run ${name})`,
},
],
details: undefined,
};
}
return {
content: [{ type: "text" as const, text: `Shell session ended for '${name}'.` }],
details: undefined,
};
},
};
-39
View File
@@ -1,39 +0,0 @@
import { Type } from "typebox";
import type { ToolDefinition } from "../types.js";
import { SandboxConfig, type SandboxOptions } from "../lib/config.js";
import { composeDown, composeEnv } from "../lib/docker.js";
import { run } from "../lib/exec.js";
export const stopTool: ToolDefinition = {
name: "sandbox_stop",
label: "Stop Sandbox",
description: "Stop and remove the sandbox container (docker compose down).",
promptSnippet: "sandbox_stop — stop and remove the sandbox container",
parameters: Type.Object({
name: Type.Optional(Type.String({ description: "Sandbox name (auto-resolved if omitted)" })),
}),
async execute(_toolCallId, params: Record<string, unknown>) {
const config = new SandboxConfig(params as unknown as SandboxOptions);
const env = composeEnv(config);
try {
run(composeDown(config), { env });
} catch {
return {
content: [
{
type: "text" as const,
text: `Error: no sandbox '${config.name}' found to stop.`,
},
],
details: undefined,
};
}
return {
content: [{ type: "text" as const, text: `Sandbox '${config.name}' stopped.` }],
details: undefined,
};
},
};
-59
View File
@@ -1,59 +0,0 @@
import { Type } from "typebox";
import type { ToolDefinition } from "../types.js";
import { SandboxConfig } from "../lib/config.js";
import { listRoutes, removeRoute, type Exposure } from "../lib/exposures.js";
import {
detectMode,
reloadCaddy,
renderCaddyfile,
writeCaddyfile,
type Route,
} from "../lib/caddy.js";
export const unexposeTool: ToolDefinition = {
name: "sandbox_unexpose",
label: "Unexpose App",
description: "Remove a Caddy route for a sandbox app.",
promptSnippet: "sandbox_unexpose — remove a Caddy route",
parameters: Type.Object({
name: Type.Optional(Type.String({ description: "Sandbox name" })),
routeName: Type.String({ description: "Route name to remove" }),
}),
async execute(_toolCallId, params: Record<string, unknown>) {
const routeName = params.routeName as string;
const config = new SandboxConfig({ name: params.name as string | undefined });
const lines: string[] = [];
const removed = removeRoute(routeName);
if (!removed) {
lines.push(`No route named '${routeName}' is active.`);
return {
content: [{ type: "text" as const, text: lines.join("\n") }],
details: undefined,
};
}
const mode = detectMode();
const routes: Route[] = listRoutes().map((e) => ({
name: e.routeName,
port: e.port,
sandbox: e.sandbox,
}));
writeCaddyfile(renderCaddyfile(routes, mode));
const reloaded = reloadCaddy(config.name);
if (!reloaded) {
lines.push(
`Warning: caddy reload failed. Route removed from registry but gateway may still serve it until next start.`,
);
}
lines.push(`Route '${routeName}' removed.`);
return {
content: [{ type: "text" as const, text: lines.join("\n") }],
details: undefined,
};
},
};
export { type Exposure };
-67
View File
@@ -1,67 +0,0 @@
import { Type } from "typebox";
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import type { ToolDefinition } from "../types.js";
export const worktreeTool: ToolDefinition = {
name: "sandbox_worktree",
label: "Create Worktree",
description:
"Create a git worktree for a sandbox (advanced — for branch isolation without a container).",
promptSnippet: "sandbox_worktree — create a git worktree for branch isolation",
parameters: Type.Object({
name: Type.String({ description: "Sandbox name" }),
branch: Type.Optional(Type.String({ description: "Git branch (default: agent/<name>)" })),
baseBranch: Type.Optional(
Type.String({
default: "main",
description: "Base branch for worktree (default: main)",
}),
),
}),
async execute(_toolCallId, params: Record<string, unknown>) {
const name = params.name as string;
const branch = (params.branch as string) ?? `agent/${name}`;
const baseBranch = (params.baseBranch as string) ?? "main";
const worktreePath = `.worktrees/${branch}`;
const worktreeAbs = resolve(process.cwd(), worktreePath);
if (existsSync(worktreeAbs)) {
return {
content: [
{
type: "text" as const,
text: `Worktree already exists: ${worktreePath}`,
},
],
details: undefined,
};
}
try {
execSync(`git fetch origin ${baseBranch} 2>/dev/null || true`, {
encoding: "utf-8",
stdio: "pipe",
});
} catch {
// Ignore fetch errors
}
execSync(`git worktree add ${worktreePath} -b ${branch} origin/${baseBranch}`, {
encoding: "utf-8",
stdio: "inherit",
});
return {
content: [
{
type: "text" as const,
text: `Worktree created: ${worktreePath} (branch: ${branch})`,
},
],
details: undefined,
};
},
};
-83
View File
@@ -1,83 +0,0 @@
/**
* Local type definitions for openharness sandbox tools.
*
* These types are intentionally defined locally rather than imported from
* `@mariozechner/pi-coding-agent` so the package can evolve independently.
* The shapes mirror the public surface area of pi's `ToolDefinition` that
* openharness actually uses, while remaining structurally compatible so
* tools defined here can still be registered with pi's extension runtime
* (see `extensions/sandbox.ts`).
*/
import type { Static, TSchema } from "typebox";
/** Single text item returned by a tool result. Matches pi-ai's `TextContent`. */
export interface ToolTextContent {
type: "text";
text: string;
textSignature?: string;
}
/** Single image item returned by a tool result. Matches pi-ai's `ImageContent`. */
export interface ToolImageContent {
type: "image";
data: string;
mimeType: string;
}
/** Content returned by a tool. */
export type ToolResultContent = ToolTextContent | ToolImageContent;
/** Final or partial result produced by a tool. */
export interface ToolResult<TDetails = unknown> {
/** Text or image content returned to the model. */
content: ToolResultContent[];
/** Arbitrary structured details for logs or UI rendering. */
details: TDetails;
/**
* Hint that the agent should stop after the current tool batch.
* Early termination only happens when every finalized tool result in the
* batch sets this to true.
*/
terminate?: boolean;
}
/** Callback used by tools to stream partial execution updates. */
export type ToolUpdateCallback<TDetails = unknown> = (partialResult: ToolResult<TDetails>) => void;
/**
* Tool definition consumed by the openharness CLI and the agent runtime.
*
* Parameter schemas are TypeBox `Type.Object(...)` definitions. Only the
* fields openharness uses are required here; additional optional fields
* accepted by the upstream pi-coding-agent runtime (e.g. `renderShell`,
* `prepareArguments`, `executionMode`, `renderCall`, `renderResult`,
* `promptGuidelines`) are intentionally not modeled. Tool definitions
* authored against this interface remain structurally compatible with
* pi's `ToolDefinition<TParams, TDetails, TState>` because the omitted
* fields are all optional.
*/
export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = unknown> {
/** Tool name (used in LLM tool calls). */
name: string;
/** Human-readable label for UI. */
label: string;
/** Description shown to the LLM. */
description: string;
/** Optional one-line snippet for the "Available tools" prompt section. */
promptSnippet?: string;
/** Parameter schema (TypeBox). */
parameters: TParams;
/**
* Execute the tool.
*
* The trailing parameters (`signal`, `onUpdate`, `ctx`) are typed loosely
* so tools defined here can be consumed by the pi-coding-agent runtime
* without forcing openharness to depend on its richer context types.
*/
execute(
toolCallId: string,
params: Static<TParams>,
...rest: unknown[]
): Promise<ToolResult<TDetails>>;
}
-19
View File
@@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*.ts", "extensions/**/*.ts"],
"exclude": ["src/__tests__/**/*.ts", "node_modules", "dist"]
}
-8
View File
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
globals: true,
},
});
+57 -592
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1,4 +1,3 @@
packages:
- packages/sandbox
- packages/slack
- apps/docs
+2 -2
View File
@@ -114,8 +114,8 @@
"Typecheck passes"
],
"priority": 6,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed iteration 6 on 2026-05-02; deleted packages/sandbox/ (44MB workspace), .github/workflows/build.yml (duplicate ghcr.io/ryaneggz/open-harness image producer), .github/workflows/ci.yml (sandbox-only CI now stale), the link:cli script from root package.json, the packages/sandbox entry from pnpm-workspace.yaml, and the CLI build/symlink block in .devcontainer/entrypoint.sh; pnpm-lock.yaml regenerated (-49 packages); release.yml stands alone as image producer for ghcr.io/ryaneggz/openharness; tests 9/9 lint clean"
},
{
"id": "US-007",