diff --git a/packages/cli/src/commands/hub/client.test.ts b/packages/cli/src/commands/hub/client.test.ts index 7cb47ba82..f4948b320 100644 --- a/packages/cli/src/commands/hub/client.test.ts +++ b/packages/cli/src/commands/hub/client.test.ts @@ -91,6 +91,35 @@ describe("Hub HTTP client", () => { ); }); + it("reads configuration resources in the Hub's slug vocabulary", async () => { + const requests: Array<{ url: string | undefined; body: string }> = []; + const origin = await startServer( + () => ({ + status: 200, + body: { + daemons: [{ id: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", slug: "macbook" }], + github: [ + { + slug: "getpaseo", + accountLogin: "getpaseo", + accountType: "Organization", + repositories: ["getpaseo/paseo"], + }, + ], + discord: [{ slug: "paseo", guildName: "Paseo" }], + slack: [{ slug: "paseo", teamName: "Paseo" }], + }, + }), + requests, + ); + + const resources = await new HubHttpClient().listConfigurationResources(origin, "secret"); + + assert.equal(resources.daemons[0]?.slug, "macbook"); + assert.equal(resources.discord[0]?.slug, "paseo"); + assert.equal(requests[0]?.url, "/api/v1/configuration-resources"); + }); + it("renders file-aware Hub validation issues without exposing credentials or response bodies", async () => { const requests: Array<{ url: string | undefined; body: string }> = []; const origin = await startServer( @@ -126,10 +155,14 @@ describe("Hub HTTP client", () => { (error: unknown) => { assert.ok(error instanceof HubCommandError); assert.equal(error.message.includes("operator-secret"), false); + assert.equal(error.message.includes("Correct the canonical"), false); assert.equal(error.details?.includes("operator-secret"), false); assert.equal(error.message.includes("sensitive bundle content"), false); assert.equal(error.details?.includes("sensitive bundle content"), false); - assert.match(error.details ?? "", /\.paseo\/workflows\/answer\.yml\.steps\.work\.agent/u); + assert.equal( + error.details, + ".paseo/workflows/answer.yml: steps.work.agent: unknown named agent [redacted]", + ); return true; }, ); diff --git a/packages/cli/src/commands/hub/commands.test.ts b/packages/cli/src/commands/hub/commands.test.ts index a68f1ab2d..0857bd197 100644 --- a/packages/cli/src/commands/hub/commands.test.ts +++ b/packages/cli/src/commands/hub/commands.test.ts @@ -21,6 +21,7 @@ describe("Hub commands", () => { assert.deepEqual(names, [ "login", + "init", "connect", "status", "disconnect", diff --git a/packages/cli/src/commands/hub/deploy.ts b/packages/cli/src/commands/hub/deploy.ts index 7cf4cc579..d64137879 100644 --- a/packages/cli/src/commands/hub/deploy.ts +++ b/packages/cli/src/commands/hub/deploy.ts @@ -8,7 +8,7 @@ import { type HubValidationResult, } from "./hub-client/index.js"; import { PrivateHubCredentialStore, type HubCredentialStore } from "./credentials.js"; -import { discoverHubBundle } from "./deploy-bundle.js"; +import { discoverHubBundle, type HubDeployBundle } from "./deploy-bundle.js"; import { processHubReporter, reportHubProgress, type HubReporter } from "./reporter.js"; import { addHubResolutionHelp } from "./help.js"; @@ -20,7 +20,7 @@ export interface HubDeployOptions { json?: boolean; } -interface HubDeployEnvironment { +export interface HubDeployEnvironment { cwd: string; env: Readonly>; credentials?: HubCredentialStore; @@ -76,6 +76,18 @@ export async function runHubDeploy( credentials: new PrivateHubCredentialStore(), hub: new HubHttpClient(), }, +): Promise | SingleResult> { + const deployInput = await discoverHubBundle({ + cwd: environment.cwd, + ...(options.project === undefined ? {} : { project: options.project }), + }); + return runHubDeployBundle(options, deployInput, environment); +} + +export async function runHubDeployBundle( + options: HubDeployOptions, + deployInput: HubDeployBundle, + environment: HubDeployEnvironment, ): Promise | SingleResult> { const credentials = environment.credentials ?? new PrivateHubCredentialStore(environment.env); const resolution = { @@ -84,10 +96,6 @@ export async function runHubDeploy( credentials, }; const origin = resolveHubOrigin(resolution); - const deployInput = await discoverHubBundle({ - cwd: environment.cwd, - ...(options.project === undefined ? {} : { project: options.project }), - }); const action = options.dryRun === true ? "Validating" : "Deploying"; reportHubProgress( environment.reporter ?? processHubReporter, diff --git a/packages/cli/src/commands/hub/hub-client/index.ts b/packages/cli/src/commands/hub/hub-client/index.ts index 8bc50b314..3086d648f 100644 --- a/packages/cli/src/commands/hub/hub-client/index.ts +++ b/packages/cli/src/commands/hub/hub-client/index.ts @@ -3,6 +3,7 @@ import { HubCommandError } from "../error.js"; import { authorizationPollSchema, authorizationSchema, + configurationResourcesSchema, enrollmentTokenSchema, installResponseSchema, projectsResponseSchema, @@ -10,6 +11,7 @@ import { type CliAuthorization, type CliAuthorizationPoll, type HubInstallResult, + type HubConfigurationResources, type HubProject, type HubValidationResult, } from "./internal/contracts.js"; @@ -19,6 +21,7 @@ export type { CliAuthorization, CliAuthorizationPoll, HubInstallResult, + HubConfigurationResources, HubProject, HubValidationResult, } from "./internal/contracts.js"; @@ -80,6 +83,18 @@ export class HubHttpClient { return response.projects; } + listConfigurationResources(origin: string, apiKey: string): Promise { + return requestHub({ + origin, + path: "/api/v1/configuration-resources", + method: "GET", + apiKey, + successStatus: 200, + schema: configurationResourcesSchema, + failureMessage: "Hub configuration resource listing failed", + }); + } + installConfiguration(input: HubConfigurationInput): Promise { return requestHub({ origin: input.origin, diff --git a/packages/cli/src/commands/hub/hub-client/internal/contracts.ts b/packages/cli/src/commands/hub/hub-client/internal/contracts.ts index efbbf27d6..82b7e3ca5 100644 --- a/packages/cli/src/commands/hub/hub-client/internal/contracts.ts +++ b/packages/cli/src/commands/hub/hub-client/internal/contracts.ts @@ -36,6 +36,24 @@ const projectSchema = z export const projectsResponseSchema = z.object({ projects: z.array(projectSchema) }).strict(); +export const configurationResourcesSchema = z + .object({ + daemons: z.array(z.object({ id: z.string().uuid(), slug: z.string().min(1) }).strict()), + github: z.array( + z + .object({ + slug: z.string().min(1), + accountLogin: z.string().min(1), + accountType: z.string().min(1), + repositories: z.array(z.string().min(1)), + }) + .strict(), + ), + discord: z.array(z.object({ slug: z.string().min(1), guildName: z.string().min(1) }).strict()), + slack: z.array(z.object({ slug: z.string().min(1), teamName: z.string().min(1) }).strict()), + }) + .strict(); + export const installResponseSchema = z .object({ projectSlug: z.string().min(1), @@ -56,5 +74,6 @@ export const enrollmentTokenSchema = z export type CliAuthorization = z.infer; export type CliAuthorizationPoll = z.infer; export type HubProject = z.infer; +export type HubConfigurationResources = z.infer; export type HubInstallResult = z.infer; export type HubValidationResult = z.infer; diff --git a/packages/cli/src/commands/hub/hub-client/internal/problem.ts b/packages/cli/src/commands/hub/hub-client/internal/problem.ts index 8ed7bdf30..38482953d 100644 --- a/packages/cli/src/commands/hub/hub-client/internal/problem.ts +++ b/packages/cli/src/commands/hub/hub-client/internal/problem.ts @@ -51,9 +51,14 @@ export async function hubRequestFailure( ); } const title = parsed.data.title ?? `${failureMessage} with HTTP ${response.status}`; - const message = parsed.data.detail === undefined ? title : `${title}: ${parsed.data.detail}`; const details = formatFieldIssues(parsed.data.errors, parsed.data.issues); - const code = response.status === 422 ? "HUB_VALIDATION_FAILED" : "HUB_REQUEST_FAILED"; + const message = + details !== undefined || parsed.data.detail === undefined + ? title + : `${title}: ${parsed.data.detail}`; + let code = "HUB_REQUEST_FAILED"; + if (response.status === 422) code = "HUB_VALIDATION_FAILED"; + if (response.status === 404) code = "HUB_NOT_FOUND"; return new HubCommandError( code, redactSecret(message, apiKey), @@ -82,12 +87,20 @@ function formatFieldIssues( function formatIssuePath(path: z.infer | undefined): string | undefined { if (path === undefined || typeof path === "string") return path; + const [file, ...fieldPath] = path; + if (typeof file === "string" && file.startsWith(".paseo/") && fieldPath.length > 0) { + return `${file}: ${formatPathSegments(fieldPath)}`; + } + return formatPathSegments(path) || undefined; +} + +function formatPathSegments(path: readonly (string | number)[]): string { let formatted = ""; for (const segment of path) { if (typeof segment === "number") formatted += `[${segment}]`; else formatted += formatted.length === 0 ? segment : `.${segment}`; } - return formatted || undefined; + return formatted; } function redactSecret(value: string, secret: string | undefined): string { diff --git a/packages/cli/src/commands/hub/index.ts b/packages/cli/src/commands/hub/index.ts index 1dac54fc7..3edd48ef3 100644 --- a/packages/cli/src/commands/hub/index.ts +++ b/packages/cli/src/commands/hub/index.ts @@ -18,6 +18,7 @@ import { addHubProjectsCommand } from "./projects.js"; import { processHubReporter, type HubReporter } from "./reporter.js"; import { hubStatusResult } from "./status-output.js"; import { addHubResolutionHelp } from "./help.js"; +import { addHubInitCommand } from "./init.js"; interface HubCommandEnvironment { env: Readonly>; @@ -56,6 +57,7 @@ export function createHubCommand(overrides: Partial = {}) flow: environment.login, reporter: environment.reporter, }); + addHubInitCommand(hub, environment); addHubConnectCommand(hub, { env: environment.env, credentials: environment.credentials, diff --git a/packages/cli/src/commands/hub/init-plan.test.ts b/packages/cli/src/commands/hub/init-plan.test.ts new file mode 100644 index 000000000..afc91a690 --- /dev/null +++ b/packages/cli/src/commands/hub/init-plan.test.ts @@ -0,0 +1,159 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import YAML from "yaml"; +import { afterEach, describe, expect, it } from "vitest"; +import { discoverHubBundle } from "./deploy-bundle.js"; +import { runHubDeploy } from "./deploy.js"; +import { + createHubInitScaffold, + planHubInitOpening, + resolveHubInitConnection, + resolveHubInitProjects, + type HubInitProvider, +} from "./init-plan.js"; +import { githubRepositoryFromRemote } from "./init.js"; + +const directories: string[] = []; +const project = (slug: string) => ({ + id: "7e950d84-eec0-4e18-b1f9-9c115bdb31e4", + slug, + name: slug.toUpperCase(), +}); + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); +}); + +describe("Hub init planning", () => { + it("includes login only when there is no active login", () => { + expect(planHubInitOpening({ loggedIn: false, paseoDirectoryExists: false })).toEqual({ + replaceExisting: false, + steps: ["login", "connect", "project", "scaffold"], + }); + expect(planHubInitOpening({ loggedIn: true, paseoDirectoryExists: false })).toEqual({ + replaceExisting: false, + steps: ["connect", "project", "scaffold"], + }); + }); + + it("plans a confirmed replacement for an existing .paseo directory", () => { + expect(planHubInitOpening({ loggedIn: true, paseoDirectoryExists: true })).toEqual({ + replaceExisting: true, + steps: ["connect", "project", "scaffold"], + }); + }); + + it("stops, defaults, or asks according to the project count", () => { + expect(resolveHubInitProjects([])).toEqual({ kind: "none" }); + expect(resolveHubInitProjects([project("one")])).toEqual({ + kind: "selected", + project: project("one"), + }); + expect(resolveHubInitProjects([project("one"), project("two")])).toEqual({ + kind: "choose", + projects: [project("one"), project("two")], + }); + }); + + it("reuses a connected daemon, waits for reconnect, and rejects a different Hub", () => { + const status = { + state: "connected", + daemonId: "daemon-1", + hubOrigin: "https://hub.test", + scopes: ["hub.execution.*"], + connectedAt: null, + lastError: null, + }; + expect(resolveHubInitConnection(status, "https://hub.test")).toEqual({ + kind: "connected", + daemonId: "daemon-1", + }); + expect(resolveHubInitConnection(status, "https://other.test")).toEqual({ + kind: "conflict", + origin: "https://hub.test", + }); + expect( + resolveHubInitConnection({ ...status, state: "reconnecting" }, "https://hub.test"), + ).toEqual({ kind: "pending", state: "reconnecting" }); + expect( + resolveHubInitConnection( + { ...status, state: "not_connected", daemonId: null, hubOrigin: null }, + "https://hub.test", + ), + ).toEqual({ kind: "connect" }); + }); +}); + +describe("Hub init scaffold", () => { + it.each([ + ["github", { repo: "getpaseo/paseo", user: "boudra" }], + ["slack", { workspace: "paseo", user: "boudra" }], + ["discord", { guild: "paseo", user: "boudra" }], + ] satisfies readonly [HubInitProvider, Record][])( + "creates a closed %s trigger that the deploy discovery path accepts", + async (provider, providerFilters) => { + const cwd = await temporaryDirectory(); + const scaffold = createHubInitScaffold({ + cwd, + daemonSlug: "build-studio", + provider, + providerFilters, + }); + await mkdir(path.join(cwd, ".paseo", "workflows"), { recursive: true }); + await writeFile(path.join(cwd, ".paseo", "hub.yml"), scaffold.hub); + await writeFile(path.join(cwd, scaffold.workflowPath), scaffold.workflow); + + const bundle = await discoverHubBundle({ cwd, project: "paseo" }); + expect(bundle.workflowCount).toBe(1); + expect(bundle.files.map((file) => file.path)).toEqual([ + ".paseo/hub.yml", + scaffold.workflowPath, + ]); + const parsed = YAML.parse(scaffold.workflow) as { + filters: { from_users: string[]; channels?: string[] }; + }; + expect(parsed.filters.from_users).toEqual([providerFilters.user]); + expect(parsed.filters.channels).toBeUndefined(); + + let validatedPaths: readonly string[] = []; + const result = await runHubDeploy( + { project: "paseo", hub: "https://hub.test", dryRun: true }, + { + cwd, + env: { PASEO_HUB_API_KEY: "test-key" }, + reporter: { progress() {} }, + hub: { + async validateConfiguration(input) { + validatedPaths = input.files.map((file) => file.path); + for (const file of input.files) YAML.parse(file.content); + return { projectSlug: input.projectSlug, valid: true }; + }, + async installConfiguration() { + throw new Error("dry-run must not install"); + }, + }, + }, + ); + expect(result.data).toMatchObject({ projectSlug: "paseo", valid: true, workflows: 1 }); + expect(validatedPaths).toEqual([".paseo/hub.yml", scaffold.workflowPath]); + }, + ); +}); + +describe("GitHub origin detection", () => { + it.each([ + ["git@github.com:getpaseo/paseo.git", "getpaseo/paseo"], + ["ssh://git@github.com/getpaseo/paseo.git", "getpaseo/paseo"], + ["https://github.com/getpaseo/paseo.git", "getpaseo/paseo"], + ["https://gitlab.com/getpaseo/paseo.git", undefined], + ])("resolves %s", (remote, expected) => { + expect(githubRepositoryFromRemote(remote)).toBe(expected); + }); +}); + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-init-")); + directories.push(directory); + return directory; +} diff --git a/packages/cli/src/commands/hub/init-plan.ts b/packages/cli/src/commands/hub/init-plan.ts new file mode 100644 index 000000000..88e90de38 --- /dev/null +++ b/packages/cli/src/commands/hub/init-plan.ts @@ -0,0 +1,201 @@ +import path from "node:path"; +import YAML from "yaml"; +import type { HubDeployBundle } from "./deploy-bundle.js"; +import type { HubProject } from "./hub-client/index.js"; +import type { HubStatus } from "./daemon-client.js"; + +export type HubInitProvider = "github" | "slack" | "discord"; + +export type HubInitProjectResolution = + | { kind: "none" } + | { kind: "selected"; project: HubProject } + | { kind: "choose"; projects: readonly HubProject[] }; + +export type HubInitConnectionResolution = + | { kind: "connected"; daemonId: string } + | { kind: "pending"; state: "connecting" | "reconnecting" } + | { kind: "connect" } + | { kind: "conflict"; origin: string }; + +export interface HubInitOpeningPlan { + replaceExisting: boolean; + steps: readonly ("login" | "connect" | "project" | "scaffold")[]; +} + +export interface HubInitScaffoldInput { + cwd: string; + daemonSlug: string; + provider: HubInitProvider; + providerFilters: Readonly>; +} + +export interface HubInitScaffold { + hub: string; + workflowPath: string; + workflow: string; + testAction: string; +} + +export function planHubInitOpening(input: { + loggedIn: boolean; + paseoDirectoryExists: boolean; +}): HubInitOpeningPlan { + return { + replaceExisting: input.paseoDirectoryExists, + steps: [...(input.loggedIn ? [] : (["login"] as const)), "connect", "project", "scaffold"], + }; +} + +export function createHubInitBundle( + projectSlug: string, + scaffold: HubInitScaffold, +): HubDeployBundle { + return { + projectSlug, + workflowCount: 1, + files: [ + { path: ".paseo/hub.yml", content: scaffold.hub }, + { path: scaffold.workflowPath, content: scaffold.workflow }, + ], + }; +} + +export function resolveHubInitProjects(projects: readonly HubProject[]): HubInitProjectResolution { + if (projects.length === 0) return { kind: "none" }; + if (projects.length === 1) return { kind: "selected", project: projects[0]! }; + return { kind: "choose", projects }; +} + +export function resolveHubInitConnection( + status: HubStatus, + origin: string, +): HubInitConnectionResolution { + if (status.state === "connected" && status.hubOrigin === origin && status.daemonId !== null) { + return { kind: "connected", daemonId: status.daemonId }; + } + if ( + (status.state === "connecting" || status.state === "reconnecting") && + status.hubOrigin === origin + ) { + return { kind: "pending", state: status.state }; + } + if (status.hubOrigin !== null && status.state !== "not_connected" && status.state !== "revoked") { + return { kind: "conflict", origin: status.hubOrigin }; + } + return { kind: "connect" }; +} + +export function createHubInitScaffold(input: HubInitScaffoldInput): HubInitScaffold { + const environmentName = input.daemonSlug; + const hub = YAML.stringify( + { + environments: { + [environmentName]: { + kind: "daemon", + daemon: input.daemonSlug, + cwd: path.resolve(input.cwd), + }, + }, + agents: { + codex: { + provider: "codex", + mode: "full-access", + }, + }, + }, + { lineWidth: 0 }, + ); + const provider = providerScaffold(input.provider, input.providerFilters, environmentName); + return { + hub, + workflowPath: `.paseo/workflows/${input.provider}-help.yml`, + workflow: YAML.stringify(provider.workflow, { lineWidth: 0 }), + testAction: provider.testAction, + }; +} + +function providerScaffold( + provider: HubInitProvider, + filters: Readonly>, + environment: string, +): { workflow: object; testAction: string } { + if (provider === "github") { + const repo = requireFilter(filters, "repo"); + const user = requireFilter(filters, "user"); + return { + workflow: workflow({ + name: "github-help", + on: "github.issue_comment", + filters: { repo, contains: "@paseo", from_users: [user] }, + environment, + }), + testAction: `Comment \`@paseo have a look\` on ${repo}.`, + }; + } + + const user = requireFilter(filters, "user"); + if (provider === "slack") { + const workspace = requireFilter(filters, "workspace"); + return { + workflow: workflow({ + name: "slack-help", + on: "slack.mention", + filters: { workspace, from_users: [user] }, + environment, + reply: "slack.reply", + }), + testAction: "Mention `@Paseo have a look` in Slack.", + }; + } + + const guild = requireFilter(filters, "guild"); + return { + workflow: workflow({ + name: "discord-help", + on: "discord.mention", + filters: { guild, from_users: [user] }, + environment, + reply: "discord.reply", + }), + testAction: "Mention `@Paseo have a look` in Discord.", + }; +} + +function workflow(input: { + name: string; + on: string; + filters: object; + environment: string; + reply?: "slack.reply" | "discord.reply"; +}): object { + const replyInstruction = input.reply === undefined ? "" : "Answer with hub.reply, then "; + return { + name: input.name, + on: input.on, + max_runtime: "2h", + filters: input.filters, + steps: [ + { + id: "work", + environment: input.environment, + max_runtime: "90m", + idle_timeout: "10m", + agent: "codex", + prompt: [ + { + text: `${replyInstruction}complete this request and call hub.finish_execution when done.\n\n\n\${{ paseo.prompt }}\n\n`, + }, + ], + ...(input.reply === undefined + ? {} + : { allow_outputs: [{ type: input.reply, max: 1, required: true }] }), + }, + ], + }; +} + +function requireFilter(filters: Readonly>, name: string): string { + const value = filters[name]?.trim(); + if (!value) throw new Error(`${name} is required for this provider`); + return value; +} diff --git a/packages/cli/src/commands/hub/init.ts b/packages/cli/src/commands/hub/init.ts new file mode 100644 index 000000000..c86f79b4e --- /dev/null +++ b/packages/cli/src/commands/hub/init.ts @@ -0,0 +1,558 @@ +import { + cancel, + confirm, + intro, + isCancel, + log, + note, + outro, + select, + spinner, + text, +} from "@clack/prompts"; +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { lstat, mkdir, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import type { Command } from "commander"; +import { DEFAULT_HUB_ORIGIN, resolveHubCredential } from "./authority.js"; +import type { HubCredentialStore } from "./credentials.js"; +import type { HubDaemonConnection } from "./daemon-client.js"; +import { withHubDaemon } from "./daemon-client.js"; +import { HubCommandError } from "./error.js"; +import type { HubConfigurationResources, HubHttpClient, HubProject } from "./hub-client/index.js"; +import { + createHubInitBundle, + createHubInitScaffold, + planHubInitOpening, + resolveHubInitConnection, + resolveHubInitProjects, + type HubInitProvider, +} from "./init-plan.js"; +import type { CliLoginFlow } from "./login-flow.js"; +import { runHubLogin } from "./login.js"; +import { runHubConnect } from "./connect.js"; +import { runHubDeployBundle } from "./deploy.js"; +import { runHubProjects } from "./projects.js"; +import type { HubReporter } from "./reporter.js"; +import { normalizeHubOrigin } from "./origin.js"; + +const execFileAsync = promisify(execFile); +const DAEMON_READY_TIMEOUT_MS = 60_000; +const DAEMON_READY_POLL_MS = 250; + +interface HubInitEnvironment { + env: Readonly>; + credentials: HubCredentialStore; + hub: HubHttpClient; + login: Pick; + daemon: HubDaemonConnection; + reporter: HubReporter; + cwd(): string; +} + +class HubInitCancelledError extends Error {} + +function initErrorMessage(error: unknown): string { + if (error instanceof HubCommandError && error.details) { + return `${error.message}\n${error.details}`; + } + return error instanceof Error ? error.message : String(error); +} + +export function addHubInitCommand(parent: Command, environment: HubInitEnvironment): void { + parent + .command("init") + .description("Create and optionally deploy a safe starter Hub bundle") + .action(async () => { + try { + await runHubInit(environment); + } catch (error) { + if (error instanceof HubInitCancelledError) { + cancel(error.message); + return; + } + cancel(initErrorMessage(error)); + process.exitCode = 1; + } + }); +} + +export async function runHubInit(environment: HubInitEnvironment): Promise { + requireInteractiveTerminal(); + intro("Set up Paseo Hub"); + + const cwd = environment.cwd(); + const activeLogin = environment.credentials.active(); + const opening = planHubInitOpening({ + loggedIn: activeLogin !== null, + paseoDirectoryExists: await pathExists(path.join(cwd, ".paseo")), + }); + if ( + opening.replaceExisting && + !(await requiredConfirm("Replace the existing .paseo/ Hub bundle?", false)) + ) { + throw new HubInitCancelledError("Existing .paseo/ bundle left unchanged."); + } + + const origin = await ensureLogin(activeLogin?.origin, environment); + const daemonId = await ensureDaemonConnection(origin, environment); + const project = await chooseProject(origin, environment); + const resources = await loadConfigurationResources(origin, environment); + const daemon = resources.daemons.find(({ id }) => id === daemonId); + if (daemon === undefined) { + throw new HubCommandError( + "HUB_DAEMON_RESOURCE_MISSING", + "The connected daemon is not available in this Hub organization. Reconnect it and try again.", + ); + } + log.success(`Connected as ${daemon.slug}`); + const provider = await chooseProvider(); + const providerFilters = await collectProviderFilters(provider, resources, cwd); + const scaffold = createHubInitScaffold({ + cwd, + daemonSlug: daemon.slug, + provider, + providerFilters, + }); + + const bundle = createHubInitBundle(project.slug, scaffold); + + await withSpinner("Validating bundle", async () => { + await runHubDeployBundle({ project: project.slug, hub: origin, dryRun: true }, bundle, { + cwd, + env: environment.env, + credentials: environment.credentials, + hub: environment.hub, + reporter: { progress() {} }, + }); + }); + log.success("Dry run passed"); + await writeScaffold(cwd, scaffold, opening.replaceExisting); + log.success(`Created .paseo/hub.yml and ${scaffold.workflowPath}`); + + const deploy = await requiredConfirm("Deploy now?", true); + if (deploy) { + await withSpinner("Deploying bundle", async () => { + await runHubDeployBundle({ project: project.slug, hub: origin }, bundle, { + cwd, + env: environment.env, + credentials: environment.credentials, + hub: environment.hub, + reporter: { progress() {} }, + }); + }); + log.success("Deployed"); + } else { + log.message(`Skipped deployment. Run: paseo hub deploy -p ${project.slug}`); + } + + const activityUrl = new URL(`/projects/${project.slug}/activity`, origin).toString(); + note(`${scaffold.testAction}\nWatch it at ${activityUrl}`, "Test your workflow"); + outro(deploy ? "Hub is ready" : "Hub bundle is ready"); +} + +async function ensureLogin( + activeOrigin: string | undefined, + environment: HubInitEnvironment, +): Promise { + const endpoint = await requiredSelect({ + message: "Hub endpoint", + initialValue: + activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN ? "hosted" : "custom", + options: [ + { value: "hosted", label: "hub.paseo.sh" }, + { value: "custom", label: "Custom endpoint…" }, + ], + }); + const origin = + endpoint === "hosted" + ? DEFAULT_HUB_ORIGIN + : await requiredText({ + message: "Custom Hub URL", + initialValue: + activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN + ? environment.env.PASEO_HUB_URL + : activeOrigin, + validate(value) { + try { + normalizeHubOrigin(value ?? ""); + } catch { + return "Enter a valid Hub URL"; + } + return undefined; + }, + }); + const normalizedOrigin = normalizeHubOrigin(origin); + if (environment.credentials.get(normalizedOrigin) !== null) { + log.success(`Logged in to ${normalizedOrigin}`); + return normalizedOrigin; + } + await runHubLogin( + normalizedOrigin, + {}, + { + env: environment.env, + credentials: environment.credentials, + flow: environment.login, + reporter: environment.reporter, + }, + ); + log.success(`Logged in to ${normalizedOrigin}`); + return normalizedOrigin; +} + +async function ensureDaemonConnection( + origin: string, + environment: HubInitEnvironment, +): Promise { + const status = await withHubDaemon(environment.daemon, undefined, async (daemon) => + daemon.getHubStatus().then((response) => response.status), + ); + const connection = resolveHubInitConnection(status, origin); + if (connection.kind === "connected") { + return connection.daemonId; + } + if (connection.kind === "pending") { + return waitForDaemonReady(origin, environment.daemon); + } + if (connection.kind === "conflict") { + throw new HubCommandError( + "HUB_DAEMON_ALREADY_CONNECTED", + `This daemon is connected to ${connection.origin}. Disconnect it before running Hub init for ${origin}.`, + ); + } + if (!(await requiredConfirm(`Connect this daemon to ${origin}?`, true))) { + throw new HubInitCancelledError("A connected daemon is required to create the bundle."); + } + await runHubConnect( + origin, + {}, + { + env: environment.env, + credentials: environment.credentials, + hub: environment.hub, + daemon: environment.daemon, + reporter: environment.reporter, + }, + ); + return waitForDaemonReady(origin, environment.daemon); +} + +async function waitForDaemonReady( + origin: string, + connection: HubDaemonConnection, +): Promise { + return withSpinner("Waiting for the daemon to connect", async (reporter) => + withHubDaemon(connection, undefined, async (daemon) => { + const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS; + while (true) { + const status = (await daemon.getHubStatus()).status; + const resolution = resolveHubInitConnection(status, origin); + if (resolution.kind === "connected") return resolution.daemonId; + if (resolution.kind === "conflict") { + throw new HubCommandError( + "HUB_DAEMON_ALREADY_CONNECTED", + `This daemon connected to ${resolution.origin} while Hub init was waiting for ${origin}.`, + ); + } + if (resolution.kind === "connect") { + throw new HubCommandError( + "HUB_DAEMON_CONNECTION_LOST", + "The daemon lost its Hub relationship while Hub init was waiting for it.", + ); + } + if (Date.now() >= deadline) { + throw new HubCommandError( + "HUB_DAEMON_CONNECTION_TIMEOUT", + "The daemon did not connect within 60 seconds. Check `paseo hub status`, then run Hub init again.", + ); + } + reporter.progress(`Daemon is ${resolution.state}`); + await delay(DAEMON_READY_POLL_MS); + } + }), + ); +} + +async function chooseProject(origin: string, environment: HubInitEnvironment): Promise { + const result = await runHubProjects( + { hub: origin }, + { + env: environment.env, + credentials: environment.credentials, + hub: environment.hub, + reporter: { progress() {} }, + }, + ); + const resolution = resolveHubInitProjects(result.data.projects); + if (resolution.kind === "none") { + throw new HubInitCancelledError( + `No Hub projects exist yet. Create one at ${new URL("/projects/new", origin).toString()}, then run paseo hub init again.`, + ); + } + if (resolution.kind === "selected") { + return resolution.project; + } + const slug = await requiredSelect({ + message: "Project", + options: resolution.projects.map((project) => ({ + value: project.slug, + label: project.name, + hint: project.slug, + })), + }); + const project = resolution.projects.find((candidate) => candidate.slug === slug); + if (project === undefined) { + throw new HubCommandError( + "HUB_PROJECT_SELECTION_INVALID", + "The selected Hub project is unavailable.", + ); + } + return project; +} + +async function chooseProvider(): Promise { + return requiredSelect({ + message: "Trigger provider", + options: [ + { value: "github", label: "GitHub", hint: "issue or pull request comment" }, + { value: "slack", label: "Slack", hint: "channel mention" }, + { value: "discord", label: "Discord", hint: "channel mention" }, + ], + }); +} + +async function collectProviderFilters( + provider: HubInitProvider, + resources: HubConfigurationResources, + cwd: string, +): Promise>> { + if (provider === "github") { + const [login, originRemote] = await Promise.all([ + readGhValue(["api", "user", "--jq", ".login"]), + readCommandValue("git", ["remote", "get-url", "origin"], cwd), + ]); + const repo = originRemote === undefined ? undefined : githubRepositoryFromRemote(originRemote); + if (repo === undefined) { + throw new HubCommandError( + "HUB_GITHUB_REPOSITORY_UNDETECTED", + "Could not detect a GitHub repository from the origin remote.", + ); + } + if (!resources.github.some(({ repositories }) => repositories.includes(repo))) { + const connected = resources.github.flatMap(({ repositories }) => repositories); + throw new HubCommandError( + "HUB_GITHUB_REPOSITORY_NOT_CONNECTED", + `${repo} is not connected to this Hub organization. Connected repositories: ${connected.join(", ") || "none"}.`, + ); + } + return { + user: await requiredText({ + message: "Your GitHub username (only this user can trigger the bot)", + initialValue: login, + }), + repo, + }; + } + if (provider === "slack") { + return { + workspace: await chooseConnection( + "Slack workspace", + resources.slack.map(({ slug, teamName }) => ({ slug, label: teamName })), + ), + user: await requiredText({ + message: "Your Slack username (only this user can trigger the bot)", + }), + }; + } + return { + guild: await chooseConnection( + "Discord server", + resources.discord.map(({ slug, guildName }) => ({ slug, label: guildName })), + ), + user: await requiredText({ + message: "Your Discord username (only this user can trigger the bot)", + }), + }; +} + +async function loadConfigurationResources( + origin: string, + environment: HubInitEnvironment, +): Promise { + try { + return await withSpinner("Loading Hub connections", () => + environment.hub.listConfigurationResources( + origin, + resolveHubCredential({ + options: { origin }, + env: environment.env, + credentials: environment.credentials, + origin, + }), + ), + ); + } catch (error) { + if (error instanceof HubCommandError && error.code === "HUB_NOT_FOUND") { + throw new HubCommandError( + "HUB_UPDATE_REQUIRED", + "This Hub does not support guided setup. Update the Hub and try again.", + ); + } + throw error; + } +} + +async function chooseConnection( + message: string, + connections: readonly { slug: string; label: string }[], +): Promise { + if (connections.length === 0) { + throw new HubCommandError( + "HUB_PROVIDER_CONNECTION_REQUIRED", + `No ${message.toLowerCase()} is connected in this Hub organization. Connect one and try again.`, + ); + } + if (connections.length === 1) return connections[0]!.slug; + return requiredSelect({ + message, + options: connections.map(({ slug, label }) => ({ value: slug, label })), + }); +} + +async function writeScaffold( + cwd: string, + scaffold: ReturnType, + replaceExisting: boolean, +): Promise { + const root = path.join(cwd, ".paseo"); + const staging = path.join(cwd, `.paseo-init-${randomUUID()}`); + const backup = path.join(cwd, `.paseo-backup-${randomUUID()}`); + let movedExisting = false; + try { + await mkdir(path.join(staging, "workflows"), { recursive: true }); + await writeFile(path.join(staging, "hub.yml"), scaffold.hub, { flag: "wx" }); + await writeFile( + path.join(staging, "workflows", path.basename(scaffold.workflowPath)), + scaffold.workflow, + { + flag: "wx", + }, + ); + if (replaceExisting) { + await rename(root, backup); + movedExisting = true; + } + await rename(staging, root); + if (movedExisting) await rm(backup, { recursive: true, force: true }); + } catch (error) { + await rm(staging, { recursive: true, force: true }); + if (movedExisting) { + await rename(backup, root).catch(() => undefined); + } + throw error; + } +} + +async function pathExists(target: string): Promise { + try { + await lstat(target); + return true; + } catch (error) { + if (errorCode(error) === "ENOENT") return false; + throw error; + } +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && "code" in error && typeof error.code === "string" + ? error.code + : undefined; +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function readGhValue(args: readonly string[]): Promise { + return readCommandValue("gh", args); +} + +async function readCommandValue( + command: string, + args: readonly string[], + cwd?: string, +): Promise { + try { + const { stdout } = await execFileAsync(command, [...args], { encoding: "utf8", cwd }); + return stdout.trim() || undefined; + } catch { + return undefined; + } +} + +export function githubRepositoryFromRemote(remote: string): string | undefined { + const trimmed = remote.trim().replace(/\.git$/u, ""); + const ssh = /^(?:ssh:\/\/)?git@github\.com[:/]([^/]+\/[^/]+)$/u.exec(trimmed); + if (ssh?.[1] !== undefined) return ssh[1]; + try { + const url = new URL(trimmed); + if (url.hostname !== "github.com") return undefined; + const repository = url.pathname.replace(/^\//u, ""); + return /^[^/]+\/[^/]+$/u.test(repository) ? repository : undefined; + } catch { + return undefined; + } +} + +async function withSpinner( + message: string, + action: (reporter: HubReporter) => Promise, +): Promise { + const progress = spinner(); + progress.start(message); + try { + const result = await action({ progress: (nextMessage) => progress.message(nextMessage) }); + progress.stop(message); + return result; + } catch (error) { + progress.error(message); + throw error; + } +} + +async function requiredText(options: Parameters[0]): Promise { + const answer = await text({ + ...options, + validate(value) { + const input = value ?? ""; + const customError = options.validate?.(input); + if (customError !== undefined) return customError; + return input.trim().length === 0 ? "A value is required" : undefined; + }, + }); + if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled."); + return answer.trim(); +} + +async function requiredConfirm(message: string, initialValue: boolean): Promise { + const answer = await confirm({ message, initialValue }); + if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled."); + return answer; +} + +async function requiredSelect( + options: Parameters>[0], +): Promise { + const answer = await select(options); + if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled."); + return answer; +} + +function requireInteractiveTerminal(): void { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new HubCommandError("HUB_INIT_INTERACTIVE_REQUIRED", "paseo hub init requires a TTY."); + } +}