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.
This commit is contained in:
Mohamed Boudra
2026-08-22 10:37:19 +02:00
committed by GitHub
parent c763b80824
commit 7beff9cb0b
8 changed files with 358 additions and 111 deletions
+10 -2
View File
@@ -87,6 +87,7 @@ describe("Hub commands", () => {
assert.deepEqual(events, [ assert.deepEqual(events, [
"progress:Logging in to https://hub.paseo.sh", "progress:Logging in to https://hub.paseo.sh",
"authorize:https://hub.paseo.sh", "authorize:https://hub.paseo.sh",
"progress:Logged in",
]); ]);
assert.equal(result.data.origin, "https://hub.paseo.sh"); assert.equal(result.data.origin, "https://hub.paseo.sh");
}); });
@@ -113,11 +114,18 @@ describe("Hub commands", () => {
events.push("init"); events.push("init");
events.push("deploy"); events.push("deploy");
}, },
reporter: quietReporter, reporter: { progress: (message) => events.push(`progress:${message}`) },
}, },
); );
assert.deepEqual(events, ["login", "connect:https://hub.test", "init", "deploy"]); assert.deepEqual(events, [
"progress:Logging in to https://hub.test",
"login",
"progress:Logged in",
"connect:https://hub.test",
"init",
"deploy",
]);
}); });
it("JSON and noninteractive login remain login-only", async () => { it("JSON and noninteractive login remain login-only", async () => {
+102 -21
View File
@@ -3,6 +3,7 @@ import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, it } from "vitest"; import { afterEach, describe, it } from "vitest";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { HubCredentialStore, StoredHubCredential } from "./credentials.js"; import type { HubCredentialStore, StoredHubCredential } from "./credentials.js";
import type { HubDaemonClient, HubStatus } from "./daemon-client.js"; import type { HubDaemonClient, HubStatus } from "./daemon-client.js";
import type { HubHttpClient } from "./hub-client/index.js"; import type { HubHttpClient } from "./hub-client/index.js";
@@ -24,11 +25,7 @@ describe("Hub guided setup continuation", () => {
const cwd = await temporaryDirectory(); const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials(); const credentials = new MemoryCredentials();
const daemon = new SetupDaemon(); const daemon = new SetupDaemon();
const prompts = new PromptAnswers( const prompts = new PromptAnswers([true, true], ["codex", "gpt-5", "full-access"], ["U123"]);
[true, true],
["codex", "gpt-5", "full-access", "slack"],
["U123"],
);
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = []; const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls); const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls);
@@ -46,24 +43,22 @@ describe("Hub guided setup continuation", () => {
); );
assert.deepEqual(prompts.confirmations, [ assert.deepEqual(prompts.confirmations, [
"Connect this daemon to https://hub.test?", "Connect this daemon to this Hub?",
"Initialize and deploy a starter workflow?", "Initialize and deploy a starter workflow?",
]); ]);
assert.deepEqual(prompts.selections, [ assert.deepEqual(prompts.selections, [
"Starter agent provider", "Starter agent provider",
"Starter agent model", "Starter agent model",
"Starter agent mode", "Starter agent mode",
"Trigger provider",
]); ]);
assert.deepEqual(prompts.selectionOptions, [ assert.deepEqual(prompts.selectionOptions, [
["Codex (suggested)"], ["Codex"],
["GPT-5 (suggested)", "GPT-5 mini"], ["GPT-5 (suggested)", "GPT-5 mini"],
["Read only", "Full access (suggested)"], ["Read only", "Full access (suggested)"],
[ ]);
"GitHub (issue or pull request comment)", assert.deepEqual(prompts.messages, [
"Slack (channel mention)", "Hub app connections ready for this workflow:\nSlack — Paseo\n\nOnly configured connections are shown. To add another, open Hub → Apps, then run `paseo hub init` again.",
"Discord (channel mention)", "Using Slack — Paseo",
],
]); ]);
assert.deepEqual(calls, [ assert.deepEqual(calls, [
{ operation: "token", origin: "https://hub.test" }, { operation: "token", origin: "https://hub.test" },
@@ -111,6 +106,36 @@ describe("Hub guided setup continuation", () => {
assert.deepEqual(initDeclined.messages, ["Skipped starter workflow. Run: paseo hub init"]); assert.deepEqual(initDeclined.messages, ["Skipped starter workflow. Run: paseo hub init"]);
}); });
it("keeps login successful when no Hub app connection can initialize a workflow", async () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
const daemon = new SetupDaemon();
const prompts = new PromptAnswers([true, true], [], []);
await runHubLogin(
"https://hub.test",
{},
{
env: {},
credentials,
flow: { authorize: async () => "paseo_cli_prefix_durable-secret" },
isInteractive: () => true,
continueGuidedSetup: (origin) =>
continueHubGuidedSetup(
origin,
setupEnvironment(cwd, credentials, daemon, prompts, [], {
setupResources: { github: [], slack: [], discord: [] },
}),
),
reporter: { progress() {} },
},
);
assert.deepEqual(prompts.messages, [
"No Hub app connection is ready for this workflow.\nConnect GitHub, Slack, or Discord in Hub → Apps, then run `paseo hub init` again.",
]);
});
it("keeps hub init's existing replacement confirmation", async () => { it("keeps hub init's existing replacement confirmation", async () => {
const cwd = await temporaryDirectory(); const cwd = await temporaryDirectory();
await mkdir(path.join(cwd, ".paseo")); await mkdir(path.join(cwd, ".paseo"));
@@ -140,7 +165,7 @@ describe("Hub guided setup continuation", () => {
}, },
]); ]);
const prompts = new PromptAnswers([true], ["claude", "sonnet", "auto", "slack"], ["U123"]); const prompts = new PromptAnswers([true], ["claude", "sonnet", "auto"], ["U123"]);
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = []; const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
await runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, calls), { await runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, calls), {
@@ -162,6 +187,52 @@ describe("Hub guided setup continuation", () => {
["validate", "install"], ["validate", "install"],
); );
}); });
it("stops before runtime discovery and file writes when no Hub app connection is usable", async () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "secret" });
const daemon = new SetupDaemon();
const prompts = new PromptAnswers([], [], []);
await assert.rejects(
runHubGuidedSetup(
setupEnvironment(cwd, credentials, daemon, prompts, [], {
setupResources: { github: [], slack: [], discord: [] },
}),
{ origin: "https://hub.test", daemonId: "daemon-1", deploy: true },
),
/No Hub app connection is ready for this workflow/u,
);
assert.equal(daemon.snapshotCwds.length, 0);
await assert.rejects(readFile(path.join(cwd, ".paseo", "hub.yml")), { code: "ENOENT" });
});
it("waits for fresh-daemon provider discovery before offering runtime choices", async () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "secret" });
const ready = new SetupDaemon().providerEntries;
const daemon = new SetupDaemon(ready, [
[{ provider: "codex", status: "loading", enabled: true }],
ready,
]);
const prompts = new PromptAnswers([], ["codex", "gpt-5", "full-access"], ["U123"]);
await runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, []), {
origin: "https://hub.test",
daemonId: "daemon-1",
deploy: true,
});
assert.equal(daemon.snapshotCwds.length, 2);
assert.deepEqual(prompts.selections.slice(0, 3), [
"Starter agent provider",
"Starter agent model",
"Starter agent mode",
]);
});
}); });
function setupEnvironment( function setupEnvironment(
@@ -170,6 +241,9 @@ function setupEnvironment(
daemon: SetupDaemon, daemon: SetupDaemon,
prompts: PromptAnswers, prompts: PromptAnswers,
calls: Array<{ operation: string; origin: string; files?: readonly string[] }>, calls: Array<{ operation: string; origin: string; files?: readonly string[] }>,
options: {
setupResources?: Awaited<ReturnType<HubHttpClient["listSetupResources"]>>;
} = {},
): HubGuidedSetupEnvironment { ): HubGuidedSetupEnvironment {
const hub = { const hub = {
async issueEnrollmentToken(origin: string) { async issueEnrollmentToken(origin: string) {
@@ -182,11 +256,13 @@ function setupEnvironment(
}, },
async listSetupResources(origin: string) { async listSetupResources(origin: string) {
calls.push({ operation: "setup", origin }); calls.push({ operation: "setup", origin });
return { return (
github: [], options.setupResources ?? {
discord: [], github: [],
slack: [{ teamId: "T123", teamName: "Paseo" }], discord: [],
}; slack: [{ teamId: "T123", teamName: "Paseo" }],
}
);
}, },
async listConfigurationResources(origin: string) { async listConfigurationResources(origin: string) {
calls.push({ operation: "resources", origin }); calls.push({ operation: "resources", origin });
@@ -294,7 +370,7 @@ class SetupDaemon implements HubDaemonClient {
private origin: string | null = null; private origin: string | null = null;
constructor( constructor(
private readonly providerEntries = [ readonly providerEntries: readonly ProviderSnapshotEntry[] = [
{ {
provider: "codex", provider: "codex",
status: "ready" as const, status: "ready" as const,
@@ -311,6 +387,7 @@ class SetupDaemon implements HubDaemonClient {
defaultModeId: "full-access", defaultModeId: "full-access",
}, },
], ],
private readonly providerEntrySequence?: readonly (readonly ProviderSnapshotEntry[])[],
) {} ) {}
async connectHub(origin: string) { async connectHub(origin: string) {
@@ -325,7 +402,11 @@ class SetupDaemon implements HubDaemonClient {
async getProvidersSnapshot(options?: { cwd?: string }) { async getProvidersSnapshot(options?: { cwd?: string }) {
this.snapshotCwds.push(options?.cwd); this.snapshotCwds.push(options?.cwd);
return { entries: this.providerEntries }; const index = Math.min(
this.snapshotCwds.length - 1,
(this.providerEntrySequence?.length ?? 1) - 1,
);
return { entries: this.providerEntrySequence?.[index] ?? this.providerEntries };
} }
async disconnectHub() { async disconnectHub() {
+112 -81
View File
@@ -34,7 +34,6 @@ import {
planHubInitOpening, planHubInitOpening,
resolveHubInitConnection, resolveHubInitConnection,
resolveHubInitProjects, resolveHubInitProjects,
type HubInitProvider,
} from "./init-plan.js"; } from "./init-plan.js";
import type { CliLoginFlow } from "./login-flow.js"; import type { CliLoginFlow } from "./login-flow.js";
import { runHubConnect } from "./connect.js"; import { runHubConnect } from "./connect.js";
@@ -43,16 +42,21 @@ import { runHubProjects } from "./projects.js";
import type { HubReporter } from "./reporter.js"; import type { HubReporter } from "./reporter.js";
import { normalizeHubOrigin } from "./origin.js"; import { normalizeHubOrigin } from "./origin.js";
import { import {
availableStarterAgentProviders,
selectedStarterAgentRuntime, selectedStarterAgentRuntime,
starterAgentProviderSnapshotState,
suggestedStarterAgentChoice, suggestedStarterAgentChoice,
type HubStarterAgentProvider, type HubStarterAgentProvider,
type HubStarterAgentRuntime, type HubStarterAgentRuntime,
} from "./starter-agent-runtime.js"; } from "./starter-agent-runtime.js";
import {
availableStarterTriggerConnections,
type HubStarterTriggerConnection,
} from "./starter-trigger.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const DAEMON_READY_TIMEOUT_MS = 60_000; const DAEMON_READY_TIMEOUT_MS = 60_000;
const DAEMON_READY_POLL_MS = 250; const DAEMON_READY_POLL_MS = 250;
const PROVIDER_READY_TIMEOUT_MS = 60_000;
export interface HubGuidedSetupEnvironment { export interface HubGuidedSetupEnvironment {
env: Readonly<Record<string, string | undefined>>; env: Readonly<Record<string, string | undefined>>;
@@ -132,16 +136,18 @@ export async function runHubGuidedSetup(
const daemonId = state.daemonId ?? (await ensureDaemonConnection(origin, environment)); const daemonId = state.daemonId ?? (await ensureDaemonConnection(origin, environment));
const project = await chooseProject(origin, environment); const project = await chooseProject(origin, environment);
const resources = await loadSetupResources(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); const daemon = await loadDaemonResource(origin, daemonId, environment);
log.success(`Connected as ${daemon.slug}`); log.success(`Connected as ${daemon.slug}`);
const agent = await chooseStarterAgentRuntime(environment, cwd); const agent = await chooseStarterAgentRuntime(environment, cwd);
const provider = await chooseProvider(environment); const providerFilters = await collectProviderIdentity(trigger, environment);
const providerFilters = await collectProviderFilters(provider, resources, cwd, environment);
const scaffold = createHubInitScaffold({ const scaffold = createHubInitScaffold({
cwd, cwd,
daemonSlug: daemon.slug, daemonSlug: daemon.slug,
agent, agent,
provider, provider: trigger.provider,
providerFilters, providerFilters,
}); });
@@ -185,7 +191,7 @@ export async function continueHubGuidedSetup(
origin: string, origin: string,
environment: HubGuidedSetupEnvironment, environment: HubGuidedSetupEnvironment,
): Promise<void> { ): Promise<void> {
if (!(await requiredConfirm(environment, `Connect this daemon to ${origin}?`, true))) { if (!(await requiredConfirm(environment, "Connect this daemon to this Hub?", true))) {
reportMessage( reportMessage(
environment, environment,
`Skipped daemon connection. Run: ${hubLoginResumeCommand("connect", origin)}; then paseo hub init`, `Skipped daemon connection. Run: ${hubLoginResumeCommand("connect", origin)}; then paseo hub init`,
@@ -200,7 +206,20 @@ export async function continueHubGuidedSetup(
); );
return; return;
} }
await runHubGuidedSetup(environment, { origin, daemonId, deploy: true }); 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( async function ensureLogin(
@@ -370,31 +389,61 @@ async function chooseProject(
return project; return project;
} }
async function chooseProvider(environment: HubGuidedSetupEnvironment): Promise<HubInitProvider> { async function resolveStarterTriggerConnections(
return requiredSelect(environment, { resources: HubSetupResources,
message: "Trigger provider", cwd: string,
options: [ ): Promise<HubStarterTriggerConnection[]> {
{ value: "github", label: "GitHub", hint: "issue or pull request comment" }, const remote = await readCommandValue("git", ["remote", "get-url", "origin"], cwd);
{ value: "slack", label: "Slack", hint: "channel mention" }, const repository = remote === undefined ? undefined : githubRepositoryFromRemote(remote);
{ value: "discord", label: "Discord", hint: "channel mention" }, 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( async function chooseStarterAgentRuntime(
environment: HubGuidedSetupEnvironment, environment: HubGuidedSetupEnvironment,
cwd: string, cwd: string,
): Promise<HubStarterAgentRuntime> { ): Promise<HubStarterAgentRuntime> {
const snapshot = await withHubDaemon(environment.daemon, undefined, (daemon) => const providers = await waitForStarterAgentProviders(environment, cwd);
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 provider = await chooseStarterAgentProvider(environment, providers);
const model = await chooseStarterAgentModel(environment, provider); const model = await chooseStarterAgentModel(environment, provider);
const mode = await chooseStarterAgentMode(environment, provider); const mode = await chooseStarterAgentMode(environment, provider);
@@ -408,18 +457,45 @@ async function chooseStarterAgentRuntime(
return runtime; 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( async function chooseStarterAgentProvider(
environment: HubGuidedSetupEnvironment, environment: HubGuidedSetupEnvironment,
providers: readonly HubStarterAgentProvider[], providers: readonly HubStarterAgentProvider[],
): Promise<HubStarterAgentProvider> { ): Promise<HubStarterAgentProvider> {
const suggested = suggestedStarterAgentChoice(providers);
const selected = await requiredSelect(environment, { const selected = await requiredSelect(environment, {
message: "Starter agent provider", message: "Starter agent provider",
...(suggested === undefined ? {} : { initialValue: suggested.id }),
options: providers.map((provider) => ({ options: providers.map((provider) => ({
value: provider.id, value: provider.id,
label: provider.label, label: provider.label,
...(provider.suggested ? { hint: "suggested" } : {}),
})), })),
}); });
const provider = providers.find((candidate) => candidate.id === selected); const provider = providers.find((candidate) => candidate.id === selected);
@@ -471,57 +547,30 @@ function invalidStarterAgentSelection(): HubCommandError {
); );
} }
async function collectProviderFilters( async function collectProviderIdentity(
provider: HubInitProvider, trigger: HubStarterTriggerConnection,
resources: HubSetupResources,
cwd: string,
environment: HubGuidedSetupEnvironment, environment: HubGuidedSetupEnvironment,
): Promise<Readonly<Record<string, string>>> { ): Promise<Readonly<Record<string, string>>> {
if (provider === "github") { if (trigger.provider === "github") {
const [login, originRemote] = await Promise.all([ const login = await readGhValue(["api", "user", "--jq", ".login"]);
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 { return {
...trigger.filters,
user: await requiredText(environment, { user: await requiredText(environment, {
message: "Your GitHub username (only this user can trigger the bot)", message: "Your GitHub username (only this user can trigger the bot)",
initialValue: login, initialValue: login,
}), }),
repo,
}; };
} }
if (provider === "slack") { if (trigger.provider === "slack") {
return { return {
workspace: await chooseConnection( ...trigger.filters,
environment,
"Slack workspace",
resources.slack.map(({ teamId, teamName }) => ({ id: teamId, label: teamName })),
),
user: await requiredText(environment, { user: await requiredText(environment, {
message: "Your Slack member ID (only this user can trigger the bot)", message: "Your Slack member ID (only this user can trigger the bot)",
}), }),
}; };
} }
return { return {
guild: await chooseConnection( ...trigger.filters,
environment,
"Discord server",
resources.discord.map(({ guildId, guildName }) => ({ id: guildId, label: guildName })),
),
user: await requiredText(environment, { user: await requiredText(environment, {
message: "Your Discord user ID (only this user can trigger the bot)", message: "Your Discord user ID (only this user can trigger the bot)",
}), }),
@@ -579,24 +628,6 @@ async function loadDaemonResource(
return daemon; return daemon;
} }
async function chooseConnection(
environment: HubGuidedSetupEnvironment,
message: string,
connections: readonly { id: string; label: string }[],
): Promise<string> {
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]!.id;
return requiredSelect(environment, {
message,
options: connections.map(({ id, label }) => ({ value: id, label })),
});
}
async function writeScaffold( async function writeScaffold(
cwd: string, cwd: string,
scaffold: ReturnType<typeof createHubInitScaffold>, scaffold: ReturnType<typeof createHubInitScaffold>,
+1
View File
@@ -42,6 +42,7 @@ export async function runHubLogin(
reportHubProgress(dependencies.reporter, options, `Logging in to ${origin}`); reportHubProgress(dependencies.reporter, options, `Logging in to ${origin}`);
const credential = await dependencies.flow.authorize(origin); const credential = await dependencies.flow.authorize(origin);
dependencies.credentials.save({ origin, credential }); dependencies.credentials.save({ origin, credential });
reportHubProgress(dependencies.reporter, options, "Logged in");
if ( if (
!options.json && !options.json &&
dependencies.isInteractive?.() && dependencies.isInteractive?.() &&
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
availableStarterAgentProviders, availableStarterAgentProviders,
selectedStarterAgentRuntime, selectedStarterAgentRuntime,
starterAgentProviderSnapshotState,
suggestedStarterAgentChoice, suggestedStarterAgentChoice,
} from "./starter-agent-runtime.js"; } from "./starter-agent-runtime.js";
@@ -30,7 +31,6 @@ describe("starter agent runtime choices", () => {
{ {
id: "codex", id: "codex",
label: "Codex", label: "Codex",
suggested: true,
models: [ models: [
{ id: "gpt-5", label: "GPT-5", suggested: true }, { id: "gpt-5", label: "GPT-5", suggested: true },
{ id: "gpt-5-mini", label: "GPT-5 mini", suggested: false }, { id: "gpt-5-mini", label: "GPT-5 mini", suggested: false },
@@ -59,7 +59,6 @@ describe("starter agent runtime choices", () => {
{ {
id: "claude", id: "claude",
label: "Claude", label: "Claude",
suggested: false,
models: [{ id: "sonnet", label: "Sonnet", suggested: true }], models: [{ id: "sonnet", label: "Sonnet", suggested: true }],
modes: [{ id: "auto", label: "Auto", suggested: false }], modes: [{ id: "auto", label: "Auto", suggested: false }],
}, },
@@ -81,7 +80,6 @@ describe("starter agent runtime choices", () => {
{ {
id: "claude", id: "claude",
label: "claude", label: "claude",
suggested: false,
models: [{ id: "sonnet", label: "Sonnet", suggested: true }], models: [{ id: "sonnet", label: "Sonnet", suggested: true }],
modes: [{ id: "auto", label: "Auto", suggested: false }], modes: [{ id: "auto", label: "Auto", suggested: false }],
}, },
@@ -118,4 +116,16 @@ describe("starter agent runtime choices", () => {
}); });
expect(suggestedStarterAgentChoice(provider!.modes)).toBeUndefined(); expect(suggestedStarterAgentChoice(provider!.modes)).toBeUndefined();
}); });
it("distinguishes discovery in progress from a terminally unusable snapshot", () => {
expect(starterAgentProviderSnapshotState([])).toEqual({ kind: "loading" });
expect(
starterAgentProviderSnapshotState([{ provider: "claude", status: "loading", enabled: true }]),
).toEqual({ kind: "loading" });
expect(
starterAgentProviderSnapshotState([
{ provider: "claude", status: "unavailable", enabled: true },
]),
).toEqual({ kind: "unavailable" });
});
}); });
@@ -11,7 +11,6 @@ export interface HubStarterAgentProvider {
label: string; label: string;
models: readonly HubStarterAgentModel[]; models: readonly HubStarterAgentModel[];
modes: readonly HubStarterAgentMode[]; modes: readonly HubStarterAgentMode[];
suggested: boolean;
} }
export interface HubStarterAgentModel { export interface HubStarterAgentModel {
@@ -26,6 +25,11 @@ export interface HubStarterAgentMode {
suggested: boolean; suggested: boolean;
} }
export type HubStarterAgentProviderSnapshotState =
| { kind: "ready"; providers: readonly HubStarterAgentProvider[] }
| { kind: "loading" }
| { kind: "unavailable" };
export function availableStarterAgentProviders( export function availableStarterAgentProviders(
entries: readonly ProviderSnapshotEntry[], entries: readonly ProviderSnapshotEntry[],
): HubStarterAgentProvider[] { ): HubStarterAgentProvider[] {
@@ -47,14 +51,23 @@ export function availableStarterAgentProviders(
label: entry.label ?? entry.provider, label: entry.label ?? entry.provider,
models, models,
modes, modes,
suggested:
models.some((model) => model.suggested) &&
(modes.length === 0 || modes.some((mode) => mode.suggested)),
}, },
]; ];
}); });
} }
export function starterAgentProviderSnapshotState(
entries: readonly ProviderSnapshotEntry[],
): HubStarterAgentProviderSnapshotState {
const providers = availableStarterAgentProviders(entries);
if (providers.length > 0) return { kind: "ready", providers };
if (entries.length === 0) return { kind: "loading" };
if (entries.some((entry) => entry.enabled && entry.status === "loading")) {
return { kind: "loading" };
}
return { kind: "unavailable" };
}
export function suggestedStarterAgentChoice<T extends { suggested: boolean }>( export function suggestedStarterAgentChoice<T extends { suggested: boolean }>(
choices: readonly T[], choices: readonly T[],
): T | undefined { ): T | undefined {
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { availableStarterTriggerConnections } from "./starter-trigger.js";
describe("starter trigger connections", () => {
it("returns only concrete connections that can back the generated workflow", () => {
expect(
availableStarterTriggerConnections(
{
github: [
{
slug: "github-getpaseo",
accountLogin: "getpaseo",
accountType: "Organization",
repositories: ["getpaseo/paseo"],
},
],
slack: [{ teamId: "T123", teamName: "Paseo" }],
discord: [{ guildId: "456", guildName: "Paseo Discord" }],
},
"getpaseo/paseo",
),
).toEqual([
{
id: "github:getpaseo/paseo",
label: "GitHub — getpaseo/paseo",
provider: "github",
filters: { repo: "getpaseo/paseo" },
},
{
id: "slack:T123",
label: "Slack — Paseo",
provider: "slack",
filters: { workspace: "T123" },
},
{
id: "discord:456",
label: "Discord — Paseo Discord",
provider: "discord",
filters: { guild: "456" },
},
]);
});
it("does not offer GitHub when the current repository is not connected", () => {
expect(
availableStarterTriggerConnections(
{
github: [
{
slug: "github-getpaseo",
accountLogin: "getpaseo",
accountType: "Organization",
repositories: ["getpaseo/hub"],
},
],
slack: [],
discord: [],
},
"getpaseo/paseo",
),
).toEqual([]);
});
});
@@ -0,0 +1,40 @@
import type { HubSetupResources } from "./hub-client/index.js";
import type { HubInitProvider } from "./init-plan.js";
export interface HubStarterTriggerConnection {
id: string;
label: string;
provider: HubInitProvider;
filters: Readonly<Record<string, string>>;
}
export function availableStarterTriggerConnections(
resources: HubSetupResources,
githubRepository?: string,
): HubStarterTriggerConnection[] {
return [
...(githubRepository !== undefined &&
resources.github.some(({ repositories }) => repositories.includes(githubRepository))
? [
{
id: `github:${githubRepository}`,
label: `GitHub — ${githubRepository}`,
provider: "github" as const,
filters: { repo: githubRepository },
},
]
: []),
...resources.slack.map(({ teamId, teamName }) => ({
id: `slack:${teamId}`,
label: `Slack — ${teamName}`,
provider: "slack" as const,
filters: { workspace: teamId },
})),
...resources.discord.map(({ guildId, guildName }) => ({
id: `discord:${guildId}`,
label: `Discord — ${guildName}`,
provider: "discord" as const,
filters: { guild: guildId },
})),
];
}