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:
@@ -87,6 +87,7 @@ describe("Hub commands", () => {
|
||||
assert.deepEqual(events, [
|
||||
"progress:Logging in to https://hub.paseo.sh",
|
||||
"authorize:https://hub.paseo.sh",
|
||||
"progress:Logged in",
|
||||
]);
|
||||
assert.equal(result.data.origin, "https://hub.paseo.sh");
|
||||
});
|
||||
@@ -113,11 +114,18 @@ describe("Hub commands", () => {
|
||||
events.push("init");
|
||||
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 () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, it } from "vitest";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { HubCredentialStore, StoredHubCredential } from "./credentials.js";
|
||||
import type { HubDaemonClient, HubStatus } from "./daemon-client.js";
|
||||
import type { HubHttpClient } from "./hub-client/index.js";
|
||||
@@ -24,11 +25,7 @@ describe("Hub guided setup continuation", () => {
|
||||
const cwd = await temporaryDirectory();
|
||||
const credentials = new MemoryCredentials();
|
||||
const daemon = new SetupDaemon();
|
||||
const prompts = new PromptAnswers(
|
||||
[true, true],
|
||||
["codex", "gpt-5", "full-access", "slack"],
|
||||
["U123"],
|
||||
);
|
||||
const prompts = new PromptAnswers([true, true], ["codex", "gpt-5", "full-access"], ["U123"]);
|
||||
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
|
||||
const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls);
|
||||
|
||||
@@ -46,24 +43,22 @@ describe("Hub guided setup continuation", () => {
|
||||
);
|
||||
|
||||
assert.deepEqual(prompts.confirmations, [
|
||||
"Connect this daemon to https://hub.test?",
|
||||
"Connect this daemon to this Hub?",
|
||||
"Initialize and deploy a starter workflow?",
|
||||
]);
|
||||
assert.deepEqual(prompts.selections, [
|
||||
"Starter agent provider",
|
||||
"Starter agent model",
|
||||
"Starter agent mode",
|
||||
"Trigger provider",
|
||||
]);
|
||||
assert.deepEqual(prompts.selectionOptions, [
|
||||
["Codex (suggested)"],
|
||||
["Codex"],
|
||||
["GPT-5 (suggested)", "GPT-5 mini"],
|
||||
["Read only", "Full access (suggested)"],
|
||||
[
|
||||
"GitHub (issue or pull request comment)",
|
||||
"Slack (channel mention)",
|
||||
"Discord (channel mention)",
|
||||
],
|
||||
]);
|
||||
assert.deepEqual(prompts.messages, [
|
||||
"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.",
|
||||
"Using Slack — Paseo",
|
||||
]);
|
||||
assert.deepEqual(calls, [
|
||||
{ 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"]);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const cwd = await temporaryDirectory();
|
||||
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[] }> = [];
|
||||
|
||||
await runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, calls), {
|
||||
@@ -162,6 +187,52 @@ describe("Hub guided setup continuation", () => {
|
||||
["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(
|
||||
@@ -170,6 +241,9 @@ function setupEnvironment(
|
||||
daemon: SetupDaemon,
|
||||
prompts: PromptAnswers,
|
||||
calls: Array<{ operation: string; origin: string; files?: readonly string[] }>,
|
||||
options: {
|
||||
setupResources?: Awaited<ReturnType<HubHttpClient["listSetupResources"]>>;
|
||||
} = {},
|
||||
): HubGuidedSetupEnvironment {
|
||||
const hub = {
|
||||
async issueEnrollmentToken(origin: string) {
|
||||
@@ -182,11 +256,13 @@ function setupEnvironment(
|
||||
},
|
||||
async listSetupResources(origin: string) {
|
||||
calls.push({ operation: "setup", origin });
|
||||
return {
|
||||
return (
|
||||
options.setupResources ?? {
|
||||
github: [],
|
||||
discord: [],
|
||||
slack: [{ teamId: "T123", teamName: "Paseo" }],
|
||||
};
|
||||
}
|
||||
);
|
||||
},
|
||||
async listConfigurationResources(origin: string) {
|
||||
calls.push({ operation: "resources", origin });
|
||||
@@ -294,7 +370,7 @@ class SetupDaemon implements HubDaemonClient {
|
||||
private origin: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly providerEntries = [
|
||||
readonly providerEntries: readonly ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "ready" as const,
|
||||
@@ -311,6 +387,7 @@ class SetupDaemon implements HubDaemonClient {
|
||||
defaultModeId: "full-access",
|
||||
},
|
||||
],
|
||||
private readonly providerEntrySequence?: readonly (readonly ProviderSnapshotEntry[])[],
|
||||
) {}
|
||||
|
||||
async connectHub(origin: string) {
|
||||
@@ -325,7 +402,11 @@ class SetupDaemon implements HubDaemonClient {
|
||||
|
||||
async getProvidersSnapshot(options?: { cwd?: string }) {
|
||||
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() {
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
planHubInitOpening,
|
||||
resolveHubInitConnection,
|
||||
resolveHubInitProjects,
|
||||
type HubInitProvider,
|
||||
} from "./init-plan.js";
|
||||
import type { CliLoginFlow } from "./login-flow.js";
|
||||
import { runHubConnect } from "./connect.js";
|
||||
@@ -43,16 +42,21 @@ import { runHubProjects } from "./projects.js";
|
||||
import type { HubReporter } from "./reporter.js";
|
||||
import { normalizeHubOrigin } from "./origin.js";
|
||||
import {
|
||||
availableStarterAgentProviders,
|
||||
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>>;
|
||||
@@ -132,16 +136,18 @@ export async function runHubGuidedSetup(
|
||||
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 provider = await chooseProvider(environment);
|
||||
const providerFilters = await collectProviderFilters(provider, resources, cwd, environment);
|
||||
const providerFilters = await collectProviderIdentity(trigger, environment);
|
||||
const scaffold = createHubInitScaffold({
|
||||
cwd,
|
||||
daemonSlug: daemon.slug,
|
||||
agent,
|
||||
provider,
|
||||
provider: trigger.provider,
|
||||
providerFilters,
|
||||
});
|
||||
|
||||
@@ -185,7 +191,7 @@ export async function continueHubGuidedSetup(
|
||||
origin: string,
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
): Promise<void> {
|
||||
if (!(await requiredConfirm(environment, `Connect this daemon to ${origin}?`, true))) {
|
||||
if (!(await requiredConfirm(environment, "Connect this daemon to this Hub?", true))) {
|
||||
reportMessage(
|
||||
environment,
|
||||
`Skipped daemon connection. Run: ${hubLoginResumeCommand("connect", origin)}; then paseo hub init`,
|
||||
@@ -200,7 +206,20 @@ export async function continueHubGuidedSetup(
|
||||
);
|
||||
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(
|
||||
@@ -370,31 +389,61 @@ async function chooseProject(
|
||||
return project;
|
||||
}
|
||||
|
||||
async function chooseProvider(environment: HubGuidedSetupEnvironment): Promise<HubInitProvider> {
|
||||
return requiredSelect(environment, {
|
||||
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 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 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 providers = await waitForStarterAgentProviders(environment, cwd);
|
||||
const provider = await chooseStarterAgentProvider(environment, providers);
|
||||
const model = await chooseStarterAgentModel(environment, provider);
|
||||
const mode = await chooseStarterAgentMode(environment, provider);
|
||||
@@ -408,18 +457,45 @@ async function chooseStarterAgentRuntime(
|
||||
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 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);
|
||||
@@ -471,57 +547,30 @@ function invalidStarterAgentSelection(): HubCommandError {
|
||||
);
|
||||
}
|
||||
|
||||
async function collectProviderFilters(
|
||||
provider: HubInitProvider,
|
||||
resources: HubSetupResources,
|
||||
cwd: string,
|
||||
async function collectProviderIdentity(
|
||||
trigger: HubStarterTriggerConnection,
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
): Promise<Readonly<Record<string, string>>> {
|
||||
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"}.`,
|
||||
);
|
||||
}
|
||||
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,
|
||||
}),
|
||||
repo,
|
||||
};
|
||||
}
|
||||
if (provider === "slack") {
|
||||
if (trigger.provider === "slack") {
|
||||
return {
|
||||
workspace: await chooseConnection(
|
||||
environment,
|
||||
"Slack workspace",
|
||||
resources.slack.map(({ teamId, teamName }) => ({ id: teamId, label: teamName })),
|
||||
),
|
||||
...trigger.filters,
|
||||
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(({ guildId, guildName }) => ({ id: guildId, label: guildName })),
|
||||
),
|
||||
...trigger.filters,
|
||||
user: await requiredText(environment, {
|
||||
message: "Your Discord user ID (only this user can trigger the bot)",
|
||||
}),
|
||||
@@ -579,24 +628,6 @@ async function loadDaemonResource(
|
||||
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(
|
||||
cwd: string,
|
||||
scaffold: ReturnType<typeof createHubInitScaffold>,
|
||||
|
||||
@@ -42,6 +42,7 @@ export async function runHubLogin(
|
||||
reportHubProgress(dependencies.reporter, options, `Logging in to ${origin}`);
|
||||
const credential = await dependencies.flow.authorize(origin);
|
||||
dependencies.credentials.save({ origin, credential });
|
||||
reportHubProgress(dependencies.reporter, options, "Logged in");
|
||||
if (
|
||||
!options.json &&
|
||||
dependencies.isInteractive?.() &&
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
availableStarterAgentProviders,
|
||||
selectedStarterAgentRuntime,
|
||||
starterAgentProviderSnapshotState,
|
||||
suggestedStarterAgentChoice,
|
||||
} from "./starter-agent-runtime.js";
|
||||
|
||||
@@ -30,7 +31,6 @@ describe("starter agent runtime choices", () => {
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
suggested: true,
|
||||
models: [
|
||||
{ id: "gpt-5", label: "GPT-5", suggested: true },
|
||||
{ id: "gpt-5-mini", label: "GPT-5 mini", suggested: false },
|
||||
@@ -59,7 +59,6 @@ describe("starter agent runtime choices", () => {
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
suggested: false,
|
||||
models: [{ id: "sonnet", label: "Sonnet", suggested: true }],
|
||||
modes: [{ id: "auto", label: "Auto", suggested: false }],
|
||||
},
|
||||
@@ -81,7 +80,6 @@ describe("starter agent runtime choices", () => {
|
||||
{
|
||||
id: "claude",
|
||||
label: "claude",
|
||||
suggested: false,
|
||||
models: [{ id: "sonnet", label: "Sonnet", suggested: true }],
|
||||
modes: [{ id: "auto", label: "Auto", suggested: false }],
|
||||
},
|
||||
@@ -118,4 +116,16 @@ describe("starter agent runtime choices", () => {
|
||||
});
|
||||
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;
|
||||
models: readonly HubStarterAgentModel[];
|
||||
modes: readonly HubStarterAgentMode[];
|
||||
suggested: boolean;
|
||||
}
|
||||
|
||||
export interface HubStarterAgentModel {
|
||||
@@ -26,6 +25,11 @@ export interface HubStarterAgentMode {
|
||||
suggested: boolean;
|
||||
}
|
||||
|
||||
export type HubStarterAgentProviderSnapshotState =
|
||||
| { kind: "ready"; providers: readonly HubStarterAgentProvider[] }
|
||||
| { kind: "loading" }
|
||||
| { kind: "unavailable" };
|
||||
|
||||
export function availableStarterAgentProviders(
|
||||
entries: readonly ProviderSnapshotEntry[],
|
||||
): HubStarterAgentProvider[] {
|
||||
@@ -47,14 +51,23 @@ export function availableStarterAgentProviders(
|
||||
label: entry.label ?? entry.provider,
|
||||
models,
|
||||
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 }>(
|
||||
choices: readonly T[],
|
||||
): 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 },
|
||||
})),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user