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, [
|
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 () => {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user