feat(hub): guide starter setup (#3651)

* feat(hub): guide setup with provider IDs

* fix(hub): preserve login setup state

* feat(hub): select starter agent runtime

Use the connected daemon's provider snapshot so generated Hub bundles only
contain complete provider, model, and mode combinations.

* fix(hub): stage starter runtime choices

Keep providers with valid modes selectable when the daemon has no default mode, so users explicitly choose the complete runtime.

* fix(hub): scope runtime choices to workspace

Provider capabilities can vary by repository, so request the snapshot for the bundle directory that guided setup will deploy.
This commit is contained in:
Mohamed Boudra
2026-08-21 23:11:55 +02:00
committed by GitHub
parent 956f6ea7ad
commit 8f0c35e924
13 changed files with 1027 additions and 73 deletions
+267 -66
View File
@@ -21,28 +21,40 @@ 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 type {
HubConfigurationResources,
HubHttpClient,
HubProject,
HubSetupResources,
} from "./hub-client/index.js";
import {
createHubInitBundle,
createHubInitScaffold,
hubLoginResumeCommand,
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";
import {
availableStarterAgentProviders,
selectedStarterAgentRuntime,
suggestedStarterAgentChoice,
type HubStarterAgentProvider,
type HubStarterAgentRuntime,
} from "./starter-agent-runtime.js";
const execFileAsync = promisify(execFile);
const DAEMON_READY_TIMEOUT_MS = 60_000;
const DAEMON_READY_POLL_MS = 250;
interface HubInitEnvironment {
export interface HubGuidedSetupEnvironment {
env: Readonly<Record<string, string | undefined>>;
credentials: HubCredentialStore;
hub: HubHttpClient;
@@ -50,6 +62,19 @@ interface HubInitEnvironment {
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 {}
@@ -61,7 +86,7 @@ function initErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function addHubInitCommand(parent: Command, environment: HubInitEnvironment): void {
export function addHubInitCommand(parent: Command, environment: HubGuidedSetupEnvironment): void {
parent
.command("init")
.description("Create and optionally deploy a safe starter Hub bundle")
@@ -79,8 +104,15 @@ export function addHubInitCommand(parent: Command, environment: HubInitEnvironme
});
}
export async function runHubInit(environment: HubInitEnvironment): Promise<void> {
requireInteractiveTerminal();
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();
@@ -91,28 +123,24 @@ export async function runHubInit(environment: HubInitEnvironment): Promise<void>
});
if (
opening.replaceExisting &&
!(await requiredConfirm("Replace the existing .paseo/ Hub bundle?", false))
!(await requiredConfirm(environment, "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 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 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.",
);
}
const resources = await loadSetupResources(origin, environment);
const daemon = await loadDaemonResource(origin, daemonId, environment);
log.success(`Connected as ${daemon.slug}`);
const provider = await chooseProvider();
const providerFilters = await collectProviderFilters(provider, resources, cwd);
const agent = await chooseStarterAgentRuntime(environment, cwd);
const provider = await chooseProvider(environment);
const providerFilters = await collectProviderFilters(provider, resources, cwd, environment);
const scaffold = createHubInitScaffold({
cwd,
daemonSlug: daemon.slug,
agent,
provider,
providerFilters,
});
@@ -132,7 +160,7 @@ export async function runHubInit(environment: HubInitEnvironment): Promise<void>
await writeScaffold(cwd, scaffold, opening.replaceExisting);
log.success(`Created .paseo/hub.yml and ${scaffold.workflowPath}`);
const deploy = await requiredConfirm("Deploy now?", true);
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, {
@@ -145,7 +173,7 @@ export async function runHubInit(environment: HubInitEnvironment): Promise<void>
});
log.success("Deployed");
} else {
log.message(`Skipped deployment. Run: paseo hub deploy -p ${project.slug}`);
reportMessage(environment, `Skipped deployment. Run: paseo hub deploy -p ${project.slug}`);
}
const activityUrl = new URL(`/projects/${project.slug}/activity`, origin).toString();
@@ -153,11 +181,33 @@ export async function runHubInit(environment: HubInitEnvironment): Promise<void>
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 ${origin}?`, 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;
}
await runHubGuidedSetup(environment, { origin, daemonId, deploy: true });
}
async function ensureLogin(
activeOrigin: string | undefined,
environment: HubInitEnvironment,
environment: HubGuidedSetupEnvironment,
): Promise<string> {
const endpoint = await requiredSelect({
const endpoint = await requiredSelect(environment, {
message: "Hub endpoint",
initialValue:
activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN ? "hosted" : "custom",
@@ -169,7 +219,7 @@ async function ensureLogin(
const origin =
endpoint === "hosted"
? DEFAULT_HUB_ORIGIN
: await requiredText({
: await requiredText(environment, {
message: "Custom Hub URL",
initialValue:
activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN
@@ -189,23 +239,17 @@ async function ensureLogin(
log.success(`Logged in to ${normalizedOrigin}`);
return normalizedOrigin;
}
await runHubLogin(
normalizedOrigin,
{},
{
env: environment.env,
credentials: environment.credentials,
flow: environment.login,
reporter: environment.reporter,
},
);
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: HubInitEnvironment,
environment: HubGuidedSetupEnvironment,
confirmed = false,
): Promise<string> {
const status = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
daemon.getHubStatus().then((response) => response.status),
@@ -223,9 +267,19 @@ async function ensureDaemonConnection(
`This daemon is connected to ${connection.origin}. Disconnect it before running Hub init for ${origin}.`,
);
}
if (!(await requiredConfirm(`Connect this daemon to ${origin}?`, true))) {
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,
{},
@@ -276,7 +330,10 @@ async function waitForDaemonReady(
);
}
async function chooseProject(origin: string, environment: HubInitEnvironment): Promise<HubProject> {
async function chooseProject(
origin: string,
environment: HubGuidedSetupEnvironment,
): Promise<HubProject> {
const result = await runHubProjects(
{ hub: origin },
{
@@ -295,7 +352,7 @@ async function chooseProject(origin: string, environment: HubInitEnvironment): P
if (resolution.kind === "selected") {
return resolution.project;
}
const slug = await requiredSelect({
const slug = await requiredSelect(environment, {
message: "Project",
options: resolution.projects.map((project) => ({
value: project.slug,
@@ -313,8 +370,8 @@ async function chooseProject(origin: string, environment: HubInitEnvironment): P
return project;
}
async function chooseProvider(): Promise<HubInitProvider> {
return requiredSelect({
async function chooseProvider(environment: HubGuidedSetupEnvironment): Promise<HubInitProvider> {
return requiredSelect(environment, {
message: "Trigger provider",
options: [
{ value: "github", label: "GitHub", hint: "issue or pull request comment" },
@@ -324,10 +381,101 @@ async function chooseProvider(): Promise<HubInitProvider> {
});
}
async function chooseStarterAgentRuntime(
environment: HubGuidedSetupEnvironment,
cwd: string,
): Promise<HubStarterAgentRuntime> {
const snapshot = await withHubDaemon(environment.daemon, undefined, (daemon) =>
daemon.getProvidersSnapshot({ cwd }),
);
const providers = availableStarterAgentProviders(snapshot.entries);
if (providers.length === 0) {
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.",
);
}
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 chooseStarterAgentProvider(
environment: HubGuidedSetupEnvironment,
providers: readonly HubStarterAgentProvider[],
): Promise<HubStarterAgentProvider> {
const suggested = suggestedStarterAgentChoice(providers);
const selected = await requiredSelect(environment, {
message: "Starter agent provider",
...(suggested === undefined ? {} : { initialValue: suggested.id }),
options: providers.map((provider) => ({
value: provider.id,
label: provider.label,
...(provider.suggested ? { hint: "suggested" } : {}),
})),
});
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 collectProviderFilters(
provider: HubInitProvider,
resources: HubConfigurationResources,
resources: HubSetupResources,
cwd: string,
environment: HubGuidedSetupEnvironment,
): Promise<Readonly<Record<string, string>>> {
if (provider === "github") {
const [login, originRemote] = await Promise.all([
@@ -349,7 +497,7 @@ async function collectProviderFilters(
);
}
return {
user: await requiredText({
user: await requiredText(environment, {
message: "Your GitHub username (only this user can trigger the bot)",
initialValue: login,
}),
@@ -359,32 +507,34 @@ async function collectProviderFilters(
if (provider === "slack") {
return {
workspace: await chooseConnection(
environment,
"Slack workspace",
resources.slack.map(({ slug, teamName }) => ({ slug, label: teamName })),
resources.slack.map(({ teamId, teamName }) => ({ id: teamId, label: teamName })),
),
user: await requiredText({
message: "Your Slack username (only this user can trigger the bot)",
user: await requiredText(environment, {
message: "Your Slack member ID (only this user can trigger the bot)",
}),
};
}
return {
guild: await chooseConnection(
environment,
"Discord server",
resources.discord.map(({ slug, guildName }) => ({ slug, label: guildName })),
resources.discord.map(({ guildId, guildName }) => ({ id: guildId, label: guildName })),
),
user: await requiredText({
message: "Your Discord username (only this user can trigger the bot)",
user: await requiredText(environment, {
message: "Your Discord user ID (only this user can trigger the bot)",
}),
};
}
async function loadConfigurationResources(
async function loadSetupResources(
origin: string,
environment: HubInitEnvironment,
): Promise<HubConfigurationResources> {
environment: HubGuidedSetupEnvironment,
): Promise<HubSetupResources> {
try {
return await withSpinner("Loading Hub connections", () =>
environment.hub.listConfigurationResources(
environment.hub.listSetupResources(
origin,
resolveHubCredential({
options: { origin },
@@ -398,16 +548,41 @@ async function loadConfigurationResources(
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.",
"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 chooseConnection(
environment: HubGuidedSetupEnvironment,
message: string,
connections: readonly { slug: string; label: string }[],
connections: readonly { id: string; label: string }[],
): Promise<string> {
if (connections.length === 0) {
throw new HubCommandError(
@@ -415,10 +590,10 @@ async function chooseConnection(
`No ${message.toLowerCase()} is connected in this Hub organization. Connect one and try again.`,
);
}
if (connections.length === 1) return connections[0]!.slug;
return requiredSelect({
if (connections.length === 1) return connections[0]!.id;
return requiredSelect(environment, {
message,
options: connections.map(({ slug, label }) => ({ value: slug, label })),
options: connections.map(({ id, label }) => ({ value: id, label })),
});
}
@@ -523,36 +698,62 @@ async function withSpinner<T>(
}
}
async function requiredText(options: Parameters<typeof text>[0]): Promise<string> {
const answer = await text({
async function requiredText(
environment: HubGuidedSetupEnvironment,
options: Parameters<typeof text>[0],
): Promise<string> {
const request = {
...options,
validate(value) {
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(message: string, initialValue: boolean): Promise<boolean> {
const answer = await confirm({ message, initialValue });
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 = await select<T>(options);
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(): void {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
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);
}