Files
paseo/packages/cli/src/commands/hub/init.ts
T
Mohamed BoudraandGitHub 7beff9cb0b fix(hub): make guided setup choices actionable (#3677)
Show a concise login success state, scope starter choices to available Hub connections, wait for daemon capabilities, and avoid partial starter bundles when setup cannot proceed.
2026-08-22 10:37:19 +02:00

791 lines
26 KiB
TypeScript

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,
HubSetupResources,
} from "./hub-client/index.js";
import {
createHubInitBundle,
createHubInitScaffold,
hubLoginResumeCommand,
planHubInitOpening,
resolveHubInitConnection,
resolveHubInitProjects,
} from "./init-plan.js";
import type { CliLoginFlow } from "./login-flow.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";
import {
selectedStarterAgentRuntime,
starterAgentProviderSnapshotState,
suggestedStarterAgentChoice,
type HubStarterAgentProvider,
type HubStarterAgentRuntime,
} from "./starter-agent-runtime.js";
import {
availableStarterTriggerConnections,
type HubStarterTriggerConnection,
} from "./starter-trigger.js";
const execFileAsync = promisify(execFile);
const DAEMON_READY_TIMEOUT_MS = 60_000;
const DAEMON_READY_POLL_MS = 250;
const PROVIDER_READY_TIMEOUT_MS = 60_000;
export interface HubGuidedSetupEnvironment {
env: Readonly<Record<string, string | undefined>>;
credentials: HubCredentialStore;
hub: HubHttpClient;
login: Pick<CliLoginFlow, "authorize">;
daemon: HubDaemonConnection;
reporter: HubReporter;
cwd(): string;
isInteractive?(): boolean;
prompts?: {
confirm(message: string, initialValue: boolean): Promise<boolean>;
select(options: Parameters<typeof select<string>>[0]): Promise<string>;
text(options: Parameters<typeof text>[0]): Promise<string>;
message(value: string): void;
};
}
interface HubGuidedSetupState {
origin?: string;
daemonId?: string;
deploy?: boolean;
}
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: HubGuidedSetupEnvironment): 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: HubGuidedSetupEnvironment): Promise<void> {
await runHubGuidedSetup(environment);
}
export async function runHubGuidedSetup(
environment: HubGuidedSetupEnvironment,
state: HubGuidedSetupState = {},
): Promise<void> {
requireInteractiveTerminal(environment);
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(environment, "Replace the existing .paseo/ Hub bundle?", false))
) {
throw new HubInitCancelledError("Existing .paseo/ bundle left unchanged.");
}
const origin = state.origin ?? (await ensureLogin(activeLogin?.origin, environment));
const daemonId = state.daemonId ?? (await ensureDaemonConnection(origin, environment));
const project = await chooseProject(origin, environment);
const resources = await loadSetupResources(origin, environment);
const triggerConnections = await resolveStarterTriggerConnections(resources, cwd);
reportStarterTriggerConnections(environment, triggerConnections);
const trigger = await chooseStarterTriggerConnection(environment, triggerConnections);
const daemon = await loadDaemonResource(origin, daemonId, environment);
log.success(`Connected as ${daemon.slug}`);
const agent = await chooseStarterAgentRuntime(environment, cwd);
const providerFilters = await collectProviderIdentity(trigger, environment);
const scaffold = createHubInitScaffold({
cwd,
daemonSlug: daemon.slug,
agent,
provider: trigger.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 = state.deploy ?? (await requiredConfirm(environment, "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 {
reportMessage(environment, `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");
}
export async function continueHubGuidedSetup(
origin: string,
environment: HubGuidedSetupEnvironment,
): Promise<void> {
if (!(await requiredConfirm(environment, "Connect this daemon to this Hub?", true))) {
reportMessage(
environment,
`Skipped daemon connection. Run: ${hubLoginResumeCommand("connect", origin)}; then paseo hub init`,
);
return;
}
const daemonId = await ensureDaemonConnection(origin, environment, true);
if (!(await requiredConfirm(environment, "Initialize and deploy a starter workflow?", true))) {
reportMessage(
environment,
`Skipped starter workflow. Run: ${hubLoginResumeCommand("init", origin)}`,
);
return;
}
try {
await runHubGuidedSetup(environment, { origin, daemonId, deploy: true });
} catch (error) {
if (error instanceof HubInitCancelledError) {
if (environment.prompts === undefined) {
log.message(error.message);
outro("Connect an app to continue");
} else {
environment.prompts.message(error.message);
}
return;
}
throw error;
}
}
async function ensureLogin(
activeOrigin: string | undefined,
environment: HubGuidedSetupEnvironment,
): Promise<string> {
const endpoint = await requiredSelect(environment, {
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(environment, {
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;
}
environment.reporter.progress(`Logging in to ${normalizedOrigin}`);
const credential = await environment.login.authorize(normalizedOrigin);
environment.credentials.save({ origin: normalizedOrigin, credential });
log.success(`Logged in to ${normalizedOrigin}`);
return normalizedOrigin;
}
async function ensureDaemonConnection(
origin: string,
environment: HubGuidedSetupEnvironment,
confirmed = false,
): Promise<string> {
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 (
!confirmed &&
!(await requiredConfirm(environment, `Connect this daemon to ${origin}?`, true))
) {
throw new HubInitCancelledError("A connected daemon is required to create the bundle.");
}
return connectDaemon(origin, environment);
}
async function connectDaemon(
origin: string,
environment: HubGuidedSetupEnvironment,
): Promise<string> {
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<string> {
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: HubGuidedSetupEnvironment,
): Promise<HubProject> {
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(environment, {
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 resolveStarterTriggerConnections(
resources: HubSetupResources,
cwd: string,
): Promise<HubStarterTriggerConnection[]> {
const remote = await readCommandValue("git", ["remote", "get-url", "origin"], cwd);
const repository = remote === undefined ? undefined : githubRepositoryFromRemote(remote);
const connections = availableStarterTriggerConnections(resources, repository);
if (connections.length === 0) {
throw new HubInitCancelledError(
"No Hub app connection is ready for this workflow.\nConnect GitHub, Slack, or Discord in Hub → Apps, then run `paseo hub init` again.",
);
}
return connections;
}
function reportStarterTriggerConnections(
environment: HubGuidedSetupEnvironment,
connections: readonly HubStarterTriggerConnection[],
): void {
const details = `${connections.map(({ label }) => label).join("\n")}\n\nOnly configured connections are shown. To add another, open Hub → Apps, then run \`paseo hub init\` again.`;
if (environment.prompts === undefined) {
note(details, "Hub app connections ready for this workflow");
return;
}
environment.prompts.message(`Hub app connections ready for this workflow:\n${details}`);
}
async function chooseStarterTriggerConnection(
environment: HubGuidedSetupEnvironment,
connections: readonly HubStarterTriggerConnection[],
): Promise<HubStarterTriggerConnection> {
if (connections.length === 1) {
const connection = connections[0]!;
reportMessage(environment, `Using ${connection.label}`);
return connection;
}
const id = await requiredSelect(environment, {
message: "Trigger connection",
options: connections.map((connection) => ({ value: connection.id, label: connection.label })),
});
const connection = connections.find((candidate) => candidate.id === id);
if (connection === undefined) {
throw new HubCommandError(
"HUB_PROVIDER_CONNECTION_INVALID",
"The selected Hub app connection is no longer available. Run paseo hub init again.",
);
}
return connection;
}
async function chooseStarterAgentRuntime(
environment: HubGuidedSetupEnvironment,
cwd: string,
): Promise<HubStarterAgentRuntime> {
const providers = await waitForStarterAgentProviders(environment, cwd);
const provider = await chooseStarterAgentProvider(environment, providers);
const model = await chooseStarterAgentModel(environment, provider);
const mode = await chooseStarterAgentMode(environment, provider);
const runtime = selectedStarterAgentRuntime(provider, model, mode);
if (runtime === undefined) {
throw new HubCommandError(
"HUB_AGENT_RUNTIME_SELECTION_INVALID",
"The selected starter agent runtime is no longer available. Run paseo hub init again.",
);
}
return runtime;
}
async function waitForStarterAgentProviders(
environment: HubGuidedSetupEnvironment,
cwd: string,
): Promise<readonly HubStarterAgentProvider[]> {
return withSpinner("Discovering agent runtimes", async (reporter) =>
withHubDaemon(environment.daemon, undefined, async (daemon) => {
const deadline = Date.now() + PROVIDER_READY_TIMEOUT_MS;
while (true) {
const snapshot = await daemon.getProvidersSnapshot({ cwd });
const state = starterAgentProviderSnapshotState(snapshot.entries);
if (state.kind === "ready") return state.providers;
if (state.kind === "unavailable") {
throw new HubCommandError(
"HUB_AGENT_RUNTIME_REQUIRED",
"No usable agent runtime is available from this daemon. Configure an enabled provider with a selectable model, then run paseo hub init again.",
);
}
if (Date.now() >= deadline) {
throw new HubCommandError(
"HUB_AGENT_RUNTIME_TIMEOUT",
"Agent runtime discovery did not finish within 60 seconds. Check the daemon's provider configuration, then run paseo hub init again.",
);
}
reporter.progress("Waiting for agent runtime discovery");
await delay(DAEMON_READY_POLL_MS);
}
}),
);
}
async function chooseStarterAgentProvider(
environment: HubGuidedSetupEnvironment,
providers: readonly HubStarterAgentProvider[],
): Promise<HubStarterAgentProvider> {
const selected = await requiredSelect(environment, {
message: "Starter agent provider",
options: providers.map((provider) => ({
value: provider.id,
label: provider.label,
})),
});
const provider = providers.find((candidate) => candidate.id === selected);
if (provider === undefined) throw invalidStarterAgentSelection();
return provider;
}
async function chooseStarterAgentModel(
environment: HubGuidedSetupEnvironment,
provider: HubStarterAgentProvider,
): Promise<string> {
const suggested = suggestedStarterAgentChoice(provider.models);
const selected = await requiredSelect(environment, {
message: "Starter agent model",
...(suggested === undefined ? {} : { initialValue: suggested.id }),
options: provider.models.map((model) => ({
value: model.id,
label: model.label,
...(model.suggested ? { hint: "suggested" } : {}),
})),
});
if (!provider.models.some((model) => model.id === selected)) throw invalidStarterAgentSelection();
return selected;
}
async function chooseStarterAgentMode(
environment: HubGuidedSetupEnvironment,
provider: HubStarterAgentProvider,
): Promise<string | undefined> {
if (provider.modes.length === 0) return undefined;
const suggested = suggestedStarterAgentChoice(provider.modes);
const selected = await requiredSelect(environment, {
message: "Starter agent mode",
...(suggested === undefined ? {} : { initialValue: suggested.id }),
options: provider.modes.map((mode) => ({
value: mode.id,
label: mode.label,
...(mode.suggested ? { hint: "suggested" } : {}),
})),
});
if (!provider.modes.some((mode) => mode.id === selected)) throw invalidStarterAgentSelection();
return selected;
}
function invalidStarterAgentSelection(): HubCommandError {
return new HubCommandError(
"HUB_AGENT_RUNTIME_SELECTION_INVALID",
"The selected starter agent runtime is no longer available. Run paseo hub init again.",
);
}
async function collectProviderIdentity(
trigger: HubStarterTriggerConnection,
environment: HubGuidedSetupEnvironment,
): Promise<Readonly<Record<string, string>>> {
if (trigger.provider === "github") {
const login = await readGhValue(["api", "user", "--jq", ".login"]);
return {
...trigger.filters,
user: await requiredText(environment, {
message: "Your GitHub username (only this user can trigger the bot)",
initialValue: login,
}),
};
}
if (trigger.provider === "slack") {
return {
...trigger.filters,
user: await requiredText(environment, {
message: "Your Slack member ID (only this user can trigger the bot)",
}),
};
}
return {
...trigger.filters,
user: await requiredText(environment, {
message: "Your Discord user ID (only this user can trigger the bot)",
}),
};
}
async function loadSetupResources(
origin: string,
environment: HubGuidedSetupEnvironment,
): Promise<HubSetupResources> {
try {
return await withSpinner("Loading Hub connections", () =>
environment.hub.listSetupResources(
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 needs an update before guided setup can use provider IDs. Update Hub and try again.",
);
}
throw error;
}
}
async function loadDaemonResource(
origin: string,
daemonId: string,
environment: HubGuidedSetupEnvironment,
): Promise<HubConfigurationResources["daemons"][number]> {
const resources = await environment.hub.listConfigurationResources(
origin,
resolveHubCredential({
options: { origin },
env: environment.env,
credentials: environment.credentials,
origin,
}),
);
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.",
);
}
return daemon;
}
async function writeScaffold(
cwd: string,
scaffold: ReturnType<typeof createHubInitScaffold>,
replaceExisting: boolean,
): Promise<void> {
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<boolean> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function readGhValue(args: readonly string[]): Promise<string | undefined> {
return readCommandValue("gh", args);
}
async function readCommandValue(
command: string,
args: readonly string[],
cwd?: string,
): Promise<string | undefined> {
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<T>(
message: string,
action: (reporter: HubReporter) => Promise<T>,
): Promise<T> {
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(
environment: HubGuidedSetupEnvironment,
options: Parameters<typeof text>[0],
): Promise<string> {
const request = {
...options,
validate(value: string | undefined) {
const input = value ?? "";
const customError = options.validate?.(input);
if (customError !== undefined) return customError;
return input.trim().length === 0 ? "A value is required" : undefined;
},
};
const answer =
environment.prompts === undefined
? await text(request)
: await environment.prompts.text(request);
if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled.");
return answer.trim();
}
async function requiredConfirm(
environment: HubGuidedSetupEnvironment,
message: string,
initialValue: boolean,
): Promise<boolean> {
const answer =
environment.prompts === undefined
? await confirm({ message, initialValue })
: await environment.prompts.confirm(message, initialValue);
if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled.");
return answer;
}
async function requiredSelect<T extends string>(
environment: HubGuidedSetupEnvironment,
options: Parameters<typeof select<T>>[0],
): Promise<T> {
const answer =
environment.prompts === undefined
? await select<T>(options)
: ((await environment.prompts.select(options as Parameters<typeof select<string>>[0])) as T);
if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled.");
return answer;
}
function requireInteractiveTerminal(environment: HubGuidedSetupEnvironment): void {
if (!(environment.isInteractive?.() ?? (process.stdin.isTTY && process.stdout.isTTY))) {
throw new HubCommandError("HUB_INIT_INTERACTIVE_REQUIRED", "paseo hub init requires a TTY.");
}
}
function reportMessage(environment: HubGuidedSetupEnvironment, message: string): void {
if (environment.prompts === undefined) {
log.message(message);
return;
}
environment.prompts.message(message);
}