diff --git a/packages/cli/src/commands/hub/commands.test.ts b/packages/cli/src/commands/hub/commands.test.ts index 4aefde042..e2950748a 100644 --- a/packages/cli/src/commands/hub/commands.test.ts +++ b/packages/cli/src/commands/hub/commands.test.ts @@ -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 () => { diff --git a/packages/cli/src/commands/hub/init-flow.test.ts b/packages/cli/src/commands/hub/init-flow.test.ts index 4d02f8a49..f43b87393 100644 --- a/packages/cli/src/commands/hub/init-flow.test.ts +++ b/packages/cli/src/commands/hub/init-flow.test.ts @@ -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>; + } = {}, ): HubGuidedSetupEnvironment { const hub = { async issueEnrollmentToken(origin: string) { @@ -182,11 +256,13 @@ function setupEnvironment( }, async listSetupResources(origin: string) { calls.push({ operation: "setup", origin }); - return { - github: [], - discord: [], - slack: [{ teamId: "T123", teamName: "Paseo" }], - }; + 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() { diff --git a/packages/cli/src/commands/hub/init.ts b/packages/cli/src/commands/hub/init.ts index 1900b5af0..15c86d523 100644 --- a/packages/cli/src/commands/hub/init.ts +++ b/packages/cli/src/commands/hub/init.ts @@ -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>; @@ -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 { - 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; } - 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( @@ -370,31 +389,61 @@ async function chooseProject( return project; } -async function chooseProvider(environment: HubGuidedSetupEnvironment): Promise { - 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 { + 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 { + 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 { - 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 { + 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 { - 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>> { - 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 { - 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, diff --git a/packages/cli/src/commands/hub/login.ts b/packages/cli/src/commands/hub/login.ts index f8d20e2c3..e77329094 100644 --- a/packages/cli/src/commands/hub/login.ts +++ b/packages/cli/src/commands/hub/login.ts @@ -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?.() && diff --git a/packages/cli/src/commands/hub/starter-agent-runtime.test.ts b/packages/cli/src/commands/hub/starter-agent-runtime.test.ts index 59c0729b4..da7beaece 100644 --- a/packages/cli/src/commands/hub/starter-agent-runtime.test.ts +++ b/packages/cli/src/commands/hub/starter-agent-runtime.test.ts @@ -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" }); + }); }); diff --git a/packages/cli/src/commands/hub/starter-agent-runtime.ts b/packages/cli/src/commands/hub/starter-agent-runtime.ts index 66c910fb7..e583cf337 100644 --- a/packages/cli/src/commands/hub/starter-agent-runtime.ts +++ b/packages/cli/src/commands/hub/starter-agent-runtime.ts @@ -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( choices: readonly T[], ): T | undefined { diff --git a/packages/cli/src/commands/hub/starter-trigger.test.ts b/packages/cli/src/commands/hub/starter-trigger.test.ts new file mode 100644 index 000000000..570ea0005 --- /dev/null +++ b/packages/cli/src/commands/hub/starter-trigger.test.ts @@ -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([]); + }); +}); diff --git a/packages/cli/src/commands/hub/starter-trigger.ts b/packages/cli/src/commands/hub/starter-trigger.ts new file mode 100644 index 000000000..c327beb32 --- /dev/null +++ b/packages/cli/src/commands/hub/starter-trigger.ts @@ -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>; +} + +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 }, + })), + ]; +}