diff --git a/docs/openclawcode/README.md b/docs/openclawcode/README.md index 1b9258cc2d..9bdc7ef4a3 100644 --- a/docs/openclawcode/README.md +++ b/docs/openclawcode/README.md @@ -91,6 +91,14 @@ loop with: - a local builder/verifier runtime adapter built on top of OpenClaw's embedded agent entrypoint - an `openclaw code run ...` CLI path for issue-driven execution +- an `openclaw code bootstrap --repo owner/repo` CLI path for low-touch target + repo bootstrap, including: + - target repo clone/attach + - operator env/config materialization + - bootstrap repo binding persistence + - blueprint / role-routing / discovery / stage-gate seeding + - gateway startup attempt + - strict setup-check and built-startup proof summary - a versioned top-level JSON contract for `openclaw code run --json`, anchored by `contractVersion: 1` and documented in `run-json-contract.md` - a versioned machine-readable policy contract for: diff --git a/docs/openclawcode/codex-openclawcode-install.md b/docs/openclawcode/codex-openclawcode-install.md index 5041b62dfb..6eaaab9558 100644 --- a/docs/openclawcode/codex-openclawcode-install.md +++ b/docs/openclawcode/codex-openclawcode-install.md @@ -41,12 +41,9 @@ entry or interactive approval: 3. run: - `pnpm install` - `pnpm build` -4. prepare a starter operator config and repo mapping -5. run health checks: - - `./scripts/openclawcode-setup-check.sh --strict --json` - - `./scripts/openclawcode-setup-check.sh --strict --probe-built-startup --json` -6. prepare the target repository checkout and show the exact - `openclaw code run ...` command to use +4. run `openclaw code bootstrap --repo owner/repo --json` +5. prepare the target repository checkout and show the exact + `openclaw code run ...` or chat command to use ## What The User Must Do @@ -76,9 +73,8 @@ Requirements: - clone https://github.com/zhyongrui/openclawcode.git to ~/pros/openclawcode - run pnpm install - run pnpm build -- run: - - ./scripts/openclawcode-setup-check.sh --strict --json - - ./scripts/openclawcode-setup-check.sh --strict --probe-built-startup --json +- then run: + - openclaw code bootstrap --repo owner/repo --json Important repository constraint: - this is the forked OpenClaw checkout that contains openclawcode @@ -95,9 +91,9 @@ When finished, report: - pnpm version - codex version - whether build passed -- whether strict setup-check passed -- whether built-startup proof passed -- the exact next commands for binding or running against a new target repo +- whether bootstrap completed +- the bootstrap JSON summary +- the exact next commands for binding or running against the new target repo ``` ## Minimal Install Flow @@ -108,10 +104,8 @@ The smallest successful machine bootstrap looks like this: 2. Codex runs `pnpm install`. 3. Codex runs `pnpm build`. 4. The user provides `GH_TOKEN` or `GITHUB_TOKEN`. -5. Codex runs strict setup-check. -6. Codex runs built-startup setup-check. -7. Codex prepares the target repo mapping. -8. The user chooses CLI-only or chatops validation. +5. Codex runs `openclaw code bootstrap --repo owner/repo --json`. +6. The user chooses CLI-only or chatops validation. ## Lowest-Touch User Goal @@ -122,7 +116,7 @@ The desired product outcome is even smaller than today's install flow: 3. the user chooses `owner/repo` 4. one bootstrap command configures the operator automatically -That command is not fully productized yet. See +That command now exists as an MVP, but it is not fully productized yet. See `single-login-bootstrap-proposal.md` for the intended end state and the split between `openclaw` and `openclawcode`. @@ -137,10 +131,13 @@ There are two supported paths. Use this when you want the fastest proof on a new machine. -1. clone the new target repo locally -2. add a repo entry in the operator config, or pass the repo information - directly to the CLI -3. run: +1. run bootstrap for the target repo: + +```bash +openclaw code bootstrap --repo / --json +``` + +2. then run: ```bash openclaw code blueprint-init --title "Project Blueprint" --goal "Describe the target goal" @@ -151,15 +148,21 @@ openclaw code run --issue 123 --owner --repo --repo-root / --mode chatops --channel feishu --chat-target --json +``` + +2. bring up the local gateway if bootstrap reported that it could not start it +3. connect the real chat surface +4. bind the repo from the desired conversation if bootstrap only created a placeholder binding: ```text /occode-bind / ``` -4. then use: +5. then use: ```text /occode-intake diff --git a/docs/openclawcode/dev-log/2026-03-17.md b/docs/openclawcode/dev-log/2026-03-17.md index 7f1bc600e7..bccb331061 100644 --- a/docs/openclawcode/dev-log/2026-03-17.md +++ b/docs/openclawcode/dev-log/2026-03-17.md @@ -554,3 +554,33 @@ The runbook now states explicitly that Codex must clone: It also now warns Codex not to search for a separate public repository named `openclawcode`, and to stop immediately if the checkout does not contain `scripts/openclawcode-setup-check.sh`. + +## Bootstrap MVP + +Implemented the first real `openclaw code bootstrap --repo owner/repo` command. + +What it now does automatically: + +- parses `owner/repo` +- clones or attaches the target repository +- persists `GH_TOKEN`, `OPENCLAWCODE_GITHUB_REPO`, and a generated + `OPENCLAWCODE_GITHUB_WEBHOOK_SECRET` into `~/.openclaw/openclawcode.env` +- writes or updates the bundled `openclawcode` repo entry in `openclaw.json` +- persists a bootstrap repo binding in `plugins/openclawcode/chatops-state.json` +- seeds `PROJECT-BLUEPRINT.md` in the target repo when missing +- refreshes role-routing, discovery, and stage-gate artifacts +- attempts to start the local gateway +- runs strict setup-check plus built-startup proof and returns the result + through `--json` + +What it still does not automate: + +- GitHub webhook create/reuse +- OpenClaw-side GitHub login flow +- provider credential onboarding +- auto-detecting the real active chat target + +Validation for this bootstrap slice: + +- `pnpm exec vitest run src/commands/openclawcode.test.ts --pool threads` + passed (`92` tests) diff --git a/docs/openclawcode/fresh-host-install.md b/docs/openclawcode/fresh-host-install.md index 4310751c4c..cc2247cfd7 100644 --- a/docs/openclawcode/fresh-host-install.md +++ b/docs/openclawcode/fresh-host-install.md @@ -24,9 +24,8 @@ If you want Codex to perform this bootstrap on the fresh machine, use 1. clone the repo 2. install dependencies 3. build once -4. run strict setup-check -5. configure the OpenClaw plugin repo binding -6. validate one narrow issue locally before relying on webhook auto mode +4. run `openclaw code bootstrap --repo owner/repo --json` +5. validate one narrow issue locally before relying on webhook auto mode ## Lowest-Touch Fresh-Host Goal @@ -37,20 +36,40 @@ The intended operator experience should eventually be: 3. the user names the target repo 4. `openclaw code bootstrap --repo owner/repo` handles the rest -That full product path does not exist yet. +That full product path is not complete yet, but there is now a real bootstrap +MVP. Today, the shortest practical path is: 1. Codex clones and builds this repo 2. the user provides one GitHub token -3. Codex writes the operator env file and bundled plugin repo config -4. Codex runs strict setup-check and built-startup proof -5. the user performs one narrow validation: +3. Codex runs: + +```bash +openclaw code bootstrap --repo owner/repo --json +``` + +4. the user performs one narrow validation: - CLI-only, or - one chat-side bind / read command if ChatOps is already connected See `single-login-bootstrap-proposal.md` for the target end state. +## What Bootstrap Already Automates + +`openclaw code bootstrap --repo owner/repo` now handles: + +- target repo clone or attach +- operator env persistence under `~/.openclaw/openclawcode.env` +- bundled plugin repo config materialization in `openclaw.json` +- placeholder or explicit repo binding persistence in `chatops-state.json` +- `PROJECT-BLUEPRINT.md` scaffold creation in the target repo when missing +- role-routing, discovery, and stage-gate artifact seeding +- local gateway startup attempt +- strict setup-check plus built-startup proof + +It still does not create or reuse the GitHub webhook automatically. + ## Expected Healthy Outputs - `setup-check --strict --json` returns all required readiness signals diff --git a/docs/openclawcode/run-json-contract.md b/docs/openclawcode/run-json-contract.md index 959c604f0f..bfb510d756 100644 --- a/docs/openclawcode/run-json-contract.md +++ b/docs/openclawcode/run-json-contract.md @@ -214,6 +214,9 @@ those nested objects. - `workspaceHasWorktreePath` - `workspaceWorktreePath` +`workspacePreparedAt` mirrors `workspace.preparedAt` when that nested value exists +and otherwise uses `null`. + ### Pull Request And Merge State - `draftPullRequestBranchName` diff --git a/docs/openclawcode/single-login-bootstrap-proposal.md b/docs/openclawcode/single-login-bootstrap-proposal.md index 43726c7de4..f8faecac16 100644 --- a/docs/openclawcode/single-login-bootstrap-proposal.md +++ b/docs/openclawcode/single-login-bootstrap-proposal.md @@ -46,22 +46,39 @@ surface for a fresh machine or a new repo. ## Current Reality -Today the repo still expects manual configuration in these areas: +The repo now includes a first bootstrap command: + +```bash +openclaw code bootstrap --repo owner/repo +``` + +The implemented MVP already does these pieces automatically: + +- clone or attach the target repo +- persist `GH_TOKEN`, repo key, and a generated webhook secret into + `~/.openclaw/openclawcode.env` +- materialize the bundled plugin repo entry inside `openclaw.json` +- persist a bootstrap repo binding in `chatops-state.json` +- seed `PROJECT-BLUEPRINT.md`, role-routing, discovery, and stage-gate artifacts +- try to start the local gateway +- run strict setup-check plus built-startup proof by default +- emit a machine-readable bootstrap summary through `--json` + +What is still manual or only partially automated: - `docs/openclawcode/operator-setup.md` - - explicit `GH_TOKEN` - - explicit `OPENCLAWCODE_GITHUB_WEBHOOK_SECRET` - explicit `OPENCLAWCODE_GITHUB_HOOK_ID` - - explicit plugin repo entry in `openclaw.json` +- provider credentials still come from the surrounding OpenClaw/operator login - `scripts/openclawcode-webhook-tunnel.sh` - rewrites an existing webhook - does not create the webhook for the operator - runtime repo binding - `/occode-bind` exists and works - - but it still comes after manual repo config/bootstrap + - bootstrap can seed a placeholder or explicit binding, but cannot yet + discover the active chat target on its own -So the desired experience is clearly reachable, but not yet productized as one -command. +So the desired experience is now partially productized as one command, but it +still stops short of the full single-login end state. ## Product Split @@ -213,36 +230,38 @@ The first bootstrap should prefer safety over autonomy: ## Simplest Practical Path Today -Until the full bootstrap command exists, the lowest-touch real workflow should -be: +With the current bootstrap MVP, the lowest-touch real workflow is: 1. Codex clones and builds `zhyongrui/openclawcode`. 2. The user provides one GitHub token. -3. Codex writes: - - `~/.openclaw/openclawcode.env` - - `~/.openclaw/openclaw.json` -4. Codex runs: - - `./scripts/openclawcode-setup-check.sh --strict --json` - - `./scripts/openclawcode-setup-check.sh --strict --probe-built-startup --json` -5. If chat is already connected, the user sends one bind or verification +3. Codex runs: + - `openclaw code bootstrap --repo owner/repo --json` +4. If chat is already connected, the user sends one bind or verification command from the real conversation. -That is the shortest currently achievable path without first implementing the -bootstrap command. +That is the shortest currently achievable path before webhook creation and chat +target discovery are automated too. ## MVP Delivery Plan ### Phase 1: CLI-Only Bootstrap -Ship: +Status: shipped as MVP. + +Delivered: -- GitHub credential reuse from `openclaw` - repo clone/attach - repo config materialization -- strict setup-check +- operator env persistence +- placeholder or explicit repo binding persistence +- strict setup-check and built-startup proof - `--json` output +- blueprint + role-routing + discovery + stage-gate seeding + +Still missing inside this phase: -Do not require ChatOps or webhook creation yet. +- GitHub login handoff from `openclaw onboard` +- zero-touch provider credential reuse ### Phase 2: Webhook Bootstrap diff --git a/src/cli/program/register.code.ts b/src/cli/program/register.code.ts index 508ce38fe1..706ed129e7 100644 --- a/src/cli/program/register.code.ts +++ b/src/cli/program/register.code.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { + openclawCodeBootstrapCommand, openclawCodeBlueprintClarifyCommand, openclawCodeBlueprintDecomposeCommand, openclawCodeBlueprintInitCommand, @@ -52,6 +53,10 @@ export function registerCodeCommands(program: Command) { ` ${theme.heading("Examples:")} ${formatHelpExamples([ + [ + "openclaw code bootstrap --repo owner/repo --json", + "Clone or attach a target repo, persist operator config, seed blueprint artifacts, and run readiness checks.", + ], [ 'openclaw code blueprint-init --title "OpenClawCode Blueprint" --goal "Ship blueprint-first autonomous development"', "Create the fixed repo-local project blueprint scaffold.", @@ -161,6 +166,47 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/code", "docs.openclaw.ai/cli/code code.help({ error: true }); }); + code + .command("bootstrap") + .description( + "Bootstrap a target repository into the local openclawcode operator with minimal manual setup", + ) + .requiredOption("--repo ", "GitHub repo to bootstrap") + .option("--repo-root ", "Target repository checkout path") + .option("--state-dir ", "Operator state directory") + .option("--mode ", "Bootstrap mode (auto, cli-only, chatops)", "auto") + .option("--channel ", "Chat channel to bind immediately, such as feishu") + .option("--chat-target ", "Chat target identifier to persist for notifications") + .option("--base-branch ", "Base branch for issue work") + .option("--builder-agent ", "Builder agent id to persist in repo config") + .option("--verifier-agent ", "Verifier agent id to persist in repo config") + .option("--test ", "Test command to persist in repo config", collectOption, []) + .option("--no-start-gateway", "Do not try to start the local gateway after writing config") + .option("--no-probe-built-startup", "Skip the isolated built-startup proof during setup-check") + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runCommandWithRuntime(defaultRuntime, async () => { + await openclawCodeBootstrapCommand( + { + repo: opts.repo as string, + repoRoot: opts.repoRoot as string | undefined, + stateDir: opts.stateDir as string | undefined, + mode: opts.mode as "auto" | "cli-only" | "chatops", + channel: opts.channel as string | undefined, + chatTarget: opts.chatTarget as string | undefined, + baseBranch: opts.baseBranch as string | undefined, + builderAgent: opts.builderAgent as string | undefined, + verifierAgent: opts.verifierAgent as string | undefined, + test: (opts.test as string[] | undefined) ?? [], + startGateway: Boolean(opts.startGateway), + probeBuiltStartup: Boolean(opts.probeBuiltStartup), + json: Boolean(opts.json), + }, + defaultRuntime, + ); + }); + }); + code .command("blueprint-init") .description("Create the fixed project blueprint scaffold in the current repo") diff --git a/src/commands/openclawcode.test.ts b/src/commands/openclawcode.test.ts index 2e149cb240..660e1714d8 100644 --- a/src/commands/openclawcode.test.ts +++ b/src/commands/openclawcode.test.ts @@ -6,12 +6,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { OpenClawCodeChatopsStore } from "../integrations/openclaw-plugin/store.js"; import type { WorkflowRun } from "../openclawcode/index.js"; import { + openclawCodeBootstrapCommand, openclawCodeBlueprintClarifyCommand, openclawCodeBlueprintDecomposeCommand, openclawCodeDiscoverWorkItemsCommand, DEFAULT_OPENCLAWCODE_BUILDER_TIMEOUT_SECONDS, DEFAULT_OPENCLAWCODE_VERIFIER_TIMEOUT_SECONDS, openclawCodeBlueprintInitCommand, + openclawCodeBootstrapInternals, openclawCodePolicyShowCommand, openclawCodeOperatorStatusSnapshotShowCommand, openclawCodeBlueprintSetSectionCommand, @@ -296,6 +298,7 @@ describe("openclawCodeRunCommand", () => { expect(payload.workspaceBranchMatchesIssue).toBe(true); expect(payload.workspaceRepoRootPresent).toBe(true); expect(payload.workspaceHasPreparedAt).toBe(true); + expect(payload.workspacePreparedAt).toBe("2026-01-01T00:00:00.000Z"); expect(payload.workspaceHasWorktreePath).toBe(true); expect(payload.draftPullRequestBranchName).toBe("openclawcode/issue-2"); expect(payload.draftPullRequestBaseBranch).toBe("main"); @@ -3752,6 +3755,265 @@ describe("openclawCodeRunCommand", () => { }); }); +describe("openclawCodeBootstrapCommand", () => { + const runtime = createTestRuntime(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + mocks.resolveGitHubRepoFromGit.mockResolvedValue({ owner: "acme", repo: "demo" }); + }); + + it("bootstraps operator files, repo binding, blueprint artifacts, and inferred test commands", async () => { + const operatorRoot = await mkdtemp(path.join(os.tmpdir(), "openclawcode-bootstrap-operator-")); + const targetRepoRoot = await mkdtemp(path.join(os.tmpdir(), "openclawcode-bootstrap-target-")); + await writeFile( + path.join(targetRepoRoot, "package.json"), + JSON.stringify( + { + name: "demo", + scripts: { + test: "vitest run", + }, + }, + null, + 2, + ), + "utf8", + ); + await writeFile(path.join(targetRepoRoot, "pnpm-lock.yaml"), "lockfileVersion: 9.0\n", "utf8"); + vi.stubEnv("GH_TOKEN", "ghs_bootstrap_token"); + + const setupCheckSpy = vi + .spyOn(openclawCodeBootstrapInternals, "runSetupCheck") + .mockReturnValue({ + payload: { + ok: true, + strict: true, + repoRoot: "/operator/repo", + operatorRoot, + readiness: { + basic: true, + strict: true, + lowRiskProofReady: true, + fallbackProofReady: false, + promotionReady: true, + gatewayReachable: false, + routeProbeReady: true, + routeProbeSkipped: false, + builtStartupProofRequested: false, + builtStartupProofReady: false, + nextAction: "ready-for-low-risk-proof", + }, + summary: { + pass: 9, + warn: 0, + fail: 0, + }, + checks: [], + }, + stderr: "", + status: 0, + }); + + await openclawCodeBootstrapCommand( + { + repo: "acme/demo", + repoRoot: targetRepoRoot, + stateDir: operatorRoot, + baseBranch: "main", + startGateway: false, + probeBuiltStartup: false, + json: true, + }, + runtime, + ); + + setupCheckSpy.mockRestore(); + + const payload = JSON.parse(runtime.log.mock.calls.at(-1)?.[0] ?? "null"); + expect(payload.contractVersion).toBe(1); + expect(payload.repo.repoKey).toBe("acme/demo"); + expect(payload.repo.repoRoot).toBe(targetRepoRoot); + expect(payload.repo.repoRootSelection).toBe("explicit"); + expect(payload.repo.checkoutAction).toBe("attached"); + expect(payload.mode).toBe("cli-only"); + expect(payload.notify.bindingMode).toBe("cli-placeholder"); + expect(payload.credentials.githubTokenSource).toBe("GH_TOKEN"); + expect(payload.config.repoEntryAction).toBe("created"); + expect(payload.config.testCommands).toEqual(["pnpm test"]); + expect(payload.config.testCommandSource).toBe("package-manager"); + expect(payload.binding.action).toBe("created"); + expect(payload.blueprint.action).toBe("created"); + expect(payload.stageGates.executionStartReadiness).toBe("ready"); + expect(payload.gateway.action).toBe("skipped"); + expect(payload.setupCheck.payload.readiness.nextAction).toBe("ready-for-low-risk-proof"); + expect(payload.nextAction).toBe("ready-for-low-risk-proof"); + + const envFile = await readFile(path.join(operatorRoot, "openclawcode.env"), "utf8"); + expect(envFile).toContain("export GH_TOKEN='ghs_bootstrap_token'"); + expect(envFile).toContain("export OPENCLAWCODE_GITHUB_REPO='acme/demo'"); + expect(envFile).toContain("export OPENCLAWCODE_GITHUB_WEBHOOK_SECRET="); + + const config = JSON.parse(await readFile(path.join(operatorRoot, "openclaw.json"), "utf8")); + const repoEntry = config.plugins.entries.openclawcode.config.repos[0]; + expect(repoEntry.owner).toBe("acme"); + expect(repoEntry.repo).toBe("demo"); + expect(repoEntry.repoRoot).toBe(targetRepoRoot); + expect(repoEntry.testCommands).toEqual(["pnpm test"]); + + const chatopsState = JSON.parse( + await readFile( + path.join(operatorRoot, "plugins", "openclawcode", "chatops-state.json"), + "utf8", + ), + ); + expect(chatopsState.repoBindingsByRepo["acme/demo"]).toEqual( + expect.objectContaining({ + notifyChannel: "bootstrap", + notifyTarget: "cli-only:acme/demo", + }), + ); + + await expect( + readFile(path.join(targetRepoRoot, "PROJECT-BLUEPRINT.md"), "utf8"), + ).resolves.toContain("Bootstrap acme/demo"); + const stageGates = JSON.parse( + await readFile(path.join(targetRepoRoot, ".openclawcode", "stage-gates.json"), "utf8"), + ); + expect(stageGates.exists).toBe(true); + }); + + it("reuses existing operator config defaults and accepts explicit chat targets", async () => { + const operatorRoot = await mkdtemp(path.join(os.tmpdir(), "openclawcode-bootstrap-existing-")); + const targetRepoRoot = await mkdtemp( + path.join(os.tmpdir(), "openclawcode-bootstrap-existing-target-"), + ); + await writeFile( + path.join(operatorRoot, "openclaw.json"), + JSON.stringify( + { + plugins: { + entries: { + openclawcode: { + enabled: true, + config: { + repos: [ + { + owner: "acme", + repo: "demo", + repoRoot: targetRepoRoot, + baseBranch: "develop", + triggerMode: "approve", + notifyChannel: "telegram", + notifyTarget: "chat:123", + builderAgent: "builder-existing", + verifierAgent: "verifier-existing", + testCommands: ["npm test"], + openPullRequest: true, + mergeOnApprove: false, + }, + ], + }, + }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + vi.stubEnv("GITHUB_TOKEN", "github_fallback_token"); + + const setupCheckSpy = vi + .spyOn(openclawCodeBootstrapInternals, "runSetupCheck") + .mockReturnValue({ + payload: { + ok: false, + strict: true, + repoRoot: "/operator/repo", + operatorRoot, + readiness: { + basic: true, + strict: false, + lowRiskProofReady: false, + fallbackProofReady: false, + promotionReady: false, + gatewayReachable: false, + routeProbeReady: false, + routeProbeSkipped: false, + builtStartupProofRequested: true, + builtStartupProofReady: true, + nextAction: "start-or-restart-live-gateway", + }, + summary: { + pass: 7, + warn: 1, + fail: 1, + }, + checks: [], + }, + stderr: "", + status: 1, + }); + + await openclawCodeBootstrapCommand( + { + repo: "acme/demo", + stateDir: operatorRoot, + mode: "chatops", + channel: "feishu", + chatTarget: "user:new-chat", + startGateway: false, + probeBuiltStartup: true, + json: true, + }, + runtime, + ); + + setupCheckSpy.mockRestore(); + + const payload = JSON.parse(runtime.log.mock.calls.at(-1)?.[0] ?? "null"); + expect(payload.repo.repoRootSelection).toBe("existing-operator-config"); + expect(payload.mode).toBe("chatops"); + expect(payload.notify.bindingMode).toBe("explicit"); + expect(payload.notify.notifyChannel).toBe("feishu"); + expect(payload.notify.notifyTarget).toBe("user:new-chat"); + expect(payload.config.testCommandSource).toBe("existing-config"); + expect(payload.config.builderAgent).toBe("builder-existing"); + expect(payload.config.verifierAgent).toBe("verifier-existing"); + expect(payload.credentials.githubTokenSource).toBe("GITHUB_TOKEN"); + expect(payload.nextAction).toBe("start-or-restart-live-gateway"); + + const config = JSON.parse(await readFile(path.join(operatorRoot, "openclaw.json"), "utf8")); + const repoEntry = config.plugins.entries.openclawcode.config.repos[0]; + expect(repoEntry.notifyChannel).toBe("feishu"); + expect(repoEntry.notifyTarget).toBe("user:new-chat"); + expect(repoEntry.testCommands).toEqual(["npm test"]); + }); + + it("fails fast when GitHub credentials are missing", async () => { + const targetRepoRoot = await mkdtemp( + path.join(os.tmpdir(), "openclawcode-bootstrap-no-token-"), + ); + + await expect( + openclawCodeBootstrapCommand( + { + repo: "acme/demo", + repoRoot: targetRepoRoot, + startGateway: false, + probeBuiltStartup: false, + }, + runtime, + ), + ).rejects.toThrow( + "Bootstrap requires GH_TOKEN or GITHUB_TOKEN in the environment so the target repo can be inspected and configured.", + ); + }); +}); + describe("openclawCodePolicyShowCommand", () => { const runtime = createTestRuntime(); diff --git a/src/commands/openclawcode.ts b/src/commands/openclawcode.ts index 1bc99ff432..70999dfed2 100644 --- a/src/commands/openclawcode.ts +++ b/src/commands/openclawcode.ts @@ -1,7 +1,12 @@ -import { readFile } from "node:fs/promises"; +import * as childProcess from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import * as net from "node:net"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { + OpenClawCodeChatopsStore, resolveOpenClawCodePluginConfig, type OpenClawCodeChatopsRepoConfig, } from "../integrations/openclaw-plugin/index.js"; @@ -99,6 +104,24 @@ export interface OpenClawCodeRunOpts { json?: boolean; } +export type OpenClawCodeBootstrapMode = "auto" | "cli-only" | "chatops"; + +export interface OpenClawCodeBootstrapOpts { + repo: string; + repoRoot?: string; + stateDir?: string; + mode?: OpenClawCodeBootstrapMode; + channel?: string; + chatTarget?: string; + baseBranch?: string; + builderAgent?: string; + verifierAgent?: string; + test?: string[]; + startGateway?: boolean; + probeBuiltStartup?: boolean; + json?: boolean; +} + export interface OpenClawCodeSeedValidationIssueOpts { template?: ValidationIssueTemplateId; owner?: string; @@ -387,6 +410,683 @@ async function resolveOperatorRepoConfig(params: { ); } +const OPENCLAWCODE_BOOTSTRAP_CONTRACT_VERSION = 1; +const DEFAULT_OPENCLAWCODE_BOOTSTRAP_NOTIFY_CHANNEL = "bootstrap"; +const DEFAULT_OPENCLAWCODE_BOOTSTRAP_TRIGGER_MODE = "approve"; +const DEFAULT_OPENCLAWCODE_BOOTSTRAP_BUILDER_AGENT = "main"; +const DEFAULT_OPENCLAWCODE_BOOTSTRAP_VERIFIER_AGENT = "main"; +const DEFAULT_OPENCLAWCODE_BOOTSTRAP_HOOK_EVENTS = "issues,pull_request,pull_request_review"; +const DEFAULT_OPENCLAWCODE_BOOTSTRAP_GATEWAY_PORT = 18789; + +interface BootstrapSetupCheckPayload { + ok: boolean; + strict: boolean; + repoRoot: string; + operatorRoot: string; + readiness: { + basic: boolean; + strict: boolean; + lowRiskProofReady: boolean; + fallbackProofReady: boolean; + promotionReady: boolean; + gatewayReachable: boolean; + routeProbeReady: boolean; + routeProbeSkipped: boolean; + builtStartupProofRequested: boolean; + builtStartupProofReady: boolean; + nextAction: string; + }; + summary: { + pass: number; + warn: number; + fail: number; + }; + checks?: Array<{ + status: string; + message: string; + }>; +} + +function parseBootstrapRepoRef(value: string): { owner: string; repo: string } { + const trimmed = value.trim(); + const parts = trimmed + .split("/") + .map((part) => part.trim()) + .filter(Boolean); + if (parts.length !== 2) { + throw new Error(`--repo must be in owner/repo form. Received: ${value}`); + } + return { + owner: parts[0], + repo: parts[1], + }; +} + +function resolveOpenClawCodeOperatorRepoRoot(): string { + return path.resolve(fileURLToPath(new URL("../..", import.meta.url))); +} + +async function pathExists(targetPath: string): Promise { + try { + await stat(targetPath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } +} + +async function isDirectoryEmpty(targetPath: string): Promise { + const entries = await readdir(targetPath); + return entries.length === 0; +} + +function buildGitHubRepoUrl(repoRef: { owner: string; repo: string }): string { + return `https://github.com/${repoRef.owner}/${repoRef.repo}.git`; +} + +function buildGitHubExtraHeader(token: string): string { + const basic = Buffer.from(`x-access-token:${token}`).toString("base64"); + return `AUTHORIZATION: basic ${basic}`; +} + +function runGitCommand(params: { + cwd?: string; + args: string[]; + token?: string; + allowFailure?: boolean; +}): string | null { + const args = params.token + ? ["-c", `http.extraHeader=${buildGitHubExtraHeader(params.token)}`, ...params.args] + : params.args; + const result = childProcess.spawnSync("git", args, { + cwd: params.cwd, + encoding: "utf8", + }); + if (result.status !== 0) { + if (params.allowFailure) { + return null; + } + const detail = [result.stderr, result.stdout].filter(Boolean).join("\n").trim(); + throw new Error( + detail + ? `git ${params.args.join(" ")} failed: ${detail}` + : `git ${params.args.join(" ")} failed`, + ); + } + const stdout = result.stdout.trim(); + return stdout.length > 0 ? stdout : null; +} + +async function resolveBootstrapRepoRoot(params: { + requestedRepoRoot?: string; + repoRef: { + owner: string; + repo: string; + }; +}): Promise<{ + repoRoot: string; + selection: + | "explicit" + | "existing-operator-config" + | "current-working-tree" + | "default-repo-name" + | "existing-default-repo-name" + | "owner-prefixed-repo-name"; +}> { + if (params.requestedRepoRoot) { + return { + repoRoot: path.resolve(params.requestedRepoRoot), + selection: "explicit", + }; + } + + const cwd = path.resolve(process.cwd()); + try { + const currentRepo = await resolveGitHubRepoFromGit(cwd); + if ( + normalizeRepoKeyPart(currentRepo.owner) === normalizeRepoKeyPart(params.repoRef.owner) && + normalizeRepoKeyPart(currentRepo.repo) === normalizeRepoKeyPart(params.repoRef.repo) + ) { + return { + repoRoot: cwd, + selection: "current-working-tree", + }; + } + } catch { + // Ignore non-repository working directories. + } + + const defaultRoot = path.join(os.homedir(), "pros", params.repoRef.repo); + if (!(await pathExists(defaultRoot))) { + return { + repoRoot: defaultRoot, + selection: "default-repo-name", + }; + } + try { + const defaultRepo = await resolveGitHubRepoFromGit(defaultRoot); + if ( + normalizeRepoKeyPart(defaultRepo.owner) === normalizeRepoKeyPart(params.repoRef.owner) && + normalizeRepoKeyPart(defaultRepo.repo) === normalizeRepoKeyPart(params.repoRef.repo) + ) { + return { + repoRoot: defaultRoot, + selection: "existing-default-repo-name", + }; + } + } catch { + // Fall through to owner-prefixed root when the default path is occupied by another checkout. + } + + return { + repoRoot: path.join(os.homedir(), "pros", `${params.repoRef.owner}-${params.repoRef.repo}`), + selection: "owner-prefixed-repo-name", + }; +} + +async function ensureBootstrapRepoCheckout(params: { + repoRoot: string; + repoRef: { + owner: string; + repo: string; + }; + token: string; +}): Promise<{ + action: "cloned" | "attached"; + remoteUrl: string; +}> { + const repoRoot = path.resolve(params.repoRoot); + const remoteUrl = buildGitHubRepoUrl(params.repoRef); + const exists = await pathExists(repoRoot); + + if (!exists) { + await mkdir(path.dirname(repoRoot), { recursive: true }); + runGitCommand({ + args: ["clone", remoteUrl, repoRoot], + token: params.token, + }); + } else { + try { + const existingRepo = await resolveGitHubRepoFromGit(repoRoot); + if ( + normalizeRepoKeyPart(existingRepo.owner) !== normalizeRepoKeyPart(params.repoRef.owner) || + normalizeRepoKeyPart(existingRepo.repo) !== normalizeRepoKeyPart(params.repoRef.repo) + ) { + throw new Error( + `Existing checkout at ${repoRoot} points to ${existingRepo.owner}/${existingRepo.repo}, not ${params.repoRef.owner}/${params.repoRef.repo}.`, + ); + } + return { + action: "attached", + remoteUrl, + }; + } catch (error) { + if (!(error instanceof Error) || !error.message.includes("points to")) { + const targetStat = await stat(repoRoot); + if (!targetStat.isDirectory()) { + throw new Error(`Bootstrap target root is not a directory: ${repoRoot}`, { + cause: error, + }); + } + if (!(await isDirectoryEmpty(repoRoot))) { + throw new Error( + `Bootstrap target root exists but is not a matching git checkout: ${repoRoot}`, + { cause: error }, + ); + } + runGitCommand({ + args: ["clone", remoteUrl, repoRoot], + token: params.token, + }); + } else { + throw error; + } + } + } + + const verified = await resolveGitHubRepoFromGit(repoRoot); + if ( + normalizeRepoKeyPart(verified.owner) !== normalizeRepoKeyPart(params.repoRef.owner) || + normalizeRepoKeyPart(verified.repo) !== normalizeRepoKeyPart(params.repoRef.repo) + ) { + throw new Error( + `Cloned checkout at ${repoRoot} resolved to ${verified.owner}/${verified.repo}, not ${params.repoRef.owner}/${params.repoRef.repo}.`, + ); + } + return { + action: "cloned", + remoteUrl, + }; +} + +function resolveBootstrapBaseBranch(repoRoot: string, explicitBaseBranch?: string): string { + const trimmed = explicitBaseBranch?.trim(); + if (trimmed) { + return trimmed; + } + const remoteHead = runGitCommand({ + cwd: repoRoot, + args: ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], + allowFailure: true, + }); + if (remoteHead?.startsWith("origin/")) { + return remoteHead.slice("origin/".length); + } + for (const candidate of ["main", "master"]) { + const exists = runGitCommand({ + cwd: repoRoot, + args: ["rev-parse", "--verify", `refs/heads/${candidate}`], + allowFailure: true, + }); + if (exists) { + return candidate; + } + } + return ( + runGitCommand({ + cwd: repoRoot, + args: ["branch", "--show-current"], + allowFailure: true, + }) ?? "main" + ); +} + +async function detectBootstrapTestCommands(repoRoot: string): Promise<{ + commands: string[]; + source: + | "explicit" + | "existing-config" + | "vitest-openclawcode" + | "package-manager" + | "go" + | "cargo" + | "pytest"; +}> { + const openclawVitestConfig = path.join(repoRoot, "vitest.openclawcode.config.mjs"); + if (await pathExists(openclawVitestConfig)) { + return { + commands: [ + "pnpm exec vitest run --config vitest.openclawcode.config.mjs --pool threads --maxWorkers 1", + ], + source: "vitest-openclawcode", + }; + } + + const packageJsonPath = path.join(repoRoot, "package.json"); + if (await pathExists(packageJsonPath)) { + const rawPackageJson = await readFile(packageJsonPath, "utf8"); + const packageJson = JSON.parse(rawPackageJson) as { + scripts?: { + test?: string; + }; + }; + if (typeof packageJson.scripts?.test === "string" && packageJson.scripts.test.trim()) { + if (await pathExists(path.join(repoRoot, "pnpm-lock.yaml"))) { + return { commands: ["pnpm test"], source: "package-manager" }; + } + if (await pathExists(path.join(repoRoot, "yarn.lock"))) { + return { commands: ["yarn test"], source: "package-manager" }; + } + return { commands: ["npm test"], source: "package-manager" }; + } + } + + if (await pathExists(path.join(repoRoot, "go.mod"))) { + return { commands: ["go test ./..."], source: "go" }; + } + if (await pathExists(path.join(repoRoot, "Cargo.toml"))) { + return { commands: ["cargo test"], source: "cargo" }; + } + if ( + (await pathExists(path.join(repoRoot, "pyproject.toml"))) || + (await pathExists(path.join(repoRoot, "pytest.ini"))) + ) { + return { commands: ["pytest"], source: "pytest" }; + } + + throw new Error( + `Unable to infer test commands for ${repoRoot}. Pass --test explicitly so bootstrap can persist a safe repo config.`, + ); +} + +function resolveBootstrapMode(params: { + requestedMode?: OpenClawCodeBootstrapMode; + channel?: string; + chatTarget?: string; +}): "cli-only" | "chatops" { + if (params.requestedMode && params.requestedMode !== "auto") { + return params.requestedMode; + } + return params.channel && params.chatTarget ? "chatops" : "cli-only"; +} + +function shellQuoteEnvValue(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function parseEnvLineValue(line: string): string | null { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + return null; + } + const withoutExport = trimmed.startsWith("export ") ? trimmed.slice("export ".length) : trimmed; + const match = /^([A-Z0-9_]+)=(.*)$/.exec(withoutExport); + if (!match) { + return null; + } + let value = match[2] ?? ""; + if ( + (value.startsWith("'") && value.endsWith("'")) || + (value.startsWith('"') && value.endsWith('"')) + ) { + value = value.slice(1, -1); + } + return value; +} + +async function writeBootstrapEnvFile(params: { + envFilePath: string; + repoKey: string; + token: string; +}): Promise<{ + created: boolean; + updatedKeys: string[]; + webhookSecretGenerated: boolean; + values: Record; +}> { + const exists = await pathExists(params.envFilePath); + const original = exists ? await readFile(params.envFilePath, "utf8") : ""; + const lines = original ? original.replace(/\r\n/g, "\n").split("\n") : []; + const webhookSecretLine = lines.find((line) => + /^\s*(export\s+)?OPENCLAWCODE_GITHUB_WEBHOOK_SECRET=/.test(line), + ); + const existingWebhookSecret = webhookSecretLine ? parseEnvLineValue(webhookSecretLine) : null; + const desiredValues = new Map([ + ["GH_TOKEN", params.token], + [ + "OPENCLAWCODE_GITHUB_WEBHOOK_SECRET", + existingWebhookSecret || randomBytes(32).toString("hex"), + ], + ["OPENCLAWCODE_GITHUB_REPO", params.repoKey], + ["OPENCLAWCODE_GITHUB_HOOK_EVENTS", DEFAULT_OPENCLAWCODE_BOOTSTRAP_HOOK_EVENTS], + ]); + + const updatedKeys: string[] = []; + const nextLines = [...lines]; + for (const [key, value] of desiredValues.entries()) { + const rendered = `export ${key}=${shellQuoteEnvValue(value)}`; + const index = nextLines.findIndex((line) => new RegExp(`^\\s*(export\\s+)?${key}=`).test(line)); + if (index >= 0) { + if (nextLines[index] !== rendered) { + nextLines[index] = rendered; + updatedKeys.push(key); + } + continue; + } + nextLines.push(rendered); + updatedKeys.push(key); + } + + await mkdir(path.dirname(params.envFilePath), { recursive: true }); + const body = `${nextLines.filter((line, index, source) => !(index === source.length - 1 && line === "")).join("\n")}\n`; + await writeFile(params.envFilePath, body, "utf8"); + return { + created: !exists, + updatedKeys, + webhookSecretGenerated: !existingWebhookSecret, + values: Object.fromEntries(desiredValues), + }; +} + +async function writeBootstrapOperatorConfig(params: { + configPath: string; + repoRef: { + owner: string; + repo: string; + }; + targetRepoRoot: string; + baseBranch: string; + notifyChannel: string; + notifyTarget: string; + builderAgent: string; + verifierAgent: string; + testCommands: string[]; +}): Promise<{ + created: boolean; + repoEntryAction: "created" | "updated" | "unchanged"; +}> { + const exists = await pathExists(params.configPath); + const parsed = exists + ? (JSON.parse(await readFile(params.configPath, "utf8")) as Record) + : {}; + const config = parsed; + const gateway = + config.gateway && typeof config.gateway === "object" + ? ({ ...(config.gateway as Record) } as Record) + : {}; + if (!gateway.mode) { + gateway.mode = "local"; + } + if (!gateway.port) { + gateway.port = DEFAULT_OPENCLAWCODE_BOOTSTRAP_GATEWAY_PORT; + } + config.gateway = gateway; + + const plugins = + config.plugins && typeof config.plugins === "object" + ? ({ ...(config.plugins as Record) } as Record) + : {}; + plugins.enabled = true; + const allow = Array.isArray(plugins.allow) ? [...plugins.allow] : []; + if (!allow.includes("openclawcode")) { + allow.push("openclawcode"); + } + plugins.allow = allow; + + const entries = + plugins.entries && typeof plugins.entries === "object" + ? ({ ...(plugins.entries as Record) } as Record) + : {}; + const pluginEntry = + entries.openclawcode && typeof entries.openclawcode === "object" + ? ({ ...(entries.openclawcode as Record) } as Record) + : {}; + pluginEntry.enabled = true; + const pluginConfig = + pluginEntry.config && typeof pluginEntry.config === "object" + ? ({ ...(pluginEntry.config as Record) } as Record) + : {}; + pluginConfig.githubWebhookSecretEnv = "OPENCLAWCODE_GITHUB_WEBHOOK_SECRET"; + const existingResolved = resolveOpenClawCodePluginConfig(pluginConfig); + const existingRepo = existingResolved.repos.find( + (entry) => + normalizeRepoKeyPart(entry.owner) === normalizeRepoKeyPart(params.repoRef.owner) && + normalizeRepoKeyPart(entry.repo) === normalizeRepoKeyPart(params.repoRef.repo), + ); + const nextRepoEntry: OpenClawCodeChatopsRepoConfig = { + owner: params.repoRef.owner, + repo: params.repoRef.repo, + repoRoot: params.targetRepoRoot, + baseBranch: params.baseBranch, + triggerMode: DEFAULT_OPENCLAWCODE_BOOTSTRAP_TRIGGER_MODE, + notifyChannel: params.notifyChannel, + notifyTarget: params.notifyTarget, + builderAgent: params.builderAgent, + verifierAgent: params.verifierAgent, + testCommands: params.testCommands, + triggerLabels: existingRepo?.triggerLabels ?? [], + skipLabels: existingRepo?.skipLabels ?? [], + openPullRequest: existingRepo?.openPullRequest ?? true, + mergeOnApprove: existingRepo?.mergeOnApprove ?? false, + }; + const existingReposRaw = Array.isArray(pluginConfig.repos) ? pluginConfig.repos : []; + const repoIndex = existingReposRaw.findIndex((entry) => { + if (!entry || typeof entry !== "object") { + return false; + } + const candidate = entry as Record; + const owner = + typeof candidate.owner === "string" && candidate.owner.trim() ? candidate.owner : ""; + const repo = typeof candidate.repo === "string" && candidate.repo.trim() ? candidate.repo : ""; + return ( + normalizeRepoKeyPart(owner) === normalizeRepoKeyPart(params.repoRef.owner) && + normalizeRepoKeyPart(repo) === normalizeRepoKeyPart(params.repoRef.repo) + ); + }); + let repoEntryAction: "created" | "updated" | "unchanged" = "created"; + if (repoIndex >= 0) { + const currentEntry = existingReposRaw[repoIndex]; + if (JSON.stringify(currentEntry) === JSON.stringify(nextRepoEntry)) { + repoEntryAction = "unchanged"; + } else { + repoEntryAction = "updated"; + } + existingReposRaw[repoIndex] = nextRepoEntry; + } else { + existingReposRaw.push(nextRepoEntry); + } + pluginConfig.repos = existingReposRaw; + pluginEntry.config = pluginConfig; + entries.openclawcode = pluginEntry; + plugins.entries = entries; + config.plugins = plugins; + + await mkdir(path.dirname(params.configPath), { recursive: true }); + await writeFile(params.configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + return { + created: !exists, + repoEntryAction, + }; +} + +async function ensureBootstrapRepoBinding(params: { + operatorStateDir: string; + repoKey: string; + notifyChannel: string; + notifyTarget: string; +}): Promise<{ + action: "created" | "updated" | "unchanged"; +}> { + const store = OpenClawCodeChatopsStore.fromStateDir(params.operatorStateDir); + const current = await store.getRepoBinding(params.repoKey); + if ( + current?.notifyChannel === params.notifyChannel && + current?.notifyTarget === params.notifyTarget + ) { + return { action: "unchanged" }; + } + await store.setRepoBinding({ + repoKey: params.repoKey, + notifyChannel: params.notifyChannel, + notifyTarget: params.notifyTarget, + }); + return { + action: current ? "updated" : "created", + }; +} + +function parseBootstrapSetupCheckPayload(stdout: string): BootstrapSetupCheckPayload | null { + const trimmed = stdout.trim(); + if (!trimmed) { + return null; + } + try { + return JSON.parse(trimmed) as BootstrapSetupCheckPayload; + } catch { + return null; + } +} + +function runBootstrapSetupCheck(params: { + operatorRepoRoot: string; + operatorStateDir: string; + probeBuiltStartup: boolean; +}): { + payload: BootstrapSetupCheckPayload | null; + status: number | null; + stderr: string; +} { + const scriptPath = path.join(params.operatorRepoRoot, "scripts", "openclawcode-setup-check.sh"); + const args = ["--strict", "--json"]; + if (params.probeBuiltStartup) { + args.splice(1, 0, "--probe-built-startup"); + } + const result = childProcess.spawnSync("bash", [scriptPath, ...args], { + cwd: params.operatorRepoRoot, + encoding: "utf8", + env: { + ...process.env, + OPENCLAWCODE_SETUP_REPO_ROOT: params.operatorRepoRoot, + OPENCLAWCODE_SETUP_OPERATOR_ROOT: params.operatorStateDir, + }, + }); + return { + payload: parseBootstrapSetupCheckPayload(result.stdout), + status: result.status, + stderr: result.stderr.trim(), + }; +} + +function isGatewayReachable(port: number): Promise { + return new Promise((resolve) => { + const socket = net.createConnection({ host: "127.0.0.1", port }); + const finish = (value: boolean) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(value); + }; + socket.once("connect", () => finish(true)); + socket.once("error", () => finish(false)); + socket.setTimeout(1000, () => finish(false)); + }); +} + +async function startBootstrapGateway(params: { + operatorRepoRoot: string; + operatorStateDir: string; + extraEnv: Record; + port?: number; +}): Promise<{ + action: "already-running" | "started" | "failed"; +}> { + const port = params.port ?? DEFAULT_OPENCLAWCODE_BOOTSTRAP_GATEWAY_PORT; + if (await isGatewayReachable(port)) { + return { action: "already-running" }; + } + const distEntry = path.join(params.operatorRepoRoot, "dist", "index.js"); + const child = childProcess.spawn( + process.execPath, + [distEntry, "gateway", "run", "--bind", "loopback", "--port", String(port)], + { + cwd: params.operatorRepoRoot, + env: { + ...process.env, + ...params.extraEnv, + OPENCLAW_STATE_DIR: params.operatorStateDir, + }, + detached: true, + stdio: "ignore", + }, + ); + child.unref(); + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (await isGatewayReachable(port)) { + return { action: "started" }; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + return { action: "failed" }; +} + +export const openclawCodeBootstrapInternals = { + runSetupCheck: runBootstrapSetupCheck, + startGateway: startBootstrapGateway, +}; + function logProjectBlueprintSummary(params: { summary: Awaited>; runtime: RuntimeEnv; @@ -1635,6 +2335,265 @@ export async function openclawCodeRunCommand( } } +export async function openclawCodeBootstrapCommand( + opts: OpenClawCodeBootstrapOpts, + runtime: RuntimeEnv, +): Promise { + const repoRef = parseBootstrapRepoRef(opts.repo); + const repoKey = `${repoRef.owner}/${repoRef.repo}`; + const operatorRepoRoot = resolveOpenClawCodeOperatorRepoRoot(); + const operatorStateDir = resolveOperatorStateDir(opts.stateDir); + const token = process.env.GH_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim(); + if (!token) { + throw new Error( + "Bootstrap requires GH_TOKEN or GITHUB_TOKEN in the environment so the target repo can be inspected and configured.", + ); + } + + const initialRepoRoot = await resolveBootstrapRepoRoot({ + requestedRepoRoot: opts.repoRoot, + repoRef, + }); + let targetRepoRoot = initialRepoRoot.repoRoot; + let repoRootSelection = initialRepoRoot.selection; + const existingOperatorRepoConfig = await resolveOperatorRepoConfig({ + operatorStateDir, + repoRoot: targetRepoRoot, + repoRef, + }); + if (!opts.repoRoot && existingOperatorRepoConfig?.repoRoot) { + targetRepoRoot = path.resolve(existingOperatorRepoConfig.repoRoot); + repoRootSelection = "existing-operator-config"; + } + + const checkout = await ensureBootstrapRepoCheckout({ + repoRoot: targetRepoRoot, + repoRef, + token, + }); + targetRepoRoot = path.resolve(targetRepoRoot); + + const mode = resolveBootstrapMode({ + requestedMode: opts.mode, + channel: opts.channel, + chatTarget: opts.chatTarget, + }); + const defaultNotifyTarget = + mode === "chatops" ? `bind-pending:${repoKey}` : `cli-only:${repoKey}`; + const notifyChannel = + opts.channel?.trim() || + existingOperatorRepoConfig?.notifyChannel || + DEFAULT_OPENCLAWCODE_BOOTSTRAP_NOTIFY_CHANNEL; + const notifyTarget = + opts.chatTarget?.trim() || existingOperatorRepoConfig?.notifyTarget || defaultNotifyTarget; + const notifyBindingMode = + opts.channel?.trim() && opts.chatTarget?.trim() + ? "explicit" + : existingOperatorRepoConfig?.notifyChannel && existingOperatorRepoConfig?.notifyTarget + ? "existing-config" + : mode === "chatops" + ? "chat-placeholder" + : "cli-placeholder"; + + const testCommandsResult = opts.test?.length + ? { + commands: opts.test, + source: "explicit" as const, + } + : existingOperatorRepoConfig?.testCommands?.length + ? { + commands: existingOperatorRepoConfig.testCommands, + source: "existing-config" as const, + } + : await detectBootstrapTestCommands(targetRepoRoot); + const baseBranch = resolveBootstrapBaseBranch( + targetRepoRoot, + opts.baseBranch ?? existingOperatorRepoConfig?.baseBranch, + ); + const builderAgent = + opts.builderAgent?.trim() || + existingOperatorRepoConfig?.builderAgent || + DEFAULT_OPENCLAWCODE_BOOTSTRAP_BUILDER_AGENT; + const verifierAgent = + opts.verifierAgent?.trim() || + existingOperatorRepoConfig?.verifierAgent || + DEFAULT_OPENCLAWCODE_BOOTSTRAP_VERIFIER_AGENT; + + const envFilePath = path.join(operatorStateDir, "openclawcode.env"); + const configPath = path.join(operatorStateDir, "openclaw.json"); + const chatopsStatePath = path.join( + operatorStateDir, + "plugins", + "openclawcode", + "chatops-state.json", + ); + + const envFile = await writeBootstrapEnvFile({ + envFilePath, + repoKey, + token, + }); + const config = await writeBootstrapOperatorConfig({ + configPath, + repoRef, + targetRepoRoot, + baseBranch, + notifyChannel, + notifyTarget, + builderAgent, + verifierAgent, + testCommands: testCommandsResult.commands, + }); + const binding = await ensureBootstrapRepoBinding({ + operatorStateDir, + repoKey, + notifyChannel, + notifyTarget, + }); + + const existingBlueprint = await readProjectBlueprint(targetRepoRoot); + const blueprint = existingBlueprint.exists + ? existingBlueprint + : await createProjectBlueprint({ + repoRoot: targetRepoRoot, + title: `${repoRef.repo} Blueprint`, + goal: `Bootstrap ${repoKey} for blueprint-first autonomous development.`, + }); + const blueprintAction = existingBlueprint.exists ? "existing" : "created"; + const roleRouting = await writeProjectRoleRoutingPlan(targetRepoRoot); + const discovery = await writeProjectDiscoveryInventory(targetRepoRoot); + const stageGates = await writeProjectStageGateArtifact(targetRepoRoot); + + const gateway = + opts.startGateway === false + ? { action: "skipped" as const } + : await openclawCodeBootstrapInternals.startGateway({ + operatorRepoRoot, + operatorStateDir, + extraEnv: envFile.values, + }); + const setupCheck = openclawCodeBootstrapInternals.runSetupCheck({ + operatorRepoRoot, + operatorStateDir, + probeBuiltStartup: opts.probeBuiltStartup !== false, + }); + const nextAction = + notifyBindingMode === "chat-placeholder" + ? "connect-chat-and-run-occode-bind" + : (setupCheck.payload?.readiness.nextAction ?? + (gateway.action === "failed" + ? "start-or-restart-live-gateway" + : "inspect-setup-check-output")); + + const payload = { + contractVersion: OPENCLAWCODE_BOOTSTRAP_CONTRACT_VERSION, + repo: { + owner: repoRef.owner, + repo: repoRef.repo, + repoKey, + repoRoot: targetRepoRoot, + repoRootSelection, + checkoutAction: checkout.action, + remoteUrl: checkout.remoteUrl, + baseBranch, + }, + operator: { + operatorRepoRoot, + operatorStateDir, + envFilePath, + configPath, + chatopsStatePath, + }, + mode, + notify: { + notifyChannel, + notifyTarget, + bindingMode: notifyBindingMode, + }, + credentials: { + githubTokenSource: process.env.GH_TOKEN?.trim() ? "GH_TOKEN" : "GITHUB_TOKEN", + envFileCreated: envFile.created, + envUpdatedKeys: envFile.updatedKeys, + webhookSecretGenerated: envFile.webhookSecretGenerated, + }, + config: { + configCreated: config.created, + repoEntryAction: config.repoEntryAction, + builderAgent, + verifierAgent, + testCommands: testCommandsResult.commands, + testCommandSource: testCommandsResult.source, + }, + binding, + blueprint: { + action: blueprintAction, + blueprintPath: blueprint.blueprintPath, + status: blueprint.status, + revisionId: blueprint.revisionId, + defaultedSectionCount: blueprint.defaultedSectionCount, + }, + roleRouting: { + artifactPath: roleRouting.artifactPath, + unresolvedRoleCount: roleRouting.unresolvedRoleCount, + fallbackConfigured: roleRouting.fallbackConfigured, + }, + discovery: { + artifactPath: discovery.inventoryPath, + evidenceCount: discovery.evidenceCount, + }, + stageGates: { + artifactPath: stageGates.artifactPath, + blockedGateCount: stageGates.blockedGateCount, + needsHumanDecisionCount: stageGates.needsHumanDecisionCount, + executionStartReadiness: + stageGates.gates.find((gate) => gate.gateId === "execution-start")?.readiness ?? null, + }, + gateway, + setupCheck: { + status: setupCheck.status, + stderr: setupCheck.stderr, + payload: setupCheck.payload, + }, + nextAction, + }; + + if (opts.json) { + runtime.log(JSON.stringify(payload, null, 2)); + return; + } + + runtime.log(`Repo: ${repoKey}`); + runtime.log(`Target repo root: ${targetRepoRoot}`); + runtime.log(`Repo root selection: ${repoRootSelection}`); + runtime.log(`Checkout: ${checkout.action}`); + runtime.log(`Mode: ${mode}`); + runtime.log(`Notify target: ${notifyChannel}:${notifyTarget} (${notifyBindingMode})`); + runtime.log(`Operator root: ${operatorStateDir}`); + runtime.log(`Env file: ${envFilePath}`); + runtime.log(`Config file: ${configPath}`); + runtime.log(`Repo entry: ${config.repoEntryAction}`); + runtime.log(`Repo binding: ${binding.action}`); + runtime.log(`Blueprint: ${blueprintAction} (${blueprint.status ?? "unknown"})`); + runtime.log(`Role routing unresolved roles: ${roleRouting.unresolvedRoleCount}`); + runtime.log(`Discovery evidence: ${discovery.evidenceCount}`); + runtime.log( + `Stage gates: blocked=${stageGates.blockedGateCount} needsHuman=${stageGates.needsHumanDecisionCount}`, + ); + runtime.log(`Gateway: ${gateway.action}`); + if (setupCheck.payload) { + runtime.log( + `Setup-check: ok=${setupCheck.payload.ok} pass=${setupCheck.payload.summary.pass} warn=${setupCheck.payload.summary.warn} fail=${setupCheck.payload.summary.fail}`, + ); + runtime.log(`Setup-check next action: ${setupCheck.payload.readiness.nextAction}`); + } else { + runtime.log(`Setup-check: unavailable (status=${String(setupCheck.status)})`); + if (setupCheck.stderr) { + runtime.log(setupCheck.stderr); + } + } + runtime.log(`Next action: ${nextAction}`); +} + export async function openclawCodePolicyShowCommand( opts: OpenClawCodePolicyShowOpts, runtime: RuntimeEnv,