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:
1 parent
b9c490bbbf
commit
d6c4886d6e
85 files changed
+61
-7206
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -1 +1 @@
|
||||
cd packages/sandbox && pnpm exec lint-staged && pnpm run build && pnpm run test
|
||||
pnpm run lint && pnpm run test
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}}",
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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" };
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/__tests__/**/*.test.ts"],
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Generated
+57
-592
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,3 @@
|
||||
packages:
|
||||
- packages/sandbox
|
||||
- packages/slack
|
||||
- apps/docs
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user