From 5e5e89099bd07b0a3f140fd2168f90529970a4f2 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:23:40 +0000 Subject: [PATCH 1/4] fix(ai): Resolve issue #1958 - Extract the UI-agnostic local setup engine for CLI Implemented by ProPR AI using gpt-5.6-sol model. Implementation completed successfully. --- package-lock.json | 19 + package.json | 2 +- packages/cli/package.json | 1 + .../src/commands/setup/agentHostActions.ts | 65 + packages/cli/src/commands/setup/agents.ts | 296 +-- packages/cli/src/commands/setup/engine.ts | 1589 +---------------- packages/cli/src/commands/setup/github.ts | 270 +-- .../cli/src/commands/setup/hostActions.ts | 242 +++ packages/cli/src/commands/setup/state.ts | 422 +---- packages/cli/src/commands/setup/types.ts | 155 +- packages/cli/src/commands/setupCommand.ts | 6 + packages/local-setup/package.json | 22 + packages/local-setup/src/agents.ts | 231 +++ packages/local-setup/src/engine.test.ts | 104 ++ packages/local-setup/src/engine.ts | 1530 ++++++++++++++++ packages/local-setup/src/envFile.ts | 117 ++ packages/local-setup/src/github.ts | 269 +++ packages/local-setup/src/index.ts | 5 + packages/local-setup/src/state.test.ts | 44 + packages/local-setup/src/state.ts | 420 +++++ packages/local-setup/src/types.ts | 154 ++ packages/local-setup/tsconfig.json | 17 + packages/local-setup/tsconfig.test.json | 6 + 23 files changed, 3280 insertions(+), 2706 deletions(-) create mode 100644 packages/cli/src/commands/setup/agentHostActions.ts create mode 100644 packages/cli/src/commands/setup/hostActions.ts create mode 100644 packages/local-setup/package.json create mode 100644 packages/local-setup/src/agents.ts create mode 100644 packages/local-setup/src/engine.test.ts create mode 100644 packages/local-setup/src/engine.ts create mode 100644 packages/local-setup/src/envFile.ts create mode 100644 packages/local-setup/src/github.ts create mode 100644 packages/local-setup/src/index.ts create mode 100644 packages/local-setup/src/state.test.ts create mode 100644 packages/local-setup/src/state.ts create mode 100644 packages/local-setup/src/types.ts create mode 100644 packages/local-setup/tsconfig.json create mode 100644 packages/local-setup/tsconfig.test.json diff --git a/package-lock.json b/package-lock.json index 77e374f58..ab331dc64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2189,6 +2189,10 @@ "resolved": "packages/core", "link": true }, + "node_modules/@propr/local-setup": { + "resolved": "packages/local-setup", + "link": true + }, "node_modules/@propr/shared": { "resolved": "packages/shared", "link": true @@ -13048,6 +13052,7 @@ "name": "@propr/cli", "version": "0.8.15", "dependencies": { + "@propr/local-setup": "^0.8.15", "@propr/shared": "^0.8.15", "commander": "^13.1.0", "dotenv": "^16.5.0", @@ -13123,6 +13128,20 @@ "fastest-levenshtein": "^1.0.7" } }, + "packages/local-setup": { + "name": "@propr/local-setup", + "version": "0.8.15", + "dependencies": { + "@propr/shared": "^0.8.15" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22" + } + }, "packages/shared": { "name": "@propr/shared", "version": "0.8.15", diff --git a/package.json b/package.json index b294c8126..63a290e96 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "lint": "eslint src/", "typecheck": "tsc --noEmit", "test": "node --test", - "test:prepare": "npm run build --workspace=packages/shared && npm run build --workspace=packages/core && npm run build --workspace=packages/cli", + "test:prepare": "npm run build --workspace=packages/shared && npm run build --workspace=packages/core && npm run build --workspace=packages/local-setup && npm run build --workspace=packages/cli", "test:server": "node scripts/run-test-suite.mjs", "test:full:prepared": "npm run test:server", "test:full": "npm run test:prepare && npm run test:full:prepared", diff --git a/packages/cli/package.json b/packages/cli/package.json index b6b90fcde..89b70ae9c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -21,6 +21,7 @@ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json" }, "dependencies": { + "@propr/local-setup": "^0.8.15", "@propr/shared": "^0.8.15", "commander": "^13.1.0", "dotenv": "^16.5.0", diff --git a/packages/cli/src/commands/setup/agentHostActions.ts b/packages/cli/src/commands/setup/agentHostActions.ts new file mode 100644 index 000000000..1cf470714 --- /dev/null +++ b/packages/cli/src/commands/setup/agentHostActions.ts @@ -0,0 +1,65 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import type { AgentSetupActions } from "@propr/local-setup"; +import type { ConfigManager } from "../../config/index.js"; +import { localhostServiceUrl } from "../../utils/dockerPort.js"; + +/** Bind the portable agent setup engine to the CLI API and Docker launcher. */ +export function createDefaultAgentSetupActions(configManager?: ConfigManager): AgentSetupActions { + const localApiClient = async (rootDir: string): Promise => { + const { getHostConfig } = await import("../../orchestrator/index.js"); + const { cfg } = await getHostConfig({ configManager, root: rootDir }); + const { createApiClient } = await import("../../api/client.js"); + return createApiClient({ baseUrl: localhostServiceUrl(cfg.apiPort) }); + }; + + return { + async listAgents(rootDir) { + const { listAgents } = await import("../../api/agents.js"); + return (await listAgents(await localApiClient(rootDir))).agents; + }, + async addAgent(rootDir, options) { + const { addAgent } = await import("../../api/agents.js"); + await addAgent(options, await localApiClient(rootDir)); + }, + async loginableAgents() { + const { loginableAgents } = await import("../agentValidation.js"); + return loginableAgents(); + }, + async loginAgent(rootDir, type) { + const { getHostConfig } = await import("../../orchestrator/index.js"); + const { planAgentLogin } = await import("../agentValidation.js"); + const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); + const temporaryRoot = mkdtempSync(join(tmpdir(), "propr-setup-login-")); + const workspaceDir = join(temporaryRoot, "workspace"); + mkdirSync(workspaceDir, { recursive: true, mode: 0o700 }); + try { + const { plan, error } = planAgentLogin(type, cfg, workspaceDir, orch.validateDockerBindPath); + if (error || !plan) return { available: false, success: false, detail: error }; + if (!orch.docker(["images", "-q", plan.image], { capture: true }).stdout.trim()) { + return { available: true, success: false, detail: `image ${plan.image} not present locally — run \`propr images pull\`` }; + } + mkdirSync(plan.hostDir, { recursive: true, mode: 0o700 }); + const result = spawnSync("docker", plan.dockerArgs, { stdio: "inherit" }); + return result.status === 0 + ? { available: true, success: true, detail: `${type} login finished — credentials written to ${plan.hostDir}` } + : { available: true, success: false, detail: `${type} login exited with code ${result.status ?? "?"}` }; + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } + }, + async validateAgents(rootDir, types) { + const { getHostConfig } = await import("../../orchestrator/index.js"); + const { validateAgents } = await import("../agentValidation.js"); + const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); + const rows = await validateAgents(orch, cfg, { agents: types, skipHost: true }); + return rows.map((row) => ({ + type: row.type, + status: row.image.status === "ok" ? "ok" as const : row.image.status === "fail" ? "failed" as const : "skipped" as const, + detail: row.image.detail, + })); + }, + }; +} diff --git a/packages/cli/src/commands/setup/agents.ts b/packages/cli/src/commands/setup/agents.ts index f10dac354..ce6c1455a 100644 --- a/packages/cli/src/commands/setup/agents.ts +++ b/packages/cli/src/commands/setup/agents.ts @@ -1,294 +1,2 @@ -/** - * Agent enablement + image-based authentication for `propr setup`. - * - * This runs as a setup step *after the stack is up* (the backend must be - * reachable to read and write agent configuration). It does three things, each - * non-destructively: - * - * 1. Reads the agents already configured in the running backend. - * 2. Adds any *selected* agent whose type is not yet configured, seeding it - * from the shared {@link AGENT_DEFAULTS} metadata (alias + supported - * models). Existing agents are never disabled, deleted, or re-aliased — a - * re-run only fills in what is missing. - * 3. For selected agents that support an interactive image login (see - * {@link planAgentLogin}), offers to authenticate through the agent's - * Docker image and runs the login only for the ones the user confirms. - * - * Like the engine, this module is UI-agnostic: the side effects live behind the - * injectable {@link AgentSetupActions} seam (tests pass mocks so the flow runs - * without Docker, the network, or a TTY) and the single user decision is - * collected through the optional {@link AgentSetupParams.confirmLogin} callback - * (a missing callback means "authenticate nothing", the safe default). - */ - -import type { ConfigManager } from "../../config/index.js"; -import { AGENT_DEFAULTS, type AgentType } from "@propr/shared"; -import type { AddAgentOptions, AgentConfig } from "../../api/agents.js"; -import { localhostServiceUrl } from "../../utils/dockerPort.js"; - -/** Outcome of attempting to authenticate a single agent through its image. */ -export interface AgentLoginResult { - /** False when the agent has no usable image-login plan (nothing was run). */ - available: boolean; - /** True when an interactive login ran and exited successfully. */ - success: boolean; - /** Human-readable detail (error reason or status line). */ - detail?: string; -} - -export interface AgentConnectivityResult { - type: string; - status: "ok" | "failed" | "skipped"; - detail: string; -} - -/** - * The side effects the agent-setup step performs against the running stack. - * Defaults bind to the real backend API and orchestrator (see - * {@link createDefaultAgentSetupActions}); tests override any subset. - */ -export interface AgentSetupActions { - /** List the agents currently configured in the running backend. */ - listAgents(rootDir: string): Promise; - /** Add a new agent to the backend configuration. */ - addAgent(rootDir: string, options: AddAgentOptions): Promise; - /** Agent types that support an interactive image login (have a login plan). */ - loginableAgents(): Promise; - /** Authenticate one agent through its image; interactive (inherits stdio). */ - loginAgent(rootDir: string, type: string): Promise; - /** Run a live, image-only request that mirrors the worker credential mount. */ - validateAgents(rootDir: string, types: string[]): Promise; -} - -/** Inputs for {@link runAgentSetup}. */ -export interface AgentSetupParams { - rootDir: string; - /** Agent types the user selected earlier in the flow (pull/configure steps). */ - selectedAgents: string[]; - actions: AgentSetupActions; - /** - * Confirm which of the loginable candidates to authenticate now. Returns the - * subset to log in. Omitted (or returning an empty array) authenticates none. - */ - confirmLogin?(ctx: { candidates: string[]; rootDir: string }): Promise; - onLog?(line: string): void; -} - -/** What the agent-setup step did, for the caller to render as a step status. */ -export interface AgentSetupOutcome { - /** Agent types newly added to the backend configuration. */ - added: string[]; - /** Selected agent types that were already configured (left untouched). */ - alreadyConfigured: string[]; - /** Agents that authenticated successfully through their image. */ - authenticated: string[]; - /** Agents the user chose to authenticate but whose login did not succeed. */ - authFailed: string[]; - /** Agents whose worker-image connectivity check returned a valid response. */ - validated: string[]; - /** Agents whose live image check failed or could not run. */ - validationFailed: string[]; - /** Exact recovery commands for agents that still need attention. */ - nextCommands: string[]; - /** Non-fatal problems encountered (surfaced as a warning by the caller). */ - errors: string[]; -} - -/** - * Enable the selected agents in the running backend and, on confirmation, - * authenticate the ones that support an image login. Never throws for expected - * conditions — every failure is captured in {@link AgentSetupOutcome.errors} so - * the caller can settle the step as a warning rather than aborting setup. - */ -export async function runAgentSetup(params: AgentSetupParams): Promise { - const { rootDir, selectedAgents, actions, confirmLogin, onLog } = params; - const outcome: AgentSetupOutcome = { - added: [], - alreadyConfigured: [], - authenticated: [], - authFailed: [], - validated: [], - validationFailed: [], - nextCommands: [], - errors: [], - }; - - if (selectedAgents.length === 0) return outcome; - - // 1. Read the current backend configuration. Without it we cannot safely tell - // which agents are new, so a read failure stops here (nothing was changed). - let existing: AgentConfig[]; - try { - existing = await actions.listAgents(rootDir); - } catch (error) { - outcome.errors.push(`could not read backend agents: ${(error as Error).message}`); - return outcome; - } - - // 2. Add the selected agents that are not yet configured. Match by type so we - // never add a second agent for a type the user already runs — existing - // agents (enabled or not) are left exactly as they are. - const configuredTypes = new Set(existing.map((agent) => agent.type)); - for (const type of selectedAgents) { - if (configuredTypes.has(type as AgentType)) { - outcome.alreadyConfigured.push(type); - continue; - } - const defaults = AGENT_DEFAULTS[type as AgentType]; - if (!defaults) continue; // unknown type — guarded, but never trust the input - try { - onLog?.(`enabling agent ${type}…`); - // Seed from shared metadata: alias + the full supported-model set. The - // backend resolves the default docker image and host config path, so we - // don't pass them (a literal "~" path would otherwise reach the backend). - await actions.addAgent(rootDir, { - alias: defaults.defaultAlias, - type: type as AgentType, - models: defaults.defaultModels, - enabled: true, - }); - outcome.added.push(type); - configuredTypes.add(type as AgentType); - } catch (error) { - outcome.errors.push(`could not enable ${type}: ${(error as Error).message}`); - } - } - - // 3. Image-based authentication — only for selected agents that actually have - // a login plan, and only for the ones the user confirms. - let loginable: Set; - try { - loginable = new Set(await actions.loginableAgents()); - } catch (error) { - outcome.errors.push(`could not determine which agents support image login: ${(error as Error).message}`); - loginable = new Set(); - } - const candidates = selectedAgents.filter((type) => loginable.has(type)); - if (candidates.length > 0 && confirmLogin) { - let chosen: string[] = []; - try { - chosen = await confirmLogin({ candidates, rootDir }); - } catch (error) { - // A failed/cancelled prompt must not abort the whole run — validation and - // exact recovery commands are still useful. - outcome.errors.push(`agent login prompt failed: ${(error as Error).message}`); - } - const chosenSet = new Set(chosen.filter((type) => loginable.has(type))); - // Iterate the candidate order (not the user's), so logins run in a stable order. - for (const type of candidates) { - if (!chosenSet.has(type)) continue; - try { - onLog?.(`authenticating ${type} through its image…`); - const result = await actions.loginAgent(rootDir, type); - if (result.detail) onLog?.(result.detail); - if (result.available && result.success) outcome.authenticated.push(type); - else outcome.authFailed.push(type); - } catch (error) { - outcome.authFailed.push(type); - outcome.errors.push(`login for ${type} failed: ${(error as Error).message}`); - } - } - } - - // 4. Always validate the selected agents from the same image/mount shape the - // worker uses. This is one live call per agent (host calls are deliberately - // skipped), so setup catches a successful host login that was not mounted into - // Docker without doubling subscription usage. - try { - onLog?.(`checking agent connectivity through worker image${selectedAgents.length === 1 ? "" : "s"}…`); - const checks = await actions.validateAgents(rootDir, selectedAgents); - for (const check of checks) { - onLog?.(`${check.type}: ${check.detail}`); - if (check.status === "ok") { - outcome.validated.push(check.type); - continue; - } - outcome.validationFailed.push(check.type); - if (loginable.has(check.type)) outcome.nextCommands.push(`propr agent login ${check.type}`); - outcome.nextCommands.push(`propr check agents --agents ${check.type}`); - } - } catch (error) { - outcome.errors.push(`could not validate agent connectivity: ${(error as Error).message}`); - for (const type of selectedAgents) { - if (loginable.has(type)) outcome.nextCommands.push(`propr agent login ${type}`); - outcome.nextCommands.push(`propr check agents --agents ${type}`); - } - } - - outcome.nextCommands = Array.from(new Set(outcome.nextCommands)); - - return outcome; -} - -/** - * Build the production {@link AgentSetupActions}, lazily importing the heavy - * orchestrator/API/validation modules only when an action runs — keeping the - * engine import cheap and Docker-free for tests, which replace these anyway. - */ -export function createDefaultAgentSetupActions(configManager?: ConfigManager): AgentSetupActions { - /** A client pointed at the local stack's API port (not the saved remote URL). */ - const localApiClient = async (rootDir: string): Promise => { - const { getHostConfig } = await import("../../orchestrator/index.js"); - const { cfg } = await getHostConfig({ configManager, root: rootDir }); - const { createApiClient } = await import("../../api/client.js"); - return createApiClient({ baseUrl: localhostServiceUrl(cfg.apiPort) }); - }; - - return { - async listAgents(rootDir) { - const { listAgents } = await import("../../api/agents.js"); - const client = await localApiClient(rootDir); - const response = await listAgents(client); - return response.agents; - }, - async addAgent(rootDir, options) { - const { addAgent } = await import("../../api/agents.js"); - const client = await localApiClient(rootDir); - await addAgent(options, client); - }, - async loginableAgents() { - const { loginableAgents } = await import("../agentValidation.js"); - return loginableAgents(); - }, - async loginAgent(rootDir, type) { - const { mkdirSync, mkdtempSync, rmSync } = await import("node:fs"); - const { tmpdir } = await import("node:os"); - const { join } = await import("node:path"); - const { spawnSync } = await import("node:child_process"); - const { getHostConfig } = await import("../../orchestrator/index.js"); - const { planAgentLogin } = await import("../agentValidation.js"); - - const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); - const tmp = mkdtempSync(join(tmpdir(), "propr-setup-login-")); - const workspaceDir = join(tmp, "workspace"); - mkdirSync(workspaceDir, { recursive: true }); - try { - const { plan, error } = planAgentLogin(type, cfg, workspaceDir, orch.validateDockerBindPath); - if (error || !plan) return { available: false, success: false, detail: error }; - // The image must be present locally; setup pulls the unified agent image - // when any agent is selected, but a failed pull would leave it absent. - if (orch.docker(["images", "-q", plan.image], { capture: true }).stdout.trim().length === 0) { - return { available: true, success: false, detail: `image ${plan.image} not present locally — run \`propr images pull\`` }; - } - mkdirSync(plan.hostDir, { recursive: true, mode: 0o700 }); - const res = spawnSync("docker", plan.dockerArgs, { stdio: "inherit" }); - return res.status === 0 - ? { available: true, success: true, detail: `${type} login finished — credentials written to ${plan.hostDir}` } - : { available: true, success: false, detail: `${type} login exited with code ${res.status ?? "?"}` }; - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }, - async validateAgents(rootDir, types) { - const { getHostConfig } = await import("../../orchestrator/index.js"); - const { validateAgents } = await import("../agentValidation.js"); - const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); - const rows = await validateAgents(orch, cfg, { agents: types, skipHost: true }); - return rows.map((row) => ({ - type: row.type, - status: row.image.status === "ok" ? "ok" : row.image.status === "fail" ? "failed" : "skipped", - detail: row.image.detail, - })); - }, - }; -} +export * from "@propr/local-setup"; +export { createDefaultAgentSetupActions } from "./agentHostActions.js"; diff --git a/packages/cli/src/commands/setup/engine.ts b/packages/cli/src/commands/setup/engine.ts index 15700eda6..7effef45b 100644 --- a/packages/cli/src/commands/setup/engine.ts +++ b/packages/cli/src/commands/setup/engine.ts @@ -1,1581 +1,36 @@ -/** - * Setup wizard engine. - * - * `propr setup` walks a new user from a bare host to a running local - * control-plane stack. It combines what `propr check` and `propr init stack` - * already do, then sequences the remaining one-time tasks — pulling images, - * recording agent credentials, choosing GitHub auth, starting the stack and - * validating its health, configuring the whitelist, optionally connecting a - * first repository, and surfacing the UI URL. - * - * The engine is intentionally UI-agnostic. It owns the *order* of the flow and - * the *decision logic* (what to run, what to skip, what is safe), but performs - * no rendering and prompts no user directly. Two seams keep it decoupled: - * - * - {@link SetupPrompts} — callback hooks a renderer supplies to collect user - * decisions (which agents, which auth mode, whether to add a repo, …). Every - * hook is optional; a missing hook falls back to a safe, non-interactive - * default (keep what exists, skip optional work). Ink and the readline - * fallback will provide these in later issues. - * - {@link SetupActions} — the side-effecting operations (run checks, scaffold, - * pull, start, health-probe, add repo). Defaults bind to the real - * orchestrator and commands via {@link createDefaultActions}; tests inject - * mocks so the whole flow runs without Docker, the network, or a TTY. - * - * Safety contract (enforced here, not just by convention): - * - The stack is initialized only when `.env` is missing or the user picks a - * new root — an existing functional install is left intact on re-run. - * - `.env` is never overwritten wholesale; edits go through the non-destructive - * {@link applyEnvSelection} (per-key, never blanks an existing value). - * - No step deletes user data; a running stack is reused, not recreated. - * - Core images pull by default; the agent image pulls when an agent is selected. - */ - -import { existsSync, mkdirSync } from "node:fs"; -import { homedir, hostname } from "node:os"; -import { isAbsolute, join, normalize } from "node:path"; -import { - resolveGithubEventIntakeMode, - validateIntakeModePrerequisites, - DEFAULT_PROPR_GH_RELAY_URL, - type GithubAuthMode, - type GithubAuthModeResult, -} from "@propr/shared"; -import type { ConfigManager } from "../../config/index.js"; -import type { AuthorizedInstallation, RelayClientOptions } from "../../api/relay.js"; import { - buildIntakeEnvVars, - defaultIntakeChoice, - intakeModeLabel, - saveWhitelist, - type GithubIntakeDecision, - type GithubIntakeMode, -} from "./github.js"; -import type { ChecksOutcome, RunChecksOptions } from "../checkCommands.js"; -import type { InitStackOptions, InitStackResult } from "../initStack.js"; -import { - createDefaultAgentSetupActions, - runAgentSetup, - type AgentSetupActions, -} from "./agents.js"; -import { - applyEnvSelection, - clearEnvKeys, - createSetupState, - detectGithubAuthMode, - getStep, - inspectDatastoreAdministrators, - inspectStackInit, - isSetupComplete, - readEnvVars, + runSetup as runLocalSetup, + retrySetup as retryLocalSetup, resolveSetupRoot, - updateStep, - type EnvSelectionResult, - type DatastoreAdminInspection, - type StackInitState, -} from "./state.js"; -import type { SetupState, SetupStep, SetupStepId, SetupStepPatch } from "./types.js"; -import { localhostServiceUrl } from "../../utils/dockerPort.js"; - -const DEFAULT_PROPR_GITHUB_APP_INSTALL_URL = "https://github.com/apps/propr-dev/installations/new"; - -/** Match the API's distinction between real OAuth credentials and example placeholders. */ -function isConfiguredOAuthValue(value: string | undefined): boolean { - const normalized = value?.trim().toLowerCase(); - return Boolean(normalized && !normalized.startsWith("your_") && normalized !== "changeme"); -} - -function isTruthyEnvFlag(value: string | undefined): boolean { - const normalized = value?.trim().toLowerCase(); - return normalized === "true" || normalized === "1"; -} - -function normalizeServiceUrl(value: string | undefined): string | undefined { - try { - if (!value?.trim()) return undefined; - const url = new URL(value.trim()); - if (url.username || url.password || url.search || url.hash) return undefined; - const path = url.pathname.replace(/\/+$/, ""); - return `${url.origin}${path}`; - } catch { - return undefined; - } -} - -function isSupportedLoopbackCallback(value: string | undefined): boolean { - try { - if (!value?.trim()) return false; - const url = new URL(value.trim()); - const hostname = url.hostname.toLowerCase(); - return ( - url.protocol === "http:" && - (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]") && - url.username === "" && - url.password === "" && - url.pathname === "/api/auth/github/callback" && - url.search === "" && - url.hash === "" - ); - } catch { - return false; - } -} - -/** - * Catalog of supported agents: the image each one needs and the host - * credential directories recorded into `.env` when it is selected. Mirrors - * `agentDescriptors()` in ../checkCommands.ts and `detectCredentials()` in - * ../initStack.ts — kept local so the engine has no rendering/command imports. - */ -interface AgentDescriptor { - type: string; - /** Unified agent manifest image key. */ - imageKey: string; - /** Host credential dirs mounted into the agent container. */ - credentials: { envKey: string; defaultDir: string }[]; -} - -function agentCatalog(): AgentDescriptor[] { - const home = homedir(); - return [ - { type: "claude", imageKey: "agent", credentials: [{ envKey: "HOST_CLAUDE_DIR", defaultDir: join(home, ".claude") }] }, - { type: "codex", imageKey: "agent", credentials: [{ envKey: "HOST_CODEX_DIR", defaultDir: join(home, ".codex") }] }, - { type: "antigravity", imageKey: "agent", credentials: [{ envKey: "HOST_ANTIGRAVITY_DIR", defaultDir: join(home, ".gemini") }] }, - { - type: "opencode", - imageKey: "agent", - credentials: [ - { envKey: "HOST_OPENCODE_XDG_DIR", defaultDir: join(home, ".config", "opencode") }, - { envKey: "HOST_OPENCODE_DATA_DIR", defaultDir: join(home, ".local", "share", "opencode") }, - ], - }, - { type: "vibe", imageKey: "agent", credentials: [{ envKey: "HOST_VIBE_DIR", defaultDir: join(home, ".vibe") }] }, - ]; -} - -/** Reject unsafe Docker bind sources before any recursive filesystem write. */ -function assertSafeAgentCredentialDir(path: string, name = "Agent credential path"): void { - if ( - !isAbsolute(path) - || normalize(path) === "/" - || path.includes(":") - || /[\u0000-\u001f\u007f-\u009f]/.test(path) - ) { - throw new Error(`${name} must be an absolute, non-root Linux path without ':' or control characters`); - } -} - -/** Agent types whose default credential directory exists on this host. */ -function detectInstalledAgents(catalog: AgentDescriptor[]): string[] { - return catalog.filter((a) => a.credentials.some((c) => existsSync(c.defaultDir))).map((a) => a.type); -} - -// --------------------------------------------------------------------------- -// Decisions the renderer collects from the user. -// --------------------------------------------------------------------------- - -/** Where to put the stack, and whether to scaffold it. */ -export interface RootDecision { - /** Stack root to use (absolute). May differ from the resolved default. */ - rootDir: string; - /** - * Ensure this root is scaffolded, creating any *missing* `.env`/data/logs/repos - * pieces. Non-destructive: scaffolding runs without `force`, so an existing - * `.env` is always preserved — this fills in what is absent, it never resets a - * working install. (A root with a missing `.env` or sub-directory is scaffolded - * regardless of this flag; the flag only forces a scaffold pass on a root that - * already looks complete.) - */ - reinitialize: boolean; -} - -/** Outcome of the GitHub-auth prompt. */ -export interface GithubAuthDecision { - /** Keep the existing configuration untouched. */ - keep?: boolean; - /** Informational: the auth mode the user picked. */ - mode?: GithubAuthMode; - /** Env values to write (non-destructively, overwriting only these keys). */ - vars?: Record; - /** - * Relay path: the user chose token relay and wants the engine to enroll on - * their behalf (discover the installation, mint the token, write the relay - * env vars) using the stored `propr login` token. `relayUrl` is the relay base - * URL to enroll against — the hosted default unless overridden. Mutually - * exclusive with `vars`. - */ - enrollRelay?: { relayUrl: string }; -} - -/** A repository to start monitoring. */ -export interface RepoSelection { - fullName: string; - alias?: string; - baseBranch?: string; -} - -/** - * Hooks a renderer implements to drive user decisions. All optional: a missing - * hook means "use the safe default" (keep existing config, skip optional work), - * which is exactly what lets the engine run unattended in tests. - */ -export interface SetupPrompts { - /** Choose/confirm the stack root. Default: keep resolved root, scaffold only if `.env` is absent. */ - resolveStackRoot?(ctx: { currentRoot: string; init: StackInitState }): Promise; - /** Pick which agents to enable. Default: the agents detected on this host. */ - selectAgents?(ctx: { available: string[]; detected: string[] }): Promise; - /** Configure GitHub auth. Default: keep whatever `.env` already has. */ - configureGithubAuth?(ctx: { current: GithubAuthModeResult }): Promise; - /** - * Choose which installation to enroll when the relay reports more than one the - * user can access. Only consulted for the ambiguous (>1) case; a single - * installation is auto-selected and zero is an error. Default (no hook): the - * first installation. - */ - selectInstallation?(ctx: { installations: AuthorizedInstallation[] }): Promise; - /** - * Ask whether to run the interactive `propr login` (gh CLI) now when Connect - * enrollment or protected local API steps need a user token and none is - * stored. `reason` explains which part of setup needs it. - */ - confirmGithubLogin?(ctx: { reason: string }): Promise; - /** Offer to open the official hosted ProPR GitHub App installation page. */ - confirmGithubAppInstall?(ctx: { url: string }): Promise; - /** Continue enrollment after the user finishes the browser installation. */ - confirmGithubAppInstalled?(ctx: { url: string }): Promise; - /** - * Choose how the backend ingests GitHub events (routing WebSocket, polling, or - * direct webhooks). `defaultMode` is the choice to pre-select: the auth-derived - * recommendation on a fresh install, but `"keep"` when `.env` already carries - * an intake decision so a blank Enter never rewrites a working config. - * `currentMode` is the intake mode `.env` resolves to today. Default: keep. - */ - configureIntake?(ctx: { - authMode: GithubAuthMode; - defaultMode: GithubIntakeMode | "keep"; - currentMode: GithubIntakeMode; - }): Promise; - /** Confirm starting the stack. Default: start it. */ - confirmStartStack?(ctx: { rootDir: string; alreadyRunning: boolean }): Promise; - /** - * Choose which of the selected agents to authenticate through their image - * (only agents with an image-login plan are offered). Returns the subset to - * log in. Default: authenticate none. - */ - confirmAgentLogin?(ctx: { candidates: string[]; rootDir: string }): Promise; - /** Provide the user whitelist. Return null to keep the current value. Default: keep. */ - configureWhitelist?(ctx: { current: string[]; demoMode: boolean }): Promise; - /** Optionally add a first repository. Return null to skip. Default: skip. */ - addRepository?(ctx: { rootDir: string }): Promise; - /** - * Ask whether to open the UI in a browser. Returning `true` makes the engine - * launch it (via {@link SetupActions.openUrl}); the renderer only collects the - * yes/no. Default: don't open, just report the URL. - */ - launchUi?(ctx: { url: string }): Promise; -} - -// --------------------------------------------------------------------------- -// Progress reporting. -// --------------------------------------------------------------------------- - -/** Progress hooks a renderer implements to reflect engine state. All optional. */ -export interface SetupReporter { - /** Fired after every state transition with the latest immutable snapshot. */ - onState?(state: SetupState): void; - /** Fired when a step becomes active. */ - onStepStart?(step: SetupStep): void; - /** Fired when a step reaches a terminal status. */ - onStepSettled?(step: SetupStep): void; - /** Free-form progress lines (e.g. docker pull output). */ - onLog?(line: string): void; -} - -// --------------------------------------------------------------------------- -// Injectable side effects. -// --------------------------------------------------------------------------- - -export interface PullImagesParams { - rootDir: string; - /** Agent types whose images should be pulled (in addition to core images). */ - agentTypes: string[]; - onLog?: (line: string) => void; -} - -export interface PullImagesResult { - pulledCore: string[]; - pulledAgents: string[]; - /** Core images that failed to pull — fatal, the stack cannot start. */ - failedCore: string[]; - /** Agent images that failed to pull — non-fatal, only those agents are affected. */ - failedAgents: string[]; -} - -export interface StartStackParams { - rootDir: string; - ui?: boolean; - docs?: boolean; - onLog?: (line: string) => void; -} - -export interface BackendHealthParams { - rootDir: string; - timeoutMs?: number; -} - -export interface BackendHealth { - healthy: boolean; - detail: string; - /** - * Set when the backend answered the probe (it is reachable and running) but - * rejected the request for authentication or authorization reasons rather - * than being genuinely unhealthy. The value lets the caller recommend login - * for a 401 without giving the same incorrect advice for a 403. - */ - accessFailure?: "unauthorized" | "forbidden"; -} - -/** Classify an HTTP access failure from the protected backend status route. */ -export function classifyBackendAccessError(error: unknown): BackendHealth | undefined { - const httpStatus = (error as { status?: unknown } | null)?.status; - if (httpStatus !== 401 && httpStatus !== 403) return undefined; + type RunSetupOptions as LocalRunSetupOptions, + type SetupActions, + type SetupRunResult, +} from "@propr/local-setup"; +import type { ConfigManager } from "../../config/index.js"; +import { createDefaultActions } from "./hostActions.js"; - const accessFailure = httpStatus === 401 ? "unauthorized" : "forbidden"; - const message = error instanceof Error ? error.message : String(error); - return { - healthy: false, - accessFailure, - detail: `backend is running but rejected the status request as ${accessFailure} (${message})`, - }; -} +export * from "@propr/local-setup"; +export { createDefaultActions } from "./hostActions.js"; -/** - * The operations the engine performs against the outside world. Defaults bind - * to the real orchestrator/commands (see {@link createDefaultActions}); tests - * override any subset. - */ -export interface SetupActions extends AgentSetupActions { - runChecks(options: RunChecksOptions): Promise; - inspectStackInit(rootDir: string): StackInitState; - /** Inspect the configured datastore's durable administrator state without modifying it. */ - inspectDatastoreAdministrators(rootDir: string): Promise; - scaffoldStack(options: InitStackOptions): Promise; - /** - * Persist the resolved stack root to the CLI config so later `propr start` / - * `propr status` invoked without `--root` target this stack. `scaffoldStack` - * already records it whenever it runs; this exists for the reuse path (an - * already-initialized root that setup leaves untouched), which would otherwise - * leave config pointing at a stale root or the cwd. A no-op without a config. - */ - persistStackRoot(rootDir: string): Promise; - readEnvVars(rootDir: string): Record; - applyEnvSelection(rootDir: string, vars: Record, opts?: { overwrite?: boolean }): EnvSelectionResult; - /** Remove keys from `.env` entirely (used to clear a value, not blank it). */ - clearEnvKeys(rootDir: string, keys: string[]): void; - detectGithubAuthMode(rootDir: string): GithubAuthModeResult; - /** Ensure a selected agent's host credential path is a directory, creating it securely when absent. */ - prepareAgentCredentialDir(path: string): void; - pullImages(params: PullImagesParams): Promise; - isStackRunning(rootDir: string): Promise; - startStack(params: StartStackParams): Promise; - checkBackendHealth(params: BackendHealthParams): Promise; - addRepository(selection: RepoSelection, rootDir: string): Promise; - resolveUiUrl(rootDir: string): Promise; - /** Open `url` in the host's default browser (best-effort; may reject). */ - openUrl(url: string): Promise; - /** - * Save the user whitelist through the running backend's settings API. A - * partial update — only the whitelist key is sent, so unrelated settings are - * left intact. - */ - saveWhitelistSetting(rootDir: string, users: string[]): Promise; - /** True when a GitHub user token is stored (relay enrollment and protected local API calls need it). */ - hasGithubToken(): boolean; - /** - * List the relay installations the stored GitHub identity can access (drives - * auto-select / the picker during relay enrollment). Throws if not logged in. - */ - fetchRelayInstallations(params: { - relayUrl?: string; - }): Promise<{ username: string; installations: AuthorizedInstallation[] }>; - /** - * Mint a relay token for `installationId`, returning the token and the relay - * URL it was minted against (the hosted default unless `relayUrl` overrides). - */ - enrollRelay(params: { - relayUrl?: string; - installationId: string; - label?: string; - }): Promise<{ relayUrl: string; token: string }>; - /** Authenticate with GitHub via the interactive `gh` CLI and store the token. */ - loginWithGithub(params?: { onLog?: (line: string) => void }): Promise; -} - -/** Options for {@link runSetup}. */ -export interface RunSetupOptions { +/** CLI-compatible options layered over the host-neutral package contract. */ +export interface RunSetupOptions extends Omit { configManager?: ConfigManager; - /** Explicit stack root flag (highest precedence). */ root?: string; - prompts?: SetupPrompts; - reporter?: SetupReporter; - /** Override any subset of the default actions (tests inject mocks here). */ actions?: Partial; - skipRemoteImageCheck?: boolean; -} - -/** Final outcome of a setup run. */ -export interface SetupRunResult { - rootDir: string; - state: SetupState; - /** Environment-check outcome, when the check step ran. */ - checks?: ChecksOutcome; - /** True when every required step finished without a blocking failure. */ - completed: boolean; -} - -const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); - -/** - * Build the production {@link SetupActions}, lazily importing the heavy - * orchestrator/command/API modules only when an action actually runs. This - * keeps `import`ing the engine cheap (and Docker-free) for tests, which replace - * these actions anyway. - */ -export function createDefaultActions(configManager?: ConfigManager): SetupActions { - /** A client pointed at the local stack's API port (not the saved remote URL). */ - const localApiClient = async (rootDir: string): Promise => { - const { getHostConfig } = await import("../../orchestrator/index.js"); - const { cfg } = await getHostConfig({ configManager, root: rootDir }); - const { createApiClient, createApiClientWithConfig } = await import("../../api/client.js"); - const options = { baseUrl: localhostServiceUrl(cfg.apiPort) }; - // Keep the local client on setup's active profile and, importantly, the - // token that an in-progress setup login just stored. Creating an unrelated - // manager here can otherwise lose profile context and call protected local - // endpoints without the token setup has already obtained. - return configManager - ? createApiClientWithConfig(configManager, options) - : createApiClient(options); - }; - - return { - // Agent enablement + image-login actions, bound to the local stack. - ...createDefaultAgentSetupActions(configManager), - async runChecks(options) { - const { runChecks } = await import("../checkCommands.js"); - return runChecks(options); - }, - inspectStackInit, - inspectDatastoreAdministrators, - async scaffoldStack(options) { - const { scaffoldStack } = await import("../initStack.js"); - return scaffoldStack(options); - }, - async persistStackRoot(rootDir) { - // Mirror scaffoldStack's `configManager.setStackRoot` so the reuse path - // records the root too. Best-effort: without a config there is nowhere to - // persist it (tests run this way), so it is simply a no-op. - await configManager?.setStackRoot(rootDir); - }, - readEnvVars, - applyEnvSelection, - clearEnvKeys, - detectGithubAuthMode, - prepareAgentCredentialDir(path) { - assertSafeAgentCredentialDir(path); - mkdirSync(path, { recursive: true, mode: 0o700 }); - }, - async pullImages({ rootDir, agentTypes, onLog }) { - const { getHostConfig } = await import("../../orchestrator/index.js"); - const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); - const selected = new Set(agentTypes); - const result: PullImagesResult = { pulledCore: [], pulledAgents: [], failedCore: [], failedAgents: [] }; - - for (const [key, tag] of Object.entries(cfg.images)) { - if (key === "docs" && !cfg.docsEnabled) continue; - const isAgent = key === "agent"; - // Pull the shared agent image when the user selected any agent; core images - // (api/worker/daemon/redis/…) always pull. - if (isAgent && selected.size === 0) continue; - - onLog?.(`pulling ${tag}…`); - // Async exec keeps the event loop free so the wizard's Ink spinner keeps - // animating while the (often slow) pull runs, instead of freezing. - const pulled = await orch.dockerAsync(["pull", tag]); - if (pulled.status === 0) { - try { - orch.tagAgentLatest(key, tag); - } catch { - /* best-effort local retag; the pull itself succeeded */ - } - (isAgent ? result.pulledAgents : result.pulledCore).push(tag); - } else { - (isAgent ? result.failedAgents : result.failedCore).push(tag); - } - } - return result; - }, - async isStackRunning(rootDir) { - const { getHostConfig } = await import("../../orchestrator/index.js"); - const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); - return orch.isStackRunningAsync(cfg); - }, - async startStack({ rootDir, ui, docs, onLog }) { - const { getHostConfig } = await import("../../orchestrator/index.js"); - const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); - // Pre-create the host Vibe prompt-cache dir owned by this user so Docker - // does not auto-create it as root on first bind-mount — a root-owned dir - // would fail the writability check and block future `propr start` runs. - try { - const { ensureVibePromptCacheDir } = await import("../initStack.js"); - ensureVibePromptCacheDir(cfg.hostVibePromptCacheDir); - } catch { - /* best-effort: startup validation will surface an actionable error */ - } - const validation = orch.validateEnv(cfg); - for (const warning of validation.warnings) onLog?.(`warning: ${warning}`); - if (!validation.ok) { - throw new Error(`stack environment is not ready:\n - ${validation.errors.join("\n - ")}`); - } - // Use the async start path: `propr setup` drives this from behind a live - // Ink TUI, so the blocking synchronous startStack would freeze the spinner - // and swallow keystrokes for the seconds-to-minutes a cold start takes. - await orch.ensureNetworkAsync(cfg, onLog); - await orch.startStackAsync(cfg, { - ui: ui ?? configManager?.getUiEnabled() ?? true, - docs: docs ?? cfg.docsEnabled, - onLog, - }); - }, - async checkBackendHealth({ rootDir, timeoutMs = 60_000 }) { - const { getSystemStatus } = await import("../../api/system.js"); - const client = await localApiClient(rootDir); - const deadline = Date.now() + timeoutMs; - let lastError = "no response"; - // Containers take a few seconds to report healthy; poll until the deadline. - do { - try { - const status = await getSystemStatus(client); - if (String(status.api).toLowerCase() === "healthy") { - return { healthy: true, detail: `API healthy (daemon ${status.daemon}, worker ${status.worker})` }; - } - lastError = `API reports "${status.api}"`; - } catch (error) { - // A 401/403 is not an unhealthy backend — the API answered but denied - // this protected request. Return immediately so setup does not stall - // on a running backend, while preserving whether remediation requires - // authentication (401) or an authorization/configuration check (403). - const accessFailure = classifyBackendAccessError(error); - if (accessFailure) return accessFailure; - lastError = (error as Error).message; - } - if (Date.now() >= deadline) break; - await sleep(2_000); - } while (Date.now() < deadline); - return { healthy: false, detail: `backend not healthy within ${Math.round(timeoutMs / 1000)}s (${lastError})` }; - }, - async addRepository({ fullName, alias, baseBranch }, rootDir) { - const { addRepo } = await import("../../api/repos.js"); - // Point the client at this stack's API port rather than the saved remote. - const client = await localApiClient(rootDir); - await addRepo(fullName, { alias, baseBranch }, client); - }, - async resolveUiUrl(rootDir) { - const { getHostConfig } = await import("../../orchestrator/index.js"); - const { cfg } = await getHostConfig({ configManager, root: rootDir }); - return localhostServiceUrl(cfg.uiPort); - }, - async openUrl(url) { - // Open in the host's default browser with the platform launcher. Detached - // and unref'd so the wizard isn't held open by the child, with stdio - // ignored so the launcher can't scribble over the TUI. - const { spawn } = await import("node:child_process"); - const platform = process.platform; - const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; - const args = platform === "win32" ? ["/c", "start", "", url] : [url]; - await new Promise((resolve, reject) => { - const child = spawn(command, args, { stdio: "ignore", detached: true }); - child.once("error", reject); - // The launcher returns immediately; once it has spawned we're done. - child.once("spawn", () => { - child.unref(); - resolve(); - }); - }); - }, - async saveWhitelistSetting(rootDir, users) { - const { updateSetting } = await import("../../api/settings.js"); - // Point the client at this stack's API port rather than the saved remote. - const client = await localApiClient(rootDir); - await updateSetting("github_user_whitelist", users, client); - }, - hasGithubToken() { - return Boolean(configManager?.getGithubToken()); - }, - async fetchRelayInstallations({ relayUrl }) { - const { fetchAuthenticatedUser } = await import("../../api/relay.js"); - const me = await fetchAuthenticatedUser(relayClient(relayUrl)); - return { username: me.username, installations: me.installations }; - }, - async enrollRelay({ relayUrl, installationId, label }) { - const { enrollRelayToken } = await import("../../api/relay.js"); - const client = relayClient(relayUrl); - // Default the token label to the hostname, mirroring `propr relay enroll`. - const result = await enrollRelayToken(client, { installationId, label: label ?? hostname() }); - return { relayUrl: client.baseUrl, token: result.token }; - }, - async loginWithGithub({ onLog } = {}) { - if (!configManager) return false; - const { loginWithGithubCli } = await import("../../auth/githubLogin.js"); - const result = await loginWithGithubCli(configManager, { interactive: true, onLog }); - if (!result.ok) onLog?.(result.message); - return result.ok; - }, - }; - - /** - * Build a relay client bound to the stored GitHub token. The hosted relay is - * the default base URL; an explicit `relayUrl` (self-hosted) overrides it. - */ - function relayClient(relayUrl?: string): RelayClientOptions { - const githubToken = configManager?.getGithubToken(); - if (!githubToken) { - throw new Error("Not logged in to GitHub. Run `propr login` first."); - } - return { baseUrl: relayUrl ?? DEFAULT_PROPR_GH_RELAY_URL, githubToken }; - } } -/** - * Run the setup flow end to end, in a safe order, driven by the supplied - * prompts and reflected through the reporter. Returns the final step state and - * the environment-check outcome. Never throws for expected conditions (a failed - * required step stops the flow and is reported in the returned state); only - * truly unexpected programmer errors propagate. - */ export async function runSetup(options: RunSetupOptions = {}): Promise { - const { configManager, prompts = {}, reporter = {}, skipRemoteImageCheck } = options; - const actions: SetupActions = { ...createDefaultActions(configManager), ...options.actions }; - const catalog = agentCatalog(); - - let rootDir = resolveSetupRoot(configManager, options.root); - let state = createSetupState(rootDir); - let checks: ChecksOutcome | undefined; - /** Agents chosen at the pull step, reused when recording credentials. */ - let selectedAgents: string[] = []; - /** True only when the configured datastore conclusively has no durable administrator. */ - let bootstrapIdentityEligible = false; - /** Set after this run successfully writes an authenticated identity to the administrator environment. */ - let bootstrapAdministratorSeeded = false; - let datastoreAdminInspection: DatastoreAdminInspection | undefined; - /** True only after the local API answers the setup health probe. */ - let backendReady = false; - - const emit = (): void => reporter.onState?.(state); - const stepOf = (id: SetupStepId): SetupStep => getStep(state, id)!; - const begin = (id: SetupStepId): void => { - state = updateStep(state, id, { status: "active", detail: undefined, nextAction: undefined }); - emit(); - reporter.onStepStart?.(stepOf(id)); - }; - const settle = (id: SetupStepId, patch: SetupStepPatch): void => { - state = updateStep(state, id, patch); - emit(); - reporter.onStepSettled?.(stepOf(id)); - }; - const log = (line: string): void => reporter.onLog?.(line); - const finish = (): SetupRunResult => ({ - rootDir, - state, - checks, - // A terminal-looking step list is not a working installation unless the - // API actually became healthy during this run. - completed: isSetupComplete(state) && backendReady, + const { configManager, actions: overrides, root, ...portable } = options; + const actions = { ...createDefaultActions(configManager), ...overrides } as SetupActions; + return runLocalSetup({ + ...portable, + root: resolveSetupRoot(configManager, root), + actions, }); - - /** - * Relay enrollment for the auth step. Ensures a GitHub token (offering the - * interactive login when a `confirmGithubLogin` hook is present), discovers the - * installation (auto-select one, pick among many, error on none), mints the - * relay token, and writes the relay env vars. Returns a success `detail` or a - * actionable `note`. It never throws for expected problems; the caller marks - * the auth step failed and stops before launching a backend that cannot boot. - */ - const enrollRelayForSetup = async ( - relayUrl: string - ): Promise<{ detail?: string; note?: { detail: string; nextAction?: string } }> => { - // 1. A stored GitHub token is required. Offer interactive login when the - // renderer supports it. The Ink entry point performs this handoff before - // enabling raw mode; the sequential renderer prompts through this hook. - if (!actions.hasGithubToken()) { - const reason = "Relay enrollment needs a GitHub token."; - if (prompts.confirmGithubLogin && (await prompts.confirmGithubLogin({ reason }))) { - await actions.loginWithGithub({ onLog: log }); - } - if (!actions.hasGithubToken()) { - return { - note: { - detail: "relay not enrolled — not logged in to GitHub", - nextAction: "Run `propr login`, then re-run `propr setup` and accept ProPR Connect.", - }, - }; - } - } - - try { - // 2. Discover installations: auto-select the only one, pick among many, - // error when there are none. - let { username, installations } = await actions.fetchRelayInstallations({ relayUrl }); - const usingHostedRelay = - relayUrl.replace(/\/+$/, "") === DEFAULT_PROPR_GH_RELAY_URL.replace(/\/+$/, ""); - if (installations.length === 0 && usingHostedRelay && prompts.confirmGithubAppInstall) { - const installUrl = DEFAULT_PROPR_GITHUB_APP_INSTALL_URL; - if (await prompts.confirmGithubAppInstall({ url: installUrl })) { - await actions.openUrl(installUrl); - const installed = prompts.confirmGithubAppInstalled - ? await prompts.confirmGithubAppInstalled({ url: installUrl }) - : false; - if (installed) { - ({ username, installations } = await actions.fetchRelayInstallations({ relayUrl })); - } - } - } - if (installations.length === 0) { - return { - note: { - detail: "relay not enrolled — no GitHub App installation available", - nextAction: usingHostedRelay - ? `Install the default ProPR GitHub App at ${DEFAULT_PROPR_GITHUB_APP_INSTALL_URL}, then re-run setup.` - : `Ask the administrator of ${relayUrl} for that relay's GitHub App installation URL, install it, then re-run setup.`, - }, - }; - } - let installationId: string; - if (installations.length === 1) { - installationId = String(installations[0].installation_id); - log(`relay: using installation ${installationId} (${installations[0].account_login})`); - } else if (prompts.selectInstallation) { - installationId = await prompts.selectInstallation({ installations }); - } else { - installationId = String(installations[0].installation_id); - } - - // 3. Mint the relay token and write the relay env vars (overwriting only - // these keys). PROPR_DEMO_MODE=false ensures the new relay config isn't - // shadowed by a leftover demo flag (see detectGithubAuthMode). - const { relayUrl: resolvedRelayUrl, token } = await actions.enrollRelay({ relayUrl, installationId }); - const existingEnv = actions.readEnvVars(rootDir); - const existingAdminUsers = [...new Set( - (existingEnv.PROPR_ADMIN_USERS ?? "") - .split(",") - .map((value) => value.trim().toLowerCase()) - .filter(Boolean) - )]; - const hasExistingAdminUsers = existingAdminUsers.length > 0; - const seedBootstrapAdmin = bootstrapIdentityEligible && !hasExistingAdminUsers; - const existingWhitelist = (existingEnv.GITHUB_USER_WHITELIST ?? "") - .split(",") - .map((value) => value.trim()) - .filter(Boolean); - const whitelistHasIdentity = existingWhitelist.some( - (value) => value.toLowerCase() === username.trim().toLowerCase() - ); - const bootstrapWhitelist = seedBootstrapAdmin && !whitelistHasIdentity - ? [...existingWhitelist, username].join(",") - : undefined; - const tunnelOverride = configManager?.getTunnelEnabled(rootDir); - const managedTunnelEnabled = tunnelOverride ?? Boolean( - existingEnv.PROPR_UI_TUNNEL_TOKEN?.trim() || isTruthyEnvFlag(existingEnv.PROPR_UI_TUNNEL_ENABLED) - ); - const explicitBrowserAuthMode = existingEnv.PROPR_WEB_AUTH_MODE?.trim().toLowerCase(); - const hasExplicitBrowserAuthMode = - explicitBrowserAuthMode === "connect" || - explicitBrowserAuthMode === "github" || - explicitBrowserAuthMode === "disabled"; - const customBrowserOAuthApplies = - !managedTunnelEnabled && - !hasExplicitBrowserAuthMode && - isConfiguredOAuthValue(existingEnv.GH_OAUTH_CLIENT_ID) && - isConfiguredOAuthValue(existingEnv.GH_OAUTH_CLIENT_SECRET); - const usesHostedConnect = - normalizeServiceUrl(resolvedRelayUrl) === normalizeServiceUrl(DEFAULT_PROPR_GH_RELAY_URL) && - normalizeServiceUrl(existingEnv.PROPR_CONNECT_URL || "https://connect.propr.dev") === - "https://connect.propr.dev"; - const callbackUrl = existingEnv.GH_OAUTH_CALLBACK_URL || - "http://localhost:4000/api/auth/github/callback"; - const automaticConnectApplies = - managedTunnelEnabled || - (usesHostedConnect && isSupportedLoopbackCallback(callbackUrl)); - actions.applyEnvSelection( - rootDir, - { - PROPR_DEMO_MODE: "false", - GH_AUTH_MODE: "relay", - PROPR_GH_RELAY_URL: resolvedRelayUrl, - PROPR_GH_RELAY_TOKEN: token, - GH_INSTALLATION_ID: installationId, - // Select hosted Connect only for its managed tunnel and exact - // loopback callback deployments. Explicit modes, custom OAuth, and - // custom/self-hosted relay paths remain operator-owned. - ...(automaticConnectApplies && !hasExplicitBrowserAuthMode && !customBrowserOAuthApplies - ? { PROPR_WEB_AUTH_MODE: "connect" } - : {}), - // The relay identity was just authenticated by GitHub and owns this - // installation, so it is the safe bootstrap administrator only when - // the configured datastore is absent or conclusively contains no - // durable administrator. Existing environment administrators and - // durable database administrators are always preserved. - ...(seedBootstrapAdmin ? { PROPR_ADMIN_USERS: username } : {}), - // Preserve every user-managed whitelist entry, adding the enrolled - // identity only when bootstrap enrollment needs it. - ...(bootstrapWhitelist ? { GITHUB_USER_WHITELIST: bootstrapWhitelist } : {}), - }, - { overwrite: true } - ); - bootstrapAdministratorSeeded = seedBootstrapAdmin; - const adminDetail = hasExistingAdminUsers - ? "kept existing administrators" - : seedBootstrapAdmin - ? `bootstrap administrator: ${username}` - : datastoreAdminInspection?.status === "uninspectable" - ? "left administrators unchanged because the datastore could not be inspected" - : "left administrators unchanged on existing stack"; - return { - detail: `auth mode: relay (installation ${installationId}); ${adminDetail}`, - }; - } catch (error) { - return { - note: { - detail: `relay enrollment failed — ${(error as Error).message}`, - nextAction: "Confirm the shared GitHub App is installed and you own the installation, then re-run setup.", - }, - }; - } - }; - - emit(); - - // 1. Environment checks — run first; their results steer the rest. - begin("check"); - try { - checks = await actions.runChecks({ root: rootDir, skipRemoteImageCheck }); - } catch (error) { - settle("check", { - status: "failed", - detail: `could not run environment checks: ${(error as Error).message}`, - nextAction: "Resolve the error above, then re-run setup.", - }); - return finish(); - } - const dockerProblem = blockingDockerFailure(checks); - if (dockerProblem) { - settle("check", { - status: "failed", - detail: dockerProblem, - nextAction: "Install/start Docker and ensure this user can run `docker info`, then re-run setup.", - }); - return finish(); - } - const fails = checks.results.filter((r) => r.status === "fail").length; - const warns = checks.results.filter((r) => r.status === "warn").length; - settle("check", { - status: warns > 0 || fails > 0 ? "warning" : "done", - detail: `${checks.results.length} checks (${fails} failing, ${warns} warnings) — addressing them below`, - }); - - // 2. Initialize stack — only when `.env` is missing or the user picks a new - // root. An existing functional install is never re-scaffolded or clobbered. - begin("init-stack"); - try { - let initSettlement: SetupStepPatch; - let init = actions.inspectStackInit(rootDir); - let userChoseReinit = false; - if (prompts.resolveStackRoot) { - const decision = await prompts.resolveStackRoot({ currentRoot: rootDir, init }); - if (decision.rootDir && decision.rootDir !== rootDir) { - rootDir = decision.rootDir; - state = { ...state, rootDir }; - init = actions.inspectStackInit(rootDir); - } - userChoseReinit = decision.reinitialize; - } - - // Scaffold whenever the stack is incomplete — `.env` missing *or* a required - // sub-directory (data/logs/repos) absent — or when the user explicitly chose - // to (re)initialize a root. Keying off `initialized` (not just `envExists`) - // means a half-scaffolded root with a stray `.env` but no `data/` still gets - // its directories created, instead of being silently treated as ready and - // failing later at startup. scaffoldStack runs without `force`, so an existing - // `.env` is always preserved — re-running setup never clobbers it. - const reinitialize = !init.initialized || userChoseReinit; - if (reinitialize) { - // No `force`: scaffoldStack creates a fresh `.env` only when absent and - // otherwise leaves the existing one in place. - const result = await actions.scaffoldStack({ root: rootDir }); - // Adopt the absolute root scaffoldStack actually resolved. A root typed at - // the prompt may be relative or have a trailing slash; without this every - // later step (env writes, health probe, UI URL) would key off the raw - // string while the scaffold landed at the resolved path. - if (result.rootDir && result.rootDir !== rootDir) { - rootDir = result.rootDir; - state = { ...state, rootDir }; - } - // Persist through setup's active ConfigManager as well as scaffoldStack's - // initializer. Otherwise later setup saves (for example GitHub login or - // tunnel preferences) can write a stale in-memory config and silently - // discard the root that scaffoldStack recorded through its own manager. - await actions.persistStackRoot(rootDir); - const created = [...result.dirsCreated]; - initSettlement = { - status: "done", - detail: result.envCreated - ? `scaffolded stack at ${rootDir}${created.length ? ` (created ${created.join(", ")})` : ""}` - : `stack root ready at ${rootDir} (existing .env kept)`, - }; - } else { - // Reuse path: scaffolding is skipped, so nothing has recorded this root in - // config. Persist it now so a later `propr start` / `propr status` without - // --root targets this stack rather than an old saved root or the cwd. - await actions.persistStackRoot(rootDir); - initSettlement = { status: "skipped", detail: `using existing stack at ${rootDir} (.env preserved)` }; - } - - // Eligibility comes from the configured datastore itself, not scaffold - // artifacts. This recovers migrated databases with no durable administrator - // and follows the runtime's DB_FILENAME/DATA_DIR resolution. Configured - // paths outside the launcher's data bind mount cannot be safely inspected - // from the host and remain ineligible (fail closed). - datastoreAdminInspection = await actions.inspectDatastoreAdministrators(rootDir); - bootstrapIdentityEligible = - datastoreAdminInspection.status === "absent" || datastoreAdminInspection.status === "no-admin"; - if (datastoreAdminInspection.status === "uninspectable") { - const inspectionDetail = datastoreAdminInspection.detail ?? "configured datastore is unavailable"; - log(`administrator inspection: ${inspectionDetail}`); - } - // Inspect before reporting initialization success so this step has exactly - // one terminal settlement even when inspection itself throws. An - // uninspectable datastore is evaluated after auth resolves because demo - // mode does not require an instance administrator. - settle("init-stack", initSettlement); - } catch (error) { - settle("init-stack", { - status: "failed", - detail: `could not initialize stack: ${(error as Error).message}`, - nextAction: "Check directory permissions and that .env.example is available, then re-run setup.", - }); - return finish(); - } - - // 3. Pull images — core images by default, plus the shared agent image when - // the user selects an agent (defaulting to those detected on this host). - begin("pull-images"); - const detected = detectInstalledAgents(catalog); - try { - const requested = prompts.selectAgents - ? await prompts.selectAgents({ available: catalog.map((a) => a.type), detected }) - : detected; - // Guard the engine boundary: a renderer may hand back unknown or duplicate - // agent names. Keep only types we know about, de-duped (first occurrence - // wins), so unknown names never reach pullImages() and a duplicate can't - // double-apply credentials in the configure-agents step below. - const known = new Set(catalog.map((a) => a.type)); - selectedAgents = [...new Set(requested)].filter((type) => known.has(type)); - - const pull = await actions.pullImages({ rootDir, agentTypes: selectedAgents, onLog: log }); - if (pull.failedCore.length > 0) { - settle("pull-images", { - status: "failed", - detail: `failed to pull core image(s): ${pull.failedCore.join(", ")}`, - nextAction: "Check registry access / network and re-run setup; the stack cannot start without core images.", - }); - return finish(); - } - const pulledCount = pull.pulledCore.length + pull.pulledAgents.length; - if (pull.failedAgents.length > 0) { - settle("pull-images", { - status: "warning", - detail: `pulled ${pulledCount} image(s); ${pull.failedAgents.length} agent image(s) unavailable`, - nextAction: "Jobs using those agents fail until their images pull. Re-run `propr images pull` later.", - }); - } else { - settle("pull-images", { status: "done", detail: `pulled ${pulledCount} image(s)` }); - } - } catch (error) { - settle("pull-images", { - status: "failed", - detail: `could not pull images: ${(error as Error).message}`, - nextAction: "Check Docker and registry access, then re-run setup.", - }); - return finish(); - } - - // 4. Configure agents — record detected host credential dirs for the selected - // agents, non-destructively (never blanks an existing value). - begin("configure-agents"); - try { - if (selectedAgents.length === 0) { - settle("configure-agents", { - status: "skipped", - detail: "no agents selected", - nextAction: "Log in with an agent CLI on this host, then re-run setup to record its credentials.", - }); - } else { - const vars: Record = {}; - const existingEnv = actions.readEnvVars(rootDir); - for (const type of selectedAgents) { - const desc = catalog.find((a) => a.type === type); - if (!desc) continue; - for (const cred of desc.credentials) { - // A selected agent may not have logged in yet. Prepare its host mount - // before the stack starts so Docker never creates a root-owned path, - // and record it now so the post-login image validation sees exactly - // the mount the worker will use. - const configuredDir = existingEnv[cred.envKey]; - const effectiveDir = configuredDir?.trim() ? configuredDir : cred.defaultDir; - assertSafeAgentCredentialDir(effectiveDir, cred.envKey); - actions.prepareAgentCredentialDir(effectiveDir); - vars[cred.envKey] = effectiveDir; - } - } - const applied = actions.applyEnvSelection(rootDir, vars, { overwrite: false }); - const detailParts: string[] = []; - detailParts.push(applied.written.length > 0 ? `recorded ${applied.written.length} credential dir(s)` : "no new credentials to record"); - if (applied.skipped.length > 0) detailParts.push(`${applied.skipped.length} already set`); - settle("configure-agents", { status: "done", detail: detailParts.join("; ") }); - } - } catch (error) { - settle("configure-agents", { - status: "failed", - detail: `could not record agent credentials: ${(error as Error).message}`, - nextAction: "Correct invalid HOST_* credential paths and check write permissions on .env, then re-run setup.", - }); - return finish(); - } - - // 5. GitHub authentication — keep what works; only write the keys the user - // explicitly chose. Missing Connect/App credentials are a hard stop because - // every non-demo backend process exits before the health probe can pass. - begin("github-auth"); - let resolvedAuth: GithubAuthModeResult; - // Set by the relay path: `relayNote` drives a failed settle (and skips - // partial writes); `relayDoneDetail` carries the success line. Both stay unset - // for the keep / custom-App / no-prompt paths, which fall back to the - // mode-derived settle below. - let relayNote: { detail: string; nextAction?: string } | undefined; - let relayDoneDetail: string | undefined; - try { - const currentAuth = actions.detectGithubAuthMode(rootDir); - let authDecision: GithubAuthDecision | undefined; - if (prompts.configureGithubAuth) authDecision = await prompts.configureGithubAuth({ current: currentAuth }); - if (authDecision?.enrollRelay) { - const outcome = await enrollRelayForSetup(authDecision.enrollRelay.relayUrl); - relayNote = outcome.note; - relayDoneDetail = outcome.detail; - } else if (authDecision?.vars && Object.keys(authDecision.vars).length > 0) { - actions.applyEnvSelection(rootDir, authDecision.vars, { overwrite: true }); - } - resolvedAuth = relayDoneDetail - ? { mode: "relay", warnings: [] } - : actions.detectGithubAuthMode(rootDir); - } catch (error) { - settle("github-auth", { - status: "failed", - detail: `could not configure GitHub auth: ${(error as Error).message}`, - nextAction: "Check .env access and your GitHub auth settings, then re-run setup.", - }); - return finish(); - } - if (relayNote) { - settle("github-auth", { status: "failed", detail: relayNote.detail, nextAction: relayNote.nextAction }); - return finish(); - } - if (resolvedAuth.mode === "none") { - settle("github-auth", { - status: "failed", - detail: "no GitHub auth configured", - nextAction: "Choose ProPR Connect (default), configure your own GitHub App, or enable demo mode, then re-run setup.", - }); - return finish(); - } - - // Every non-demo start needs either an environment administrator or a - // durable one. Relay enrollment above already seeds its authenticated - // identity when the datastore is conclusively empty. On a keep rerun, the - // same identity can be recovered safely only when the stored GitHub session - // can access the installation already configured for this stack. - const demoModeEnabled = isTruthyEnvFlag(actions.readEnvVars(rootDir).PROPR_DEMO_MODE); - let keptRelayBootstrapIdentity: string | undefined; - const configuredAdministrators = (): string[] => - (actions.readEnvVars(rootDir).PROPR_ADMIN_USERS ?? "") - .split(",") - .map((value) => value.trim()) - .filter(Boolean); - const durableAdministratorExists = datastoreAdminInspection?.status === "has-admin"; - if ( - !demoModeEnabled && - !durableAdministratorExists && - !bootstrapAdministratorSeeded && - configuredAdministrators().length === 0 && - bootstrapIdentityEligible && - resolvedAuth.mode === "relay" && - actions.hasGithubToken() - ) { - const env = actions.readEnvVars(rootDir); - const installationId = env.GH_INSTALLATION_ID?.trim(); - if (installationId) { - try { - const identity = await actions.fetchRelayInstallations({ - relayUrl: env.PROPR_GH_RELAY_URL?.trim() || undefined, - }); - const username = identity.username.trim(); - const ownsConfiguredInstallation = identity.installations.some( - (installation) => String(installation.installation_id) === installationId - ); - if (username && ownsConfiguredInstallation) { - const existingWhitelist = (env.GITHUB_USER_WHITELIST ?? "") - .split(",") - .map((value) => value.trim()) - .filter(Boolean); - const whitelistHasIdentity = existingWhitelist.some( - (value) => value.toLowerCase() === username.toLowerCase() - ); - actions.applyEnvSelection( - rootDir, - { - PROPR_ADMIN_USERS: username, - ...(!whitelistHasIdentity - ? { GITHUB_USER_WHITELIST: [...existingWhitelist, username].join(",") } - : {}), - }, - { overwrite: true } - ); - bootstrapAdministratorSeeded = true; - keptRelayBootstrapIdentity = username; - } - } catch (error) { - log(`administrator bootstrap: could not verify the configured relay identity: ${(error as Error).message}`); - } - } - } - - if ( - !demoModeEnabled && - !durableAdministratorExists && - !bootstrapAdministratorSeeded && - configuredAdministrators().length === 0 - ) { - const inspectionDetail = datastoreAdminInspection?.status === "uninspectable" - ? ` (${datastoreAdminInspection.detail ?? "the configured datastore could not be inspected"})` - : ""; - settle("github-auth", { - status: "failed", - detail: `no instance administrator is configured${inspectionDetail}`, - nextAction: - "Set PROPR_ADMIN_USERS to at least one GitHub username, repair the configured datastore, or re-run setup and enroll ProPR Connect with an authenticated GitHub account.", - }); - return finish(); - } - - // The GitHub App authenticates the backend to GitHub, but it does not - // authenticate this CLI user to the backend. Everything setup does after the - // stack starts (/api/status, agent configuration, settings, and repositories) - // is protected by bearer auth, so obtain the same user token as `propr login` - // before making any of those calls. Connect enrollment already guarantees a - // token; this covers custom-App and GitHub-only demo configurations alike. - if (!demoModeEnabled && !actions.hasGithubToken()) { - const reason = "Finishing setup requires a GitHub user token for protected backend API steps."; - if (prompts.confirmGithubLogin && (await prompts.confirmGithubLogin({ reason }))) { - await actions.loginWithGithub({ onLog: log }); - } - if (!actions.hasGithubToken()) { - settle("github-auth", { - status: "failed", - detail: `auth mode: ${resolvedAuth.mode}; GitHub user login is required to finish setup`, - nextAction: "Run `propr login`, then re-run `propr setup`; the existing stack configuration will be reused.", - }); - return finish(); - } - } - - if (relayDoneDetail) { - settle("github-auth", { status: "done", detail: relayDoneDetail }); - } else if (resolvedAuth.warnings.length > 0) { - // The mode resolves, but the shared detector flagged a partial/ambiguous - // configuration — surface it so the user can fix it before it bites later. - settle("github-auth", { - status: "warning", - detail: `auth mode: ${resolvedAuth.mode} — ${resolvedAuth.warnings.join("; ")}`, - }); - } else { - settle("github-auth", { - status: "done", - detail: keptRelayBootstrapIdentity - ? `auth mode: ${resolvedAuth.mode}; bootstrap administrator: ${keptRelayBootstrapIdentity}` - : `auth mode: ${resolvedAuth.mode}`, - }); - } - - // 5b. GitHub event intake — how the backend learns about GitHub events - // (routing WebSocket, polling, or direct webhooks). Written before startup - // because the API/daemon resolve GITHUB_EVENT_INTAKE_MODE at boot. Demo - // mode has no GitHub access, so there is nothing to ingest. - begin("intake"); - try { - if (resolvedAuth.mode === "demo") { - settle("intake", { status: "skipped", detail: "demo mode — no GitHub events to ingest" }); - } else { - const envNow = actions.readEnvVars(rootDir); - // Resolve the mode the backend would pick from today's `.env` (unset - // defaults to routing_websocket, the hosted relay path) so the prompt and - // any "kept current" message reflect what actually runs. - const { mode: currentMode } = resolveGithubEventIntakeMode({ - eventIntakeMode: envNow.GITHUB_EVENT_INTAKE_MODE, - enableGithubWebhooks: envNow.ENABLE_GITHUB_WEBHOOKS, - }); - // When `.env` already records an intake decision, default the prompt to - // "keep" so a blank Enter on a re-run can't silently flip a working config - // (e.g. disable existing direct webhooks). This also covers older `.env` - // files that only carry the legacy `ENABLE_GITHUB_WEBHOOKS` boolean: it - // still resolves to a real `currentMode`, so a blank Enter must keep that - // rather than rewrite it to the auth-derived recommendation. Only a truly - // fresh install (neither key set) falls back to the recommendation. - const intakeConfigured = - envNow.GITHUB_EVENT_INTAKE_MODE !== undefined || envNow.ENABLE_GITHUB_WEBHOOKS !== undefined; - const defaultMode = defaultIntakeChoice(resolvedAuth.mode, { intakeConfigured }); - let decision: GithubIntakeDecision | undefined; - if (prompts.configureIntake) { - decision = await prompts.configureIntake({ authMode: resolvedAuth.mode, defaultMode, currentMode }); - } - // The mode that will be in effect after this step — the explicit pick, or - // the current `.env` value when the user keeps it. `effectiveEnv` mirrors - // what `.env` holds *after* any write so the prerequisite check below sees - // the freshly written secret/mode, not the pre-write snapshot. - let effectiveMode = currentMode; - let effectiveEnv = envNow; - let detail: string; - if (decision && !decision.keep && decision.mode) { - // buildIntakeEnvVars rejects an empty webhook secret — caught below and - // surfaced as a warning rather than writing a config the API won't boot. - const vars = buildIntakeEnvVars(decision.mode, { webhookSecret: decision.webhookSecret }); - actions.applyEnvSelection(rootDir, vars, { overwrite: true }); - effectiveMode = decision.mode; - effectiveEnv = { ...envNow, ...vars }; - detail = `intake: ${intakeModeLabel(decision.mode)}`; - } else { - detail = `intake: kept current (${intakeModeLabel(currentMode)})`; - } - // Validate the resolved mode against the shared prerequisite rules so a - // silently-broken intake config (most commonly routing_websocket without - // relay auth + a relay token) surfaces here instead of as a backend boot - // failure after `propr start`. - const prereq = validateIntakeModePrerequisites({ - intakeMode: effectiveMode, - authMode: resolvedAuth.mode, - routingUrl: effectiveEnv.PROPR_ROUTING_URL, - relayUrl: effectiveEnv.PROPR_GH_RELAY_URL, - relayToken: effectiveEnv.PROPR_GH_RELAY_TOKEN, - webhookSecret: effectiveEnv.GH_WEBHOOK_SECRET, - }); - if (prereq.valid) { - settle("intake", { status: "done", detail }); - } else { - settle("intake", { - status: "failed", - detail: `${detail} — ${prereq.errors.join("; ")}`, - nextAction: - effectiveMode === "routing_websocket" - ? "Enroll with the hosted relay (`propr relay enroll`) so routing_websocket has relay auth + a relay token, or choose polling." - : "Resolve the missing intake prerequisites in .env, then re-run setup.", - }); - return finish(); - } - } - } catch (error) { - // An IntakeConfigError (e.g. direct webhooks chosen with no secret) is - // non-blocking: leave intake as-is and tell the user how to finish it. - settle("intake", { - status: "warning", - detail: `could not configure GitHub intake: ${(error as Error).message}`, - nextAction: - "Set GITHUB_EVENT_INTAKE_MODE (and GH_WEBHOOK_SECRET for direct_webhook) in .env, then re-run setup.", - }); - } - - // 6. Start the stack and validate backend health. A running stack is reused, - // not recreated, so user data and live work are untouched. - begin("start-stack"); - try { - const alreadyRunning = await actions.isStackRunning(rootDir); - const startConfirmed = prompts.confirmStartStack ? await prompts.confirmStartStack({ rootDir, alreadyRunning }) : true; - if (!startConfirmed) { - settle("start-stack", { - status: "skipped", - detail: "stack not started — setup is incomplete until the backend is running", - nextAction: "Start it later with `propr start`, or re-run `propr setup` and confirm startup.", - }); - } else { - if (alreadyRunning) { - log("stack already running — leaving it intact"); - } else { - await actions.startStack({ rootDir, onLog: log }); - } - const health = await actions.checkBackendHealth({ rootDir }); - if (health.healthy) { - backendReady = true; - settle("start-stack", { - status: "done", - detail: alreadyRunning ? `stack already running — ${health.detail}` : health.detail, - }); - } else { - settle("start-stack", { - status: "failed", - detail: health.detail, - // The backend answered, so access failures need account-oriented - // remediation rather than service-health troubleshooting. A 401 calls - // for login; a 403 calls for permission/configuration checks. - nextAction: health.accessFailure === "unauthorized" - ? "Run `propr login` to obtain a GitHub user token, then re-run `propr setup`; the running stack will be reused." - : health.accessFailure === "forbidden" - ? "Check the authenticated account, the stack's bootstrap-admin configuration, and its access permissions, then re-run `propr setup`; the running stack will be reused." - : "Run `propr status` / `propr remote-status` and inspect the API logs, then re-run setup.", - }); - } - } - } catch (error) { - settle("start-stack", { - status: "failed", - detail: `could not start the stack: ${(error as Error).message}`, - nextAction: "Run `propr start` to see the full startup output.", - }); - return finish(); - } - - // 7. Enable agents in the running backend — add the selected agents that are - // missing (existing ones are never disabled or deleted) and, on - // confirmation, authenticate the ones that support an image login. This - // runs after startup because it talks to the live backend API. Any problem - // is a non-blocking warning: agents can always be configured later. - begin("enable-agents"); - // This step talks to the live backend API, so it only makes sense once the - // stack is up. When the backend is unavailable, skip rather than fire - // doomed API calls that would surface as confusing warnings. - if (!backendReady) { - settle("enable-agents", { - status: "skipped", - detail: "backend is not healthy — agents are enabled through the running backend", - nextAction: "Start the stack (`propr start`), then re-run `propr setup` to enable and authenticate the selected agents.", - }); - } else { - try { - const outcome = await runAgentSetup({ - rootDir, - selectedAgents, - actions, - confirmLogin: prompts.confirmAgentLogin, - onLog: log, - }); - if (selectedAgents.length === 0) { - settle("enable-agents", { - status: "skipped", - detail: "no agents selected", - nextAction: "Enable agents later in the UI or with `propr agent add`.", - }); - } else { - const parts: string[] = []; - if (outcome.added.length > 0) parts.push(`enabled ${outcome.added.join(", ")}`); - if (outcome.alreadyConfigured.length > 0) parts.push(`${outcome.alreadyConfigured.length} already configured`); - if (outcome.authenticated.length > 0) parts.push(`authenticated ${outcome.authenticated.join(", ")}`); - if (outcome.authFailed.length > 0) parts.push(`${outcome.authFailed.length} login(s) did not complete`); - if (outcome.validated.length > 0) parts.push(`connectivity verified: ${outcome.validated.join(", ")}`); - if (outcome.validationFailed.length > 0) parts.push(`${outcome.validationFailed.length} connectivity check(s) need attention`); - const detail = parts.length > 0 ? parts.join("; ") : "no changes needed"; - if (outcome.errors.length > 0 || outcome.authFailed.length > 0 || outcome.validationFailed.length > 0) { - settle("enable-agents", { - status: "warning", - detail: outcome.errors.length > 0 ? `${detail}; ${outcome.errors.join("; ")}` : detail, - nextAction: outcome.nextCommands.length > 0 - ? `Run: ${outcome.nextCommands.map((command) => `\`${command}\``).join("; then ")}` - : "Enable or authenticate agents later in the UI or with `propr agent add` / `propr agent login`.", - }); - } else { - settle("enable-agents", { status: "done", detail }); - } - } - } catch (error) { - // runAgentSetup is built not to throw for expected conditions; anything that - // escapes is treated as a non-blocking warning so it can't abort setup. - settle("enable-agents", { - status: "warning", - detail: `could not configure agents: ${(error as Error).message}`, - nextAction: "Enable or authenticate agents later in the UI or with `propr agent add` / `propr agent login`.", - }); - } - } - - // 8. Whitelist — restrict who can trigger ProPR. Written non-destructively. - begin("whitelist"); - try { - const envNow = actions.readEnvVars(rootDir); - const currentWhitelist = (envNow.GITHUB_USER_WHITELIST ?? "").split(",").map((s) => s.trim()).filter(Boolean); - const demoMode = resolvedAuth.mode === "demo"; - let whitelist: string[] | null = null; - if (prompts.configureWhitelist) whitelist = await prompts.configureWhitelist({ current: currentWhitelist, demoMode }); - if (whitelist !== null) { - // Trim, drop blanks, and de-dupe (first occurrence wins) so the value - // matches saveWhitelist's "cleaned, de-duped usernames" contract — a - // duplicate entry would otherwise inflate the saved count and settings. - const cleaned = [...new Set(whitelist.map((s) => s.trim()).filter(Boolean))]; - // Prefer the settings API when the backend is up so the change applies - // immediately (and never overwrites unrelated settings); always mirror into - // .env so it survives a restart. Falls back to .env if the API is down. - const backendRunning = backendReady && await actions.isStackRunning(rootDir); - const saved = await saveWhitelist({ - users: cleaned, - backendRunning, - saveViaSettings: (users) => actions.saveWhitelistSetting(rootDir, users), - saveViaEnv: (users) => { - // A non-empty list is written; clearing to "none" must *remove* the key - // rather than blank it. applyEnvSelection ignores blank values (so it - // never clobbers a value), which means `GITHUB_USER_WHITELIST=""` would - // be skipped and the old list would survive on the next restart — so we - // delete the key outright instead. - if (users.length > 0) { - actions.applyEnvSelection(rootDir, { GITHUB_USER_WHITELIST: users.join(",") }, { overwrite: true }); - } else { - actions.clearEnvKeys(rootDir, ["GITHUB_USER_WHITELIST"]); - } - }, - }); - const where = saved.target === "settings" ? "via settings API" : "in .env"; - const summary = cleaned.length > 0 ? `${cleaned.length} user(s) allowed (${where})` : `whitelist cleared (${where})`; - if (saved.error) { - settle("whitelist", { - status: "warning", - detail: `${summary}; settings update failed: ${saved.error}`, - nextAction: "The whitelist is in .env; it will apply when the backend restarts.", - }); - } else { - settle("whitelist", { status: "done", detail: summary }); - } - } else if (currentWhitelist.length > 0) { - settle("whitelist", { status: "done", detail: `${currentWhitelist.length} user(s) already allowed` }); - } else if (demoMode) { - settle("whitelist", { status: "skipped", detail: "demo mode — whitelist not required" }); - } else { - settle("whitelist", { - status: "warning", - detail: "no whitelist configured — any authenticated GitHub user could trigger processing", - nextAction: "Set GITHUB_USER_WHITELIST in .env to a comma-separated list of allowed usernames.", - }); - } - } catch (error) { - settle("whitelist", { - status: "failed", - detail: `could not configure the whitelist: ${(error as Error).message}`, - nextAction: "Check .env access, then re-run setup.", - }); - return finish(); - } - - // 9. Repository (optional) — adding a repo must never fail the whole run. - begin("repo"); - // Adding a repo goes through the running backend's API, so skip it (without - // even prompting) when the backend is unavailable — there is nothing - // to add it to yet. - if (!backendReady) { - settle("repo", { - status: "skipped", - detail: "backend is not healthy — a repository is connected through the running backend", - nextAction: "Start the stack (`propr start`), then add one with `propr repo add `.", - }); - } else { - try { - // The prompt itself is part of this optional step — a renderer that throws - // while collecting the repo must degrade to a warning, not abort the run. - const repoSelection = prompts.addRepository ? await prompts.addRepository({ rootDir }) : null; - if (!repoSelection) { - settle("repo", { status: "skipped", detail: "no repository added" }); - } else { - try { - await actions.addRepository(repoSelection, rootDir); - settle("repo", { status: "done", detail: `monitoring ${repoSelection.fullName}` }); - } catch (error) { - settle("repo", { - status: "warning", - detail: `could not add ${repoSelection.fullName}: ${(error as Error).message}`, - nextAction: "Add it later with `propr repo add `.", - }); - } - } - } catch (error) { - settle("repo", { - status: "warning", - detail: `could not collect a repository to add: ${(error as Error).message}`, - nextAction: "Add it later with `propr repo add `.", - }); - } - } - - // 10. UI (optional) — surface the URL and, when the user confirms, actually - // open it in their default browser. - begin("launch-ui"); - if (!backendReady) { - settle("launch-ui", { - status: "skipped", - detail: "UI not opened — the backend is not healthy", - nextAction: "Resolve the startup failure, then re-run `propr setup`.", - }); - return finish(); - } - let uiUrl = ""; - try { - uiUrl = await actions.resolveUiUrl(rootDir); - } catch { - /* non-fatal: just omit the URL */ - } - let opened = false; - let openFailed = false; - try { - // The prompt only asks *whether* to open; the engine performs the open so - // both renderers behave identically and neither has to import a launcher. - const wantsOpen = uiUrl && prompts.launchUi ? await prompts.launchUi({ url: uiUrl }) : false; - if (wantsOpen) { - try { - await actions.openUrl(uiUrl); - opened = true; - } catch { - // Headless host, no launcher, etc. — fall back to just printing the URL. - openFailed = true; - } - } - } catch { - /* opening the UI is best-effort; a failed launch prompt must not fail setup */ - } - settle("launch-ui", { - status: opened ? "done" : "skipped", - detail: uiUrl - ? openFailed - ? `UI available at ${uiUrl} (could not open a browser automatically)` - : opened - ? `opened ${uiUrl}` - : `UI available at ${uiUrl}` - : "UI URL unavailable", - }); - - return finish(); } -/** - * Detect an environment problem that blocks the entire flow: Docker missing or - * its daemon unreachable. Other failures (e.g. GitHub auth) are addressed by - * later steps and must not abort setup here. - * - * Keyed off the structured `Docker` check group rather than exact check names, - * so re-wording a check in checkCommands.ts can't silently let setup continue - * past a missing/unreachable engine. Within that group only the engine checks - * ("Docker installed", "Docker daemon") ever report `fail`; the socket check is - * informational and tops out at `warn`, so a `fail` here always means Docker - * itself cannot run the stack. - */ -function blockingDockerFailure(outcome: ChecksOutcome): string | undefined { - return outcome.results.find((r) => r.group === "Docker" && r.status === "fail")?.detail; +export function retrySetup(previous: SetupRunResult, options: Omit = {}): Promise { + const { configManager, actions: overrides, ...portable } = options; + const actions = { ...createDefaultActions(configManager), ...overrides } as SetupActions; + return retryLocalSetup(previous, { ...portable, actions }); } diff --git a/packages/cli/src/commands/setup/github.ts b/packages/cli/src/commands/setup/github.ts index 3c93c457e..8c377b107 100644 --- a/packages/cli/src/commands/setup/github.ts +++ b/packages/cli/src/commands/setup/github.ts @@ -1,269 +1 @@ -/** - * GitHub event-intake + user-whitelist helpers for `propr setup`. - * - * Two concerns the setup wizard must guide a new user through, factored out of - * the engine so the decision logic lives in one tested place and both renderers - * (Ink + readline) share it: - * - * - **Intake mode** — how the backend learns about GitHub events, selected by - * the `GITHUB_EVENT_INTAKE_MODE` `.env` key (the legacy `ENABLE_GITHUB_WEBHOOKS` - * boolean is deprecated and no longer selects the mode). Three paths: - * routing_websocket — events stream over the hosted ProPR routing - * WebSocket; no inbound webhook listener and no own - * GitHub App required. The default, and only usable - * with relay auth (PROPR_GH_RELAY_TOKEN). - * polling — the daemon polls the GitHub API on an interval; works - * with any usable GitHub auth and needs no inbound URL. - * direct_webhook — GitHub posts directly to the local API; requires an - * own GitHub App plus a signing secret so forged - * payloads are rejected. - * {@link buildIntakeEnvVars} turns a chosen mode into the exact `.env` keys - * (`GITHUB_EVENT_INTAKE_MODE`, and `GH_WEBHOOK_SECRET` for direct webhooks), - * refusing to produce a direct_webhook config without a secret — the API - * would otherwise refuse to boot. - * - * - **User whitelist** — which GitHub users may trigger ProPR. Saved through - * the settings API when the backend is running (a partial update that never - * clobbers unrelated settings), and mirrored into `.env` so the value - * survives a restart. {@link saveWhitelist} owns that routing and degrades to - * an `.env`-only write when the backend is down or the API call fails. - * - * Like the rest of the setup module these helpers are UI-agnostic and free of - * Docker/network imports: side effects are passed in as callbacks so the engine - * binds them to the real API/`.env` and tests drive the whole thing in memory. - */ - -import type { GithubAuthMode, GithubEventIntakeMode } from "@propr/shared"; - -/** - * How the backend ingests GitHub events. Aliased to the shared - * {@link GithubEventIntakeMode} so the wizard and the backend boot path can't - * drift on the values the `GITHUB_EVENT_INTAKE_MODE` `.env` key accepts: - * routing_websocket — events stream over the ProPR routing WebSocket (default) - * polling — the daemon polls the GitHub API; no inbound exposure - * direct_webhook — GitHub posts to a local /webhook endpoint (needs a secret) - */ -export type GithubIntakeMode = GithubEventIntakeMode; - -/** Documentation surfaced in the intake prompt's detail text. */ -export const INTAKE_DOCS_URL = "https://docs.propr.dev/docs/architecture/daemon"; -/** Documentation for configuring direct webhook delivery. */ -export const WEBHOOK_DOCS_URL = "https://docs.propr.dev/docs/tutorials/setup-server"; - -/** - * Outcome of the intake prompt the renderer hands back to the engine. Mirrors - * {@link GithubAuthDecision}: a `keep` leaves the current `.env` untouched, - * otherwise the chosen `mode` (plus a secret for webhooks) is applied. - */ -export interface GithubIntakeDecision { - /** Keep the existing intake configuration untouched. */ - keep?: boolean; - /** The intake mode the user picked. */ - mode?: GithubIntakeMode; - /** Signing secret, required (and only used) when `mode === "direct_webhook"`. */ - webhookSecret?: string; -} - -/** Thrown when an intake selection is missing required input (e.g. a webhook secret). */ -export class IntakeConfigError extends Error { - constructor(message: string) { - super(message); - this.name = "IntakeConfigError"; - } -} - -/** - * The intake mode to pre-select for a given GitHub auth mode. The hosted routing - * WebSocket is the product default, but it only works with relay auth (it needs - * a relay token and the shared ProPR App), so it's recommended only when relay - * auth is configured. Every other auth mode falls back to polling, which works - * with any usable GitHub auth and needs no inbound network exposure — and unlike - * direct webhooks requires no public URL or own GitHub App. - */ -export function defaultIntakeMode(authMode: GithubAuthMode): GithubIntakeMode { - return authMode === "relay" ? "routing_websocket" : "polling"; -} - -/** - * The intake choice the prompt should pre-select. - * - * On a re-run where `.env` already carries an intake decision - * (`GITHUB_EVENT_INTAKE_MODE` is set), the safe default is `"keep"`: a blank Enter - * must never silently rewrite a working config — e.g. an existing - * `direct_webhook` install must not flip to `routing_websocket` just because the - * auth-derived recommendation differs. This upholds the setup engine's re-run - * safety model (keep existing config unless the user explicitly changes it). Only - * on a fresh install, with no intake config yet, do we fall back to the - * auth-derived recommendation from {@link defaultIntakeMode}. - */ -export function defaultIntakeChoice( - authMode: GithubAuthMode, - opts: { intakeConfigured: boolean } -): GithubIntakeMode | "keep" { - return opts.intakeConfigured ? "keep" : defaultIntakeMode(authMode); -} - -/** - * Translate a chosen {@link GithubIntakeMode} into the `.env` keys it implies. - * The mode is selected by `GITHUB_EVENT_INTAKE_MODE`, the value the backend boot - * path resolves (see resolveGithubEventIntakeMode); the deprecated - * `ENABLE_GITHUB_WEBHOOKS` boolean is intentionally never written here. - * - * - `routing_websocket` / `polling` set `GITHUB_EVENT_INTAKE_MODE` to the mode - * and nothing else — routing events arrive over the relay WebSocket and - * polling pulls them from the API, neither needing a local webhook listener. - * A previously recorded `GH_WEBHOOK_SECRET` is intentionally *not* cleared: - * `applyEnvSelection`/`upsertEnvVars` only set keys, never remove them. The - * leftover secret is inert while not in direct_webhook mode (the API never - * reads it), but callers wanting a pristine `.env` must remove it by hand. - * - `direct_webhook` records the signing secret alongside the mode. An - * empty/whitespace secret is rejected with {@link IntakeConfigError}: the API - * refuses to boot in direct_webhook mode with no secret, so writing it would - * only break startup. - */ -export function buildIntakeEnvVars( - mode: GithubIntakeMode, - opts: { webhookSecret?: string } = {} -): Record { - switch (mode) { - case "routing_websocket": - case "polling": - return { GITHUB_EVENT_INTAKE_MODE: mode }; - case "direct_webhook": { - const secret = (opts.webhookSecret ?? "").trim(); - if (!secret) { - throw new IntakeConfigError( - "A webhook secret is required for direct webhooks — the API refuses to start without one." - ); - } - return { GITHUB_EVENT_INTAKE_MODE: "direct_webhook", GH_WEBHOOK_SECRET: secret }; - } - } -} - -/** A short, human-readable label for an intake mode, shared by both renderers. */ -export function intakeModeLabel(mode: GithubIntakeMode): string { - switch (mode) { - case "routing_websocket": - return "ProPR routing WebSocket (hosted relay)"; - case "polling": - return "polling (no inbound webhooks)"; - case "direct_webhook": - return "direct webhooks (signing secret recorded)"; - } -} - -/** - * One intake mode's availability under a given GitHub auth mode, for the intake - * prompt. Each renderer maps this onto a selectable (or inactive) option. - */ -export interface IntakeModeOption { - /** The intake mode this entry describes. */ - mode: GithubIntakeMode; - /** False when the chosen auth mode cannot support this intake path. */ - available: boolean; - /** - * A short note for the renderer to surface next to the option: when - * `available` is false this is *why* the path is closed; when true it is an - * optional caveat (e.g. polling's production-suitability warning). - */ - note?: string; -} - -/** - * The intake modes to show for a given GitHub auth mode, in display order, each - * flagged available or not. Unavailable modes are intentionally still returned - * so the prompt can show them inactive with the reason — a new user sees the - * full set and learns why a path is closed rather than wondering where it went. - * - * The availability rules mirror {@link validateIntakeModePrerequisites} so the - * prompt and the backend boot-time check can never disagree: - * - routing_websocket needs the ProPR token relay; a custom GitHub App can't use it. - * - direct_webhook needs your own GitHub App; the ProPR relay can't deliver to it. - * - polling works with either usable auth, but is not recommended for production. - */ -export function intakeModeOptions(authMode: GithubAuthMode): IntakeModeOption[] { - const relay = authMode === "relay"; - const app = authMode === "app"; - return [ - { - mode: "routing_websocket", - available: relay, - note: relay - ? undefined - : "needs the ProPR GitHub App (token relay); not available with a custom GitHub App", - }, - { - mode: "polling", - available: relay || app, - note: - relay || app - ? "not recommended for production: subject to GitHub API rate limits and delayed event detection (depends on the polling interval and the number of repos/PRs/issues)" - : "needs usable GitHub auth — configure the token relay or a custom GitHub App first", - }, - { - mode: "direct_webhook", - available: app, - note: app - ? undefined - : "needs your own custom GitHub App; not available with the ProPR token relay", - }, - ]; -} - -// --------------------------------------------------------------------------- -// Whitelist persistence. -// --------------------------------------------------------------------------- - -/** Where {@link saveWhitelist} persisted the whitelist. */ -export interface SaveWhitelistResult { - /** The store the value was written to as its source of truth. */ - target: "settings" | "env"; - /** Number of users in the saved whitelist (0 means cleared). */ - count: number; - /** - * Set when a settings-API save was attempted but failed, after which the - * helper fell back to `.env`. Surfaced as a warning by the caller. - */ - error?: string; -} - -/** Inputs for {@link saveWhitelist}. Side effects are injected so it stays pure-ish and testable. */ -export interface SaveWhitelistParams { - /** The cleaned, de-duped usernames to persist (may be empty to clear). */ - users: string[]; - /** Whether the local backend is up — gates the settings-API path. */ - backendRunning: boolean; - /** Persist through the running backend's settings API (partial update). */ - saveViaSettings(users: string[]): Promise; - /** Persist into `.env` (non-destructive, single key). */ - saveViaEnv(users: string[]): void; -} - -/** - * Persist the user whitelist, preferring the settings API when the backend is - * running so the change takes effect immediately without a restart, and always - * mirroring into `.env` so it survives one. If the API call fails we fall back - * to the `.env` write and report the error rather than abort setup. - * - * The settings-API path issues a *partial* update (only the whitelist key), so - * unrelated settings are never overwritten. - */ -export async function saveWhitelist(params: SaveWhitelistParams): Promise { - const { users, backendRunning, saveViaSettings, saveViaEnv } = params; - if (backendRunning) { - try { - await saveViaSettings(users); - // Mirror into `.env` so the whitelist persists across `propr start`. - saveViaEnv(users); - return { target: "settings", count: users.length }; - } catch (error) { - // The backend rejected the update (or was unreachable after all) — keep - // the value in `.env` so it is not lost, and surface why. - saveViaEnv(users); - return { target: "env", count: users.length, error: (error as Error).message }; - } - } - saveViaEnv(users); - return { target: "env", count: users.length }; -} +export * from "@propr/local-setup"; diff --git a/packages/cli/src/commands/setup/hostActions.ts b/packages/cli/src/commands/setup/hostActions.ts new file mode 100644 index 000000000..af1aa4746 --- /dev/null +++ b/packages/cli/src/commands/setup/hostActions.ts @@ -0,0 +1,242 @@ +import { mkdirSync } from "node:fs"; +import { hostname } from "node:os"; +import { isAbsolute, normalize } from "node:path"; +import { DEFAULT_PROPR_GH_RELAY_URL } from "@propr/shared"; +import { + applyEnvSelection, + clearEnvKeys, + classifyBackendAccessError, + detectGithubAuthMode, + inspectDatastoreAdministrators, + inspectStackInit, + readEnvVars, + type PullImagesResult, + type SetupActions, +} from "@propr/local-setup"; +import type { ConfigManager } from "../../config/index.js"; +import type { RelayClientOptions } from "../../api/relay.js"; +import { localhostServiceUrl } from "../../utils/dockerPort.js"; +import { createDefaultAgentSetupActions } from "./agentHostActions.js"; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +function assertSafeAgentCredentialDir(path: string, name = "Agent credential path"): void { + if (!isAbsolute(path) || normalize(path) === "/" || path.includes(":") || /[\u0000-\u001f\u007f-\u009f]/.test(path)) { + throw new Error(`${name} must be an absolute, non-root Linux path without ':' or control characters`); + } +} + +export function createDefaultActions(configManager?: ConfigManager): SetupActions { + /** A client pointed at the local stack's API port (not the saved remote URL). */ + const localApiClient = async (rootDir: string): Promise => { + const { getHostConfig } = await import("../../orchestrator/index.js"); + const { cfg } = await getHostConfig({ configManager, root: rootDir }); + const { createApiClient, createApiClientWithConfig } = await import("../../api/client.js"); + const options = { baseUrl: localhostServiceUrl(cfg.apiPort) }; + // Keep the local client on setup's active profile and, importantly, the + // token that an in-progress setup login just stored. Creating an unrelated + // manager here can otherwise lose profile context and call protected local + // endpoints without the token setup has already obtained. + return configManager + ? createApiClientWithConfig(configManager, options) + : createApiClient(options); + }; + + return { + // Agent enablement + image-login actions, bound to the local stack. + ...createDefaultAgentSetupActions(configManager), + async runChecks(options) { + const { runChecks } = await import("../checkCommands.js"); + return runChecks(options); + }, + inspectStackInit, + inspectDatastoreAdministrators, + async scaffoldStack(options) { + const { scaffoldStack } = await import("../initStack.js"); + return scaffoldStack(options); + }, + async persistStackRoot(rootDir) { + // Mirror scaffoldStack's `configManager.setStackRoot` so the reuse path + // records the root too. Best-effort: without a config there is nowhere to + // persist it (tests run this way), so it is simply a no-op. + await configManager?.setStackRoot(rootDir); + }, + readEnvVars, + applyEnvSelection, + clearEnvKeys, + detectGithubAuthMode, + prepareAgentCredentialDir(path) { + assertSafeAgentCredentialDir(path); + mkdirSync(path, { recursive: true, mode: 0o700 }); + }, + async pullImages({ rootDir, agentTypes, onLog }) { + const { getHostConfig } = await import("../../orchestrator/index.js"); + const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); + const selected = new Set(agentTypes); + const result: PullImagesResult = { pulledCore: [], pulledAgents: [], failedCore: [], failedAgents: [] }; + + for (const [key, tag] of Object.entries(cfg.images)) { + if (key === "docs" && !cfg.docsEnabled) continue; + const isAgent = key === "agent"; + // Pull the shared agent image when the user selected any agent; core images + // (api/worker/daemon/redis/…) always pull. + if (isAgent && selected.size === 0) continue; + + onLog?.(`pulling ${tag}…`); + // Async exec keeps the event loop free so the wizard's Ink spinner keeps + // animating while the (often slow) pull runs, instead of freezing. + const pulled = await orch.dockerAsync(["pull", tag]); + if (pulled.status === 0) { + try { + orch.tagAgentLatest(key, tag); + } catch { + /* best-effort local retag; the pull itself succeeded */ + } + (isAgent ? result.pulledAgents : result.pulledCore).push(tag); + } else { + (isAgent ? result.failedAgents : result.failedCore).push(tag); + } + } + return result; + }, + async isStackRunning(rootDir) { + const { getHostConfig } = await import("../../orchestrator/index.js"); + const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); + return orch.isStackRunningAsync(cfg); + }, + async startStack({ rootDir, ui, docs, onLog }) { + const { getHostConfig } = await import("../../orchestrator/index.js"); + const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); + // Pre-create the host Vibe prompt-cache dir owned by this user so Docker + // does not auto-create it as root on first bind-mount — a root-owned dir + // would fail the writability check and block future `propr start` runs. + try { + const { ensureVibePromptCacheDir } = await import("../initStack.js"); + ensureVibePromptCacheDir(cfg.hostVibePromptCacheDir); + } catch { + /* best-effort: startup validation will surface an actionable error */ + } + const validation = orch.validateEnv(cfg); + for (const warning of validation.warnings) onLog?.(`warning: ${warning}`); + if (!validation.ok) { + throw new Error(`stack environment is not ready:\n - ${validation.errors.join("\n - ")}`); + } + // Use the async start path: `propr setup` drives this from behind a live + // Ink TUI, so the blocking synchronous startStack would freeze the spinner + // and swallow keystrokes for the seconds-to-minutes a cold start takes. + await orch.ensureNetworkAsync(cfg, onLog); + await orch.startStackAsync(cfg, { + ui: ui ?? configManager?.getUiEnabled() ?? true, + docs: docs ?? cfg.docsEnabled, + onLog, + }); + }, + async checkBackendHealth({ rootDir, timeoutMs = 60_000 }) { + const { getSystemStatus } = await import("../../api/system.js"); + const client = await localApiClient(rootDir); + const deadline = Date.now() + timeoutMs; + let lastError = "no response"; + // Containers take a few seconds to report healthy; poll until the deadline. + do { + try { + const status = await getSystemStatus(client); + if (String(status.api).toLowerCase() === "healthy") { + return { healthy: true, detail: `API healthy (daemon ${status.daemon}, worker ${status.worker})` }; + } + lastError = `API reports "${status.api}"`; + } catch (error) { + // A 401/403 is not an unhealthy backend — the API answered but denied + // this protected request. Return immediately so setup does not stall + // on a running backend, while preserving whether remediation requires + // authentication (401) or an authorization/configuration check (403). + const accessFailure = classifyBackendAccessError(error); + if (accessFailure) return accessFailure; + lastError = (error as Error).message; + } + if (Date.now() >= deadline) break; + await sleep(2_000); + } while (Date.now() < deadline); + return { healthy: false, detail: `backend not healthy within ${Math.round(timeoutMs / 1000)}s (${lastError})` }; + }, + async addRepository({ fullName, alias, baseBranch }, rootDir) { + const { addRepo } = await import("../../api/repos.js"); + // Point the client at this stack's API port rather than the saved remote. + const client = await localApiClient(rootDir); + await addRepo(fullName, { alias, baseBranch }, client); + }, + async resolveUiUrl(rootDir) { + const { getHostConfig } = await import("../../orchestrator/index.js"); + const { cfg } = await getHostConfig({ configManager, root: rootDir }); + return localhostServiceUrl(cfg.uiPort); + }, + async openUrl(url) { + // Open in the host's default browser with the platform launcher. Detached + // and unref'd so the wizard isn't held open by the child, with stdio + // ignored so the launcher can't scribble over the TUI. + const { spawn } = await import("node:child_process"); + const platform = process.platform; + const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; + const args = platform === "win32" ? ["/c", "start", "", url] : [url]; + await new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: "ignore", detached: true }); + child.once("error", reject); + // The launcher returns immediately; once it has spawned we're done. + child.once("spawn", () => { + child.unref(); + resolve(); + }); + }); + }, + async saveWhitelistSetting(rootDir, users) { + const { updateSetting } = await import("../../api/settings.js"); + // Point the client at this stack's API port rather than the saved remote. + const client = await localApiClient(rootDir); + await updateSetting("github_user_whitelist", users, client); + }, + hasGithubToken() { + return Boolean(configManager?.getGithubToken()); + }, + async fetchRelayInstallations({ relayUrl }) { + const { fetchAuthenticatedUser } = await import("../../api/relay.js"); + const me = await fetchAuthenticatedUser(relayClient(relayUrl)); + return { username: me.username, installations: me.installations }; + }, + async enrollRelay({ relayUrl, installationId, label }) { + const { enrollRelayToken } = await import("../../api/relay.js"); + const client = relayClient(relayUrl); + // Default the token label to the hostname, mirroring `propr relay enroll`. + const result = await enrollRelayToken(client, { installationId, label: label ?? hostname() }); + return { relayUrl: client.baseUrl, token: result.token }; + }, + async loginWithGithub({ onLog } = {}) { + if (!configManager) return false; + const { loginWithGithubCli } = await import("../../auth/githubLogin.js"); + const result = await loginWithGithubCli(configManager, { interactive: true, onLog }); + if (!result.ok) onLog?.(result.message); + return result.ok; + }, + getTunnelEnabled(rootDir) { + return configManager?.getTunnelEnabled(rootDir); + }, + }; + + /** + * Build a relay client bound to the stored GitHub token. The hosted relay is + * the default base URL; an explicit `relayUrl` (self-hosted) overrides it. + */ + function relayClient(relayUrl?: string): RelayClientOptions { + const githubToken = configManager?.getGithubToken(); + if (!githubToken) { + throw new Error("Not logged in to GitHub. Run `propr login` first."); + } + return { baseUrl: relayUrl ?? DEFAULT_PROPR_GH_RELAY_URL, githubToken }; + } +} + +/** + * Run the setup flow end to end, in a safe order, driven by the supplied + * prompts and reflected through the reporter. Returns the final step state and + * the environment-check outcome. Never throws for expected conditions (a failed + * required step stops the flow and is reported in the returned state); only + * truly unexpected programmer errors propagate. + */ diff --git a/packages/cli/src/commands/setup/state.ts b/packages/cli/src/commands/setup/state.ts index 5a4e821e7..8c377b107 100644 --- a/packages/cli/src/commands/setup/state.ts +++ b/packages/cli/src/commands/setup/state.ts @@ -1,421 +1 @@ -/** - * Setup wizard domain helpers. - * - * Pure, side-effect-light helpers that the `propr setup` driver and both - * renderers (Ink TUI and readline fallback) build on: - * - resolving the stack root (reusing the orchestrator's precedence rules), - * - inspecting whether the stack is already initialized, - * - reading and *safely* editing .env (non-destructive by default), - * - constructing and transitioning the {@link SetupState} step model. - * - * Nothing here loads the orchestrator's Docker core or renders UI, so the - * module can be imported and unit-tested without Docker, Ink, or readline. - * `resolveStackRoot` lives in ../../orchestrator/index.js but only reads config - * and env — it does not start Docker. - */ - -import { lstatSync, readFileSync, statSync } from "node:fs"; -import { isAbsolute, join, relative, resolve, sep } from "node:path"; -import { resolveGithubAuthMode, type GithubAuthModeResult } from "@propr/shared"; -import { resolveStackRoot } from "../../orchestrator/index.js"; -import type { ConfigManager } from "../../config/index.js"; -import { clearEnvKeys as clearEnvFileKeys, upsertEnvVars } from "../../utils/envFile.js"; -import { - SETUP_STEP_DEFINITIONS, - type SetupState, - type SetupStep, - type SetupStepId, - type SetupStepPatch, -} from "./types.js"; - -/** - * Sub-directories scaffoldStack creates under the stack root. Exported so the - * setup driver and tests can create/check the same scaffold shape without - * duplicating these names. - */ -export const STACK_SUBDIRS = ["data", "logs", "repos"] as const; - -/** True only when `path` exists and is a directory. Missing paths read false. */ -function isDirectory(path: string): boolean { - try { - return statSync(path).isDirectory(); - } catch { - return false; - } -} - -/** True only when `path` exists and is a regular file. Missing paths read false. */ -function isFile(path: string): boolean { - try { - return statSync(path).isFile(); - } catch { - return false; - } -} - -/** True when a value is missing or contains only whitespace. */ -function isBlank(value: string | undefined): boolean { - return value === undefined || value.trim() === ""; -} - -/** - * Resolve the stack root for setup, reusing the orchestrator's precedence: - * explicit flag → PROPR_ROOT env → saved config stackRoot → cwd. Does not load - * Docker. - */ -export function resolveSetupRoot( - configManager: ConfigManager | undefined, - flagRoot?: string -): string { - return resolveStackRoot(configManager, flagRoot); -} - -/** Absolute path to the .env file for a given stack root. */ -export function envPathFor(rootDir: string): string { - return join(rootDir, ".env"); -} - -/** Snapshot of which scaffolded pieces of a stack root already exist. */ -export interface StackInitState { - rootDir: string; - envExists: boolean; - /** Per-subdir existence (data/, logs/, repos/). */ - dirs: Record<(typeof STACK_SUBDIRS)[number], boolean>; - /** True when .env and all expected sub-directories are present. */ - initialized: boolean; -} - -/** - * Inspect whether the stack at `rootDir` looks initialized. Read-only — never - * creates anything — so callers can decide whether to skip or re-run - * scaffolding. A plain file standing in for an expected directory (or vice - * versa) counts as *not* initialized, matching what the runtime requires. - */ -export function inspectStackInit(rootDir: string): StackInitState { - const envExists = isFile(envPathFor(rootDir)); - const dirs = {} as StackInitState["dirs"]; - for (const sub of STACK_SUBDIRS) { - dirs[sub] = isDirectory(join(rootDir, sub)); - } - const initialized = envExists && STACK_SUBDIRS.every((sub) => dirs[sub]); - return { rootDir, envExists, dirs, initialized }; -} - -export type DatastoreAdminStatus = "absent" | "no-admin" | "has-admin" | "uninspectable"; - -/** Result of inspecting the configured SQLite datastore for a durable administrator. */ -export interface DatastoreAdminInspection { - status: DatastoreAdminStatus; - /** Host path inspected, when the configured path could be resolved. */ - databasePath?: string; - /** Actionable diagnostic when inspection could not be completed safely. */ - detail?: string; -} - -/** Runtime paths used by the app image started by the CLI launcher. */ -const APP_WORKDIR = "/usr/src/app"; -const CONTAINER_DATA_DIR = join(APP_WORKDIR, "data"); - -/** - * Resolve the API's SQLite filename to the corresponding host bind-mount path. - * This mirrors @propr/core's DB_FILENAME/DATA_DIR precedence and resolves - * relative values from the app image's working directory. Only files below - * /usr/src/app/data are inspectable from the host because that is the sole data - * bind mount supplied by the CLI launcher. - */ -function resolveDatastorePath( - rootDir: string, - configuredPath: string | undefined, - configuredDataDir: string | undefined -): string { - const dbFilename = configuredPath; - const runtimePath = dbFilename - ? resolve(APP_WORKDIR, dbFilename) - : resolve(APP_WORKDIR, join(configuredDataDir ?? CONTAINER_DATA_DIR, "propr.sqlite")); - const childPath = relative(CONTAINER_DATA_DIR, runtimePath); - const outsideDataDir = - childPath === ".." || childPath.startsWith(`..${sep}`) || isAbsolute(childPath); - if (outsideDataDir) { - throw new Error( - `runtime path ${runtimePath} is outside the mounted data directory ${CONTAINER_DATA_DIR}` - ); - } - return resolve(rootDir, "data", childPath); -} - -/** - * Reject symbolic links between the host bind-mount root and the configured - * datastore. A link that is valid in the host namespace may resolve to a - * different target inside the container, so following it cannot establish - * bootstrap eligibility for the datastore the API will actually use. - */ -function assertDatastorePathHasNoSymlinks(rootDir: string, databasePath: string): void { - const dataRoot = resolve(rootDir, "data"); - const childPath = relative(dataRoot, databasePath); - let currentPath = dataRoot; - - for (const component of childPath.split(sep).filter(Boolean)) { - currentPath = join(currentPath, component); - try { - if (lstatSync(currentPath).isSymbolicLink()) { - throw new Error(`configured datastore path contains a symbolic link: ${currentPath}`); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return; - throw error; - } - } -} - -/** - * Inspect the configured SQLite datastore without creating or migrating it. - * Missing databases and databases conclusively lacking a durable administrator - * are bootstrap-eligible. Every resolution, I/O, schema, and query failure is - * reported as uninspectable so callers can fail closed. - */ -export async function inspectDatastoreAdministrators(rootDir: string): Promise { - let databasePath: string; - try { - const env = readEnvVars(rootDir); - databasePath = resolveDatastorePath(rootDir, env.DB_FILENAME, env.DATA_DIR); - } catch (error) { - return { - status: "uninspectable", - detail: `could not resolve configured datastore: ${(error as Error).message}`, - }; - } - - try { - assertDatastorePathHasNoSymlinks(rootDir, databasePath); - const stat = statSync(databasePath); - if (!stat.isFile()) { - return { status: "uninspectable", databasePath, detail: "configured datastore is not a regular file" }; - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return { status: "absent", databasePath }; - } - return { - status: "uninspectable", - databasePath, - detail: `could not inspect configured datastore: ${(error as Error).message}`, - }; - } - - let database: import("node:sqlite").DatabaseSync | undefined; - try { - const { DatabaseSync } = await import("node:sqlite"); - database = new DatabaseSync(databasePath, { readOnly: true, timeout: 5_000 }); - const membersTable = database.prepare( - "SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = 'instance_members' LIMIT 1" - ).get(); - if (!membersTable) return { status: "no-admin", databasePath }; - - const durableAdmin = database.prepare( - "SELECT 1 AS found FROM instance_members WHERE role = 'admin' LIMIT 1" - ).get(); - return { status: durableAdmin ? "has-admin" : "no-admin", databasePath }; - } catch (error) { - return { - status: "uninspectable", - databasePath, - detail: `could not query configured datastore: ${(error as Error).message}`, - }; - } finally { - try { - database?.close(); - } catch { - // The read query already produced a conclusive result; closing the - // read-only handle cannot widen authorization and needs no retry here. - } - } -} - -/** Convenience predicate over {@link inspectStackInit}. */ -export function isStackInitialized(rootDir: string): boolean { - return inspectStackInit(rootDir).initialized; -} - -/** - * Parse the .env at `rootDir` into a flat map. Returns `{}` when the file is - * absent. Mirrors the assignment shape the rest of the stack relies on: - * `KEY=value`, optionally `export `-prefixed, ignoring blanks and comments. - * For unquoted values a trailing ` # comment` is stripped, matching the - * orchestrator's env-file reader (and the round-trip that {@link upsertEnvVars} - * guards against); surrounding quotes on quoted values are stripped and their - * contents kept verbatim. This is intentionally a lightweight reader, not a - * full dotenv implementation — it does not handle escaped quotes or multiline - * values. - */ -export function readEnvVars(rootDir: string): Record { - const envPath = envPathFor(rootDir); - // Treat anything that is not a regular file (absent, a directory, a broken - // symlink) as "no vars", matching inspectStackInit's `isFile` guard, so a - // malformed stack surfaces as not-initialized instead of crashing the read. - if (!isFile(envPath)) return {}; - const vars: Record = {}; - for (const line of readFileSync(envPath, "utf-8").split(/\r?\n/)) { - const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/); - if (!match) continue; - const [, key, rawValue] = match; - const trimmed = rawValue.trim(); - const quoted = trimmed.match(/^(["'])(.*)\1$/); - // Quoted values keep their contents verbatim; unquoted values drop a - // trailing inline comment so reads agree with what upsertEnvVars allows. - vars[key] = quoted ? quoted[2] : trimmed.replace(/\s+#.*$/, ""); - } - return vars; -} - -/** True when `key` is present in .env with a non-blank value. */ -export function hasEnvValue(rootDir: string, key: string): boolean { - return !isBlank(readEnvVars(rootDir)[key]); -} - -/** Outcome of a {@link applyEnvSelection} call. */ -export interface EnvSelectionResult { - /** Keys actually written to .env this call. */ - written: string[]; - /** Keys left untouched because a value already existed (non-overwrite mode). */ - skipped: string[]; -} - -/** - * Safely edit .env for a setup step. - * - * Non-destructive by default: a key is only written when it is currently - * absent/empty, so re-running `propr setup` never clobbers values the user - * already set. Pass `{ overwrite: true }` for steps where the user explicitly - * selected a new value and intends to replace whatever is there. - * - * Blank selections (empty or whitespace-only) are ignored entirely — a step - * that has nothing to write must not blank out an existing value. Writes go - * through - * {@link upsertEnvVars}, which preserves unrelated lines and tightens the - * file's permissions. - */ -export function applyEnvSelection( - rootDir: string, - vars: Record, - opts: { overwrite?: boolean } = {} -): EnvSelectionResult { - const existing = readEnvVars(rootDir); - const toWrite: Record = {}; - const written: string[] = []; - const skipped: string[] = []; - - for (const [key, value] of Object.entries(vars)) { - if (isBlank(value)) continue; // never blank out an existing value - const alreadySet = !isBlank(existing[key]); - if (alreadySet && !opts.overwrite) { - skipped.push(key); - continue; - } - toWrite[key] = value; - written.push(key); - } - - if (written.length > 0) { - upsertEnvVars(envPathFor(rootDir), toWrite); - } - return { written, skipped }; -} - -/** - * Remove `keys` from the stack's `.env` entirely. - * - * {@link applyEnvSelection} can only set keys (and deliberately ignores blank - * values so it never clobbers a value the user set), so it cannot *clear* a key: - * writing `KEY=` would leave an empty assignment that reads back as a set-but- - * empty value. Setup steps that must genuinely drop a stale key — clearing the - * user whitelist back to "none", removing a key when switching modes — call this - * instead. A missing `.env` or absent keys are no-ops. - */ -export function clearEnvKeys(rootDir: string, keys: string[]): void { - clearEnvFileKeys(envPathFor(rootDir), keys); -} - -/** - * Infer the current GitHub auth mode from the stack's .env, so the github-auth - * step can show what is already configured (and skip prompting when valid). - * Reuses the shared resolver the backend uses, so the two can't drift. - */ -export function detectGithubAuthMode(rootDir: string): GithubAuthModeResult { - const env = readEnvVars(rootDir); - const truthy = /^(1|true|yes|on)$/i; - return resolveGithubAuthMode({ - demoMode: truthy.test(env.PROPR_DEMO_MODE ?? ""), - ghAuthMode: env.GH_AUTH_MODE, - relayUrl: env.PROPR_GH_RELAY_URL, - relayToken: env.PROPR_GH_RELAY_TOKEN, - appId: env.GH_APP_ID, - // The CLI stack records the App key as HOST_GH_PRIVATE_KEY (the orchestrator - // bind-mounts it and sets the in-container GH_PRIVATE_KEY_PATH to that path), - // so accept either when inferring app mode — otherwise a stack configured by - // `propr setup` would resolve as "none" despite being fully set up. - privateKeyPath: env.GH_PRIVATE_KEY_PATH ?? env.HOST_GH_PRIVATE_KEY, - installationId: env.GH_INSTALLATION_ID, - }); -} - -/** Build the initial, all-`pending` setup state for a resolved stack root. */ -export function createSetupState(rootDir: string): SetupState { - return { - rootDir, - steps: SETUP_STEP_DEFINITIONS.map((def) => ({ ...def, status: "pending" })), - }; -} - -/** Look up a step by id. */ -export function getStep(state: SetupState, id: SetupStepId): SetupStep | undefined { - return state.steps.find((step) => step.id === id); -} - -/** - * Return a new state with `id`'s step patched. Immutable so renderers can diff - * by reference; unknown ids return the state unchanged. - */ -export function updateStep( - state: SetupState, - id: SetupStepId, - patch: SetupStepPatch -): SetupState { - let changed = false; - const steps = state.steps.map((step) => { - if (step.id !== id) return step; - changed = true; - return { ...step, ...patch }; - }); - return changed ? { ...state, steps } : state; -} - -/** - * The next step the wizard should act on: the first one still `pending`. Used - * by the sequential renderer to drive the flow and by the TUI to highlight the - * current step. - * - * A failed required step blocks everything after it (see the `failed` status in - * ./types.ts), so once one is encountered there is no next step until it is - * retried — `undefined` is returned. Failed *optional* steps don't block. - */ -export function nextPendingStep(state: SetupState): SetupStep | undefined { - // Scan for a blocking failure first so the "a failed required step blocks - // everything after it" contract holds even if state was patched out of - // order (e.g. a later step failed before an earlier one finished). - if (state.steps.some((step) => !step.optional && step.status === "failed")) { - return undefined; - } - return state.steps.find((step) => step.status === "pending"); -} - -/** - * True once every required step has reached a terminal, non-failed state. - * Optional steps never block completion; a single failed required step does. - */ -export function isSetupComplete(state: SetupState): boolean { - return state.steps.every((step) => { - if (step.status === "failed") return false; - if (step.optional) return true; - return step.status === "done" || step.status === "skipped" || step.status === "warning"; - }); -} +export * from "@propr/local-setup"; diff --git a/packages/cli/src/commands/setup/types.ts b/packages/cli/src/commands/setup/types.ts index 436b84262..8c377b107 100644 --- a/packages/cli/src/commands/setup/types.ts +++ b/packages/cli/src/commands/setup/types.ts @@ -1,154 +1 @@ -/** - * Setup wizard domain types. - * - * `propr setup` walks a new user through getting a local control-plane stack - * running end to end. The flow coordinates several existing commands - * (environment checks, stack scaffolding, image pulls, agent + GitHub - * configuration, stack startup, whitelist + repo setup, and UI launch). - * - * These types are intentionally free of any rendering concern so the same - * step/status model can drive an Ink TUI and a plain readline fallback. They - * carry no Docker, Ink, or readline imports — see ./state.ts for the pure - * helpers that compute and transition this state. - */ - -/** Stable identifiers for each step of the setup flow, in run order. */ -export type SetupStepId = - | "check" - | "init-stack" - | "pull-images" - | "configure-agents" - | "github-auth" - | "intake" - | "start-stack" - | "enable-agents" - | "whitelist" - | "repo" - | "launch-ui"; - -/** - * Lifecycle status of a single step. - * pending — not started yet - * active — currently running - * done — completed successfully - * skipped — intentionally not run (already satisfied, or an optional step the - * user declined) - * warning — completed but with non-fatal issues the user should see - * failed — errored; blocks any step that depends on it - */ -export type SetupStepStatus = - | "pending" - | "active" - | "done" - | "skipped" - | "warning" - | "failed"; - -/** A single step in the setup flow plus its current presentation state. */ -export interface SetupStep { - id: SetupStepId; - /** Short label for progress lists. */ - title: string; - /** One-line explanation of what the step does. */ - description: string; - /** Optional steps may be skipped without blocking completion. */ - optional: boolean; - status: SetupStepStatus; - /** Live detail line (e.g. "pulled 6 images", "Docker daemon unreachable"). */ - detail?: string; - /** - * Suggested next action when the step is blocked, failed, or needs user - * input — shown by both renderers so the user knows how to proceed. - */ - nextAction?: string; -} - -/** Aggregate state for the whole setup flow. */ -export interface SetupState { - /** Resolved stack root where .env, data/, logs/, repos/ live. */ - rootDir: string; - /** Ordered steps; index order is the intended run order. */ - steps: SetupStep[]; -} - -/** - * Patch applied to a step when transitioning its state. Limited to runtime - * presentation fields — the static flow definition (title, description, - * optional) is canonical and cannot be altered through a patch. - */ -export type SetupStepPatch = Partial>; - -/** - * Canonical, ordered step definitions. All start `pending`; renderers and the - * command driver transition them via the helpers in ./state.ts. - */ -export const SETUP_STEP_DEFINITIONS: ReadonlyArray< - Pick -> = [ - { - id: "check", - title: "Environment checks", - description: "Verify Docker, images, and agent credentials are ready.", - optional: false, - }, - { - id: "init-stack", - title: "Initialize stack", - description: "Scaffold the stack root (.env, data/, logs/, repos/).", - optional: false, - }, - { - id: "pull-images", - title: "Pull images", - description: "Download the ProPR service and agent container images.", - optional: false, - }, - { - id: "configure-agents", - title: "Configure agents", - description: "Record detected host agent-credential directories in .env.", - optional: false, - }, - { - id: "github-auth", - title: "GitHub authentication", - description: "Choose how the backend authenticates to GitHub.", - optional: false, - }, - { - id: "intake", - title: "GitHub intake", - description: "Choose how the backend ingests GitHub events (routing WebSocket, polling, or direct webhooks).", - optional: false, - }, - { - id: "start-stack", - title: "Start stack", - description: "Launch the local control-plane services.", - optional: false, - }, - { - id: "enable-agents", - title: "Enable agents", - description: "Enable the selected agents in the backend and authenticate through their images.", - optional: false, - }, - { - id: "whitelist", - title: "Whitelist setup", - description: "Restrict which GitHub users may trigger ProPR.", - optional: false, - }, - { - id: "repo", - title: "Repository setup", - description: "Optionally connect a first repository to work on.", - optional: true, - }, - { - id: "launch-ui", - title: "Launch UI", - description: "Open the ProPR web UI.", - optional: true, - }, -]; +export * from "@propr/local-setup"; diff --git a/packages/cli/src/commands/setupCommand.ts b/packages/cli/src/commands/setupCommand.ts index 42efba1d4..7d6d33ff3 100644 --- a/packages/cli/src/commands/setupCommand.ts +++ b/packages/cli/src/commands/setupCommand.ts @@ -31,6 +31,7 @@ import { type AgentSkillTarget, } from "../agentSkill.js"; import { formatAgentSkillOperation } from "./agentSkillCommands.js"; +import { getLocalSetupCapability } from "@propr/local-setup"; export interface SetupCommandOptions { root?: string; @@ -216,6 +217,11 @@ cannot prompt and exits with guidance — scaffold non-interactively instead wit `) .action(async (options: SetupCommandOptions) => { try { + const capability = getLocalSetupCapability(); + if (!capability.supported) { + console.error(capability.reason); + process.exit(1); + } let skillReadline: ReturnType | undefined; const canPromptForSkill = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY); await offerSetupAgentSkill({ diff --git a/packages/local-setup/package.json b/packages/local-setup/package.json new file mode 100644 index 000000000..0c480ac16 --- /dev/null +++ b/packages/local-setup/package.json @@ -0,0 +1,22 @@ +{ + "name": "@propr/local-setup", + "version": "0.8.15", + "description": "UI-agnostic local ProPR setup state machine", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": ["dist"], + "engines": { "node": ">=22" }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", + "test": "npx tsx --test src/*.test.ts" + }, + "dependencies": { + "@propr/shared": "^0.8.15" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.9.3" + } +} diff --git a/packages/local-setup/src/agents.ts b/packages/local-setup/src/agents.ts new file mode 100644 index 000000000..2f58936e2 --- /dev/null +++ b/packages/local-setup/src/agents.ts @@ -0,0 +1,231 @@ +/** + * Agent enablement + image-based authentication for local setup. + * + * This runs as a setup step *after the stack is up* (the backend must be + * reachable to read and write agent configuration). It does three things, each + * non-destructively: + * + * 1. Reads the agents already configured in the running backend. + * 2. Adds any *selected* agent whose type is not yet configured, seeding it + * from the shared {@link AGENT_DEFAULTS} metadata (alias + supported + * models). Existing agents are never disabled, deleted, or re-aliased — a + * re-run only fills in what is missing. + * 3. For selected agents that support an interactive image login (see + * {@link planAgentLogin}), offers to authenticate through the agent's + * Docker image and runs the login only for the ones the user confirms. + * + * Like the engine, this module is UI-agnostic: the side effects live behind the + * injectable {@link AgentSetupActions} seam (tests pass mocks so the flow runs + * without Docker, the network, or a TTY) and the single user decision is + * collected through the optional {@link AgentSetupParams.confirmLogin} callback + * (a missing callback means "authenticate nothing", the safe default). + */ + +import { AGENT_DEFAULTS, type AgentType } from "@propr/shared"; + +/** Minimal backend agent shape needed by the setup engine. */ +export interface AgentConfig { + type: AgentType; +} + +/** Portable add-agent request emitted by the engine. */ +export interface AddAgentOptions { + alias: string; + type: AgentType; + models: string[]; + enabled: boolean; +} + +/** Outcome of attempting to authenticate a single agent through its image. */ +export interface AgentLoginResult { + /** False when the agent has no usable image-login plan (nothing was run). */ + available: boolean; + /** True when an interactive login ran and exited successfully. */ + success: boolean; + /** Human-readable detail (error reason or status line). */ + detail?: string; +} + +export interface AgentConnectivityResult { + type: string; + status: "ok" | "failed" | "skipped"; + detail: string; +} + +/** + * The side effects the agent-setup step performs against the running stack. + * Hosts bind these operations to their backend and launcher. Tests can provide + * in-memory implementations without Docker or network access. + */ +export interface AgentSetupActions { + /** List the agents currently configured in the running backend. */ + listAgents(rootDir: string): Promise; + /** Add a new agent to the backend configuration. */ + addAgent(rootDir: string, options: AddAgentOptions): Promise; + /** Agent types that support an interactive image login (have a login plan). */ + loginableAgents(): Promise; + /** Authenticate one agent through its image; interactive (inherits stdio). */ + loginAgent(rootDir: string, type: string): Promise; + /** Run a live, image-only request that mirrors the worker credential mount. */ + validateAgents(rootDir: string, types: string[]): Promise; +} + +/** Inputs for {@link runAgentSetup}. */ +export interface AgentSetupParams { + rootDir: string; + /** Agent types the user selected earlier in the flow (pull/configure steps). */ + selectedAgents: string[]; + actions: AgentSetupActions; + /** + * Confirm which of the loginable candidates to authenticate now. Returns the + * subset to log in. Omitted (or returning an empty array) authenticates none. + */ + confirmLogin?(ctx: { candidates: string[]; rootDir: string }): Promise; + onLog?(line: string): void; +} + +/** What the agent-setup step did, for the caller to render as a step status. */ +export interface AgentSetupOutcome { + /** Agent types newly added to the backend configuration. */ + added: string[]; + /** Selected agent types that were already configured (left untouched). */ + alreadyConfigured: string[]; + /** Agents that authenticated successfully through their image. */ + authenticated: string[]; + /** Agents the user chose to authenticate but whose login did not succeed. */ + authFailed: string[]; + /** Agents whose worker-image connectivity check returned a valid response. */ + validated: string[]; + /** Agents whose live image check failed or could not run. */ + validationFailed: string[]; + /** Exact recovery commands for agents that still need attention. */ + nextCommands: string[]; + /** Non-fatal problems encountered (surfaced as a warning by the caller). */ + errors: string[]; +} + +/** + * Enable the selected agents in the running backend and, on confirmation, + * authenticate the ones that support an image login. Never throws for expected + * conditions — every failure is captured in {@link AgentSetupOutcome.errors} so + * the caller can settle the step as a warning rather than aborting setup. + */ +export async function runAgentSetup(params: AgentSetupParams): Promise { + const { rootDir, selectedAgents, actions, confirmLogin, onLog } = params; + const outcome: AgentSetupOutcome = { + added: [], + alreadyConfigured: [], + authenticated: [], + authFailed: [], + validated: [], + validationFailed: [], + nextCommands: [], + errors: [], + }; + + if (selectedAgents.length === 0) return outcome; + + // 1. Read the current backend configuration. Without it we cannot safely tell + // which agents are new, so a read failure stops here (nothing was changed). + let existing: AgentConfig[]; + try { + existing = await actions.listAgents(rootDir); + } catch (error) { + outcome.errors.push(`could not read backend agents: ${(error as Error).message}`); + return outcome; + } + + // 2. Add the selected agents that are not yet configured. Match by type so we + // never add a second agent for a type the user already runs — existing + // agents (enabled or not) are left exactly as they are. + const configuredTypes = new Set(existing.map((agent) => agent.type)); + for (const type of selectedAgents) { + if (configuredTypes.has(type as AgentType)) { + outcome.alreadyConfigured.push(type); + continue; + } + const defaults = AGENT_DEFAULTS[type as AgentType]; + if (!defaults) continue; // unknown type — guarded, but never trust the input + try { + onLog?.(`enabling agent ${type}…`); + // Seed from shared metadata: alias + the full supported-model set. The + // backend resolves the default docker image and host config path, so we + // don't pass them (a literal "~" path would otherwise reach the backend). + await actions.addAgent(rootDir, { + alias: defaults.defaultAlias, + type: type as AgentType, + models: defaults.defaultModels, + enabled: true, + }); + outcome.added.push(type); + configuredTypes.add(type as AgentType); + } catch (error) { + outcome.errors.push(`could not enable ${type}: ${(error as Error).message}`); + } + } + + // 3. Image-based authentication — only for selected agents that actually have + // a login plan, and only for the ones the user confirms. + let loginable: Set; + try { + loginable = new Set(await actions.loginableAgents()); + } catch (error) { + outcome.errors.push(`could not determine which agents support image login: ${(error as Error).message}`); + loginable = new Set(); + } + const candidates = selectedAgents.filter((type) => loginable.has(type)); + if (candidates.length > 0 && confirmLogin) { + let chosen: string[] = []; + try { + chosen = await confirmLogin({ candidates, rootDir }); + } catch (error) { + // A failed/cancelled prompt must not abort the whole run — validation and + // exact recovery commands are still useful. + outcome.errors.push(`agent login prompt failed: ${(error as Error).message}`); + } + const chosenSet = new Set(chosen.filter((type) => loginable.has(type))); + // Iterate the candidate order (not the user's), so logins run in a stable order. + for (const type of candidates) { + if (!chosenSet.has(type)) continue; + try { + onLog?.(`authenticating ${type} through its image…`); + const result = await actions.loginAgent(rootDir, type); + if (result.detail) onLog?.(result.detail); + if (result.available && result.success) outcome.authenticated.push(type); + else outcome.authFailed.push(type); + } catch (error) { + outcome.authFailed.push(type); + outcome.errors.push(`login for ${type} failed: ${(error as Error).message}`); + } + } + } + + // 4. Always validate the selected agents from the same image/mount shape the + // worker uses. This is one live call per agent (host calls are deliberately + // skipped), so setup catches a successful host login that was not mounted into + // Docker without doubling subscription usage. + try { + onLog?.(`checking agent connectivity through worker image${selectedAgents.length === 1 ? "" : "s"}…`); + const checks = await actions.validateAgents(rootDir, selectedAgents); + for (const check of checks) { + onLog?.(`${check.type}: ${check.detail}`); + if (check.status === "ok") { + outcome.validated.push(check.type); + continue; + } + outcome.validationFailed.push(check.type); + if (loginable.has(check.type)) outcome.nextCommands.push(`propr agent login ${check.type}`); + outcome.nextCommands.push(`propr check agents --agents ${check.type}`); + } + } catch (error) { + outcome.errors.push(`could not validate agent connectivity: ${(error as Error).message}`); + for (const type of selectedAgents) { + if (loginable.has(type)) outcome.nextCommands.push(`propr agent login ${type}`); + outcome.nextCommands.push(`propr check agents --agents ${type}`); + } + } + + outcome.nextCommands = Array.from(new Set(outcome.nextCommands)); + + return outcome; +} diff --git a/packages/local-setup/src/engine.test.ts b/packages/local-setup/src/engine.test.ts new file mode 100644 index 000000000..b6e013144 --- /dev/null +++ b/packages/local-setup/src/engine.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + getLocalSetupCapability, + retrySetup, + runSetup, + type SetupActions, + type SetupProgressEvent, +} from "./index.js"; + +const unusedActions = {} as SetupActions; + +test("platform capabilities support Linux and make macOS/Windows explicitly remote-only", () => { + assert.deepEqual(getLocalSetupCapability("linux"), { + supported: true, + kind: "local", + platform: "linux", + }); + for (const platform of ["darwin", "win32"] as const) { + const capability = getLocalSetupCapability(platform); + assert.equal(capability.supported, false); + assert.equal(capability.kind, "remote-only"); + assert.match(capability.reason, /remote ProPR deployment/); + } +}); + +test("unsupported hosts return a structured result without invoking host operations", async () => { + let called = false; + const actions = new Proxy({}, { get: () => () => { called = true; } }) as SetupActions; + const result = await runSetup({ root: "/stack", platform: "darwin", actions }); + + assert.equal(called, false); + assert.equal(result.completed, false); + assert.equal(result.capability.kind, "remote-only"); + assert.equal(result.errors[0]?.code, "local-unsupported"); + assert.equal(result.state.steps[0]?.status, "failed"); +}); + +test("an already-aborted run is cancelled before invoking host operations", async () => { + const controller = new AbortController(); + controller.abort(); + const result = await runSetup({ root: "/stack", platform: "linux", actions: unusedActions, signal: controller.signal }); + + assert.equal(result.cancelled, true); + assert.equal(result.errors[0]?.code, "cancelled"); + assert.equal(result.completed, false); +}); + +test("cancellation between steps returns resumable state without starting the next host action", async () => { + const controller = new AbortController(); + let inspected = false; + const actions = { + runChecks: async () => ({ + rootDir: "/stack", + anyFail: false, + results: [{ name: "Docker daemon", group: "Docker", status: "ok", detail: "ready" }], + }), + inspectStackInit: () => { + inspected = true; + throw new Error("must not inspect after cancellation"); + }, + } as unknown as SetupActions; + const result = await runSetup({ + root: "/stack", + platform: "linux", + actions, + signal: controller.signal, + reporter: { + onStepSettled: (step) => { + if (step.id === "check") controller.abort(); + }, + }, + }); + + assert.equal(inspected, false); + assert.equal(result.cancelled, true); + assert.equal(result.state.steps.find((step) => step.id === "check")?.status, "done"); + assert.equal(result.state.steps.find((step) => step.id === "init-stack")?.status, "skipped"); +}); + +test("progress and structured errors redact values identified as secrets", async () => { + const events: SetupProgressEvent[] = []; + const actions = { + runChecks: async () => { throw new Error("token=very-secret-value"); }, + } as unknown as SetupActions; + const result = await runSetup({ + root: "/stack", + platform: "linux", + actions, + reporter: { onProgress: (event) => events.push(event) }, + }); + + const serialized = JSON.stringify({ events, errors: result.errors, state: result.state }); + assert.doesNotMatch(serialized, /very-secret-value/); + assert.match(serialized, /REDACTED/); + assert.equal(result.errors[0]?.code, "step-failed"); +}); + +test("retry preserves the previous root and re-evaluates platform capability", async () => { + const previous = await runSetup({ root: "/chosen/root", platform: "win32", actions: unusedActions }); + const retried = await retrySetup(previous, { platform: "darwin", actions: unusedActions }); + assert.equal(retried.rootDir, "/chosen/root"); + assert.equal(retried.capability.platform, "darwin"); +}); diff --git a/packages/local-setup/src/engine.ts b/packages/local-setup/src/engine.ts new file mode 100644 index 000000000..07b47ff76 --- /dev/null +++ b/packages/local-setup/src/engine.ts @@ -0,0 +1,1530 @@ +/** + * Local setup engine. + * + * `propr setup` walks a new user from a bare host to a running local + * control-plane stack. It combines what `propr check` and `propr init stack` + * already do, then sequences the remaining one-time tasks — pulling images, + * recording agent credentials, choosing GitHub auth, starting the stack and + * validating its health, configuring the whitelist, optionally connecting a + * first repository, and surfacing the UI URL. + * + * The engine is intentionally UI-agnostic. It owns the *order* of the flow and + * the *decision logic* (what to run, what to skip, what is safe), but performs + * no rendering and prompts no user directly. Two seams keep it decoupled: + * + * - {@link SetupPrompts} — callback hooks a renderer supplies to collect user + * decisions (which agents, which auth mode, whether to add a repo, …). Every + * hook is optional; a missing hook falls back to a safe, non-interactive + * default (keep what exists, skip optional work). Ink and the readline + * fallback will provide these in later issues. + * - {@link SetupActions} — the side-effecting operations (run checks, scaffold, + * pull, start, health-probe, add repo). A host must inject them explicitly; + * tests use in-memory implementations without Docker, network, or a TTY. + * + * Safety contract (enforced here, not just by convention): + * - The stack is initialized only when `.env` is missing or the user picks a + * new root — an existing functional install is left intact on re-run. + * - `.env` is never overwritten wholesale; edits go through the non-destructive + * {@link applyEnvSelection} (per-key, never blanks an existing value). + * - No step deletes user data; a running stack is reused, not recreated. + * - Core images pull by default; the agent image pulls when an agent is selected. + */ + +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join, normalize, resolve } from "node:path"; +import { + resolveGithubEventIntakeMode, + validateIntakeModePrerequisites, + DEFAULT_PROPR_GH_RELAY_URL, + type GithubAuthMode, + type GithubAuthModeResult, +} from "@propr/shared"; +import { + buildIntakeEnvVars, + defaultIntakeChoice, + intakeModeLabel, + saveWhitelist, + type GithubIntakeDecision, + type GithubIntakeMode, +} from "./github.js"; +import { + runAgentSetup, + type AgentSetupActions, +} from "./agents.js"; +import { + createSetupState, + getStep, + isSetupComplete, + updateStep, + type EnvSelectionResult, + type DatastoreAdminInspection, + type StackInitState, +} from "./state.js"; +import type { SetupState, SetupStep, SetupStepId, SetupStepPatch } from "./types.js"; + +const DEFAULT_PROPR_GITHUB_APP_INSTALL_URL = "https://github.com/apps/propr-dev/installations/new"; + +/** Match the API's distinction between real OAuth credentials and example placeholders. */ +function isConfiguredOAuthValue(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return Boolean(normalized && !normalized.startsWith("your_") && normalized !== "changeme"); +} + +function isTruthyEnvFlag(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return normalized === "true" || normalized === "1"; +} + +function normalizeServiceUrl(value: string | undefined): string | undefined { + try { + if (!value?.trim()) return undefined; + const url = new URL(value.trim()); + if (url.username || url.password || url.search || url.hash) return undefined; + const path = url.pathname.replace(/\/+$/, ""); + return `${url.origin}${path}`; + } catch { + return undefined; + } +} + +function isSupportedLoopbackCallback(value: string | undefined): boolean { + try { + if (!value?.trim()) return false; + const url = new URL(value.trim()); + const hostname = url.hostname.toLowerCase(); + return ( + url.protocol === "http:" && + (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]") && + url.username === "" && + url.password === "" && + url.pathname === "/api/auth/github/callback" && + url.search === "" && + url.hash === "" + ); + } catch { + return false; + } +} + +/** + * Catalog of supported agents: the image each one needs and the host + * credential directories recorded into `.env` when it is selected. Mirrors + * `agentDescriptors()` in ../checkCommands.ts and `detectCredentials()` in + * ../initStack.ts — kept local so the engine has no rendering/command imports. + */ +interface AgentDescriptor { + type: string; + /** Unified agent manifest image key. */ + imageKey: string; + /** Host credential dirs mounted into the agent container. */ + credentials: { envKey: string; defaultDir: string }[]; +} + +/** Reject unsafe Docker bind sources before asking a host to create them. */ +function assertSafeAgentCredentialDir(path: string, name = "Agent credential path"): void { + if ( + !isAbsolute(path) || + normalize(path) === "/" || + path.includes(":") || + /[\u0000-\u001f\u007f-\u009f]/.test(path) + ) { + throw new Error(`${name} must be an absolute, non-root Linux path without ':' or control characters`); + } +} + +function agentCatalog(): AgentDescriptor[] { + const home = homedir(); + return [ + { type: "claude", imageKey: "agent", credentials: [{ envKey: "HOST_CLAUDE_DIR", defaultDir: join(home, ".claude") }] }, + { type: "codex", imageKey: "agent", credentials: [{ envKey: "HOST_CODEX_DIR", defaultDir: join(home, ".codex") }] }, + { type: "antigravity", imageKey: "agent", credentials: [{ envKey: "HOST_ANTIGRAVITY_DIR", defaultDir: join(home, ".gemini") }] }, + { + type: "opencode", + imageKey: "agent", + credentials: [ + { envKey: "HOST_OPENCODE_XDG_DIR", defaultDir: join(home, ".config", "opencode") }, + { envKey: "HOST_OPENCODE_DATA_DIR", defaultDir: join(home, ".local", "share", "opencode") }, + ], + }, + { type: "vibe", imageKey: "agent", credentials: [{ envKey: "HOST_VIBE_DIR", defaultDir: join(home, ".vibe") }] }, + ]; +} + +/** Reject unsafe Docker bind sources before any recursive filesystem write. */ +/** Agent types whose default credential directory exists on this host. */ +function detectInstalledAgents(catalog: AgentDescriptor[]): string[] { + return catalog.filter((a) => a.credentials.some((c) => existsSync(c.defaultDir))).map((a) => a.type); +} + +// --------------------------------------------------------------------------- +// Decisions the renderer collects from the user. +// --------------------------------------------------------------------------- + +/** Where to put the stack, and whether to scaffold it. */ +export interface RootDecision { + /** Stack root to use (absolute). May differ from the resolved default. */ + rootDir: string; + /** + * Ensure this root is scaffolded, creating any *missing* `.env`/data/logs/repos + * pieces. Non-destructive: scaffolding runs without `force`, so an existing + * `.env` is always preserved — this fills in what is absent, it never resets a + * working install. (A root with a missing `.env` or sub-directory is scaffolded + * regardless of this flag; the flag only forces a scaffold pass on a root that + * already looks complete.) + */ + reinitialize: boolean; +} + +/** Outcome of the GitHub-auth prompt. */ +export interface GithubAuthDecision { + /** Keep the existing configuration untouched. */ + keep?: boolean; + /** Informational: the auth mode the user picked. */ + mode?: GithubAuthMode; + /** Env values to write (non-destructively, overwriting only these keys). */ + vars?: Record; + /** + * Relay path: the user chose token relay and wants the engine to enroll on + * their behalf (discover the installation, mint the token, write the relay + * env vars) using the stored `propr login` token. `relayUrl` is the relay base + * URL to enroll against — the hosted default unless overridden. Mutually + * exclusive with `vars`. + */ + enrollRelay?: { relayUrl: string }; +} + +/** A repository to start monitoring. */ +export interface RepoSelection { + fullName: string; + alias?: string; + baseBranch?: string; +} + +/** + * Hooks a renderer implements to drive user decisions. All optional: a missing + * hook means "use the safe default" (keep existing config, skip optional work), + * which is exactly what lets the engine run unattended in tests. + */ +export interface SetupPrompts { + /** Choose/confirm the stack root. Default: keep resolved root, scaffold only if `.env` is absent. */ + resolveStackRoot?(ctx: { currentRoot: string; init: StackInitState }): Promise; + /** Pick which agents to enable. Default: the agents detected on this host. */ + selectAgents?(ctx: { available: string[]; detected: string[] }): Promise; + /** Configure GitHub auth. Default: keep whatever `.env` already has. */ + configureGithubAuth?(ctx: { current: GithubAuthModeResult }): Promise; + /** + * Choose which installation to enroll when the relay reports more than one the + * user can access. Only consulted for the ambiguous (>1) case; a single + * installation is auto-selected and zero is an error. Default (no hook): the + * first installation. + */ + selectInstallation?(ctx: { installations: AuthorizedInstallation[] }): Promise; + /** + * Ask whether to run the interactive `propr login` (gh CLI) now when Connect + * enrollment or protected local API steps need a user token and none is + * stored. `reason` explains which part of setup needs it. + */ + confirmGithubLogin?(ctx: { reason: string }): Promise; + /** Offer to open the official hosted ProPR GitHub App installation page. */ + confirmGithubAppInstall?(ctx: { url: string }): Promise; + /** Continue enrollment after the user finishes the browser installation. */ + confirmGithubAppInstalled?(ctx: { url: string }): Promise; + /** + * Choose how the backend ingests GitHub events (routing WebSocket, polling, or + * direct webhooks). `defaultMode` is the choice to pre-select: the auth-derived + * recommendation on a fresh install, but `"keep"` when `.env` already carries + * an intake decision so a blank Enter never rewrites a working config. + * `currentMode` is the intake mode `.env` resolves to today. Default: keep. + */ + configureIntake?(ctx: { + authMode: GithubAuthMode; + defaultMode: GithubIntakeMode | "keep"; + currentMode: GithubIntakeMode; + }): Promise; + /** Confirm starting the stack. Default: start it. */ + confirmStartStack?(ctx: { rootDir: string; alreadyRunning: boolean }): Promise; + /** + * Choose which of the selected agents to authenticate through their image + * (only agents with an image-login plan are offered). Returns the subset to + * log in. Default: authenticate none. + */ + confirmAgentLogin?(ctx: { candidates: string[]; rootDir: string }): Promise; + /** Provide the user whitelist. Return null to keep the current value. Default: keep. */ + configureWhitelist?(ctx: { current: string[]; demoMode: boolean }): Promise; + /** Optionally add a first repository. Return null to skip. Default: skip. */ + addRepository?(ctx: { rootDir: string }): Promise; + /** + * Ask whether to open the UI in a browser. Returning `true` makes the engine + * launch it (via {@link SetupActions.openUrl}); the renderer only collects the + * yes/no. Default: don't open, just report the URL. + */ + launchUi?(ctx: { url: string }): Promise; +} + +// --------------------------------------------------------------------------- +// Progress reporting. +// --------------------------------------------------------------------------- + +/** Progress hooks a renderer implements to reflect engine state. All optional. */ +export interface SetupReporter { + /** Fired after every state transition with the latest immutable snapshot. */ + onState?(state: SetupState): void; + /** Fired when a step becomes active. */ + onStepStart?(step: SetupStep): void; + /** Fired when a step reaches a terminal status. */ + onStepSettled?(step: SetupStep): void; + /** Free-form progress lines (e.g. docker pull output). */ + onLog?(line: string): void; + /** Structured event stream for non-renderer hosts such as Electron main. */ + onProgress?(event: SetupProgressEvent): void; +} + +export type SetupProgressEvent = + | { type: "state"; state: SetupState } + | { type: "step-start"; step: SetupStep } + | { type: "step-settled"; step: SetupStep } + | { type: "log"; line: string }; + +// --------------------------------------------------------------------------- +// Injectable side effects. +// --------------------------------------------------------------------------- + +/** Relay installation shape used by setup prompts and enrollment. */ +export interface AuthorizedInstallation { + installation_id: number; + account_login: string; + account_type: string; +} + +/** Minimal environment-check contract consumed by the setup state machine. */ +export interface SetupCheckResult { + name: string; + status: "ok" | "warn" | "fail"; + detail: string; + group?: string; +} + +export interface RunChecksOptions { + root?: string; + skipRemoteImageCheck?: boolean; + signal?: AbortSignal; +} + +export interface ChecksOutcome { + results: SetupCheckResult[]; + rootDir: string; + anyFail: boolean; + /** Host-specific configuration returned by a checker; opaque to the engine. */ + cfg?: unknown; +} + +export interface InitStackOptions { + root?: string; + force?: boolean; + signal?: AbortSignal; +} + +export interface InitStackResult { + rootDir: string; + envCreated: boolean; + envSkipped: boolean; + envBackedUp: boolean; + dirsCreated: string[]; + dirsSkipped: string[]; + detected?: Array<{ envKey: string; path: string }>; + credentialsAppended?: boolean; + pendingCredentials?: Array<{ envKey: string; path: string }>; + runtimeModeWarning?: string; +} + +export interface PullImagesParams { + rootDir: string; + /** Agent types whose images should be pulled (in addition to core images). */ + agentTypes: string[]; + onLog?: (line: string) => void; + signal?: AbortSignal; +} + +export interface PullImagesResult { + pulledCore: string[]; + pulledAgents: string[]; + /** Core images that failed to pull — fatal, the stack cannot start. */ + failedCore: string[]; + /** Agent images that failed to pull — non-fatal, only those agents are affected. */ + failedAgents: string[]; +} + +export interface StartStackParams { + rootDir: string; + ui?: boolean; + docs?: boolean; + onLog?: (line: string) => void; + signal?: AbortSignal; +} + +export interface BackendHealthParams { + rootDir: string; + timeoutMs?: number; + signal?: AbortSignal; +} + +export interface BackendHealth { + healthy: boolean; + detail: string; + /** + * Set when the backend answered the probe (it is reachable and running) but + * rejected the request for authentication or authorization reasons rather + * than being genuinely unhealthy. The value lets the caller recommend login + * for a 401 without giving the same incorrect advice for a 403. + */ + accessFailure?: "unauthorized" | "forbidden"; +} + +/** Classify an HTTP access failure from the protected backend status route. */ +export function classifyBackendAccessError(error: unknown): BackendHealth | undefined { + const httpStatus = (error as { status?: unknown } | null)?.status; + if (httpStatus !== 401 && httpStatus !== 403) return undefined; + + const accessFailure = httpStatus === 401 ? "unauthorized" : "forbidden"; + const message = error instanceof Error ? error.message : String(error); + return { + healthy: false, + accessFailure, + detail: `backend is running but rejected the status request as ${accessFailure} (${message})`, + }; +} + +/** + * The operations the engine performs against the outside world. CLI, desktop, + * and tests each provide their own implementation. + */ +export interface SetupActions extends AgentSetupActions { + runChecks(options: RunChecksOptions): Promise; + inspectStackInit(rootDir: string): StackInitState; + /** Inspect the configured datastore's durable administrator state without modifying it. */ + inspectDatastoreAdministrators(rootDir: string): Promise; + scaffoldStack(options: InitStackOptions): Promise; + /** + * Persist the resolved stack root to the CLI config so later `propr start` / + * `propr status` invoked without `--root` target this stack. `scaffoldStack` + * already records it whenever it runs; this exists for the reuse path (an + * already-initialized root that setup leaves untouched), which would otherwise + * leave config pointing at a stale root or the cwd. A no-op without a config. + */ + persistStackRoot(rootDir: string): Promise; + readEnvVars(rootDir: string): Record; + applyEnvSelection(rootDir: string, vars: Record, opts?: { overwrite?: boolean }): EnvSelectionResult; + /** Remove keys from `.env` entirely (used to clear a value, not blank it). */ + clearEnvKeys(rootDir: string, keys: string[]): void; + detectGithubAuthMode(rootDir: string): GithubAuthModeResult; + /** Ensure a selected agent's host credential path is a directory, creating it securely when absent. */ + prepareAgentCredentialDir(path: string): void; + pullImages(params: PullImagesParams): Promise; + isStackRunning(rootDir: string): Promise; + startStack(params: StartStackParams): Promise; + checkBackendHealth(params: BackendHealthParams): Promise; + addRepository(selection: RepoSelection, rootDir: string): Promise; + resolveUiUrl(rootDir: string): Promise; + /** Open `url` in the host's default browser (best-effort; may reject). */ + openUrl(url: string): Promise; + /** + * Save the user whitelist through the running backend's settings API. A + * partial update — only the whitelist key is sent, so unrelated settings are + * left intact. + */ + saveWhitelistSetting(rootDir: string, users: string[]): Promise; + /** True when a GitHub user token is stored (relay enrollment and protected local API calls need it). */ + hasGithubToken(): boolean; + /** + * List the relay installations the stored GitHub identity can access (drives + * auto-select / the picker during relay enrollment). Throws if not logged in. + */ + fetchRelayInstallations(params: { + relayUrl?: string; + }): Promise<{ username: string; installations: AuthorizedInstallation[] }>; + /** + * Mint a relay token for `installationId`, returning the token and the relay + * URL it was minted against (the hosted default unless `relayUrl` overrides). + */ + enrollRelay(params: { + relayUrl?: string; + installationId: string; + label?: string; + }): Promise<{ relayUrl: string; token: string }>; + /** Authenticate with GitHub via the interactive `gh` CLI and store the token. */ + loginWithGithub(params?: { onLog?: (line: string) => void }): Promise; + /** Host preference used to select managed browser authentication. */ + getTunnelEnabled?(rootDir: string): boolean | undefined; +} + +/** Options for {@link runSetup}. */ +export interface RunSetupOptions { + /** Explicit stack root flag (highest precedence). */ + root?: string; + prompts?: SetupPrompts; + reporter?: SetupReporter; + /** All host I/O is supplied explicitly; the engine has no Docker or login dependency. */ + actions: SetupActions; + skipRemoteImageCheck?: boolean; + /** Defaults to the current Node platform. Override only for capability probing/tests. */ + platform?: NodeJS.Platform; + /** Cooperative cancellation, observed before every setup step. */ + signal?: AbortSignal; +} + +export type LocalSetupCapability = + | { supported: true; kind: "local"; platform: "linux" } + | { supported: false; kind: "remote-only"; platform: NodeJS.Platform; reason: string }; + +export function getLocalSetupCapability(platform: NodeJS.Platform = process.platform): LocalSetupCapability { + if (platform === "linux") return { supported: true, kind: "local", platform }; + return { + supported: false, + kind: "remote-only", + platform, + reason: `Local setup is not supported on ${platform}; use a remote ProPR deployment.`, + }; +} + +export interface SetupStructuredError { + code: "local-unsupported" | "step-failed" | "cancelled"; + message: string; + stepId?: SetupStepId; + retryable: boolean; + nextAction?: string; +} + +/** Raised when an AbortSignal is observed between setup steps. */ +export class SetupCancellation extends Error { + readonly state: SetupState; + + constructor(state: SetupState) { + super("Setup was cancelled."); + this.name = "SetupCancellation"; + this.state = state; + } +} + +/** Final outcome of a setup run. */ +export interface SetupRunResult { + rootDir: string; + state: SetupState; + capability: LocalSetupCapability; + /** Environment-check outcome, when the check step ran. */ + checks?: ChecksOutcome; + /** True when every required step finished without a blocking failure. */ + completed: boolean; + cancelled: boolean; + errors: SetupStructuredError[]; +} + +async function runSetupAttempt(options: RunSetupOptions): Promise { + const { prompts = {}, reporter = {}, skipRemoteImageCheck, actions } = options; + const catalog = agentCatalog(); + + let rootDir = resolve(options.root ?? process.cwd()); + let state = createSetupState(rootDir); + let checks: ChecksOutcome | undefined; + const capability = getLocalSetupCapability(options.platform); + /** Agents chosen at the pull step, reused when recording credentials. */ + let selectedAgents: string[] = []; + /** True only when the configured datastore conclusively has no durable administrator. */ + let bootstrapIdentityEligible = false; + /** Set after this run successfully writes an authenticated identity to the administrator environment. */ + let bootstrapAdministratorSeeded = false; + let datastoreAdminInspection: DatastoreAdminInspection | undefined; + /** True only after the local API answers the setup health probe. */ + let backendReady = false; + + const redact = (value: string): string => value + .replace(/\b(Bearer\s+)\S+/gi, "$1[REDACTED]") + .replace(/\b(gh[pousr]_[A-Za-z0-9_]{8,})\b/g, "[REDACTED]") + .replace(/\b((?:token|secret|password|private[_-]?key)\s*[=:]\s*)\S+/gi, "$1[REDACTED]"); + const safeStep = (step: SetupStep): SetupStep => ({ + ...step, + detail: step.detail ? redact(step.detail) : undefined, + nextAction: step.nextAction ? redact(step.nextAction) : undefined, + }); + const safeState = (): SetupState => ({ ...state, steps: state.steps.map(safeStep) }); + const emit = (): void => { + const snapshot = safeState(); + reporter.onState?.(snapshot); + reporter.onProgress?.({ type: "state", state: snapshot }); + }; + const stepOf = (id: SetupStepId): SetupStep => getStep(state, id)!; + const begin = (id: SetupStepId): void => { + if (options.signal?.aborted) { + state = { + ...state, + steps: state.steps.map((step) => step.status === "pending" + ? { ...step, status: "skipped", detail: "setup cancelled" } + : step), + }; + throw new SetupCancellation(state); + } + state = updateStep(state, id, { status: "active", detail: undefined, nextAction: undefined }); + emit(); + const step = safeStep(stepOf(id)); + reporter.onStepStart?.(step); + reporter.onProgress?.({ type: "step-start", step }); + }; + const settle = (id: SetupStepId, patch: SetupStepPatch): void => { + state = updateStep(state, id, patch); + emit(); + const step = safeStep(stepOf(id)); + reporter.onStepSettled?.(step); + reporter.onProgress?.({ type: "step-settled", step }); + }; + const log = (line: string): void => { + const safeLine = redact(line); + reporter.onLog?.(safeLine); + reporter.onProgress?.({ type: "log", line: safeLine }); + }; + const finish = (): SetupRunResult => ({ + rootDir, + state: safeState(), + capability, + checks, + // A terminal-looking step list is not a working installation unless the + // API actually became healthy during this run. + completed: isSetupComplete(state) && backendReady, + cancelled: false, + errors: state.steps + .filter((step) => step.status === "failed") + .map((step) => ({ + code: "step-failed" as const, + message: redact(step.detail ?? `${step.title} failed`), + stepId: step.id, + retryable: true, + nextAction: step.nextAction ? redact(step.nextAction) : undefined, + })), + }); + + if (!capability.supported) { + state = updateStep(state, "check", { + status: "failed", + detail: capability.reason, + nextAction: "Configure the CLI or desktop app to use a remote ProPR deployment.", + }); + emit(); + return { + ...finish(), + errors: [{ code: "local-unsupported", message: capability.reason, stepId: "check", retryable: false }], + }; + } + + if (options.signal?.aborted) { + emit(); + return { ...finish(), cancelled: true, errors: [{ code: "cancelled", message: "Setup was cancelled.", retryable: true }] }; + } + + /** + * Relay enrollment for the auth step. Ensures a GitHub token (offering the + * interactive login when a `confirmGithubLogin` hook is present), discovers the + * installation (auto-select one, pick among many, error on none), mints the + * relay token, and writes the relay env vars. Returns a success `detail` or a + * actionable `note`. It never throws for expected problems; the caller marks + * the auth step failed and stops before launching a backend that cannot boot. + */ + const enrollRelayForSetup = async ( + relayUrl: string + ): Promise<{ detail?: string; note?: { detail: string; nextAction?: string } }> => { + // 1. A stored GitHub token is required. Offer interactive login when the + // renderer supports it. The Ink entry point performs this handoff before + // enabling raw mode; the sequential renderer prompts through this hook. + if (!actions.hasGithubToken()) { + const reason = "Relay enrollment needs a GitHub token."; + if (prompts.confirmGithubLogin && (await prompts.confirmGithubLogin({ reason }))) { + await actions.loginWithGithub({ onLog: log }); + } + if (!actions.hasGithubToken()) { + return { + note: { + detail: "relay not enrolled — not logged in to GitHub", + nextAction: "Run `propr login`, then re-run `propr setup` and accept ProPR Connect.", + }, + }; + } + } + + try { + // 2. Discover installations: auto-select the only one, pick among many, + // error when there are none. + let { username, installations } = await actions.fetchRelayInstallations({ relayUrl }); + const usingHostedRelay = + relayUrl.replace(/\/+$/, "") === DEFAULT_PROPR_GH_RELAY_URL.replace(/\/+$/, ""); + if (installations.length === 0 && usingHostedRelay && prompts.confirmGithubAppInstall) { + const installUrl = DEFAULT_PROPR_GITHUB_APP_INSTALL_URL; + if (await prompts.confirmGithubAppInstall({ url: installUrl })) { + await actions.openUrl(installUrl); + const installed = prompts.confirmGithubAppInstalled + ? await prompts.confirmGithubAppInstalled({ url: installUrl }) + : false; + if (installed) { + ({ username, installations } = await actions.fetchRelayInstallations({ relayUrl })); + } + } + } + if (installations.length === 0) { + return { + note: { + detail: "relay not enrolled — no GitHub App installation available", + nextAction: usingHostedRelay + ? `Install the default ProPR GitHub App at ${DEFAULT_PROPR_GITHUB_APP_INSTALL_URL}, then re-run setup.` + : `Ask the administrator of ${relayUrl} for that relay's GitHub App installation URL, install it, then re-run setup.`, + }, + }; + } + let installationId: string; + if (installations.length === 1) { + installationId = String(installations[0].installation_id); + log(`relay: using installation ${installationId} (${installations[0].account_login})`); + } else if (prompts.selectInstallation) { + installationId = await prompts.selectInstallation({ installations }); + } else { + installationId = String(installations[0].installation_id); + } + + // 3. Mint the relay token and write the relay env vars (overwriting only + // these keys). PROPR_DEMO_MODE=false ensures the new relay config isn't + // shadowed by a leftover demo flag (see detectGithubAuthMode). + const { relayUrl: resolvedRelayUrl, token } = await actions.enrollRelay({ relayUrl, installationId }); + const existingEnv = actions.readEnvVars(rootDir); + const existingAdminUsers = [...new Set( + (existingEnv.PROPR_ADMIN_USERS ?? "") + .split(",") + .map((value) => value.trim().toLowerCase()) + .filter(Boolean) + )]; + const hasExistingAdminUsers = existingAdminUsers.length > 0; + const seedBootstrapAdmin = bootstrapIdentityEligible && !hasExistingAdminUsers; + const existingWhitelist = (existingEnv.GITHUB_USER_WHITELIST ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + const whitelistHasIdentity = existingWhitelist.some( + (value) => value.toLowerCase() === username.trim().toLowerCase() + ); + const bootstrapWhitelist = seedBootstrapAdmin && !whitelistHasIdentity + ? [...existingWhitelist, username].join(",") + : undefined; + const tunnelOverride = actions.getTunnelEnabled?.(rootDir); + const managedTunnelEnabled = tunnelOverride ?? Boolean( + existingEnv.PROPR_UI_TUNNEL_TOKEN?.trim() || isTruthyEnvFlag(existingEnv.PROPR_UI_TUNNEL_ENABLED) + ); + const explicitBrowserAuthMode = existingEnv.PROPR_WEB_AUTH_MODE?.trim().toLowerCase(); + const hasExplicitBrowserAuthMode = + explicitBrowserAuthMode === "connect" || + explicitBrowserAuthMode === "github" || + explicitBrowserAuthMode === "disabled"; + const customBrowserOAuthApplies = + !managedTunnelEnabled && + !hasExplicitBrowserAuthMode && + isConfiguredOAuthValue(existingEnv.GH_OAUTH_CLIENT_ID) && + isConfiguredOAuthValue(existingEnv.GH_OAUTH_CLIENT_SECRET); + const usesHostedConnect = + normalizeServiceUrl(resolvedRelayUrl) === normalizeServiceUrl(DEFAULT_PROPR_GH_RELAY_URL) && + normalizeServiceUrl(existingEnv.PROPR_CONNECT_URL || "https://connect.propr.dev") === + "https://connect.propr.dev"; + const callbackUrl = existingEnv.GH_OAUTH_CALLBACK_URL || + "http://localhost:4000/api/auth/github/callback"; + const automaticConnectApplies = + managedTunnelEnabled || + (usesHostedConnect && isSupportedLoopbackCallback(callbackUrl)); + actions.applyEnvSelection( + rootDir, + { + PROPR_DEMO_MODE: "false", + GH_AUTH_MODE: "relay", + PROPR_GH_RELAY_URL: resolvedRelayUrl, + PROPR_GH_RELAY_TOKEN: token, + GH_INSTALLATION_ID: installationId, + // Select hosted Connect only for its managed tunnel and exact + // loopback callback deployments. Explicit modes, custom OAuth, and + // custom/self-hosted relay paths remain operator-owned. + ...(automaticConnectApplies && !hasExplicitBrowserAuthMode && !customBrowserOAuthApplies + ? { PROPR_WEB_AUTH_MODE: "connect" } + : {}), + // The relay identity was just authenticated by GitHub and owns this + // installation, so it is the safe bootstrap administrator only when + // the configured datastore is absent or conclusively contains no + // durable administrator. Existing environment administrators and + // durable database administrators are always preserved. + ...(seedBootstrapAdmin ? { PROPR_ADMIN_USERS: username } : {}), + // Preserve every user-managed whitelist entry, adding the enrolled + // identity only when bootstrap enrollment needs it. + ...(bootstrapWhitelist ? { GITHUB_USER_WHITELIST: bootstrapWhitelist } : {}), + }, + { overwrite: true } + ); + bootstrapAdministratorSeeded = seedBootstrapAdmin; + const adminDetail = hasExistingAdminUsers + ? "kept existing administrators" + : seedBootstrapAdmin + ? `bootstrap administrator: ${username}` + : datastoreAdminInspection?.status === "uninspectable" + ? "left administrators unchanged because the datastore could not be inspected" + : "left administrators unchanged on existing stack"; + return { + detail: `auth mode: relay (installation ${installationId}); ${adminDetail}`, + }; + } catch (error) { + return { + note: { + detail: `relay enrollment failed — ${(error as Error).message}`, + nextAction: "Confirm the shared GitHub App is installed and you own the installation, then re-run setup.", + }, + }; + } + }; + + emit(); + + // 1. Environment checks — run first; their results steer the rest. + begin("check"); + try { + checks = await actions.runChecks({ root: rootDir, skipRemoteImageCheck, signal: options.signal }); + } catch (error) { + settle("check", { + status: "failed", + detail: `could not run environment checks: ${(error as Error).message}`, + nextAction: "Resolve the error above, then re-run setup.", + }); + return finish(); + } + const dockerProblem = blockingDockerFailure(checks); + if (dockerProblem) { + settle("check", { + status: "failed", + detail: dockerProblem, + nextAction: "Install/start Docker and ensure this user can run `docker info`, then re-run setup.", + }); + return finish(); + } + const fails = checks.results.filter((r) => r.status === "fail").length; + const warns = checks.results.filter((r) => r.status === "warn").length; + settle("check", { + status: warns > 0 || fails > 0 ? "warning" : "done", + detail: `${checks.results.length} checks (${fails} failing, ${warns} warnings) — addressing them below`, + }); + + // 2. Initialize stack — only when `.env` is missing or the user picks a new + // root. An existing functional install is never re-scaffolded or clobbered. + begin("init-stack"); + try { + let initSettlement: SetupStepPatch; + let init = actions.inspectStackInit(rootDir); + let userChoseReinit = false; + if (prompts.resolveStackRoot) { + const decision = await prompts.resolveStackRoot({ currentRoot: rootDir, init }); + if (decision.rootDir && decision.rootDir !== rootDir) { + rootDir = decision.rootDir; + state = { ...state, rootDir }; + init = actions.inspectStackInit(rootDir); + } + userChoseReinit = decision.reinitialize; + } + + // Scaffold whenever the stack is incomplete — `.env` missing *or* a required + // sub-directory (data/logs/repos) absent — or when the user explicitly chose + // to (re)initialize a root. Keying off `initialized` (not just `envExists`) + // means a half-scaffolded root with a stray `.env` but no `data/` still gets + // its directories created, instead of being silently treated as ready and + // failing later at startup. scaffoldStack runs without `force`, so an existing + // `.env` is always preserved — re-running setup never clobbers it. + const reinitialize = !init.initialized || userChoseReinit; + if (reinitialize) { + // No `force`: scaffoldStack creates a fresh `.env` only when absent and + // otherwise leaves the existing one in place. + const result = await actions.scaffoldStack({ root: rootDir, signal: options.signal }); + // Adopt the absolute root scaffoldStack actually resolved. A root typed at + // the prompt may be relative or have a trailing slash; without this every + // later step (env writes, health probe, UI URL) would key off the raw + // string while the scaffold landed at the resolved path. + if (result.rootDir && result.rootDir !== rootDir) { + rootDir = result.rootDir; + state = { ...state, rootDir }; + } + // Persist through the active host as well as its scaffold initializer. + // Otherwise later setup saves can write stale host config and silently + // discard the root that scaffolding recorded. + await actions.persistStackRoot(rootDir); + const created = [...result.dirsCreated]; + initSettlement = { + status: "done", + detail: result.envCreated + ? `scaffolded stack at ${rootDir}${created.length ? ` (created ${created.join(", ")})` : ""}` + : `stack root ready at ${rootDir} (existing .env kept)`, + }; + } else { + // Reuse path: scaffolding is skipped, so nothing has recorded this root in + // config. Persist it now so a later `propr start` / `propr status` without + // --root targets this stack rather than an old saved root or the cwd. + await actions.persistStackRoot(rootDir); + initSettlement = { status: "skipped", detail: `using existing stack at ${rootDir} (.env preserved)` }; + } + + // Eligibility comes from the configured datastore itself, not scaffold + // artifacts. This recovers migrated databases with no durable administrator + // and follows the runtime's DB_FILENAME/DATA_DIR resolution. Configured + // paths outside the launcher's data bind mount cannot be safely inspected + // from the host and remain ineligible (fail closed). + datastoreAdminInspection = await actions.inspectDatastoreAdministrators(rootDir); + bootstrapIdentityEligible = + datastoreAdminInspection.status === "absent" || datastoreAdminInspection.status === "no-admin"; + if (datastoreAdminInspection.status === "uninspectable") { + const inspectionDetail = datastoreAdminInspection.detail ?? "configured datastore is unavailable"; + log(`administrator inspection: ${inspectionDetail}`); + } + // Inspect before reporting initialization success so this step has exactly + // one terminal settlement even when inspection itself throws. An + // uninspectable datastore is evaluated after auth resolves because demo + // mode does not require an instance administrator. + settle("init-stack", initSettlement); + } catch (error) { + settle("init-stack", { + status: "failed", + detail: `could not initialize stack: ${(error as Error).message}`, + nextAction: "Check directory permissions and that .env.example is available, then re-run setup.", + }); + return finish(); + } + + // 3. Pull images — core images by default, plus the shared agent image when + // the user selects an agent (defaulting to those detected on this host). + begin("pull-images"); + const detected = detectInstalledAgents(catalog); + try { + const requested = prompts.selectAgents + ? await prompts.selectAgents({ available: catalog.map((a) => a.type), detected }) + : detected; + // Guard the engine boundary: a renderer may hand back unknown or duplicate + // agent names. Keep only types we know about, de-duped (first occurrence + // wins), so unknown names never reach pullImages() and a duplicate can't + // double-apply credentials in the configure-agents step below. + const known = new Set(catalog.map((a) => a.type)); + selectedAgents = [...new Set(requested)].filter((type) => known.has(type)); + + const pull = await actions.pullImages({ rootDir, agentTypes: selectedAgents, onLog: log, signal: options.signal }); + if (pull.failedCore.length > 0) { + settle("pull-images", { + status: "failed", + detail: `failed to pull core image(s): ${pull.failedCore.join(", ")}`, + nextAction: "Check registry access / network and re-run setup; the stack cannot start without core images.", + }); + return finish(); + } + const pulledCount = pull.pulledCore.length + pull.pulledAgents.length; + if (pull.failedAgents.length > 0) { + settle("pull-images", { + status: "warning", + detail: `pulled ${pulledCount} image(s); ${pull.failedAgents.length} agent image(s) unavailable`, + nextAction: "Jobs using those agents fail until their images pull. Re-run `propr images pull` later.", + }); + } else { + settle("pull-images", { status: "done", detail: `pulled ${pulledCount} image(s)` }); + } + } catch (error) { + settle("pull-images", { + status: "failed", + detail: `could not pull images: ${(error as Error).message}`, + nextAction: "Check Docker and registry access, then re-run setup.", + }); + return finish(); + } + + // 4. Configure agents — record detected host credential dirs for the selected + // agents, non-destructively (never blanks an existing value). + begin("configure-agents"); + try { + if (selectedAgents.length === 0) { + settle("configure-agents", { + status: "skipped", + detail: "no agents selected", + nextAction: "Log in with an agent CLI on this host, then re-run setup to record its credentials.", + }); + } else { + const vars: Record = {}; + const existingEnv = actions.readEnvVars(rootDir); + for (const type of selectedAgents) { + const desc = catalog.find((a) => a.type === type); + if (!desc) continue; + for (const cred of desc.credentials) { + // A selected agent may not have logged in yet. Prepare its host mount + // before the stack starts so Docker never creates a root-owned path, + // and record it now so the post-login image validation sees exactly + // the mount the worker will use. + const configuredDir = existingEnv[cred.envKey]; + const effectiveDir = configuredDir?.trim() ? configuredDir : cred.defaultDir; + assertSafeAgentCredentialDir(effectiveDir, cred.envKey); + actions.prepareAgentCredentialDir(effectiveDir); + vars[cred.envKey] = effectiveDir; + } + } + const applied = actions.applyEnvSelection(rootDir, vars, { overwrite: false }); + const detailParts: string[] = []; + detailParts.push(applied.written.length > 0 ? `recorded ${applied.written.length} credential dir(s)` : "no new credentials to record"); + if (applied.skipped.length > 0) detailParts.push(`${applied.skipped.length} already set`); + settle("configure-agents", { status: "done", detail: detailParts.join("; ") }); + } + } catch (error) { + settle("configure-agents", { + status: "failed", + detail: `could not record agent credentials: ${(error as Error).message}`, + nextAction: "Correct invalid HOST_* credential paths and check write permissions on .env, then re-run setup.", + }); + return finish(); + } + + // 5. GitHub authentication — keep what works; only write the keys the user + // explicitly chose. Missing Connect/App credentials are a hard stop because + // every non-demo backend process exits before the health probe can pass. + begin("github-auth"); + let resolvedAuth: GithubAuthModeResult; + // Set by the relay path: `relayNote` drives a failed settle (and skips + // partial writes); `relayDoneDetail` carries the success line. Both stay unset + // for the keep / custom-App / no-prompt paths, which fall back to the + // mode-derived settle below. + let relayNote: { detail: string; nextAction?: string } | undefined; + let relayDoneDetail: string | undefined; + try { + const currentAuth = actions.detectGithubAuthMode(rootDir); + let authDecision: GithubAuthDecision | undefined; + if (prompts.configureGithubAuth) authDecision = await prompts.configureGithubAuth({ current: currentAuth }); + if (authDecision?.enrollRelay) { + const outcome = await enrollRelayForSetup(authDecision.enrollRelay.relayUrl); + relayNote = outcome.note; + relayDoneDetail = outcome.detail; + } else if (authDecision?.vars && Object.keys(authDecision.vars).length > 0) { + actions.applyEnvSelection(rootDir, authDecision.vars, { overwrite: true }); + } + resolvedAuth = relayDoneDetail + ? { mode: "relay", warnings: [] } + : actions.detectGithubAuthMode(rootDir); + } catch (error) { + settle("github-auth", { + status: "failed", + detail: `could not configure GitHub auth: ${(error as Error).message}`, + nextAction: "Check .env access and your GitHub auth settings, then re-run setup.", + }); + return finish(); + } + if (relayNote) { + settle("github-auth", { status: "failed", detail: relayNote.detail, nextAction: relayNote.nextAction }); + return finish(); + } + if (resolvedAuth.mode === "none") { + settle("github-auth", { + status: "failed", + detail: "no GitHub auth configured", + nextAction: "Choose ProPR Connect (default), configure your own GitHub App, or enable demo mode, then re-run setup.", + }); + return finish(); + } + + // Every non-demo start needs either an environment administrator or a + // durable one. Relay enrollment above already seeds its authenticated + // identity when the datastore is conclusively empty. On a keep rerun, the + // same identity can be recovered safely only when the stored GitHub session + // can access the installation already configured for this stack. + const demoModeEnabled = isTruthyEnvFlag(actions.readEnvVars(rootDir).PROPR_DEMO_MODE); + let keptRelayBootstrapIdentity: string | undefined; + const configuredAdministrators = (): string[] => + (actions.readEnvVars(rootDir).PROPR_ADMIN_USERS ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + const durableAdministratorExists = datastoreAdminInspection?.status === "has-admin"; + if ( + !demoModeEnabled && + !durableAdministratorExists && + !bootstrapAdministratorSeeded && + configuredAdministrators().length === 0 && + bootstrapIdentityEligible && + resolvedAuth.mode === "relay" && + actions.hasGithubToken() + ) { + const env = actions.readEnvVars(rootDir); + const installationId = env.GH_INSTALLATION_ID?.trim(); + if (installationId) { + try { + const identity = await actions.fetchRelayInstallations({ + relayUrl: env.PROPR_GH_RELAY_URL?.trim() || undefined, + }); + const username = identity.username.trim(); + const ownsConfiguredInstallation = identity.installations.some( + (installation) => String(installation.installation_id) === installationId + ); + if (username && ownsConfiguredInstallation) { + const existingWhitelist = (env.GITHUB_USER_WHITELIST ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + const whitelistHasIdentity = existingWhitelist.some( + (value) => value.toLowerCase() === username.toLowerCase() + ); + actions.applyEnvSelection( + rootDir, + { + PROPR_ADMIN_USERS: username, + ...(!whitelistHasIdentity + ? { GITHUB_USER_WHITELIST: [...existingWhitelist, username].join(",") } + : {}), + }, + { overwrite: true } + ); + bootstrapAdministratorSeeded = true; + keptRelayBootstrapIdentity = username; + } + } catch (error) { + log(`administrator bootstrap: could not verify the configured relay identity: ${(error as Error).message}`); + } + } + } + + if ( + !demoModeEnabled && + !durableAdministratorExists && + !bootstrapAdministratorSeeded && + configuredAdministrators().length === 0 + ) { + const inspectionDetail = datastoreAdminInspection?.status === "uninspectable" + ? ` (${datastoreAdminInspection.detail ?? "the configured datastore could not be inspected"})` + : ""; + settle("github-auth", { + status: "failed", + detail: `no instance administrator is configured${inspectionDetail}`, + nextAction: + "Set PROPR_ADMIN_USERS to at least one GitHub username, repair the configured datastore, or re-run setup and enroll ProPR Connect with an authenticated GitHub account.", + }); + return finish(); + } + + // The GitHub App authenticates the backend to GitHub, but it does not + // authenticate this CLI user to the backend. Everything setup does after the + // stack starts (/api/status, agent configuration, settings, and repositories) + // is protected by bearer auth, so obtain the same user token as `propr login` + // before making any of those calls. Connect enrollment already guarantees a + // token; this covers custom-App and GitHub-only demo configurations alike. + if (!demoModeEnabled && !actions.hasGithubToken()) { + const reason = "Finishing setup requires a GitHub user token for protected backend API steps."; + if (prompts.confirmGithubLogin && (await prompts.confirmGithubLogin({ reason }))) { + await actions.loginWithGithub({ onLog: log }); + } + if (!actions.hasGithubToken()) { + settle("github-auth", { + status: "failed", + detail: `auth mode: ${resolvedAuth.mode}; GitHub user login is required to finish setup`, + nextAction: "Run `propr login`, then re-run `propr setup`; the existing stack configuration will be reused.", + }); + return finish(); + } + } + + if (relayDoneDetail) { + settle("github-auth", { status: "done", detail: relayDoneDetail }); + } else if (resolvedAuth.warnings.length > 0) { + // The mode resolves, but the shared detector flagged a partial/ambiguous + // configuration — surface it so the user can fix it before it bites later. + settle("github-auth", { + status: "warning", + detail: `auth mode: ${resolvedAuth.mode} — ${resolvedAuth.warnings.join("; ")}`, + }); + } else { + settle("github-auth", { + status: "done", + detail: keptRelayBootstrapIdentity + ? `auth mode: ${resolvedAuth.mode}; bootstrap administrator: ${keptRelayBootstrapIdentity}` + : `auth mode: ${resolvedAuth.mode}`, + }); + } + + // 5b. GitHub event intake — how the backend learns about GitHub events + // (routing WebSocket, polling, or direct webhooks). Written before startup + // because the API/daemon resolve GITHUB_EVENT_INTAKE_MODE at boot. Demo + // mode has no GitHub access, so there is nothing to ingest. + begin("intake"); + try { + if (resolvedAuth.mode === "demo") { + settle("intake", { status: "skipped", detail: "demo mode — no GitHub events to ingest" }); + } else { + const envNow = actions.readEnvVars(rootDir); + // Resolve the mode the backend would pick from today's `.env` (unset + // defaults to routing_websocket, the hosted relay path) so the prompt and + // any "kept current" message reflect what actually runs. + const { mode: currentMode } = resolveGithubEventIntakeMode({ + eventIntakeMode: envNow.GITHUB_EVENT_INTAKE_MODE, + enableGithubWebhooks: envNow.ENABLE_GITHUB_WEBHOOKS, + }); + // When `.env` already records an intake decision, default the prompt to + // "keep" so a blank Enter on a re-run can't silently flip a working config + // (e.g. disable existing direct webhooks). This also covers older `.env` + // files that only carry the legacy `ENABLE_GITHUB_WEBHOOKS` boolean: it + // still resolves to a real `currentMode`, so a blank Enter must keep that + // rather than rewrite it to the auth-derived recommendation. Only a truly + // fresh install (neither key set) falls back to the recommendation. + const intakeConfigured = + envNow.GITHUB_EVENT_INTAKE_MODE !== undefined || envNow.ENABLE_GITHUB_WEBHOOKS !== undefined; + const defaultMode = defaultIntakeChoice(resolvedAuth.mode, { intakeConfigured }); + let decision: GithubIntakeDecision | undefined; + if (prompts.configureIntake) { + decision = await prompts.configureIntake({ authMode: resolvedAuth.mode, defaultMode, currentMode }); + } + // The mode that will be in effect after this step — the explicit pick, or + // the current `.env` value when the user keeps it. `effectiveEnv` mirrors + // what `.env` holds *after* any write so the prerequisite check below sees + // the freshly written secret/mode, not the pre-write snapshot. + let effectiveMode = currentMode; + let effectiveEnv = envNow; + let detail: string; + if (decision && !decision.keep && decision.mode) { + // buildIntakeEnvVars rejects an empty webhook secret — caught below and + // surfaced as a warning rather than writing a config the API won't boot. + const vars = buildIntakeEnvVars(decision.mode, { webhookSecret: decision.webhookSecret }); + actions.applyEnvSelection(rootDir, vars, { overwrite: true }); + effectiveMode = decision.mode; + effectiveEnv = { ...envNow, ...vars }; + detail = `intake: ${intakeModeLabel(decision.mode)}`; + } else { + detail = `intake: kept current (${intakeModeLabel(currentMode)})`; + } + // Validate the resolved mode against the shared prerequisite rules so a + // silently-broken intake config (most commonly routing_websocket without + // relay auth + a relay token) surfaces here instead of as a backend boot + // failure after `propr start`. + const prereq = validateIntakeModePrerequisites({ + intakeMode: effectiveMode, + authMode: resolvedAuth.mode, + routingUrl: effectiveEnv.PROPR_ROUTING_URL, + relayUrl: effectiveEnv.PROPR_GH_RELAY_URL, + relayToken: effectiveEnv.PROPR_GH_RELAY_TOKEN, + webhookSecret: effectiveEnv.GH_WEBHOOK_SECRET, + }); + if (prereq.valid) { + settle("intake", { status: "done", detail }); + } else { + settle("intake", { + status: "failed", + detail: `${detail} — ${prereq.errors.join("; ")}`, + nextAction: + effectiveMode === "routing_websocket" + ? "Enroll with the hosted relay (`propr relay enroll`) so routing_websocket has relay auth + a relay token, or choose polling." + : "Resolve the missing intake prerequisites in .env, then re-run setup.", + }); + return finish(); + } + } + } catch (error) { + // An IntakeConfigError (e.g. direct webhooks chosen with no secret) is + // non-blocking: leave intake as-is and tell the user how to finish it. + settle("intake", { + status: "warning", + detail: `could not configure GitHub intake: ${(error as Error).message}`, + nextAction: + "Set GITHUB_EVENT_INTAKE_MODE (and GH_WEBHOOK_SECRET for direct_webhook) in .env, then re-run setup.", + }); + } + + // 6. Start the stack and validate backend health. A running stack is reused, + // not recreated, so user data and live work are untouched. + begin("start-stack"); + try { + const alreadyRunning = await actions.isStackRunning(rootDir); + const startConfirmed = prompts.confirmStartStack ? await prompts.confirmStartStack({ rootDir, alreadyRunning }) : true; + if (!startConfirmed) { + settle("start-stack", { + status: "skipped", + detail: "stack not started — setup is incomplete until the backend is running", + nextAction: "Start it later with `propr start`, or re-run `propr setup` and confirm startup.", + }); + } else { + if (alreadyRunning) { + log("stack already running — leaving it intact"); + } else { + await actions.startStack({ rootDir, onLog: log, signal: options.signal }); + } + const health = await actions.checkBackendHealth({ rootDir, signal: options.signal }); + if (health.healthy) { + backendReady = true; + settle("start-stack", { + status: "done", + detail: alreadyRunning ? `stack already running — ${health.detail}` : health.detail, + }); + } else { + settle("start-stack", { + status: "failed", + detail: health.detail, + // The backend answered, so access failures need account-oriented + // remediation rather than service-health troubleshooting. A 401 calls + // for login; a 403 calls for permission/configuration checks. + nextAction: health.accessFailure === "unauthorized" + ? "Run `propr login` to obtain a GitHub user token, then re-run `propr setup`; the running stack will be reused." + : health.accessFailure === "forbidden" + ? "Check the authenticated account, the stack's bootstrap-admin configuration, and its access permissions, then re-run `propr setup`; the running stack will be reused." + : "Run `propr status` / `propr remote-status` and inspect the API logs, then re-run setup.", + }); + } + } + } catch (error) { + settle("start-stack", { + status: "failed", + detail: `could not start the stack: ${(error as Error).message}`, + nextAction: "Run `propr start` to see the full startup output.", + }); + return finish(); + } + + // 7. Enable agents in the running backend — add the selected agents that are + // missing (existing ones are never disabled or deleted) and, on + // confirmation, authenticate the ones that support an image login. This + // runs after startup because it talks to the live backend API. Any problem + // is a non-blocking warning: agents can always be configured later. + begin("enable-agents"); + // This step talks to the live backend API, so it only makes sense once the + // stack is up. When the backend is unavailable, skip rather than fire + // doomed API calls that would surface as confusing warnings. + if (!backendReady) { + settle("enable-agents", { + status: "skipped", + detail: "backend is not healthy — agents are enabled through the running backend", + nextAction: "Start the stack (`propr start`), then re-run `propr setup` to enable and authenticate the selected agents.", + }); + } else { + try { + const outcome = await runAgentSetup({ + rootDir, + selectedAgents, + actions, + confirmLogin: prompts.confirmAgentLogin, + onLog: log, + }); + if (selectedAgents.length === 0) { + settle("enable-agents", { + status: "skipped", + detail: "no agents selected", + nextAction: "Enable agents later in the UI or with `propr agent add`.", + }); + } else { + const parts: string[] = []; + if (outcome.added.length > 0) parts.push(`enabled ${outcome.added.join(", ")}`); + if (outcome.alreadyConfigured.length > 0) parts.push(`${outcome.alreadyConfigured.length} already configured`); + if (outcome.authenticated.length > 0) parts.push(`authenticated ${outcome.authenticated.join(", ")}`); + if (outcome.authFailed.length > 0) parts.push(`${outcome.authFailed.length} login(s) did not complete`); + if (outcome.validated.length > 0) parts.push(`connectivity verified: ${outcome.validated.join(", ")}`); + if (outcome.validationFailed.length > 0) parts.push(`${outcome.validationFailed.length} connectivity check(s) need attention`); + const detail = parts.length > 0 ? parts.join("; ") : "no changes needed"; + if (outcome.errors.length > 0 || outcome.authFailed.length > 0 || outcome.validationFailed.length > 0) { + settle("enable-agents", { + status: "warning", + detail: outcome.errors.length > 0 ? `${detail}; ${outcome.errors.join("; ")}` : detail, + nextAction: outcome.nextCommands.length > 0 + ? `Run: ${outcome.nextCommands.map((command) => `\`${command}\``).join("; then ")}` + : "Enable or authenticate agents later in the UI or with `propr agent add` / `propr agent login`.", + }); + } else { + settle("enable-agents", { status: "done", detail }); + } + } + } catch (error) { + // runAgentSetup is built not to throw for expected conditions; anything that + // escapes is treated as a non-blocking warning so it can't abort setup. + settle("enable-agents", { + status: "warning", + detail: `could not configure agents: ${(error as Error).message}`, + nextAction: "Enable or authenticate agents later in the UI or with `propr agent add` / `propr agent login`.", + }); + } + } + + // 8. Whitelist — restrict who can trigger ProPR. Written non-destructively. + begin("whitelist"); + try { + const envNow = actions.readEnvVars(rootDir); + const currentWhitelist = (envNow.GITHUB_USER_WHITELIST ?? "").split(",").map((s) => s.trim()).filter(Boolean); + const demoMode = resolvedAuth.mode === "demo"; + let whitelist: string[] | null = null; + if (prompts.configureWhitelist) whitelist = await prompts.configureWhitelist({ current: currentWhitelist, demoMode }); + if (whitelist !== null) { + // Trim, drop blanks, and de-dupe (first occurrence wins) so the value + // matches saveWhitelist's "cleaned, de-duped usernames" contract — a + // duplicate entry would otherwise inflate the saved count and settings. + const cleaned = [...new Set(whitelist.map((s) => s.trim()).filter(Boolean))]; + // Prefer the settings API when the backend is up so the change applies + // immediately (and never overwrites unrelated settings); always mirror into + // .env so it survives a restart. Falls back to .env if the API is down. + const backendRunning = backendReady && await actions.isStackRunning(rootDir); + const saved = await saveWhitelist({ + users: cleaned, + backendRunning, + saveViaSettings: (users) => actions.saveWhitelistSetting(rootDir, users), + saveViaEnv: (users) => { + // A non-empty list is written; clearing to "none" must *remove* the key + // rather than blank it. applyEnvSelection ignores blank values (so it + // never clobbers a value), which means `GITHUB_USER_WHITELIST=""` would + // be skipped and the old list would survive on the next restart — so we + // delete the key outright instead. + if (users.length > 0) { + actions.applyEnvSelection(rootDir, { GITHUB_USER_WHITELIST: users.join(",") }, { overwrite: true }); + } else { + actions.clearEnvKeys(rootDir, ["GITHUB_USER_WHITELIST"]); + } + }, + }); + const where = saved.target === "settings" ? "via settings API" : "in .env"; + const summary = cleaned.length > 0 ? `${cleaned.length} user(s) allowed (${where})` : `whitelist cleared (${where})`; + if (saved.error) { + settle("whitelist", { + status: "warning", + detail: `${summary}; settings update failed: ${saved.error}`, + nextAction: "The whitelist is in .env; it will apply when the backend restarts.", + }); + } else { + settle("whitelist", { status: "done", detail: summary }); + } + } else if (currentWhitelist.length > 0) { + settle("whitelist", { status: "done", detail: `${currentWhitelist.length} user(s) already allowed` }); + } else if (demoMode) { + settle("whitelist", { status: "skipped", detail: "demo mode — whitelist not required" }); + } else { + settle("whitelist", { + status: "warning", + detail: "no whitelist configured — any authenticated GitHub user could trigger processing", + nextAction: "Set GITHUB_USER_WHITELIST in .env to a comma-separated list of allowed usernames.", + }); + } + } catch (error) { + settle("whitelist", { + status: "failed", + detail: `could not configure the whitelist: ${(error as Error).message}`, + nextAction: "Check .env access, then re-run setup.", + }); + return finish(); + } + + // 9. Repository (optional) — adding a repo must never fail the whole run. + begin("repo"); + // Adding a repo goes through the running backend's API, so skip it (without + // even prompting) when the backend is unavailable — there is nothing + // to add it to yet. + if (!backendReady) { + settle("repo", { + status: "skipped", + detail: "backend is not healthy — a repository is connected through the running backend", + nextAction: "Start the stack (`propr start`), then add one with `propr repo add `.", + }); + } else { + try { + // The prompt itself is part of this optional step — a renderer that throws + // while collecting the repo must degrade to a warning, not abort the run. + const repoSelection = prompts.addRepository ? await prompts.addRepository({ rootDir }) : null; + if (!repoSelection) { + settle("repo", { status: "skipped", detail: "no repository added" }); + } else { + try { + await actions.addRepository(repoSelection, rootDir); + settle("repo", { status: "done", detail: `monitoring ${repoSelection.fullName}` }); + } catch (error) { + settle("repo", { + status: "warning", + detail: `could not add ${repoSelection.fullName}: ${(error as Error).message}`, + nextAction: "Add it later with `propr repo add `.", + }); + } + } + } catch (error) { + settle("repo", { + status: "warning", + detail: `could not collect a repository to add: ${(error as Error).message}`, + nextAction: "Add it later with `propr repo add `.", + }); + } + } + + // 10. UI (optional) — surface the URL and, when the user confirms, actually + // open it in their default browser. + begin("launch-ui"); + if (!backendReady) { + settle("launch-ui", { + status: "skipped", + detail: "UI not opened — the backend is not healthy", + nextAction: "Resolve the startup failure, then re-run `propr setup`.", + }); + return finish(); + } + let uiUrl = ""; + try { + uiUrl = await actions.resolveUiUrl(rootDir); + } catch { + /* non-fatal: just omit the URL */ + } + let opened = false; + let openFailed = false; + try { + // The prompt only asks *whether* to open; the engine performs the open so + // both renderers behave identically and neither has to import a launcher. + const wantsOpen = uiUrl && prompts.launchUi ? await prompts.launchUi({ url: uiUrl }) : false; + if (wantsOpen) { + try { + await actions.openUrl(uiUrl); + opened = true; + } catch { + // Headless host, no launcher, etc. — fall back to just printing the URL. + openFailed = true; + } + } + } catch { + /* opening the UI is best-effort; a failed launch prompt must not fail setup */ + } + settle("launch-ui", { + status: opened ? "done" : "skipped", + detail: uiUrl + ? openFailed + ? `UI available at ${uiUrl} (could not open a browser automatically)` + : opened + ? `opened ${uiUrl}` + : `UI available at ${uiUrl}` + : "UI URL unavailable", + }); + + return finish(); +} + +/** Run or safely re-run the setup state machine. Existing host state is re-inspected on every call. */ +export async function runSetup(options: RunSetupOptions): Promise { + try { + return await runSetupAttempt(options); + } catch (error) { + if (!(error instanceof SetupCancellation)) throw error; + const capability = getLocalSetupCapability(options.platform); + return { + rootDir: error.state.rootDir, + state: error.state, + capability, + completed: false, + cancelled: true, + errors: [{ code: "cancelled", message: error.message, retryable: true }], + }; + } +} + +/** Retry/resume is intentionally a fresh inspection; completed work is detected and preserved by host operations. */ +export function retrySetup(previous: SetupRunResult, options: Omit): Promise { + return runSetup({ ...options, root: previous.rootDir }); +} + +/** + * Detect an environment problem that blocks the entire flow: Docker missing or + * its daemon unreachable. Other failures (e.g. GitHub auth) are addressed by + * later steps and must not abort setup here. + * + * Keyed off the structured `Docker` check group rather than exact check names, + * so re-wording a check in checkCommands.ts can't silently let setup continue + * past a missing/unreachable engine. Within that group only the engine checks + * ("Docker installed", "Docker daemon") ever report `fail`; the socket check is + * informational and tops out at `warn`, so a `fail` here always means Docker + * itself cannot run the stack. + */ +function blockingDockerFailure(outcome: ChecksOutcome): string | undefined { + return outcome.results.find((r) => r.group === "Docker" && r.status === "fail")?.detail; +} diff --git a/packages/local-setup/src/envFile.ts b/packages/local-setup/src/envFile.ts new file mode 100644 index 000000000..963504b14 --- /dev/null +++ b/packages/local-setup/src/envFile.ts @@ -0,0 +1,117 @@ +/** + * Minimal .env upsert helper. + * + * Sets each KEY to a value in a Docker --env-file-compatible dotenv file: replaces the first + * uncommented `KEY=` assignment if present, otherwise appends it. Other lines + * (comments, blank lines, commented examples) are preserved. + * + * Docker does not strip quotes in --env-file values, so values are written + * literally and must fit on one line. + */ + +import { chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function upsertEnvVars(envPath: string, vars: Record): void { + for (const [key, value] of Object.entries(vars)) { + if (/[\r\n]/.test(value)) { + throw new Error(`${key} cannot contain newlines; Docker --env-file only supports one KEY=VALUE assignment per line.`); + } + if (/^\s|\s$/.test(value)) { + throw new Error(`${key} cannot contain leading or trailing whitespace in ${envPath}; Docker --env-file does not strip quotes.`); + } + if (/\s#/.test(value)) { + // The orchestrator's env-file reader strips a trailing " #comment" from + // unquoted values, so such a value would not survive a read-back round trip. + throw new Error(`${key} cannot contain whitespace followed by '#' in ${envPath}; it would be read back as a truncated value (inline-comment syntax).`); + } + } + + const raw = existsSync(envPath) ? readFileSync(envPath, "utf-8") : ""; + const lines = raw.split(/\r?\n/); + + // Drop trailing blank lines so appends stay tidy; we re-add one newline at the end. + while (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); + } + + for (const [key, value] of Object.entries(vars)) { + const pattern = new RegExp(`^\\s*(export\\s+)?${escapeRegExp(key)}\\s*=`); + const index = lines.findIndex((line) => pattern.test(line)); + const preserveExport = index >= 0 && /^\s*export\s+/.test(lines[index]); + const assignment = `${preserveExport ? "export " : ""}${key}=${value}`; + if (index >= 0) { + lines[index] = assignment; + } else { + lines.push(assignment); + } + } + + const isNew = !existsSync(envPath); + let tightenedFrom: number | null = null; + if (!isNew) { + try { + const before = statSync(envPath).mode & 0o777; + if (before !== 0o600) { + chmodSync(envPath, 0o600); + tightenedFrom = before; + } + } catch { + // Best-effort — may fail on Windows or non-owned files. + } + } + + writeFileSync(envPath, `${lines.join("\n")}\n`, { encoding: "utf-8", mode: isNew ? 0o600 : undefined }); + if (tightenedFrom !== null) { + console.warn(`Note: tightened ${envPath} permissions from ${tightenedFrom.toString(8)} to 600 (secrets file).`); + } +} + +/** + * Remove the given keys from a .env file entirely. + * + * Deletes every uncommented `KEY=` assignment for each key — so a key that was + * accidentally assigned more than once is fully cleared, not just thinned to its + * last duplicate; every other line — comments, blanks, and unrelated keys — is + * preserved verbatim. A missing file, an empty key list, and keys that aren't + * present are all no-ops. + * + * This exists because {@link upsertEnvVars} can only *set* a value: writing a + * blank (e.g. `GITHUB_USER_WHITELIST=`) still leaves the key in the file, where + * it reads back as an empty value rather than as "unset". Setup flows that must + * genuinely clear a stale key (clearing the user whitelist, dropping a key when + * switching auth/intake modes) use this so the value does not silently return on + * the next read or restart. + */ +export function clearEnvKeys(envPath: string, keys: string[]): void { + if (keys.length === 0 || !existsSync(envPath)) return; + + const lines = readFileSync(envPath, "utf-8").split(/\r?\n/); + const patterns = keys.map((key) => new RegExp(`^\\s*(export\\s+)?${escapeRegExp(key)}\\s*=`)); + const kept = lines.filter((line) => !patterns.some((pattern) => pattern.test(line))); + + // Nothing matched → leave the file (and its mode) untouched. + if (kept.length === lines.length) return; + + // Tighten permissions like upsertEnvVars does — this is still the secrets file. + let tightenedFrom: number | null = null; + try { + const before = statSync(envPath).mode & 0o777; + if (before !== 0o600) { + chmodSync(envPath, 0o600); + tightenedFrom = before; + } + } catch { + // Best-effort — may fail on Windows or non-owned files. + } + + // Drop trailing blank lines, then re-add exactly one terminating newline. + while (kept.length > 0 && kept[kept.length - 1] === "") kept.pop(); + writeFileSync(envPath, `${kept.join("\n")}\n`, "utf-8"); + if (tightenedFrom !== null) { + console.warn(`Note: tightened ${envPath} permissions from ${tightenedFrom.toString(8)} to 600 (secrets file).`); + } +} diff --git a/packages/local-setup/src/github.ts b/packages/local-setup/src/github.ts new file mode 100644 index 000000000..ede47e447 --- /dev/null +++ b/packages/local-setup/src/github.ts @@ -0,0 +1,269 @@ +/** + * GitHub event-intake + user-whitelist helpers for local setup. + * + * Two concerns the setup wizard must guide a new user through, factored out of + * the engine so the decision logic lives in one tested place and both renderers + * (Ink + readline) share it: + * + * - **Intake mode** — how the backend learns about GitHub events, selected by + * the `GITHUB_EVENT_INTAKE_MODE` `.env` key (the legacy `ENABLE_GITHUB_WEBHOOKS` + * boolean is deprecated and no longer selects the mode). Three paths: + * routing_websocket — events stream over the hosted ProPR routing + * WebSocket; no inbound webhook listener and no own + * GitHub App required. The default, and only usable + * with relay auth (PROPR_GH_RELAY_TOKEN). + * polling — the daemon polls the GitHub API on an interval; works + * with any usable GitHub auth and needs no inbound URL. + * direct_webhook — GitHub posts directly to the local API; requires an + * own GitHub App plus a signing secret so forged + * payloads are rejected. + * {@link buildIntakeEnvVars} turns a chosen mode into the exact `.env` keys + * (`GITHUB_EVENT_INTAKE_MODE`, and `GH_WEBHOOK_SECRET` for direct webhooks), + * refusing to produce a direct_webhook config without a secret — the API + * would otherwise refuse to boot. + * + * - **User whitelist** — which GitHub users may trigger ProPR. Saved through + * the settings API when the backend is running (a partial update that never + * clobbers unrelated settings), and mirrored into `.env` so the value + * survives a restart. {@link saveWhitelist} owns that routing and degrades to + * an `.env`-only write when the backend is down or the API call fails. + * + * Like the rest of the setup module these helpers are UI-agnostic and free of + * Docker/network imports: side effects are passed in as callbacks so the engine + * binds them to the real API/`.env` and tests drive the whole thing in memory. + */ + +import type { GithubAuthMode, GithubEventIntakeMode } from "@propr/shared"; + +/** + * How the backend ingests GitHub events. Aliased to the shared + * {@link GithubEventIntakeMode} so the wizard and the backend boot path can't + * drift on the values the `GITHUB_EVENT_INTAKE_MODE` `.env` key accepts: + * routing_websocket — events stream over the ProPR routing WebSocket (default) + * polling — the daemon polls the GitHub API; no inbound exposure + * direct_webhook — GitHub posts to a local /webhook endpoint (needs a secret) + */ +export type GithubIntakeMode = GithubEventIntakeMode; + +/** Documentation surfaced in the intake prompt's detail text. */ +export const INTAKE_DOCS_URL = "https://docs.propr.dev/docs/architecture/daemon"; +/** Documentation for configuring direct webhook delivery. */ +export const WEBHOOK_DOCS_URL = "https://docs.propr.dev/docs/tutorials/setup-server"; + +/** + * Outcome of the intake prompt the renderer hands back to the engine. Mirrors + * {@link GithubAuthDecision}: a `keep` leaves the current `.env` untouched, + * otherwise the chosen `mode` (plus a secret for webhooks) is applied. + */ +export interface GithubIntakeDecision { + /** Keep the existing intake configuration untouched. */ + keep?: boolean; + /** The intake mode the user picked. */ + mode?: GithubIntakeMode; + /** Signing secret, required (and only used) when `mode === "direct_webhook"`. */ + webhookSecret?: string; +} + +/** Thrown when an intake selection is missing required input (e.g. a webhook secret). */ +export class IntakeConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "IntakeConfigError"; + } +} + +/** + * The intake mode to pre-select for a given GitHub auth mode. The hosted routing + * WebSocket is the product default, but it only works with relay auth (it needs + * a relay token and the shared ProPR App), so it's recommended only when relay + * auth is configured. Every other auth mode falls back to polling, which works + * with any usable GitHub auth and needs no inbound network exposure — and unlike + * direct webhooks requires no public URL or own GitHub App. + */ +export function defaultIntakeMode(authMode: GithubAuthMode): GithubIntakeMode { + return authMode === "relay" ? "routing_websocket" : "polling"; +} + +/** + * The intake choice the prompt should pre-select. + * + * On a re-run where `.env` already carries an intake decision + * (`GITHUB_EVENT_INTAKE_MODE` is set), the safe default is `"keep"`: a blank Enter + * must never silently rewrite a working config — e.g. an existing + * `direct_webhook` install must not flip to `routing_websocket` just because the + * auth-derived recommendation differs. This upholds the setup engine's re-run + * safety model (keep existing config unless the user explicitly changes it). Only + * on a fresh install, with no intake config yet, do we fall back to the + * auth-derived recommendation from {@link defaultIntakeMode}. + */ +export function defaultIntakeChoice( + authMode: GithubAuthMode, + opts: { intakeConfigured: boolean } +): GithubIntakeMode | "keep" { + return opts.intakeConfigured ? "keep" : defaultIntakeMode(authMode); +} + +/** + * Translate a chosen {@link GithubIntakeMode} into the `.env` keys it implies. + * The mode is selected by `GITHUB_EVENT_INTAKE_MODE`, the value the backend boot + * path resolves (see resolveGithubEventIntakeMode); the deprecated + * `ENABLE_GITHUB_WEBHOOKS` boolean is intentionally never written here. + * + * - `routing_websocket` / `polling` set `GITHUB_EVENT_INTAKE_MODE` to the mode + * and nothing else — routing events arrive over the relay WebSocket and + * polling pulls them from the API, neither needing a local webhook listener. + * A previously recorded `GH_WEBHOOK_SECRET` is intentionally *not* cleared: + * `applyEnvSelection`/`upsertEnvVars` only set keys, never remove them. The + * leftover secret is inert while not in direct_webhook mode (the API never + * reads it), but callers wanting a pristine `.env` must remove it by hand. + * - `direct_webhook` records the signing secret alongside the mode. An + * empty/whitespace secret is rejected with {@link IntakeConfigError}: the API + * refuses to boot in direct_webhook mode with no secret, so writing it would + * only break startup. + */ +export function buildIntakeEnvVars( + mode: GithubIntakeMode, + opts: { webhookSecret?: string } = {} +): Record { + switch (mode) { + case "routing_websocket": + case "polling": + return { GITHUB_EVENT_INTAKE_MODE: mode }; + case "direct_webhook": { + const secret = (opts.webhookSecret ?? "").trim(); + if (!secret) { + throw new IntakeConfigError( + "A webhook secret is required for direct webhooks — the API refuses to start without one." + ); + } + return { GITHUB_EVENT_INTAKE_MODE: "direct_webhook", GH_WEBHOOK_SECRET: secret }; + } + } +} + +/** A short, human-readable label for an intake mode, shared by both renderers. */ +export function intakeModeLabel(mode: GithubIntakeMode): string { + switch (mode) { + case "routing_websocket": + return "ProPR routing WebSocket (hosted relay)"; + case "polling": + return "polling (no inbound webhooks)"; + case "direct_webhook": + return "direct webhooks (signing secret recorded)"; + } +} + +/** + * One intake mode's availability under a given GitHub auth mode, for the intake + * prompt. Each renderer maps this onto a selectable (or inactive) option. + */ +export interface IntakeModeOption { + /** The intake mode this entry describes. */ + mode: GithubIntakeMode; + /** False when the chosen auth mode cannot support this intake path. */ + available: boolean; + /** + * A short note for the renderer to surface next to the option: when + * `available` is false this is *why* the path is closed; when true it is an + * optional caveat (e.g. polling's production-suitability warning). + */ + note?: string; +} + +/** + * The intake modes to show for a given GitHub auth mode, in display order, each + * flagged available or not. Unavailable modes are intentionally still returned + * so the prompt can show them inactive with the reason — a new user sees the + * full set and learns why a path is closed rather than wondering where it went. + * + * The availability rules mirror {@link validateIntakeModePrerequisites} so the + * prompt and the backend boot-time check can never disagree: + * - routing_websocket needs the ProPR token relay; a custom GitHub App can't use it. + * - direct_webhook needs your own GitHub App; the ProPR relay can't deliver to it. + * - polling works with either usable auth, but is not recommended for production. + */ +export function intakeModeOptions(authMode: GithubAuthMode): IntakeModeOption[] { + const relay = authMode === "relay"; + const app = authMode === "app"; + return [ + { + mode: "routing_websocket", + available: relay, + note: relay + ? undefined + : "needs the ProPR GitHub App (token relay); not available with a custom GitHub App", + }, + { + mode: "polling", + available: relay || app, + note: + relay || app + ? "not recommended for production: subject to GitHub API rate limits and delayed event detection (depends on the polling interval and the number of repos/PRs/issues)" + : "needs usable GitHub auth — configure the token relay or a custom GitHub App first", + }, + { + mode: "direct_webhook", + available: app, + note: app + ? undefined + : "needs your own custom GitHub App; not available with the ProPR token relay", + }, + ]; +} + +// --------------------------------------------------------------------------- +// Whitelist persistence. +// --------------------------------------------------------------------------- + +/** Where {@link saveWhitelist} persisted the whitelist. */ +export interface SaveWhitelistResult { + /** The store the value was written to as its source of truth. */ + target: "settings" | "env"; + /** Number of users in the saved whitelist (0 means cleared). */ + count: number; + /** + * Set when a settings-API save was attempted but failed, after which the + * helper fell back to `.env`. Surfaced as a warning by the caller. + */ + error?: string; +} + +/** Inputs for {@link saveWhitelist}. Side effects are injected so it stays pure-ish and testable. */ +export interface SaveWhitelistParams { + /** The cleaned, de-duped usernames to persist (may be empty to clear). */ + users: string[]; + /** Whether the local backend is up — gates the settings-API path. */ + backendRunning: boolean; + /** Persist through the running backend's settings API (partial update). */ + saveViaSettings(users: string[]): Promise; + /** Persist into `.env` (non-destructive, single key). */ + saveViaEnv(users: string[]): void; +} + +/** + * Persist the user whitelist, preferring the settings API when the backend is + * running so the change takes effect immediately without a restart, and always + * mirroring into `.env` so it survives one. If the API call fails we fall back + * to the `.env` write and report the error rather than abort setup. + * + * The settings-API path issues a *partial* update (only the whitelist key), so + * unrelated settings are never overwritten. + */ +export async function saveWhitelist(params: SaveWhitelistParams): Promise { + const { users, backendRunning, saveViaSettings, saveViaEnv } = params; + if (backendRunning) { + try { + await saveViaSettings(users); + // Mirror into `.env` so the whitelist persists across `propr start`. + saveViaEnv(users); + return { target: "settings", count: users.length }; + } catch (error) { + // The backend rejected the update (or was unreachable after all) — keep + // the value in `.env` so it is not lost, and surface why. + saveViaEnv(users); + return { target: "env", count: users.length, error: (error as Error).message }; + } + } + saveViaEnv(users); + return { target: "env", count: users.length }; +} diff --git a/packages/local-setup/src/index.ts b/packages/local-setup/src/index.ts new file mode 100644 index 000000000..b0599dc8d --- /dev/null +++ b/packages/local-setup/src/index.ts @@ -0,0 +1,5 @@ +export * from "./agents.js"; +export * from "./engine.js"; +export * from "./github.js"; +export * from "./state.js"; +export * from "./types.js"; diff --git a/packages/local-setup/src/state.test.ts b/packages/local-setup/src/state.test.ts new file mode 100644 index 000000000..36e528def --- /dev/null +++ b/packages/local-setup/src/state.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { applyEnvSelection, clearEnvKeys, inspectStackInit, readEnvVars } from "./state.js"; + +function withStack(run: (rootDir: string) => void): void { + const rootDir = mkdtempSync(join(tmpdir(), "propr-local-setup-test-")); + try { + run(rootDir); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +} + +test("environment writes are private and re-runs preserve existing secrets", () => withStack((rootDir) => { + const envPath = join(rootDir, ".env"); + const first = applyEnvSelection(rootDir, { API_TOKEN: "first-secret" }); + assert.deepEqual(first.written, ["API_TOKEN"]); + assert.equal(statSync(envPath).mode & 0o777, 0o600); + + const rerun = applyEnvSelection(rootDir, { API_TOKEN: "replacement", SAFE_VALUE: "yes" }); + assert.deepEqual(rerun.skipped, ["API_TOKEN"]); + assert.deepEqual(readEnvVars(rootDir), { API_TOKEN: "first-secret", SAFE_VALUE: "yes" }); + assert.doesNotMatch(readFileSync(envPath, "utf8"), /replacement/); +})); + +test("clearing a setup-owned key preserves unrelated values and private permissions", () => withStack((rootDir) => { + const envPath = join(rootDir, ".env"); + writeFileSync(envPath, "TOKEN=secret\nKEEP=value\n", { mode: 0o644 }); + clearEnvKeys(rootDir, ["TOKEN"]); + assert.equal(readFileSync(envPath, "utf8"), "KEEP=value\n"); + assert.equal(statSync(envPath).mode & 0o777, 0o600); +})); + +test("stack inspection requires the env file and every launcher directory", () => withStack((rootDir) => { + writeFileSync(join(rootDir, ".env"), "A=b\n", { mode: 0o600 }); + mkdirSync(join(rootDir, "data")); + mkdirSync(join(rootDir, "logs")); + assert.equal(inspectStackInit(rootDir).initialized, false); + mkdirSync(join(rootDir, "repos")); + assert.equal(inspectStackInit(rootDir).initialized, true); +})); diff --git a/packages/local-setup/src/state.ts b/packages/local-setup/src/state.ts new file mode 100644 index 000000000..aa190f4a6 --- /dev/null +++ b/packages/local-setup/src/state.ts @@ -0,0 +1,420 @@ +/** + * Local setup domain helpers. + * + * Pure, side-effect-light helpers that the `propr setup` driver and both + * renderers (Ink TUI and readline fallback) build on: + * - resolving the stack root (reusing the orchestrator's precedence rules), + * - inspecting whether the stack is already initialized, + * - reading and *safely* editing .env (non-destructive by default), + * - constructing and transitioning the {@link SetupState} step model. + * + * Nothing here loads a launcher or renders UI, so the module can be imported + * and unit-tested without Docker, Ink, or readline. + */ + +import { lstatSync, readFileSync, statSync } from "node:fs"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { resolveGithubAuthMode, type GithubAuthModeResult } from "@propr/shared"; +import { clearEnvKeys as clearEnvFileKeys, upsertEnvVars } from "./envFile.js"; +import { + SETUP_STEP_DEFINITIONS, + type SetupState, + type SetupStep, + type SetupStepId, + type SetupStepPatch, +} from "./types.js"; + +/** + * Sub-directories scaffoldStack creates under the stack root. Exported so the + * setup driver and tests can create/check the same scaffold shape without + * duplicating these names. + */ +export const STACK_SUBDIRS = ["data", "logs", "repos"] as const; + +/** True only when `path` exists and is a directory. Missing paths read false. */ +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +/** True only when `path` exists and is a regular file. Missing paths read false. */ +function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +/** True when a value is missing or contains only whitespace. */ +function isBlank(value: string | undefined): boolean { + return value === undefined || value.trim() === ""; +} + +/** + * Resolve the stack root for setup, reusing the orchestrator's precedence: + * explicit flag → PROPR_ROOT env → saved config stackRoot → cwd. Does not load + * Docker. + */ +export function resolveSetupRoot( + configManager: { getStackRoot(): string | undefined } | undefined, + flagRoot?: string +): string { + if (flagRoot) return resolve(flagRoot); + if (process.env.PROPR_ROOT) return resolve(process.env.PROPR_ROOT); + const saved = configManager?.getStackRoot(); + return saved ? resolve(saved) : process.cwd(); +} + +/** Absolute path to the .env file for a given stack root. */ +export function envPathFor(rootDir: string): string { + return join(rootDir, ".env"); +} + +/** Snapshot of which scaffolded pieces of a stack root already exist. */ +export interface StackInitState { + rootDir: string; + envExists: boolean; + /** Per-subdir existence (data/, logs/, repos/). */ + dirs: Record<(typeof STACK_SUBDIRS)[number], boolean>; + /** True when .env and all expected sub-directories are present. */ + initialized: boolean; +} + +/** + * Inspect whether the stack at `rootDir` looks initialized. Read-only — never + * creates anything — so callers can decide whether to skip or re-run + * scaffolding. A plain file standing in for an expected directory (or vice + * versa) counts as *not* initialized, matching what the runtime requires. + */ +export function inspectStackInit(rootDir: string): StackInitState { + const envExists = isFile(envPathFor(rootDir)); + const dirs = {} as StackInitState["dirs"]; + for (const sub of STACK_SUBDIRS) { + dirs[sub] = isDirectory(join(rootDir, sub)); + } + const initialized = envExists && STACK_SUBDIRS.every((sub) => dirs[sub]); + return { rootDir, envExists, dirs, initialized }; +} + +export type DatastoreAdminStatus = "absent" | "no-admin" | "has-admin" | "uninspectable"; + +/** Result of inspecting the configured SQLite datastore for a durable administrator. */ +export interface DatastoreAdminInspection { + status: DatastoreAdminStatus; + /** Host path inspected, when the configured path could be resolved. */ + databasePath?: string; + /** Actionable diagnostic when inspection could not be completed safely. */ + detail?: string; +} + +/** Runtime paths used by the app image started by the CLI launcher. */ +const APP_WORKDIR = "/usr/src/app"; +const CONTAINER_DATA_DIR = join(APP_WORKDIR, "data"); + +/** + * Resolve the API's SQLite filename to the corresponding host bind-mount path. + * This mirrors @propr/core's DB_FILENAME/DATA_DIR precedence and resolves + * relative values from the app image's working directory. Only files below + * /usr/src/app/data are inspectable from the host because that is the sole data + * bind mount supplied by the CLI launcher. + */ +function resolveDatastorePath( + rootDir: string, + configuredPath: string | undefined, + configuredDataDir: string | undefined +): string { + const dbFilename = configuredPath; + const runtimePath = dbFilename + ? resolve(APP_WORKDIR, dbFilename) + : resolve(APP_WORKDIR, join(configuredDataDir ?? CONTAINER_DATA_DIR, "propr.sqlite")); + const childPath = relative(CONTAINER_DATA_DIR, runtimePath); + const outsideDataDir = + childPath === ".." || childPath.startsWith(`..${sep}`) || isAbsolute(childPath); + if (outsideDataDir) { + throw new Error( + `runtime path ${runtimePath} is outside the mounted data directory ${CONTAINER_DATA_DIR}` + ); + } + return resolve(rootDir, "data", childPath); +} + +/** + * Reject symbolic links between the host bind-mount root and the configured + * datastore. A link that is valid in the host namespace may resolve to a + * different target inside the container, so following it cannot establish + * bootstrap eligibility for the datastore the API will actually use. + */ +function assertDatastorePathHasNoSymlinks(rootDir: string, databasePath: string): void { + const dataRoot = resolve(rootDir, "data"); + const childPath = relative(dataRoot, databasePath); + let currentPath = dataRoot; + + for (const component of childPath.split(sep).filter(Boolean)) { + currentPath = join(currentPath, component); + try { + if (lstatSync(currentPath).isSymbolicLink()) { + throw new Error(`configured datastore path contains a symbolic link: ${currentPath}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + } +} + +/** + * Inspect the configured SQLite datastore without creating or migrating it. + * Missing databases and databases conclusively lacking a durable administrator + * are bootstrap-eligible. Every resolution, I/O, schema, and query failure is + * reported as uninspectable so callers can fail closed. + */ +export async function inspectDatastoreAdministrators(rootDir: string): Promise { + let databasePath: string; + try { + const env = readEnvVars(rootDir); + databasePath = resolveDatastorePath(rootDir, env.DB_FILENAME, env.DATA_DIR); + } catch (error) { + return { + status: "uninspectable", + detail: `could not resolve configured datastore: ${(error as Error).message}`, + }; + } + + try { + assertDatastorePathHasNoSymlinks(rootDir, databasePath); + const stat = statSync(databasePath); + if (!stat.isFile()) { + return { status: "uninspectable", databasePath, detail: "configured datastore is not a regular file" }; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { status: "absent", databasePath }; + } + return { + status: "uninspectable", + databasePath, + detail: `could not inspect configured datastore: ${(error as Error).message}`, + }; + } + + let database: import("node:sqlite").DatabaseSync | undefined; + try { + const { DatabaseSync } = await import("node:sqlite"); + database = new DatabaseSync(databasePath, { readOnly: true, timeout: 5_000 }); + const membersTable = database.prepare( + "SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = 'instance_members' LIMIT 1" + ).get(); + if (!membersTable) return { status: "no-admin", databasePath }; + + const durableAdmin = database.prepare( + "SELECT 1 AS found FROM instance_members WHERE role = 'admin' LIMIT 1" + ).get(); + return { status: durableAdmin ? "has-admin" : "no-admin", databasePath }; + } catch (error) { + return { + status: "uninspectable", + databasePath, + detail: `could not query configured datastore: ${(error as Error).message}`, + }; + } finally { + try { + database?.close(); + } catch { + // The read query already produced a conclusive result; closing the + // read-only handle cannot widen authorization and needs no retry here. + } + } +} + +/** Convenience predicate over {@link inspectStackInit}. */ +export function isStackInitialized(rootDir: string): boolean { + return inspectStackInit(rootDir).initialized; +} + +/** + * Parse the .env at `rootDir` into a flat map. Returns `{}` when the file is + * absent. Mirrors the assignment shape the rest of the stack relies on: + * `KEY=value`, optionally `export `-prefixed, ignoring blanks and comments. + * For unquoted values a trailing ` # comment` is stripped, matching the + * orchestrator's env-file reader (and the round-trip that {@link upsertEnvVars} + * guards against); surrounding quotes on quoted values are stripped and their + * contents kept verbatim. This is intentionally a lightweight reader, not a + * full dotenv implementation — it does not handle escaped quotes or multiline + * values. + */ +export function readEnvVars(rootDir: string): Record { + const envPath = envPathFor(rootDir); + // Treat anything that is not a regular file (absent, a directory, a broken + // symlink) as "no vars", matching inspectStackInit's `isFile` guard, so a + // malformed stack surfaces as not-initialized instead of crashing the read. + if (!isFile(envPath)) return {}; + const vars: Record = {}; + for (const line of readFileSync(envPath, "utf-8").split(/\r?\n/)) { + const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/); + if (!match) continue; + const [, key, rawValue] = match; + const trimmed = rawValue.trim(); + const quoted = trimmed.match(/^(["'])(.*)\1$/); + // Quoted values keep their contents verbatim; unquoted values drop a + // trailing inline comment so reads agree with what upsertEnvVars allows. + vars[key] = quoted ? quoted[2] : trimmed.replace(/\s+#.*$/, ""); + } + return vars; +} + +/** True when `key` is present in .env with a non-blank value. */ +export function hasEnvValue(rootDir: string, key: string): boolean { + return !isBlank(readEnvVars(rootDir)[key]); +} + +/** Outcome of a {@link applyEnvSelection} call. */ +export interface EnvSelectionResult { + /** Keys actually written to .env this call. */ + written: string[]; + /** Keys left untouched because a value already existed (non-overwrite mode). */ + skipped: string[]; +} + +/** + * Safely edit .env for a setup step. + * + * Non-destructive by default: a key is only written when it is currently + * absent/empty, so re-running `propr setup` never clobbers values the user + * already set. Pass `{ overwrite: true }` for steps where the user explicitly + * selected a new value and intends to replace whatever is there. + * + * Blank selections (empty or whitespace-only) are ignored entirely — a step + * that has nothing to write must not blank out an existing value. Writes go + * through + * {@link upsertEnvVars}, which preserves unrelated lines and tightens the + * file's permissions. + */ +export function applyEnvSelection( + rootDir: string, + vars: Record, + opts: { overwrite?: boolean } = {} +): EnvSelectionResult { + const existing = readEnvVars(rootDir); + const toWrite: Record = {}; + const written: string[] = []; + const skipped: string[] = []; + + for (const [key, value] of Object.entries(vars)) { + if (isBlank(value)) continue; // never blank out an existing value + const alreadySet = !isBlank(existing[key]); + if (alreadySet && !opts.overwrite) { + skipped.push(key); + continue; + } + toWrite[key] = value; + written.push(key); + } + + if (written.length > 0) { + upsertEnvVars(envPathFor(rootDir), toWrite); + } + return { written, skipped }; +} + +/** + * Remove `keys` from the stack's `.env` entirely. + * + * {@link applyEnvSelection} can only set keys (and deliberately ignores blank + * values so it never clobbers a value the user set), so it cannot *clear* a key: + * writing `KEY=` would leave an empty assignment that reads back as a set-but- + * empty value. Setup steps that must genuinely drop a stale key — clearing the + * user whitelist back to "none", removing a key when switching modes — call this + * instead. A missing `.env` or absent keys are no-ops. + */ +export function clearEnvKeys(rootDir: string, keys: string[]): void { + clearEnvFileKeys(envPathFor(rootDir), keys); +} + +/** + * Infer the current GitHub auth mode from the stack's .env, so the github-auth + * step can show what is already configured (and skip prompting when valid). + * Reuses the shared resolver the backend uses, so the two can't drift. + */ +export function detectGithubAuthMode(rootDir: string): GithubAuthModeResult { + const env = readEnvVars(rootDir); + const truthy = /^(1|true|yes|on)$/i; + return resolveGithubAuthMode({ + demoMode: truthy.test(env.PROPR_DEMO_MODE ?? ""), + ghAuthMode: env.GH_AUTH_MODE, + relayUrl: env.PROPR_GH_RELAY_URL, + relayToken: env.PROPR_GH_RELAY_TOKEN, + appId: env.GH_APP_ID, + // The CLI stack records the App key as HOST_GH_PRIVATE_KEY (the orchestrator + // bind-mounts it and sets the in-container GH_PRIVATE_KEY_PATH to that path), + // so accept either when inferring app mode — otherwise a stack configured by + // `propr setup` would resolve as "none" despite being fully set up. + privateKeyPath: env.GH_PRIVATE_KEY_PATH ?? env.HOST_GH_PRIVATE_KEY, + installationId: env.GH_INSTALLATION_ID, + }); +} + +/** Build the initial, all-`pending` setup state for a resolved stack root. */ +export function createSetupState(rootDir: string): SetupState { + return { + rootDir, + steps: SETUP_STEP_DEFINITIONS.map((def) => ({ ...def, status: "pending" })), + }; +} + +/** Look up a step by id. */ +export function getStep(state: SetupState, id: SetupStepId): SetupStep | undefined { + return state.steps.find((step) => step.id === id); +} + +/** + * Return a new state with `id`'s step patched. Immutable so renderers can diff + * by reference; unknown ids return the state unchanged. + */ +export function updateStep( + state: SetupState, + id: SetupStepId, + patch: SetupStepPatch +): SetupState { + let changed = false; + const steps = state.steps.map((step) => { + if (step.id !== id) return step; + changed = true; + return { ...step, ...patch }; + }); + return changed ? { ...state, steps } : state; +} + +/** + * The next step the wizard should act on: the first one still `pending`. Used + * by the sequential renderer to drive the flow and by the TUI to highlight the + * current step. + * + * A failed required step blocks everything after it (see the `failed` status in + * ./types.ts), so once one is encountered there is no next step until it is + * retried — `undefined` is returned. Failed *optional* steps don't block. + */ +export function nextPendingStep(state: SetupState): SetupStep | undefined { + // Scan for a blocking failure first so the "a failed required step blocks + // everything after it" contract holds even if state was patched out of + // order (e.g. a later step failed before an earlier one finished). + if (state.steps.some((step) => !step.optional && step.status === "failed")) { + return undefined; + } + return state.steps.find((step) => step.status === "pending"); +} + +/** + * True once every required step has reached a terminal, non-failed state. + * Optional steps never block completion; a single failed required step does. + */ +export function isSetupComplete(state: SetupState): boolean { + return state.steps.every((step) => { + if (step.status === "failed") return false; + if (step.optional) return true; + return step.status === "done" || step.status === "skipped" || step.status === "warning"; + }); +} diff --git a/packages/local-setup/src/types.ts b/packages/local-setup/src/types.ts new file mode 100644 index 000000000..870bfe1e2 --- /dev/null +++ b/packages/local-setup/src/types.ts @@ -0,0 +1,154 @@ +/** + * Local setup engine domain types. + * + * `propr setup` walks a new user through getting a local control-plane stack + * running end to end. The flow coordinates several existing commands + * (environment checks, stack scaffolding, image pulls, agent + GitHub + * configuration, stack startup, whitelist + repo setup, and UI launch). + * + * These types are intentionally free of any rendering concern so the same + * step/status model can drive an Ink TUI and a plain readline fallback. They + * carry no Docker, Ink, or readline imports — see ./state.ts for the pure + * helpers that compute and transition this state. + */ + +/** Stable identifiers for each step of the setup flow, in run order. */ +export type SetupStepId = + | "check" + | "init-stack" + | "pull-images" + | "configure-agents" + | "github-auth" + | "intake" + | "start-stack" + | "enable-agents" + | "whitelist" + | "repo" + | "launch-ui"; + +/** + * Lifecycle status of a single step. + * pending — not started yet + * active — currently running + * done — completed successfully + * skipped — intentionally not run (already satisfied, or an optional step the + * user declined) + * warning — completed but with non-fatal issues the user should see + * failed — errored; blocks any step that depends on it + */ +export type SetupStepStatus = + | "pending" + | "active" + | "done" + | "skipped" + | "warning" + | "failed"; + +/** A single step in the setup flow plus its current presentation state. */ +export interface SetupStep { + id: SetupStepId; + /** Short label for progress lists. */ + title: string; + /** One-line explanation of what the step does. */ + description: string; + /** Optional steps may be skipped without blocking completion. */ + optional: boolean; + status: SetupStepStatus; + /** Live detail line (e.g. "pulled 6 images", "Docker daemon unreachable"). */ + detail?: string; + /** + * Suggested next action when the step is blocked, failed, or needs user + * input — shown by both renderers so the user knows how to proceed. + */ + nextAction?: string; +} + +/** Aggregate state for the whole setup flow. */ +export interface SetupState { + /** Resolved stack root where .env, data/, logs/, repos/ live. */ + rootDir: string; + /** Ordered steps; index order is the intended run order. */ + steps: SetupStep[]; +} + +/** + * Patch applied to a step when transitioning its state. Limited to runtime + * presentation fields — the static flow definition (title, description, + * optional) is canonical and cannot be altered through a patch. + */ +export type SetupStepPatch = Partial>; + +/** + * Canonical, ordered step definitions. All start `pending`; renderers and the + * command driver transition them via the helpers in ./state.ts. + */ +export const SETUP_STEP_DEFINITIONS: ReadonlyArray< + Pick +> = [ + { + id: "check", + title: "Environment checks", + description: "Verify Docker, images, and agent credentials are ready.", + optional: false, + }, + { + id: "init-stack", + title: "Initialize stack", + description: "Scaffold the stack root (.env, data/, logs/, repos/).", + optional: false, + }, + { + id: "pull-images", + title: "Pull images", + description: "Download the ProPR service and agent container images.", + optional: false, + }, + { + id: "configure-agents", + title: "Configure agents", + description: "Record detected host agent-credential directories in .env.", + optional: false, + }, + { + id: "github-auth", + title: "GitHub authentication", + description: "Choose how the backend authenticates to GitHub.", + optional: false, + }, + { + id: "intake", + title: "GitHub intake", + description: "Choose how the backend ingests GitHub events (routing WebSocket, polling, or direct webhooks).", + optional: false, + }, + { + id: "start-stack", + title: "Start stack", + description: "Launch the local control-plane services.", + optional: false, + }, + { + id: "enable-agents", + title: "Enable agents", + description: "Enable the selected agents in the backend and authenticate through their images.", + optional: false, + }, + { + id: "whitelist", + title: "Whitelist setup", + description: "Restrict which GitHub users may trigger ProPR.", + optional: false, + }, + { + id: "repo", + title: "Repository setup", + description: "Optionally connect a first repository to work on.", + optional: true, + }, + { + id: "launch-ui", + title: "Launch UI", + description: "Open the ProPR web UI.", + optional: true, + }, +]; diff --git a/packages/local-setup/tsconfig.json b/packages/local-setup/tsconfig.json new file mode 100644 index 000000000..43ad28167 --- /dev/null +++ b/packages/local-setup/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/local-setup/tsconfig.test.json b/packages/local-setup/tsconfig.test.json new file mode 100644 index 000000000..80b97064a --- /dev/null +++ b/packages/local-setup/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "noEmit": true, "declaration": false }, + "include": ["src/**/*"], + "exclude": [] +} From b08e5f1c6574bc14b162f078091c77276772e00d Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:33:44 +0000 Subject: [PATCH 2/4] feat(ai): Implemented the follow-up fix without committing. Implemented the follow-up fix without committing. - [pr-build-check.yml](/tmp/git-processor/worktrees/integry/propr/pr-1963-followup-2026-08-29T14-25-06/.github/workflows/pr-build-check.yml:34) now builds `@propr/local-setup` before every affected CLI typecheck/test. - [build-publish.mjs](/tmp/git-processor/worktrees/integry/propr/pr-1963-followup-2026-08-29T14-25-06/packages/cli/scripts/build-publish.mjs:77) now builds and vendors `@propr/local-setup`, rewriting private workspace imports for the standalone CLI package. Verified successfully: - Local-setup and CLI typechecks - CLI lint - Hosted-tunnel suite: 312 tests passed - Init JSON suite: 12 passed - Local-setup suite: 9 passed - Agent Skill suite: 62 passed, 1 platform skip - CLI package dry-run and installed-tarball smoke test - YAML parsing and `git diff --check` Docker was unavailable locally, so the exact containerized `actionlint` invocation could not run. PR: #1963 Comment by: @github-actions[bot] (ID: 5462947943) Model: gpt-5.6-sol --- .github/workflows/pr-build-check.yml | 16 +++++--- packages/cli/scripts/build-publish.mjs | 54 ++++++++++++++++---------- 2 files changed, 45 insertions(+), 25 deletions(-) diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index e48d6b813..36b0ccba7 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -34,6 +34,7 @@ jobs: - name: Build and test the CLI Agent Skill run: | npm run build -w @propr/shared + npm run build -w @propr/local-setup npm run typecheck -w @propr/cli npx tsx --experimental-test-module-mocks --test \ packages/cli/src/agentSkill.test.ts \ @@ -82,6 +83,7 @@ jobs: runuser --user node -- env HOME=/home/node bash -euo pipefail <<'NON_ROOT' test "$(node -p 'process.geteuid()')" -ne 0 npm run build -w @propr/shared + npm run build -w @propr/local-setup npx tsx --experimental-test-module-mocks --test \ packages/cli/src/agentSkill.test.ts \ packages/cli/src/agentSkill.forceRace.test.ts \ @@ -111,6 +113,7 @@ jobs: test "$(node -p process.platform)" = darwin test "$(node -p process.arch)" = arm64 npm run build -w @propr/shared + npm run build -w @propr/local-setup npm run typecheck -w @propr/cli npx tsx --experimental-test-module-mocks --test \ packages/cli/src/agentSkill.test.ts \ @@ -281,10 +284,11 @@ jobs: echo echo "--- Hosted tunnel regression tests ---" echo "Running hosted tunnel regression tests..." - # Build @propr/shared first: the tsx and UI tests below import from it, - # so a stale or missing dist in a clean checkout would fail or use old - # output. Build once, up front, before anything that depends on it. + # Build workspace dependencies first: the tsx and UI tests below import + # from them, so a stale or missing dist in a clean checkout would fail + # or use old output. Build once, up front, before their consumers. npm run build -w @propr/shared + npm run build -w @propr/local-setup PROPR_DEMO_MODE=true npx tsx --test \ test/orchestratorConfig.test.mjs \ packages/cli/src/commands/setup/engine.test.ts \ @@ -708,8 +712,10 @@ jobs: - name: Install dependencies run: npm ci - - name: Build shared package - run: npm run build --workspace=@propr/shared + - name: Build workspace dependencies + run: | + npm run build --workspace=@propr/shared + npm run build --workspace=@propr/local-setup - name: Parse init JSON output run: npx tsx --test packages/cli/src/commands/initCommands.test.ts diff --git a/packages/cli/scripts/build-publish.mjs b/packages/cli/scripts/build-publish.mjs index 89dacadf2..ff2f68262 100644 --- a/packages/cli/scripts/build-publish.mjs +++ b/packages/cli/scripts/build-publish.mjs @@ -2,11 +2,11 @@ // Build a standalone, publishable npm package for the CLI. // // The in-repo package is the scoped workspace package `@propr/cli`, which depends -// on the workspace package `@propr/shared`. Neither scoped package is published to -// npm, so we ship the CLI under the unscoped public name `propr-cli` with -// `@propr/shared` *vendored* into `dist/vendor/shared/` (it is dependency-free) and -// the two `@propr/shared` imports rewritten to a relative path. The result has no -// scoped dependencies and installs cleanly from the public registry. +// on the workspace packages `@propr/shared` and `@propr/local-setup`. These scoped +// packages are not published to npm, so we ship the CLI under the unscoped public +// name `propr-cli` with both packages vendored into `dist/vendor/` and their imports +// rewritten to relative paths. The result has no scoped dependencies and installs +// cleanly from the public registry. // // Usage: // node scripts/build-publish.mjs # build the staging package + npm pack --dry-run @@ -35,6 +35,7 @@ const here = dirname(fileURLToPath(import.meta.url)); const cliDir = resolve(here, ".."); const repoRoot = resolve(cliDir, "..", ".."); const sharedDir = join(repoRoot, "packages", "shared"); +const localSetupDir = join(repoRoot, "packages", "local-setup"); const stageDir = join(repoRoot, "dist-publish", "propr-cli"); const CLOUDFLARED_IMAGE = "cloudflare/cloudflared:2024.12.2"; @@ -75,6 +76,7 @@ const buildLauncherManifest = (version) => { // 1. Build the workspace packages we depend on. run("npm", ["run", "build", "-w", "@propr/shared"]); +run("npm", ["run", "build", "-w", "@propr/local-setup"]); run("npm", ["run", "build", "-w", "@propr/cli"]); // 2. Stage the CLI dist + README. @@ -103,12 +105,18 @@ for (const auditedFile of ["directory-operations.c", "README.md"]) { if (!existsSync(bundled)) throw new Error(`Audited native helper file is missing: ${bundled}`); } -// 3. Vendor shared's compiled JS (dependency-free) into dist/vendor/shared. -const vendorDir = join(stageDir, "dist", "vendor", "shared"); -mkdirSync(vendorDir, { recursive: true }); -for (const file of readdirSync(join(sharedDir, "dist"))) { - if (file.endsWith(".js")) { - cpSync(join(sharedDir, "dist", file), join(vendorDir, file)); +// 3. Vendor the compiled workspace packages into dist/vendor. +const vendorRoot = join(stageDir, "dist", "vendor"); +const vendorPackages = [ + { source: sharedDir, destination: join(vendorRoot, "shared") }, + { source: localSetupDir, destination: join(vendorRoot, "local-setup") }, +]; +for (const { source, destination } of vendorPackages) { + mkdirSync(destination, { recursive: true }); + for (const file of readdirSync(join(source, "dist"))) { + if (file.endsWith(".js")) { + cpSync(join(source, "dist", file), join(destination, file)); + } } } @@ -122,23 +130,29 @@ const stripMaps = (dir) => { }; stripMaps(join(stageDir, "dist")); -// 5. Rewrite the `@propr/shared` import specifier to the vendored relative path. -const rewriteSharedImports = (dir) => { +// 5. Rewrite private workspace imports to their vendored relative paths. +const vendoredImports = new Map([ + ["@propr/shared", join(vendorRoot, "shared", "index.js")], + ["@propr/local-setup", join(vendorRoot, "local-setup", "index.js")], +]); +const rewriteVendoredImports = (dir) => { for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) { - rewriteSharedImports(full); + rewriteVendoredImports(full); } else if (entry.name.endsWith(".js")) { - const src = readFileSync(full, "utf8"); - if (src.includes('"@propr/shared"')) { - let sharedPath = relative(dirname(full), join(vendorDir, "index.js")).split(sep).join("/"); - if (!sharedPath.startsWith(".")) sharedPath = `./${sharedPath}`; - writeFileSync(full, src.replaceAll('"@propr/shared"', `"${sharedPath}"`)); + let src = readFileSync(full, "utf8"); + for (const [specifier, target] of vendoredImports) { + if (!src.includes(`"${specifier}"`)) continue; + let vendorPath = relative(dirname(full), target).split(sep).join("/"); + if (!vendorPath.startsWith(".")) vendorPath = `./${vendorPath}`; + src = src.replaceAll(`"${specifier}"`, `"${vendorPath}"`); } + writeFileSync(full, src); } } }; -rewriteSharedImports(join(stageDir, "dist")); +rewriteVendoredImports(join(stageDir, "dist")); // 6. Write the unscoped package.json (no scoped deps, no build scripts). const cliPkg = JSON.parse(readFileSync(join(cliDir, "package.json"), "utf8")); From 56c97eb6401c50d6894eb7491c46c4379972d1aa Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:37:29 +0000 Subject: [PATCH 3/4] feat(ai): Fixed the full-suite failure in [taskInspectCommands.test.ts](/tmp/git-processor/worktrees/integry/propr/pr-1963-followup-2026-08-29T14-34-00/packages/cli/src/commands/taskInspectCommands.test.ts:107). Fixed the full-suite failure in [taskInspectCommands.test.ts](/tmp/git-processor/worktrees/integry/propr/pr-1963-followup-2026-08-29T14-34-00/packages/cli/src/commands/taskInspectCommands.test.ts:107). The test now validates concurrent state requests without assuming nondeterministic request order. Canonical output ordering remains separately verified. Validation: - Focused test passed 20 consecutive runs - CLI typecheck passed - `git diff --check` passed - No commit created PR: #1963 Comment by: @github-actions[bot] (ID: 5462991038) Model: gpt-5.6-sol --- packages/cli/src/commands/taskInspectCommands.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/taskInspectCommands.test.ts b/packages/cli/src/commands/taskInspectCommands.test.ts index ad2394e5f..f5fb011ad 100644 --- a/packages/cli/src/commands/taskInspectCommands.test.ts +++ b/packages/cli/src/commands/taskInspectCommands.test.ts @@ -105,8 +105,8 @@ test("task inspect defaults to every canonical active state, including queued wo })); assert.deepEqual( - result.requests.map((url) => url.searchParams.get("status")), - [...ACTIVE_TASK_LIFECYCLE_STATES] + result.requests.map((url) => url.searchParams.get("status")).sort(), + [...ACTIVE_TASK_LIFECYCLE_STATES].sort() ); const output = JSON.parse(result.stdout.join("\n")); assert.deepEqual(output.states, [...ACTIVE_TASK_LIFECYCLE_STATES]); From b20ae26d4c3975ca1bf33b84496633a0437a4213 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:17:00 +0000 Subject: [PATCH 4/4] feat(ai): Implemented the follow-up in [cli-node-compatibility.yml](/tmp/git-processor/worktrees/integry/propr/pr-1963-followup-2026-08-29T14-56-46/.github/workflows/cli-node-compatibility.yml:6): Implemented the follow-up in [cli-node-compatibility.yml](/tmp/git-processor/worktrees/integry/propr/pr-1963-followup-2026-08-29T14-56-46/.github/workflows/cli-node-compatibility.yml:6): - Builds `@propr/shared`, then `@propr/local-setup`, before project-options tests. - Triggers compatibility checks when `packages/local-setup/**` changes. - Audited other clean-checkout CLI paths; they already prepare both dependencies via explicit builds, `test:prepare`, or self-contained `cli:pack`. Validation passed: - Node 22: 26/26 project-options tests - Node 24: 26/26 project-options tests - Full suite: all 309 test files plus UI workspace passed; UI 465/465 tests - `npm run cli:pack` - Actionlint 1.7.12 - Workflow YAML parsing and `git diff --check` Only the compatibility workflow is modified. No commit was created. PR: #1963 Comment by: @integry (ID: 5463097453) Model: gpt-5.6-sol --- .github/workflows/cli-node-compatibility.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cli-node-compatibility.yml b/.github/workflows/cli-node-compatibility.yml index 1d44ef550..416e5a6bd 100644 --- a/.github/workflows/cli-node-compatibility.yml +++ b/.github/workflows/cli-node-compatibility.yml @@ -6,6 +6,7 @@ on: - '.github/workflows/cli-node-compatibility.yml' - 'package-lock.json' - 'packages/cli/**' + - 'packages/local-setup/**' - 'packages/shared/**' concurrency: @@ -37,8 +38,10 @@ jobs: - name: Install dependencies run: npm ci - - name: Build shared dependency - run: npm run build -w @propr/shared + - name: Build workspace dependencies + run: | + npm run build -w @propr/shared + npm run build -w @propr/local-setup - name: Run project option regressions run: >-