feat(hub): guide starter setup (#3651)
* feat(hub): guide setup with provider IDs * fix(hub): preserve login setup state * feat(hub): select starter agent runtime Use the connected daemon's provider snapshot so generated Hub bundles only contain complete provider, model, and mode combinations. * fix(hub): stage starter runtime choices Keep providers with valid modes selectable when the daemon has no default mode, so users explicitly choose the complete runtime. * fix(hub): scope runtime choices to workspace Provider capabilities can vary by repository, so request the snapshot for the bundle directory that guided setup will deploy.
This commit is contained in:
@@ -120,6 +120,52 @@ describe("Hub HTTP client", () => {
|
||||
assert.equal(requests[0]?.url, "/api/v1/configuration-resources");
|
||||
});
|
||||
|
||||
it("reads strict provider-native setup resources", async () => {
|
||||
const requests: Array<{ url: string | undefined; body: string }> = [];
|
||||
const origin = await startServer(
|
||||
() => ({
|
||||
status: 200,
|
||||
body: {
|
||||
github: [
|
||||
{
|
||||
slug: "getpaseo",
|
||||
accountLogin: "getpaseo",
|
||||
accountType: "Organization",
|
||||
repositories: ["getpaseo/paseo"],
|
||||
},
|
||||
],
|
||||
discord: [{ guildId: "guild-123", guildName: "Paseo" }],
|
||||
slack: [{ teamId: "team-123", teamName: "Paseo" }],
|
||||
},
|
||||
}),
|
||||
requests,
|
||||
);
|
||||
|
||||
const resources = await new HubHttpClient().listSetupResources(origin, "secret");
|
||||
|
||||
assert.equal(resources.slack[0]?.teamId, "team-123");
|
||||
assert.equal(resources.discord[0]?.guildId, "guild-123");
|
||||
assert.deepEqual(requests[0], { url: "/api/v1/setup-resources", body: "" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ github: [], discord: [], slack: [{ teamId: "team-123", teamName: "Paseo", slug: "wrong" }] },
|
||||
{ github: [], discord: [], slack: [{ teamId: 123, teamName: "Paseo" }] },
|
||||
])("rejects malformed or unknown setup resource fields", async (body) => {
|
||||
const requests: Array<{ url: string | undefined; body: string }> = [];
|
||||
const origin = await startServer(
|
||||
() => ({
|
||||
status: 200,
|
||||
body,
|
||||
}),
|
||||
requests,
|
||||
);
|
||||
|
||||
await assert.rejects(new HubHttpClient().listSetupResources(origin, "secret"), {
|
||||
code: "HUB_INVALID_RESPONSE",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders file-aware Hub validation issues without exposing credentials or response bodies", async () => {
|
||||
const requests: Array<{ url: string | undefined; body: string }> = [];
|
||||
const origin = await startServer(
|
||||
|
||||
@@ -91,6 +91,56 @@ describe("Hub commands", () => {
|
||||
assert.equal(result.data.origin, "https://hub.paseo.sh");
|
||||
});
|
||||
|
||||
it("interactive login continues through the injected guided setup coordinator without invoking a CLI command", async () => {
|
||||
const credentials = new MemoryCredentials();
|
||||
const events: string[] = [];
|
||||
|
||||
await runHubLogin(
|
||||
"https://hub.test",
|
||||
{},
|
||||
{
|
||||
env: {},
|
||||
credentials,
|
||||
flow: {
|
||||
authorize: async () => {
|
||||
events.push("login");
|
||||
return "paseo_cli_prefix_durable-secret";
|
||||
},
|
||||
},
|
||||
isInteractive: () => true,
|
||||
continueGuidedSetup: async (origin) => {
|
||||
events.push(`connect:${origin}`);
|
||||
events.push("init");
|
||||
events.push("deploy");
|
||||
},
|
||||
reporter: quietReporter,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(events, ["login", "connect:https://hub.test", "init", "deploy"]);
|
||||
});
|
||||
|
||||
it("JSON and noninteractive login remain login-only", async () => {
|
||||
for (const [options, interactive] of [
|
||||
[{ json: true }, true],
|
||||
[{}, false],
|
||||
] as const) {
|
||||
const credentials = new MemoryCredentials();
|
||||
let continuationCount = 0;
|
||||
await runHubLogin(undefined, options, {
|
||||
env: {},
|
||||
credentials,
|
||||
flow: { authorize: async () => "paseo_cli_prefix_durable-secret" },
|
||||
isInteractive: () => interactive,
|
||||
continueGuidedSetup: async () => {
|
||||
continuationCount += 1;
|
||||
},
|
||||
reporter: quietReporter,
|
||||
});
|
||||
assert.equal(continuationCount, 0);
|
||||
}
|
||||
});
|
||||
|
||||
it("connect exchanges authority once and gives only the enrollment token to the daemon", async () => {
|
||||
const credentials = new MemoryCredentials();
|
||||
credentials.save({ origin: "https://hub.test", credential: "stored-human-secret" });
|
||||
@@ -484,6 +534,10 @@ class FakeDaemon implements HubDaemonClient {
|
||||
return { status: hubStatus("connected", this.origin) };
|
||||
}
|
||||
|
||||
async getProvidersSnapshot() {
|
||||
return { entries: [] };
|
||||
}
|
||||
|
||||
async disconnectHub(force: boolean) {
|
||||
this.disconnects += 1;
|
||||
this.disconnectForces.push(force);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export interface HubStatus {
|
||||
state: string;
|
||||
@@ -9,10 +10,17 @@ export interface HubStatus {
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface HubProvidersSnapshotOptions {
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface HubDaemonClient {
|
||||
connectHub(url: string, token: string): Promise<{ status: HubStatus }>;
|
||||
getHubStatus(): Promise<{ status: HubStatus }>;
|
||||
disconnectHub(force: boolean): Promise<{ status: HubStatus; warning?: string }>;
|
||||
getProvidersSnapshot(
|
||||
options?: HubProvidersSnapshotOptions,
|
||||
): Promise<{ entries: ProviderSnapshotEntry[] }>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
type CliAuthorizationPoll,
|
||||
type HubInstallResult,
|
||||
type HubConfigurationResources,
|
||||
setupResourcesSchema,
|
||||
type HubSetupResources,
|
||||
type HubProject,
|
||||
type HubValidationResult,
|
||||
} from "./internal/contracts.js";
|
||||
@@ -22,6 +24,7 @@ export type {
|
||||
CliAuthorizationPoll,
|
||||
HubInstallResult,
|
||||
HubConfigurationResources,
|
||||
HubSetupResources,
|
||||
HubProject,
|
||||
HubValidationResult,
|
||||
} from "./internal/contracts.js";
|
||||
@@ -95,6 +98,18 @@ export class HubHttpClient {
|
||||
});
|
||||
}
|
||||
|
||||
listSetupResources(origin: string, apiKey: string): Promise<HubSetupResources> {
|
||||
return requestHub({
|
||||
origin,
|
||||
path: "/api/v1/setup-resources",
|
||||
method: "GET",
|
||||
apiKey,
|
||||
successStatus: 200,
|
||||
schema: setupResourcesSchema,
|
||||
failureMessage: "Hub guided setup resource listing failed",
|
||||
});
|
||||
}
|
||||
|
||||
installConfiguration(input: HubConfigurationInput): Promise<HubInstallResult> {
|
||||
return requestHub({
|
||||
origin: input.origin,
|
||||
|
||||
@@ -54,6 +54,25 @@ export const configurationResourcesSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const setupResourcesSchema = z
|
||||
.object({
|
||||
github: z.array(
|
||||
z
|
||||
.object({
|
||||
slug: z.string().min(1),
|
||||
accountLogin: z.string().min(1),
|
||||
accountType: z.string().min(1),
|
||||
repositories: z.array(z.string().min(1)),
|
||||
})
|
||||
.strict(),
|
||||
),
|
||||
discord: z.array(
|
||||
z.object({ guildId: z.string().min(1), guildName: z.string().min(1) }).strict(),
|
||||
),
|
||||
slack: z.array(z.object({ teamId: z.string().min(1), teamName: z.string().min(1) }).strict()),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const installResponseSchema = z
|
||||
.object({
|
||||
projectSlug: z.string().min(1),
|
||||
@@ -75,5 +94,6 @@ export type CliAuthorization = z.infer<typeof authorizationSchema>;
|
||||
export type CliAuthorizationPoll = z.infer<typeof authorizationPollSchema>;
|
||||
export type HubProject = z.infer<typeof projectSchema>;
|
||||
export type HubConfigurationResources = z.infer<typeof configurationResourcesSchema>;
|
||||
export type HubSetupResources = z.infer<typeof setupResourcesSchema>;
|
||||
export type HubInstallResult = z.infer<typeof installResponseSchema>;
|
||||
export type HubValidationResult = z.infer<typeof validationResponseSchema>;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { addHubProjectsCommand } from "./projects.js";
|
||||
import { processHubReporter, type HubReporter } from "./reporter.js";
|
||||
import { hubStatusResult } from "./status-output.js";
|
||||
import { addHubResolutionHelp } from "./help.js";
|
||||
import { addHubInitCommand } from "./init.js";
|
||||
import { addHubInitCommand, continueHubGuidedSetup } from "./init.js";
|
||||
|
||||
interface HubCommandEnvironment {
|
||||
env: Readonly<Record<string, string | undefined>>;
|
||||
@@ -56,6 +56,8 @@ export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {})
|
||||
credentials: environment.credentials,
|
||||
flow: environment.login,
|
||||
reporter: environment.reporter,
|
||||
isInteractive: environment.isInteractive,
|
||||
continueGuidedSetup: (origin) => continueHubGuidedSetup(origin, environment),
|
||||
});
|
||||
addHubInitCommand(hub, environment);
|
||||
addHubConnectCommand(hub, {
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
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 { HubCredentialStore, StoredHubCredential } from "./credentials.js";
|
||||
import type { HubDaemonClient, HubStatus } from "./daemon-client.js";
|
||||
import type { HubHttpClient } from "./hub-client/index.js";
|
||||
import {
|
||||
continueHubGuidedSetup,
|
||||
runHubGuidedSetup,
|
||||
type HubGuidedSetupEnvironment,
|
||||
} from "./init.js";
|
||||
import { runHubLogin } from "./login.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true })));
|
||||
});
|
||||
|
||||
describe("Hub guided setup continuation", () => {
|
||||
it("uses the login origin and connected daemon to validate, write, and deploy without replaying setup prompts", async () => {
|
||||
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 calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
|
||||
const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls);
|
||||
|
||||
await runHubLogin(
|
||||
"https://hub.test",
|
||||
{},
|
||||
{
|
||||
env: {},
|
||||
credentials,
|
||||
flow: { authorize: async () => "paseo_cli_prefix_durable-secret" },
|
||||
isInteractive: () => true,
|
||||
continueGuidedSetup: (origin) => continueHubGuidedSetup(origin, environment),
|
||||
reporter: { progress() {} },
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(prompts.confirmations, [
|
||||
"Connect this daemon to https://hub.test?",
|
||||
"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)"],
|
||||
["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(calls, [
|
||||
{ operation: "token", origin: "https://hub.test" },
|
||||
{ operation: "projects", origin: "https://hub.test" },
|
||||
{ operation: "setup", origin: "https://hub.test" },
|
||||
{ operation: "resources", origin: "https://hub.test" },
|
||||
{
|
||||
operation: "validate",
|
||||
origin: "https://hub.test",
|
||||
files: [".paseo/hub.yml", ".paseo/workflows/slack-help.yml"],
|
||||
},
|
||||
{
|
||||
operation: "install",
|
||||
origin: "https://hub.test",
|
||||
files: [".paseo/hub.yml", ".paseo/workflows/slack-help.yml"],
|
||||
},
|
||||
]);
|
||||
assert.equal(daemon.connections, 1);
|
||||
assert.deepEqual(daemon.snapshotCwds, [cwd]);
|
||||
const hub = await readFile(path.join(cwd, ".paseo", "hub.yml"), "utf8");
|
||||
assert.match(hub, /daemon: macbook/u);
|
||||
assert.match(hub, /provider: codex\n model: gpt-5\n mode: full-access/u);
|
||||
});
|
||||
|
||||
it("prints exact actionable resume commands for login continuation declines", async () => {
|
||||
const cwd = await temporaryDirectory();
|
||||
const credentials = new MemoryCredentials();
|
||||
credentials.save({ origin: "https://hub.test", credential: "secret" });
|
||||
const daemon = new SetupDaemon();
|
||||
const connectDeclined = new PromptAnswers([false], [], []);
|
||||
|
||||
await continueHubGuidedSetup(
|
||||
"https://hub.test",
|
||||
setupEnvironment(cwd, credentials, daemon, connectDeclined, []),
|
||||
);
|
||||
assert.deepEqual(connectDeclined.messages, [
|
||||
"Skipped daemon connection. Run: paseo hub connect https://hub.test; then paseo hub init",
|
||||
]);
|
||||
|
||||
const initDeclined = new PromptAnswers([true, false], [], []);
|
||||
await continueHubGuidedSetup(
|
||||
"https://hub.test",
|
||||
setupEnvironment(cwd, credentials, daemon, initDeclined, []),
|
||||
);
|
||||
assert.deepEqual(initDeclined.messages, ["Skipped starter workflow. Run: paseo hub init"]);
|
||||
});
|
||||
|
||||
it("keeps hub init's existing replacement confirmation", async () => {
|
||||
const cwd = await temporaryDirectory();
|
||||
await mkdir(path.join(cwd, ".paseo"));
|
||||
const prompts = new PromptAnswers([false], [], []);
|
||||
|
||||
await assert.rejects(
|
||||
runHubGuidedSetup(
|
||||
setupEnvironment(cwd, new MemoryCredentials(), new SetupDaemon(), prompts, []),
|
||||
),
|
||||
/Existing .paseo\/ bundle left unchanged/u,
|
||||
);
|
||||
assert.deepEqual(prompts.confirmations, ["Replace the existing .paseo/ Hub bundle?"]);
|
||||
});
|
||||
|
||||
it("writes the explicitly selected Claude mode when the daemon has no default", async () => {
|
||||
const cwd = await temporaryDirectory();
|
||||
const credentials = new MemoryCredentials();
|
||||
credentials.save({ origin: "https://hub.test", credential: "secret" });
|
||||
const daemon = new SetupDaemon([
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
models: [{ provider: "claude", id: "sonnet", label: "Sonnet", isDefault: true }],
|
||||
modes: [{ id: "auto", label: "Auto" }],
|
||||
defaultModeId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const prompts = new PromptAnswers([true], ["claude", "sonnet", "auto", "slack"], ["U123"]);
|
||||
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
|
||||
|
||||
await runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, calls), {
|
||||
origin: "https://hub.test",
|
||||
daemonId: "daemon-1",
|
||||
deploy: true,
|
||||
});
|
||||
assert.deepEqual(prompts.selectionOptions.slice(0, 3), [
|
||||
["claude"],
|
||||
["Sonnet (suggested)"],
|
||||
["Auto"],
|
||||
]);
|
||||
assert.match(
|
||||
await readFile(path.join(cwd, ".paseo", "hub.yml"), "utf8"),
|
||||
/provider: claude\n model: sonnet\n mode: auto/u,
|
||||
);
|
||||
assert.deepEqual(
|
||||
calls.slice(-2).map(({ operation }) => operation),
|
||||
["validate", "install"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function setupEnvironment(
|
||||
cwd: string,
|
||||
credentials: HubCredentialStore,
|
||||
daemon: SetupDaemon,
|
||||
prompts: PromptAnswers,
|
||||
calls: Array<{ operation: string; origin: string; files?: readonly string[] }>,
|
||||
): HubGuidedSetupEnvironment {
|
||||
const hub = {
|
||||
async issueEnrollmentToken(origin: string) {
|
||||
calls.push({ operation: "token", origin });
|
||||
return "one-time-enrollment-token-with-enough-length";
|
||||
},
|
||||
async listProjects(origin: string) {
|
||||
calls.push({ operation: "projects", origin });
|
||||
return [{ id: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", slug: "studio", name: "Studio" }];
|
||||
},
|
||||
async listSetupResources(origin: string) {
|
||||
calls.push({ operation: "setup", origin });
|
||||
return {
|
||||
github: [],
|
||||
discord: [],
|
||||
slack: [{ teamId: "T123", teamName: "Paseo" }],
|
||||
};
|
||||
},
|
||||
async listConfigurationResources(origin: string) {
|
||||
calls.push({ operation: "resources", origin });
|
||||
return { daemons: [{ id: "daemon-1", slug: "macbook" }], github: [], discord: [], slack: [] };
|
||||
},
|
||||
async validateConfiguration(input: { origin: string; files: readonly { path: string }[] }) {
|
||||
calls.push({
|
||||
operation: "validate",
|
||||
origin: input.origin,
|
||||
files: input.files.map(({ path: filePath }) => filePath),
|
||||
});
|
||||
return { projectSlug: "studio", valid: true };
|
||||
},
|
||||
async installConfiguration(input: { origin: string; files: readonly { path: string }[] }) {
|
||||
calls.push({
|
||||
operation: "install",
|
||||
origin: input.origin,
|
||||
files: input.files.map(({ path: filePath }) => filePath),
|
||||
});
|
||||
return {
|
||||
projectSlug: "studio",
|
||||
version: 1,
|
||||
versionId: "a50e05af-4f20-4c8f-8dcc-58e5ea360663",
|
||||
active: true,
|
||||
};
|
||||
},
|
||||
} as HubHttpClient;
|
||||
return {
|
||||
env: {},
|
||||
credentials,
|
||||
hub,
|
||||
login: { authorize: async () => "paseo_cli_prefix_durable-secret" },
|
||||
daemon: { connect: async () => daemon },
|
||||
reporter: { progress() {} },
|
||||
cwd: () => cwd,
|
||||
isInteractive: () => true,
|
||||
prompts,
|
||||
};
|
||||
}
|
||||
|
||||
class PromptAnswers {
|
||||
readonly confirmations: string[] = [];
|
||||
readonly selections: string[] = [];
|
||||
readonly selectionOptions: string[][] = [];
|
||||
readonly messages: string[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly confirmAnswers: boolean[],
|
||||
private readonly selectAnswers: string[],
|
||||
private readonly textAnswers: string[],
|
||||
) {}
|
||||
|
||||
async confirm(message: string): Promise<boolean> {
|
||||
this.confirmations.push(message);
|
||||
return this.confirmAnswers.shift() ?? false;
|
||||
}
|
||||
|
||||
async select(options: {
|
||||
message: string;
|
||||
options?: { label: string; hint?: string }[];
|
||||
}): Promise<string> {
|
||||
this.selections.push(options.message);
|
||||
this.selectionOptions.push(
|
||||
options.options?.map(
|
||||
(option) => `${option.label}${option.hint ? ` (${option.hint})` : ""}`,
|
||||
) ?? [],
|
||||
);
|
||||
return this.selectAnswers.shift() ?? "";
|
||||
}
|
||||
|
||||
async text(): Promise<string> {
|
||||
return this.textAnswers.shift() ?? "";
|
||||
}
|
||||
|
||||
message(value: string): void {
|
||||
this.messages.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryCredentials implements HubCredentialStore {
|
||||
private record: StoredHubCredential | null = null;
|
||||
|
||||
active(): StoredHubCredential | null {
|
||||
return this.record;
|
||||
}
|
||||
|
||||
get(origin: string): StoredHubCredential | null {
|
||||
return this.record?.origin === origin ? this.record : null;
|
||||
}
|
||||
|
||||
save(credential: StoredHubCredential): void {
|
||||
this.record = credential;
|
||||
}
|
||||
|
||||
logoutActive(): StoredHubCredential | null {
|
||||
const record = this.record;
|
||||
this.record = null;
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
class SetupDaemon implements HubDaemonClient {
|
||||
connections = 0;
|
||||
readonly snapshotCwds: Array<string | undefined> = [];
|
||||
private origin: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly providerEntries = [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "ready" as const,
|
||||
enabled: true,
|
||||
label: "Codex",
|
||||
models: [
|
||||
{ provider: "codex", id: "gpt-5", label: "GPT-5", isDefault: true },
|
||||
{ provider: "codex", id: "gpt-5-mini", label: "GPT-5 mini" },
|
||||
],
|
||||
modes: [
|
||||
{ id: "read-only", label: "Read only" },
|
||||
{ id: "full-access", label: "Full access" },
|
||||
],
|
||||
defaultModeId: "full-access",
|
||||
},
|
||||
],
|
||||
) {}
|
||||
|
||||
async connectHub(origin: string) {
|
||||
this.connections += 1;
|
||||
this.origin = origin;
|
||||
return { status: status(origin) };
|
||||
}
|
||||
|
||||
async getHubStatus() {
|
||||
return { status: this.origin === null ? disconnectedStatus() : status(this.origin) };
|
||||
}
|
||||
|
||||
async getProvidersSnapshot(options?: { cwd?: string }) {
|
||||
this.snapshotCwds.push(options?.cwd);
|
||||
return { entries: this.providerEntries };
|
||||
}
|
||||
|
||||
async disconnectHub() {
|
||||
return { status: disconnectedStatus() };
|
||||
}
|
||||
|
||||
async close() {}
|
||||
}
|
||||
|
||||
function status(origin: string): HubStatus {
|
||||
return {
|
||||
state: "connected",
|
||||
daemonId: "daemon-1",
|
||||
hubOrigin: origin,
|
||||
scopes: [],
|
||||
connectedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
function disconnectedStatus(): HubStatus {
|
||||
return {
|
||||
state: "not_connected",
|
||||
daemonId: null,
|
||||
hubOrigin: null,
|
||||
scopes: [],
|
||||
connectedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-init-flow-"));
|
||||
directories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { discoverHubBundle } from "./deploy-bundle.js";
|
||||
import { runHubDeploy } from "./deploy.js";
|
||||
import {
|
||||
createHubInitScaffold,
|
||||
hubLoginResumeCommand,
|
||||
planHubInitOpening,
|
||||
resolveHubInitConnection,
|
||||
resolveHubInitProjects,
|
||||
@@ -26,6 +27,12 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("Hub init planning", () => {
|
||||
it("prints direct resumable commands for declined login continuations", () => {
|
||||
expect(hubLoginResumeCommand("connect", "https://hub.test")).toBe(
|
||||
"paseo hub connect https://hub.test",
|
||||
);
|
||||
expect(hubLoginResumeCommand("init", "https://hub.test")).toBe("paseo hub init");
|
||||
});
|
||||
it("includes login only when there is no active login", () => {
|
||||
expect(planHubInitOpening({ loggedIn: false, paseoDirectoryExists: false })).toEqual({
|
||||
replaceExisting: false,
|
||||
@@ -86,10 +93,25 @@ describe("Hub init planning", () => {
|
||||
});
|
||||
|
||||
describe("Hub init scaffold", () => {
|
||||
it("omits mode for a provider that explicitly exposes none", () => {
|
||||
const scaffold = createHubInitScaffold({
|
||||
cwd: "/workspace",
|
||||
daemonSlug: "build-studio",
|
||||
agent: { provider: "pi", model: "default" },
|
||||
provider: "slack",
|
||||
providerFilters: { workspace: "T123456", user: "U123456" },
|
||||
});
|
||||
|
||||
expect(YAML.parse(scaffold.hub)).toMatchObject({
|
||||
agents: { starter: { provider: "pi", model: "default" } },
|
||||
});
|
||||
expect(YAML.parse(scaffold.hub).agents.starter.mode).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["github", { repo: "getpaseo/paseo", user: "boudra" }],
|
||||
["slack", { workspace: "paseo", user: "boudra" }],
|
||||
["discord", { guild: "paseo", user: "boudra" }],
|
||||
["slack", { workspace: "T123456", user: "U123456" }],
|
||||
["discord", { guild: "123456789", user: "987654321" }],
|
||||
] satisfies readonly [HubInitProvider, Record<string, string>][])(
|
||||
"creates a closed %s trigger that the deploy discovery path accepts",
|
||||
async (provider, providerFilters) => {
|
||||
@@ -97,6 +119,11 @@ describe("Hub init scaffold", () => {
|
||||
const scaffold = createHubInitScaffold({
|
||||
cwd,
|
||||
daemonSlug: "build-studio",
|
||||
agent: {
|
||||
provider: "codex",
|
||||
model: "gpt-5",
|
||||
mode: "full-access",
|
||||
},
|
||||
provider,
|
||||
providerFilters,
|
||||
});
|
||||
@@ -114,7 +141,12 @@ describe("Hub init scaffold", () => {
|
||||
filters: { from_users: string[]; channels?: string[] };
|
||||
};
|
||||
expect(parsed.filters.from_users).toEqual([providerFilters.user]);
|
||||
if (provider === "slack") expect(parsed.filters.workspace).toBe("T123456");
|
||||
if (provider === "discord") expect(parsed.filters.guild).toBe("123456789");
|
||||
expect(parsed.filters.channels).toBeUndefined();
|
||||
expect(YAML.parse(scaffold.hub)).toMatchObject({
|
||||
agents: { starter: { provider: "codex", model: "gpt-5", mode: "full-access" } },
|
||||
});
|
||||
|
||||
let validatedPaths: readonly string[] = [];
|
||||
const result = await runHubDeploy(
|
||||
|
||||
@@ -3,6 +3,7 @@ import YAML from "yaml";
|
||||
import type { HubDeployBundle } from "./deploy-bundle.js";
|
||||
import type { HubProject } from "./hub-client/index.js";
|
||||
import type { HubStatus } from "./daemon-client.js";
|
||||
import type { HubStarterAgentRuntime } from "./starter-agent-runtime.js";
|
||||
|
||||
export type HubInitProvider = "github" | "slack" | "discord";
|
||||
|
||||
@@ -25,6 +26,7 @@ export interface HubInitOpeningPlan {
|
||||
export interface HubInitScaffoldInput {
|
||||
cwd: string;
|
||||
daemonSlug: string;
|
||||
agent: HubStarterAgentRuntime;
|
||||
provider: HubInitProvider;
|
||||
providerFilters: Readonly<Record<string, string>>;
|
||||
}
|
||||
@@ -36,6 +38,10 @@ export interface HubInitScaffold {
|
||||
testAction: string;
|
||||
}
|
||||
|
||||
export function hubLoginResumeCommand(step: "connect" | "init", origin: string): string {
|
||||
return step === "connect" ? `paseo hub connect ${origin}` : "paseo hub init";
|
||||
}
|
||||
|
||||
export function planHubInitOpening(input: {
|
||||
loggedIn: boolean;
|
||||
paseoDirectoryExists: boolean;
|
||||
@@ -97,9 +103,10 @@ export function createHubInitScaffold(input: HubInitScaffoldInput): HubInitScaff
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
codex: {
|
||||
provider: "codex",
|
||||
mode: "full-access",
|
||||
starter: {
|
||||
provider: input.agent.provider,
|
||||
model: input.agent.model,
|
||||
...(input.agent.mode === undefined ? {} : { mode: input.agent.mode }),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -180,7 +187,7 @@ function workflow(input: {
|
||||
environment: input.environment,
|
||||
max_runtime: "90m",
|
||||
idle_timeout: "10m",
|
||||
agent: "codex",
|
||||
agent: "starter",
|
||||
prompt: [
|
||||
{
|
||||
text: `${replyInstruction}complete this request and call hub.finish_execution when done.\n\n<user-prompt>\n\${{ paseo.prompt }}\n</user-prompt>\n`,
|
||||
|
||||
@@ -21,28 +21,40 @@ import type { HubCredentialStore } from "./credentials.js";
|
||||
import type { HubDaemonConnection } from "./daemon-client.js";
|
||||
import { withHubDaemon } from "./daemon-client.js";
|
||||
import { HubCommandError } from "./error.js";
|
||||
import type { HubConfigurationResources, HubHttpClient, HubProject } from "./hub-client/index.js";
|
||||
import type {
|
||||
HubConfigurationResources,
|
||||
HubHttpClient,
|
||||
HubProject,
|
||||
HubSetupResources,
|
||||
} from "./hub-client/index.js";
|
||||
import {
|
||||
createHubInitBundle,
|
||||
createHubInitScaffold,
|
||||
hubLoginResumeCommand,
|
||||
planHubInitOpening,
|
||||
resolveHubInitConnection,
|
||||
resolveHubInitProjects,
|
||||
type HubInitProvider,
|
||||
} from "./init-plan.js";
|
||||
import type { CliLoginFlow } from "./login-flow.js";
|
||||
import { runHubLogin } from "./login.js";
|
||||
import { runHubConnect } from "./connect.js";
|
||||
import { runHubDeployBundle } from "./deploy.js";
|
||||
import { runHubProjects } from "./projects.js";
|
||||
import type { HubReporter } from "./reporter.js";
|
||||
import { normalizeHubOrigin } from "./origin.js";
|
||||
import {
|
||||
availableStarterAgentProviders,
|
||||
selectedStarterAgentRuntime,
|
||||
suggestedStarterAgentChoice,
|
||||
type HubStarterAgentProvider,
|
||||
type HubStarterAgentRuntime,
|
||||
} from "./starter-agent-runtime.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DAEMON_READY_TIMEOUT_MS = 60_000;
|
||||
const DAEMON_READY_POLL_MS = 250;
|
||||
|
||||
interface HubInitEnvironment {
|
||||
export interface HubGuidedSetupEnvironment {
|
||||
env: Readonly<Record<string, string | undefined>>;
|
||||
credentials: HubCredentialStore;
|
||||
hub: HubHttpClient;
|
||||
@@ -50,6 +62,19 @@ interface HubInitEnvironment {
|
||||
daemon: HubDaemonConnection;
|
||||
reporter: HubReporter;
|
||||
cwd(): string;
|
||||
isInteractive?(): boolean;
|
||||
prompts?: {
|
||||
confirm(message: string, initialValue: boolean): Promise<boolean>;
|
||||
select(options: Parameters<typeof select<string>>[0]): Promise<string>;
|
||||
text(options: Parameters<typeof text>[0]): Promise<string>;
|
||||
message(value: string): void;
|
||||
};
|
||||
}
|
||||
|
||||
interface HubGuidedSetupState {
|
||||
origin?: string;
|
||||
daemonId?: string;
|
||||
deploy?: boolean;
|
||||
}
|
||||
|
||||
class HubInitCancelledError extends Error {}
|
||||
@@ -61,7 +86,7 @@ function initErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function addHubInitCommand(parent: Command, environment: HubInitEnvironment): void {
|
||||
export function addHubInitCommand(parent: Command, environment: HubGuidedSetupEnvironment): void {
|
||||
parent
|
||||
.command("init")
|
||||
.description("Create and optionally deploy a safe starter Hub bundle")
|
||||
@@ -79,8 +104,15 @@ export function addHubInitCommand(parent: Command, environment: HubInitEnvironme
|
||||
});
|
||||
}
|
||||
|
||||
export async function runHubInit(environment: HubInitEnvironment): Promise<void> {
|
||||
requireInteractiveTerminal();
|
||||
export async function runHubInit(environment: HubGuidedSetupEnvironment): Promise<void> {
|
||||
await runHubGuidedSetup(environment);
|
||||
}
|
||||
|
||||
export async function runHubGuidedSetup(
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
state: HubGuidedSetupState = {},
|
||||
): Promise<void> {
|
||||
requireInteractiveTerminal(environment);
|
||||
intro("Set up Paseo Hub");
|
||||
|
||||
const cwd = environment.cwd();
|
||||
@@ -91,28 +123,24 @@ export async function runHubInit(environment: HubInitEnvironment): Promise<void>
|
||||
});
|
||||
if (
|
||||
opening.replaceExisting &&
|
||||
!(await requiredConfirm("Replace the existing .paseo/ Hub bundle?", false))
|
||||
!(await requiredConfirm(environment, "Replace the existing .paseo/ Hub bundle?", false))
|
||||
) {
|
||||
throw new HubInitCancelledError("Existing .paseo/ bundle left unchanged.");
|
||||
}
|
||||
|
||||
const origin = await ensureLogin(activeLogin?.origin, environment);
|
||||
const daemonId = await ensureDaemonConnection(origin, environment);
|
||||
const origin = state.origin ?? (await ensureLogin(activeLogin?.origin, environment));
|
||||
const daemonId = state.daemonId ?? (await ensureDaemonConnection(origin, environment));
|
||||
const project = await chooseProject(origin, environment);
|
||||
const resources = await loadConfigurationResources(origin, environment);
|
||||
const daemon = resources.daemons.find(({ id }) => id === daemonId);
|
||||
if (daemon === undefined) {
|
||||
throw new HubCommandError(
|
||||
"HUB_DAEMON_RESOURCE_MISSING",
|
||||
"The connected daemon is not available in this Hub organization. Reconnect it and try again.",
|
||||
);
|
||||
}
|
||||
const resources = await loadSetupResources(origin, environment);
|
||||
const daemon = await loadDaemonResource(origin, daemonId, environment);
|
||||
log.success(`Connected as ${daemon.slug}`);
|
||||
const provider = await chooseProvider();
|
||||
const providerFilters = await collectProviderFilters(provider, resources, cwd);
|
||||
const agent = await chooseStarterAgentRuntime(environment, cwd);
|
||||
const provider = await chooseProvider(environment);
|
||||
const providerFilters = await collectProviderFilters(provider, resources, cwd, environment);
|
||||
const scaffold = createHubInitScaffold({
|
||||
cwd,
|
||||
daemonSlug: daemon.slug,
|
||||
agent,
|
||||
provider,
|
||||
providerFilters,
|
||||
});
|
||||
@@ -132,7 +160,7 @@ export async function runHubInit(environment: HubInitEnvironment): Promise<void>
|
||||
await writeScaffold(cwd, scaffold, opening.replaceExisting);
|
||||
log.success(`Created .paseo/hub.yml and ${scaffold.workflowPath}`);
|
||||
|
||||
const deploy = await requiredConfirm("Deploy now?", true);
|
||||
const deploy = state.deploy ?? (await requiredConfirm(environment, "Deploy now?", true));
|
||||
if (deploy) {
|
||||
await withSpinner("Deploying bundle", async () => {
|
||||
await runHubDeployBundle({ project: project.slug, hub: origin }, bundle, {
|
||||
@@ -145,7 +173,7 @@ export async function runHubInit(environment: HubInitEnvironment): Promise<void>
|
||||
});
|
||||
log.success("Deployed");
|
||||
} else {
|
||||
log.message(`Skipped deployment. Run: paseo hub deploy -p ${project.slug}`);
|
||||
reportMessage(environment, `Skipped deployment. Run: paseo hub deploy -p ${project.slug}`);
|
||||
}
|
||||
|
||||
const activityUrl = new URL(`/projects/${project.slug}/activity`, origin).toString();
|
||||
@@ -153,11 +181,33 @@ export async function runHubInit(environment: HubInitEnvironment): Promise<void>
|
||||
outro(deploy ? "Hub is ready" : "Hub bundle is ready");
|
||||
}
|
||||
|
||||
export async function continueHubGuidedSetup(
|
||||
origin: string,
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
): Promise<void> {
|
||||
if (!(await requiredConfirm(environment, `Connect this daemon to ${origin}?`, true))) {
|
||||
reportMessage(
|
||||
environment,
|
||||
`Skipped daemon connection. Run: ${hubLoginResumeCommand("connect", origin)}; then paseo hub init`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const daemonId = await ensureDaemonConnection(origin, environment, true);
|
||||
if (!(await requiredConfirm(environment, "Initialize and deploy a starter workflow?", true))) {
|
||||
reportMessage(
|
||||
environment,
|
||||
`Skipped starter workflow. Run: ${hubLoginResumeCommand("init", origin)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await runHubGuidedSetup(environment, { origin, daemonId, deploy: true });
|
||||
}
|
||||
|
||||
async function ensureLogin(
|
||||
activeOrigin: string | undefined,
|
||||
environment: HubInitEnvironment,
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
): Promise<string> {
|
||||
const endpoint = await requiredSelect({
|
||||
const endpoint = await requiredSelect(environment, {
|
||||
message: "Hub endpoint",
|
||||
initialValue:
|
||||
activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN ? "hosted" : "custom",
|
||||
@@ -169,7 +219,7 @@ async function ensureLogin(
|
||||
const origin =
|
||||
endpoint === "hosted"
|
||||
? DEFAULT_HUB_ORIGIN
|
||||
: await requiredText({
|
||||
: await requiredText(environment, {
|
||||
message: "Custom Hub URL",
|
||||
initialValue:
|
||||
activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN
|
||||
@@ -189,23 +239,17 @@ async function ensureLogin(
|
||||
log.success(`Logged in to ${normalizedOrigin}`);
|
||||
return normalizedOrigin;
|
||||
}
|
||||
await runHubLogin(
|
||||
normalizedOrigin,
|
||||
{},
|
||||
{
|
||||
env: environment.env,
|
||||
credentials: environment.credentials,
|
||||
flow: environment.login,
|
||||
reporter: environment.reporter,
|
||||
},
|
||||
);
|
||||
environment.reporter.progress(`Logging in to ${normalizedOrigin}`);
|
||||
const credential = await environment.login.authorize(normalizedOrigin);
|
||||
environment.credentials.save({ origin: normalizedOrigin, credential });
|
||||
log.success(`Logged in to ${normalizedOrigin}`);
|
||||
return normalizedOrigin;
|
||||
}
|
||||
|
||||
async function ensureDaemonConnection(
|
||||
origin: string,
|
||||
environment: HubInitEnvironment,
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
confirmed = false,
|
||||
): Promise<string> {
|
||||
const status = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
|
||||
daemon.getHubStatus().then((response) => response.status),
|
||||
@@ -223,9 +267,19 @@ async function ensureDaemonConnection(
|
||||
`This daemon is connected to ${connection.origin}. Disconnect it before running Hub init for ${origin}.`,
|
||||
);
|
||||
}
|
||||
if (!(await requiredConfirm(`Connect this daemon to ${origin}?`, true))) {
|
||||
if (
|
||||
!confirmed &&
|
||||
!(await requiredConfirm(environment, `Connect this daemon to ${origin}?`, true))
|
||||
) {
|
||||
throw new HubInitCancelledError("A connected daemon is required to create the bundle.");
|
||||
}
|
||||
return connectDaemon(origin, environment);
|
||||
}
|
||||
|
||||
async function connectDaemon(
|
||||
origin: string,
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
): Promise<string> {
|
||||
await runHubConnect(
|
||||
origin,
|
||||
{},
|
||||
@@ -276,7 +330,10 @@ async function waitForDaemonReady(
|
||||
);
|
||||
}
|
||||
|
||||
async function chooseProject(origin: string, environment: HubInitEnvironment): Promise<HubProject> {
|
||||
async function chooseProject(
|
||||
origin: string,
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
): Promise<HubProject> {
|
||||
const result = await runHubProjects(
|
||||
{ hub: origin },
|
||||
{
|
||||
@@ -295,7 +352,7 @@ async function chooseProject(origin: string, environment: HubInitEnvironment): P
|
||||
if (resolution.kind === "selected") {
|
||||
return resolution.project;
|
||||
}
|
||||
const slug = await requiredSelect({
|
||||
const slug = await requiredSelect(environment, {
|
||||
message: "Project",
|
||||
options: resolution.projects.map((project) => ({
|
||||
value: project.slug,
|
||||
@@ -313,8 +370,8 @@ async function chooseProject(origin: string, environment: HubInitEnvironment): P
|
||||
return project;
|
||||
}
|
||||
|
||||
async function chooseProvider(): Promise<HubInitProvider> {
|
||||
return requiredSelect({
|
||||
async function chooseProvider(environment: HubGuidedSetupEnvironment): Promise<HubInitProvider> {
|
||||
return requiredSelect(environment, {
|
||||
message: "Trigger provider",
|
||||
options: [
|
||||
{ value: "github", label: "GitHub", hint: "issue or pull request comment" },
|
||||
@@ -324,10 +381,101 @@ async function chooseProvider(): Promise<HubInitProvider> {
|
||||
});
|
||||
}
|
||||
|
||||
async function chooseStarterAgentRuntime(
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
cwd: string,
|
||||
): Promise<HubStarterAgentRuntime> {
|
||||
const snapshot = await withHubDaemon(environment.daemon, undefined, (daemon) =>
|
||||
daemon.getProvidersSnapshot({ cwd }),
|
||||
);
|
||||
const providers = availableStarterAgentProviders(snapshot.entries);
|
||||
if (providers.length === 0) {
|
||||
throw new HubCommandError(
|
||||
"HUB_AGENT_RUNTIME_REQUIRED",
|
||||
"No usable agent runtime is available from this daemon. Configure an enabled provider with a selectable model, then run paseo hub init again.",
|
||||
);
|
||||
}
|
||||
const provider = await chooseStarterAgentProvider(environment, providers);
|
||||
const model = await chooseStarterAgentModel(environment, provider);
|
||||
const mode = await chooseStarterAgentMode(environment, provider);
|
||||
const runtime = selectedStarterAgentRuntime(provider, model, mode);
|
||||
if (runtime === undefined) {
|
||||
throw new HubCommandError(
|
||||
"HUB_AGENT_RUNTIME_SELECTION_INVALID",
|
||||
"The selected starter agent runtime is no longer available. Run paseo hub init again.",
|
||||
);
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
async function chooseStarterAgentProvider(
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
providers: readonly HubStarterAgentProvider[],
|
||||
): Promise<HubStarterAgentProvider> {
|
||||
const suggested = suggestedStarterAgentChoice(providers);
|
||||
const selected = await requiredSelect(environment, {
|
||||
message: "Starter agent provider",
|
||||
...(suggested === undefined ? {} : { initialValue: suggested.id }),
|
||||
options: providers.map((provider) => ({
|
||||
value: provider.id,
|
||||
label: provider.label,
|
||||
...(provider.suggested ? { hint: "suggested" } : {}),
|
||||
})),
|
||||
});
|
||||
const provider = providers.find((candidate) => candidate.id === selected);
|
||||
if (provider === undefined) throw invalidStarterAgentSelection();
|
||||
return provider;
|
||||
}
|
||||
|
||||
async function chooseStarterAgentModel(
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
provider: HubStarterAgentProvider,
|
||||
): Promise<string> {
|
||||
const suggested = suggestedStarterAgentChoice(provider.models);
|
||||
const selected = await requiredSelect(environment, {
|
||||
message: "Starter agent model",
|
||||
...(suggested === undefined ? {} : { initialValue: suggested.id }),
|
||||
options: provider.models.map((model) => ({
|
||||
value: model.id,
|
||||
label: model.label,
|
||||
...(model.suggested ? { hint: "suggested" } : {}),
|
||||
})),
|
||||
});
|
||||
if (!provider.models.some((model) => model.id === selected)) throw invalidStarterAgentSelection();
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function chooseStarterAgentMode(
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
provider: HubStarterAgentProvider,
|
||||
): Promise<string | undefined> {
|
||||
if (provider.modes.length === 0) return undefined;
|
||||
const suggested = suggestedStarterAgentChoice(provider.modes);
|
||||
const selected = await requiredSelect(environment, {
|
||||
message: "Starter agent mode",
|
||||
...(suggested === undefined ? {} : { initialValue: suggested.id }),
|
||||
options: provider.modes.map((mode) => ({
|
||||
value: mode.id,
|
||||
label: mode.label,
|
||||
...(mode.suggested ? { hint: "suggested" } : {}),
|
||||
})),
|
||||
});
|
||||
if (!provider.modes.some((mode) => mode.id === selected)) throw invalidStarterAgentSelection();
|
||||
return selected;
|
||||
}
|
||||
|
||||
function invalidStarterAgentSelection(): HubCommandError {
|
||||
return new HubCommandError(
|
||||
"HUB_AGENT_RUNTIME_SELECTION_INVALID",
|
||||
"The selected starter agent runtime is no longer available. Run paseo hub init again.",
|
||||
);
|
||||
}
|
||||
|
||||
async function collectProviderFilters(
|
||||
provider: HubInitProvider,
|
||||
resources: HubConfigurationResources,
|
||||
resources: HubSetupResources,
|
||||
cwd: string,
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
): Promise<Readonly<Record<string, string>>> {
|
||||
if (provider === "github") {
|
||||
const [login, originRemote] = await Promise.all([
|
||||
@@ -349,7 +497,7 @@ async function collectProviderFilters(
|
||||
);
|
||||
}
|
||||
return {
|
||||
user: await requiredText({
|
||||
user: await requiredText(environment, {
|
||||
message: "Your GitHub username (only this user can trigger the bot)",
|
||||
initialValue: login,
|
||||
}),
|
||||
@@ -359,32 +507,34 @@ async function collectProviderFilters(
|
||||
if (provider === "slack") {
|
||||
return {
|
||||
workspace: await chooseConnection(
|
||||
environment,
|
||||
"Slack workspace",
|
||||
resources.slack.map(({ slug, teamName }) => ({ slug, label: teamName })),
|
||||
resources.slack.map(({ teamId, teamName }) => ({ id: teamId, label: teamName })),
|
||||
),
|
||||
user: await requiredText({
|
||||
message: "Your Slack username (only this user can trigger the bot)",
|
||||
user: await requiredText(environment, {
|
||||
message: "Your Slack member ID (only this user can trigger the bot)",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
guild: await chooseConnection(
|
||||
environment,
|
||||
"Discord server",
|
||||
resources.discord.map(({ slug, guildName }) => ({ slug, label: guildName })),
|
||||
resources.discord.map(({ guildId, guildName }) => ({ id: guildId, label: guildName })),
|
||||
),
|
||||
user: await requiredText({
|
||||
message: "Your Discord username (only this user can trigger the bot)",
|
||||
user: await requiredText(environment, {
|
||||
message: "Your Discord user ID (only this user can trigger the bot)",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadConfigurationResources(
|
||||
async function loadSetupResources(
|
||||
origin: string,
|
||||
environment: HubInitEnvironment,
|
||||
): Promise<HubConfigurationResources> {
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
): Promise<HubSetupResources> {
|
||||
try {
|
||||
return await withSpinner("Loading Hub connections", () =>
|
||||
environment.hub.listConfigurationResources(
|
||||
environment.hub.listSetupResources(
|
||||
origin,
|
||||
resolveHubCredential({
|
||||
options: { origin },
|
||||
@@ -398,16 +548,41 @@ async function loadConfigurationResources(
|
||||
if (error instanceof HubCommandError && error.code === "HUB_NOT_FOUND") {
|
||||
throw new HubCommandError(
|
||||
"HUB_UPDATE_REQUIRED",
|
||||
"This Hub does not support guided setup. Update the Hub and try again.",
|
||||
"This Hub needs an update before guided setup can use provider IDs. Update Hub and try again.",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDaemonResource(
|
||||
origin: string,
|
||||
daemonId: string,
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
): Promise<HubConfigurationResources["daemons"][number]> {
|
||||
const resources = await environment.hub.listConfigurationResources(
|
||||
origin,
|
||||
resolveHubCredential({
|
||||
options: { origin },
|
||||
env: environment.env,
|
||||
credentials: environment.credentials,
|
||||
origin,
|
||||
}),
|
||||
);
|
||||
const daemon = resources.daemons.find(({ id }) => id === daemonId);
|
||||
if (daemon === undefined) {
|
||||
throw new HubCommandError(
|
||||
"HUB_DAEMON_RESOURCE_MISSING",
|
||||
"The connected daemon is not available in this Hub organization. Reconnect it and try again.",
|
||||
);
|
||||
}
|
||||
return daemon;
|
||||
}
|
||||
|
||||
async function chooseConnection(
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
message: string,
|
||||
connections: readonly { slug: string; label: string }[],
|
||||
connections: readonly { id: string; label: string }[],
|
||||
): Promise<string> {
|
||||
if (connections.length === 0) {
|
||||
throw new HubCommandError(
|
||||
@@ -415,10 +590,10 @@ async function chooseConnection(
|
||||
`No ${message.toLowerCase()} is connected in this Hub organization. Connect one and try again.`,
|
||||
);
|
||||
}
|
||||
if (connections.length === 1) return connections[0]!.slug;
|
||||
return requiredSelect({
|
||||
if (connections.length === 1) return connections[0]!.id;
|
||||
return requiredSelect(environment, {
|
||||
message,
|
||||
options: connections.map(({ slug, label }) => ({ value: slug, label })),
|
||||
options: connections.map(({ id, label }) => ({ value: id, label })),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -523,36 +698,62 @@ async function withSpinner<T>(
|
||||
}
|
||||
}
|
||||
|
||||
async function requiredText(options: Parameters<typeof text>[0]): Promise<string> {
|
||||
const answer = await text({
|
||||
async function requiredText(
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
options: Parameters<typeof text>[0],
|
||||
): Promise<string> {
|
||||
const request = {
|
||||
...options,
|
||||
validate(value) {
|
||||
validate(value: string | undefined) {
|
||||
const input = value ?? "";
|
||||
const customError = options.validate?.(input);
|
||||
if (customError !== undefined) return customError;
|
||||
return input.trim().length === 0 ? "A value is required" : undefined;
|
||||
},
|
||||
});
|
||||
};
|
||||
const answer =
|
||||
environment.prompts === undefined
|
||||
? await text(request)
|
||||
: await environment.prompts.text(request);
|
||||
if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled.");
|
||||
return answer.trim();
|
||||
}
|
||||
|
||||
async function requiredConfirm(message: string, initialValue: boolean): Promise<boolean> {
|
||||
const answer = await confirm({ message, initialValue });
|
||||
async function requiredConfirm(
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
message: string,
|
||||
initialValue: boolean,
|
||||
): Promise<boolean> {
|
||||
const answer =
|
||||
environment.prompts === undefined
|
||||
? await confirm({ message, initialValue })
|
||||
: await environment.prompts.confirm(message, initialValue);
|
||||
if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled.");
|
||||
return answer;
|
||||
}
|
||||
|
||||
async function requiredSelect<T extends string>(
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
options: Parameters<typeof select<T>>[0],
|
||||
): Promise<T> {
|
||||
const answer = await select<T>(options);
|
||||
const answer =
|
||||
environment.prompts === undefined
|
||||
? await select<T>(options)
|
||||
: ((await environment.prompts.select(options as Parameters<typeof select<string>>[0])) as T);
|
||||
if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled.");
|
||||
return answer;
|
||||
}
|
||||
|
||||
function requireInteractiveTerminal(): void {
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
function requireInteractiveTerminal(environment: HubGuidedSetupEnvironment): void {
|
||||
if (!(environment.isInteractive?.() ?? (process.stdin.isTTY && process.stdout.isTTY))) {
|
||||
throw new HubCommandError("HUB_INIT_INTERACTIVE_REQUIRED", "paseo hub init requires a TTY.");
|
||||
}
|
||||
}
|
||||
|
||||
function reportMessage(environment: HubGuidedSetupEnvironment, message: string): void {
|
||||
if (environment.prompts === undefined) {
|
||||
log.message(message);
|
||||
return;
|
||||
}
|
||||
environment.prompts.message(message);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ interface HubLoginDependencies {
|
||||
credentials: HubCredentialStore;
|
||||
flow: Pick<CliLoginFlow, "authorize">;
|
||||
reporter: HubReporter;
|
||||
isInteractive?(): boolean;
|
||||
continueGuidedSetup?(origin: string): Promise<void>;
|
||||
}
|
||||
|
||||
export async function runHubLogin(
|
||||
@@ -40,6 +42,13 @@ export async function runHubLogin(
|
||||
reportHubProgress(dependencies.reporter, options, `Logging in to ${origin}`);
|
||||
const credential = await dependencies.flow.authorize(origin);
|
||||
dependencies.credentials.save({ origin, credential });
|
||||
if (
|
||||
!options.json &&
|
||||
dependencies.isInteractive?.() &&
|
||||
dependencies.continueGuidedSetup !== undefined
|
||||
) {
|
||||
await dependencies.continueGuidedSetup(origin);
|
||||
}
|
||||
return { type: "single", data: { origin, status: "logged_in" }, schema };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
availableStarterAgentProviders,
|
||||
selectedStarterAgentRuntime,
|
||||
suggestedStarterAgentChoice,
|
||||
} from "./starter-agent-runtime.js";
|
||||
|
||||
describe("starter agent runtime choices", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "uses a complete daemon default as a staged suggestion",
|
||||
entries: [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "ready" as const,
|
||||
enabled: true,
|
||||
label: "Codex",
|
||||
models: [
|
||||
{ provider: "codex", id: "gpt-5", label: "GPT-5", isDefault: true },
|
||||
{ provider: "codex", id: "gpt-5-mini", label: "GPT-5 mini" },
|
||||
],
|
||||
modes: [
|
||||
{ id: "read-only", label: "Read only" },
|
||||
{ id: "full-access", label: "Full access" },
|
||||
],
|
||||
defaultModeId: "full-access",
|
||||
},
|
||||
],
|
||||
expected: [
|
||||
{
|
||||
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 },
|
||||
],
|
||||
modes: [
|
||||
{ id: "read-only", label: "Read only", suggested: false },
|
||||
{ id: "full-access", label: "Full access", suggested: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "keeps Claude selectable when it has modes but no default mode",
|
||||
entries: [
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready" as const,
|
||||
enabled: true,
|
||||
label: "Claude",
|
||||
models: [{ provider: "claude", id: "sonnet", label: "Sonnet", isDefault: true }],
|
||||
modes: [{ id: "auto", label: "Auto" }],
|
||||
defaultModeId: null,
|
||||
},
|
||||
],
|
||||
expected: [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
suggested: false,
|
||||
models: [{ id: "sonnet", label: "Sonnet", suggested: true }],
|
||||
modes: [{ id: "auto", label: "Auto", suggested: false }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "treats an invalid default mode as no suggestion",
|
||||
entries: [
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready" as const,
|
||||
enabled: true,
|
||||
models: [{ provider: "claude", id: "sonnet", label: "Sonnet", isDefault: true }],
|
||||
modes: [{ id: "auto", label: "Auto" }],
|
||||
defaultModeId: "missing",
|
||||
},
|
||||
],
|
||||
expected: [
|
||||
{
|
||||
id: "claude",
|
||||
label: "claude",
|
||||
suggested: false,
|
||||
models: [{ id: "sonnet", label: "Sonnet", suggested: true }],
|
||||
modes: [{ id: "auto", label: "Auto", suggested: false }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "excludes unavailable, disabled, and model-less providers",
|
||||
entries: [
|
||||
{ provider: "claude", status: "unavailable" as const, enabled: true },
|
||||
{ provider: "codex", status: "ready" as const, enabled: false, models: [] },
|
||||
{ provider: "opencode", status: "ready" as const, enabled: true, models: [] },
|
||||
],
|
||||
expected: [],
|
||||
},
|
||||
])("$name", ({ entries, expected }) => {
|
||||
expect(availableStarterAgentProviders(entries)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("creates mode-less definitions for providers that expose no modes", () => {
|
||||
const [provider] = availableStarterAgentProviders([
|
||||
{
|
||||
provider: "pi",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
models: [{ provider: "pi", id: "default", label: "Default", isDefault: true }],
|
||||
modes: [],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(provider).toBeDefined();
|
||||
expect(selectedStarterAgentRuntime(provider!, "default")).toEqual({
|
||||
provider: "pi",
|
||||
model: "default",
|
||||
});
|
||||
expect(suggestedStarterAgentChoice(provider!.modes)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export interface HubStarterAgentRuntime {
|
||||
provider: string;
|
||||
model: string;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
export interface HubStarterAgentProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
models: readonly HubStarterAgentModel[];
|
||||
modes: readonly HubStarterAgentMode[];
|
||||
suggested: boolean;
|
||||
}
|
||||
|
||||
export interface HubStarterAgentModel {
|
||||
id: string;
|
||||
label: string;
|
||||
suggested: boolean;
|
||||
}
|
||||
|
||||
export interface HubStarterAgentMode {
|
||||
id: string;
|
||||
label: string;
|
||||
suggested: boolean;
|
||||
}
|
||||
|
||||
export function availableStarterAgentProviders(
|
||||
entries: readonly ProviderSnapshotEntry[],
|
||||
): HubStarterAgentProvider[] {
|
||||
return entries.flatMap((entry) => {
|
||||
if (entry.status !== "ready" || !entry.enabled) return [];
|
||||
const models = (entry.models ?? [])
|
||||
.filter((model) => model.isSelectable !== false)
|
||||
.map((model) => ({ id: model.id, label: model.label, suggested: model.isDefault === true }));
|
||||
if (models.length === 0) return [];
|
||||
|
||||
const modes = (entry.modes ?? []).map((mode) => ({
|
||||
id: mode.id,
|
||||
label: mode.label,
|
||||
suggested: mode.id === entry.defaultModeId,
|
||||
}));
|
||||
return [
|
||||
{
|
||||
id: entry.provider,
|
||||
label: entry.label ?? entry.provider,
|
||||
models,
|
||||
modes,
|
||||
suggested:
|
||||
models.some((model) => model.suggested) &&
|
||||
(modes.length === 0 || modes.some((mode) => mode.suggested)),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function suggestedStarterAgentChoice<T extends { suggested: boolean }>(
|
||||
choices: readonly T[],
|
||||
): T | undefined {
|
||||
return choices.find((choice) => choice.suggested);
|
||||
}
|
||||
|
||||
export function selectedStarterAgentRuntime(
|
||||
provider: HubStarterAgentProvider,
|
||||
modelId: string,
|
||||
modeId?: string,
|
||||
): HubStarterAgentRuntime | undefined {
|
||||
if (!provider.models.some((model) => model.id === modelId)) return undefined;
|
||||
if (provider.modes.length === 0) {
|
||||
return modeId === undefined ? { provider: provider.id, model: modelId } : undefined;
|
||||
}
|
||||
if (modeId === undefined || !provider.modes.some((mode) => mode.id === modeId)) return undefined;
|
||||
return { provider: provider.id, model: modelId, mode: modeId };
|
||||
}
|
||||
Reference in New Issue
Block a user