From b1050d9bedb7a677697e6409d9522a447eeed30c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 30 Aug 2026 14:11:19 +0200 Subject: [PATCH] feat(cli): simplify Hub login and export triggers (#4050) --- docs/hub.md | 2 +- packages/cli/src/commands/hub/client.test.ts | 26 ++++ .../cli/src/commands/hub/commands.test.ts | 9 +- packages/cli/src/commands/hub/export.test.ts | 119 ++++++++++++++++ packages/cli/src/commands/hub/export.ts | 131 ++++++++++++++++++ .../cli/src/commands/hub/hub-client/index.ts | 16 +++ .../hub/hub-client/internal/contracts.ts | 13 ++ packages/cli/src/commands/hub/index.ts | 8 ++ .../cli/src/commands/hub/init-flow.test.ts | 63 ++------- packages/cli/src/commands/hub/init.ts | 33 ++--- 10 files changed, 340 insertions(+), 80 deletions(-) create mode 100644 packages/cli/src/commands/hub/export.test.ts create mode 100644 packages/cli/src/commands/hub/export.ts diff --git a/docs/hub.md b/docs/hub.md index 4dd484e219..29b6738f9b 100644 --- a/docs/hub.md +++ b/docs/hub.md @@ -4,7 +4,7 @@ Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Run not register it with a Hub. The relationship begins only when a user runs `paseo hub connect [url]` from the daemon machine with an explicit API key or matching stored CLI login. -The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential. +The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold. `paseo hub export [directory]` writes the active organization's current triggers as one self-contained YAML file per trigger, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential. ## Connection and authority diff --git a/packages/cli/src/commands/hub/client.test.ts b/packages/cli/src/commands/hub/client.test.ts index 8c777fc1c4..c211b7d9af 100644 --- a/packages/cli/src/commands/hub/client.test.ts +++ b/packages/cli/src/commands/hub/client.test.ts @@ -91,6 +91,32 @@ describe("Hub HTTP client", () => { ); }); + it("reads self-contained trigger documents for export", async () => { + const requests: Array<{ url: string | undefined; body: string }> = []; + const origin = await startServer( + () => ({ + status: 200, + body: { + triggers: [ + { + id: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", + name: "slack-help", + enabled: true, + format: "single_run", + yaml: "name: slack-help\n", + }, + ], + }, + }), + requests, + ); + + const triggers = await new HubHttpClient().listTriggers(origin, "secret"); + + assert.equal(triggers[0]?.yaml, "name: slack-help\n"); + assert.deepEqual(requests[0], { url: "/api/v1/triggers", body: "" }); + }); + it("reads configuration resources in the Hub's slug vocabulary", async () => { const requests: Array<{ url: string | undefined; body: string }> = []; const origin = await startServer( diff --git a/packages/cli/src/commands/hub/commands.test.ts b/packages/cli/src/commands/hub/commands.test.ts index e2950748a9..166183ab9c 100644 --- a/packages/cli/src/commands/hub/commands.test.ts +++ b/packages/cli/src/commands/hub/commands.test.ts @@ -26,6 +26,7 @@ describe("Hub commands", () => { "status", "disconnect", "projects", + "export", "deploy", "logout", ]); @@ -92,7 +93,7 @@ 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 () => { + it("interactive login continues through the injected daemon and Hub guidance coordinator", async () => { const credentials = new MemoryCredentials(); const events: string[] = []; @@ -111,8 +112,7 @@ describe("Hub commands", () => { isInteractive: () => true, continueGuidedSetup: async (origin) => { events.push(`connect:${origin}`); - events.push("init"); - events.push("deploy"); + events.push("show-guidance"); }, reporter: { progress: (message) => events.push(`progress:${message}`) }, }, @@ -123,8 +123,7 @@ describe("Hub commands", () => { "login", "progress:Logged in", "connect:https://hub.test", - "init", - "deploy", + "show-guidance", ]); }); diff --git a/packages/cli/src/commands/hub/export.test.ts b/packages/cli/src/commands/hub/export.test.ts new file mode 100644 index 0000000000..608679b9c3 --- /dev/null +++ b/packages/cli/src/commands/hub/export.test.ts @@ -0,0 +1,119 @@ +import { strict as assert } from "node:assert"; +import { mkdtemp, readFile, rm, writeFile } 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 { HubCommandError } from "./error.js"; +import { runHubExport } from "./export.js"; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); +}); + +describe("Hub trigger export", () => { + it("uses the active login and writes one self-contained YAML file per trigger", async () => { + const cwd = await temporaryDirectory(); + const credentials = new MemoryCredentials({ + origin: "https://hub.test", + credential: "stored-secret", + }); + const requests: Array<{ origin: string; credential: string }> = []; + + const result = await runHubExport( + undefined, + {}, + { + cwd: () => cwd, + env: {}, + credentials, + reporter: { progress() {} }, + hub: { + async listTriggers(origin, credential) { + requests.push({ origin, credential }); + return [ + { + id: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", + name: "slack-help", + enabled: true, + format: "single_run", + yaml: "name: slack-help\nenabled: true\n", + }, + ]; + }, + }, + }, + ); + + assert.deepEqual(requests, [{ origin: "https://hub.test", credential: "stored-secret" }]); + assert.equal( + await readFile(path.join(cwd, ".paseo", "triggers", "slack-help.yml"), "utf8"), + "name: slack-help\nenabled: true\n", + ); + assert.deepEqual(result.data, { + origin: "https://hub.test", + directory: path.join(cwd, ".paseo", "triggers"), + exported: 1, + unchanged: 0, + }); + }); + + it("leaves identical files alone and refuses to overwrite changed files without --force", async () => { + const cwd = await temporaryDirectory(); + const destination = path.join(cwd, "trigger.yml"); + const credentials = new MemoryCredentials({ origin: "https://hub.test", credential: "secret" }); + const dependencies = { + cwd: () => cwd, + env: {}, + credentials, + reporter: { progress() {} }, + hub: { + listTriggers: async () => [ + { + id: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", + name: "trigger", + enabled: true, + format: "single_run" as const, + yaml: "name: trigger\n", + }, + ], + }, + }; + await writeFile(destination, "name: trigger\n"); + assert.equal((await runHubExport(".", {}, dependencies)).data.unchanged, 1); + + await writeFile(destination, "local changes\n"); + await assert.rejects(runHubExport(".", {}, dependencies), (error: unknown) => { + assert.ok(error instanceof HubCommandError); + assert.equal(error.code, "HUB_EXPORT_CONFLICT"); + return true; + }); + await runHubExport(".", { force: true }, dependencies); + assert.equal(await readFile(destination, "utf8"), "name: trigger\n"); + }); +}); + +class MemoryCredentials implements HubCredentialStore { + constructor(private readonly record: StoredHubCredential) {} + + active(): StoredHubCredential { + return this.record; + } + + get(origin: string): StoredHubCredential | null { + return origin === this.record.origin ? this.record : null; + } + + save(): void {} + logoutActive(): StoredHubCredential { + return this.record; + } +} + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-export-")); + directories.push(directory); + return directory; +} diff --git a/packages/cli/src/commands/hub/export.ts b/packages/cli/src/commands/hub/export.ts new file mode 100644 index 0000000000..7311c0ae78 --- /dev/null +++ b/packages/cli/src/commands/hub/export.ts @@ -0,0 +1,131 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { Command } from "commander"; +import { withOutput, type OutputSchema, type SingleResult } from "../../output/index.js"; +import { addJsonOption } from "../../utils/command-options.js"; +import { resolveHubCredential, resolveHubOrigin } from "./authority.js"; +import type { HubCredentialStore } from "./credentials.js"; +import { HubCommandError } from "./error.js"; +import { addHubResolutionHelp } from "./help.js"; +import type { HubHttpClient } from "./hub-client/index.js"; +import { reportHubProgress, type HubReporter } from "./reporter.js"; + +interface HubExportResult { + origin: string; + directory: string; + exported: number; + unchanged: number; +} + +const schema: OutputSchema = { + idField: "directory", + columns: [ + { header: "DIRECTORY", field: "directory" }, + { header: "EXPORTED", field: "exported" }, + { header: "UNCHANGED", field: "unchanged" }, + { header: "HUB", field: "origin" }, + ], +}; + +export interface HubExportOptions { + hub?: string; + apiKey?: string; + force?: boolean; + json?: boolean; +} + +interface HubExportDependencies { + env: Readonly>; + credentials: HubCredentialStore; + hub: Pick; + reporter: HubReporter; + cwd(): string; +} + +export async function runHubExport( + directoryInput: string | undefined, + options: HubExportOptions, + dependencies: HubExportDependencies, +): Promise> { + const resolution = { + options: { origin: options.hub, apiKey: options.apiKey }, + env: dependencies.env, + credentials: dependencies.credentials, + }; + const origin = resolveHubOrigin(resolution); + const credential = resolveHubCredential({ ...resolution, origin }); + const directory = path.resolve(dependencies.cwd(), directoryInput ?? ".paseo/triggers"); + reportHubProgress(dependencies.reporter, options, `Exporting triggers from ${origin}`); + const triggers = await dependencies.hub.listTriggers(origin, credential); + await mkdir(directory, { recursive: true }); + + const files = await Promise.all( + triggers.map(async (trigger) => { + const destination = path.join(directory, `${trigger.name}.yml`); + return { destination, trigger, existing: await readOptionalFile(destination) }; + }), + ); + const conflict = files.find( + ({ existing, trigger }) => + existing !== undefined && existing !== trigger.yaml && options.force !== true, + ); + if (conflict !== undefined) { + throw new HubCommandError( + "HUB_EXPORT_CONFLICT", + `${conflict.destination} already exists with different contents. Pass --force to replace it.`, + ); + } + + for (const file of files) { + if (file.existing === file.trigger.yaml) continue; + await writeFile( + file.destination, + file.trigger.yaml, + file.existing === undefined ? { flag: "wx" } : undefined, + ); + } + + return { + type: "single", + data: { + origin, + directory, + exported: files.filter(({ existing, trigger }) => existing !== trigger.yaml).length, + unchanged: files.filter(({ existing, trigger }) => existing === trigger.yaml).length, + }, + schema, + }; +} + +export function addHubExportCommand(parent: Command, dependencies: HubExportDependencies): void { + addJsonOption( + addHubResolutionHelp( + parent + .command("export") + .description("Export active Hub triggers as one YAML file per trigger") + .argument("[directory]", "Destination directory", ".paseo/triggers") + .option("--hub ", "Paseo Hub origin") + .option("--api-key ", "Organization API key") + .option("--force", "Replace trigger files with different contents"), + ), + ).action( + withOutput(async (...args) => { + const directory = args[0] as string | undefined; + const options = args.at(-2) as HubExportOptions; + return runHubExport(directory, options, dependencies); + }), + ); +} + +async function readOptionalFile(filePath: string): Promise { + try { + return await readFile(filePath, "utf8"); + } catch (error) { + if (isMissingFile(error)) return undefined; + throw error; + } +} + +function isMissingFile(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} diff --git a/packages/cli/src/commands/hub/hub-client/index.ts b/packages/cli/src/commands/hub/hub-client/index.ts index 3f87ab3dc0..ab880705d6 100644 --- a/packages/cli/src/commands/hub/hub-client/index.ts +++ b/packages/cli/src/commands/hub/hub-client/index.ts @@ -7,6 +7,7 @@ import { enrollmentTokenSchema, installResponseSchema, projectsResponseSchema, + triggersResponseSchema, validationResponseSchema, type CliAuthorization, type CliAuthorizationPoll, @@ -15,6 +16,7 @@ import { setupResourcesSchema, type HubSetupResources, type HubProject, + type HubTrigger, type HubValidationResult, } from "./internal/contracts.js"; import { requestHub } from "./internal/transport.js"; @@ -26,6 +28,7 @@ export type { HubConfigurationResources, HubSetupResources, HubProject, + HubTrigger, HubValidationResult, } from "./internal/contracts.js"; @@ -86,6 +89,19 @@ export class HubHttpClient { return response.projects; } + async listTriggers(origin: string, apiKey: string): Promise { + const response = await requestHub({ + origin, + path: "/api/v1/triggers", + method: "GET", + apiKey, + successStatus: 200, + schema: triggersResponseSchema, + failureMessage: "Hub trigger export failed", + }); + return response.triggers; + } + listConfigurationResources(origin: string, apiKey: string): Promise { return requestHub({ origin, diff --git a/packages/cli/src/commands/hub/hub-client/internal/contracts.ts b/packages/cli/src/commands/hub/hub-client/internal/contracts.ts index 2efa77aff3..059f66cb14 100644 --- a/packages/cli/src/commands/hub/hub-client/internal/contracts.ts +++ b/packages/cli/src/commands/hub/hub-client/internal/contracts.ts @@ -36,6 +36,18 @@ const projectSchema = z export const projectsResponseSchema = z.object({ projects: z.array(projectSchema) }).strict(); +const triggerSchema = z + .object({ + id: z.string().uuid(), + name: z.string().regex(/^[a-z][a-z0-9_-]*$/u), + enabled: z.boolean(), + format: z.enum(["single_run", "legacy_multistep"]), + yaml: z.string(), + }) + .strict(); + +export const triggersResponseSchema = z.object({ triggers: z.array(triggerSchema) }).strict(); + export const configurationResourcesSchema = z .object({ daemons: z.array(z.object({ id: z.string().uuid(), slug: z.string().min(1) }).strict()), @@ -93,6 +105,7 @@ export const enrollmentTokenSchema = z export type CliAuthorization = z.infer; export type CliAuthorizationPoll = z.infer; export type HubProject = z.infer; +export type HubTrigger = z.infer; export type HubConfigurationResources = z.infer; export type HubSetupResources = z.infer; export type HubInstallResult = z.infer; diff --git a/packages/cli/src/commands/hub/index.ts b/packages/cli/src/commands/hub/index.ts index d4921835de..063a72f65b 100644 --- a/packages/cli/src/commands/hub/index.ts +++ b/packages/cli/src/commands/hub/index.ts @@ -15,6 +15,7 @@ import { createCliLoginFlow, type CliLoginFlow } from "./login-flow.js"; import { addHubLoginCommand } from "./login.js"; import { addHubLogoutCommand, productionLogoutPrompt } from "./logout.js"; import { addHubProjectsCommand } from "./projects.js"; +import { addHubExportCommand } from "./export.js"; import { processHubReporter, type HubReporter } from "./reporter.js"; import { hubStatusResult } from "./status-output.js"; import { addHubResolutionHelp } from "./help.js"; @@ -85,6 +86,13 @@ export function createHubCommand(overrides: Partial = {}) hub: environment.hub, reporter: environment.reporter, }); + addHubExportCommand(hub, { + env: environment.env, + credentials: environment.credentials, + hub: environment.hub, + reporter: environment.reporter, + cwd: environment.cwd, + }); addHubDeployCommand(hub, { env: environment.env, credentials: environment.credentials, diff --git a/packages/cli/src/commands/hub/init-flow.test.ts b/packages/cli/src/commands/hub/init-flow.test.ts index f43b873935..55b7dab57f 100644 --- a/packages/cli/src/commands/hub/init-flow.test.ts +++ b/packages/cli/src/commands/hub/init-flow.test.ts @@ -21,11 +21,11 @@ afterEach(async () => { }); describe("Hub guided setup continuation", () => { - it("uses the login origin and connected daemon to validate, write, and deploy without replaying setup prompts", async () => { + it("offers daemon connection after login, then points to Hub without scaffolding files", async () => { const cwd = await temporaryDirectory(); const credentials = new MemoryCredentials(); const daemon = new SetupDaemon(); - const prompts = new PromptAnswers([true, true], ["codex", "gpt-5", "full-access"], ["U123"]); + const prompts = new PromptAnswers([true], [], []); const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = []; const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls); @@ -42,45 +42,15 @@ describe("Hub guided setup continuation", () => { }, ); - assert.deepEqual(prompts.confirmations, [ - "Connect this daemon to this Hub?", - "Initialize and deploy a starter workflow?", - ]); - assert.deepEqual(prompts.selections, [ - "Starter agent provider", - "Starter agent model", - "Starter agent mode", - ]); - assert.deepEqual(prompts.selectionOptions, [ - ["Codex"], - ["GPT-5 (suggested)", "GPT-5 mini"], - ["Read only", "Full access (suggested)"], - ]); + assert.deepEqual(prompts.confirmations, ["Connect this daemon to this Hub?"]); + assert.deepEqual(prompts.selections, []); assert.deepEqual(prompts.messages, [ - "Hub app connections ready for this workflow:\nSlack — Paseo\n\nOnly configured connections are shown. To add another, open Hub → Apps, then run `paseo hub init` again.", - "Using Slack — Paseo", - ]); - assert.deepEqual(calls, [ - { operation: "token", origin: "https://hub.test" }, - { 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"], - }, + "Configure triggers in Hub: https://hub.test/triggers\nOr scaffold triggers as code: paseo hub init", ]); + assert.deepEqual(calls, [{ operation: "token", origin: "https://hub.test" }]); 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); + assert.deepEqual(daemon.snapshotCwds, []); + await assert.rejects(readFile(path.join(cwd, ".paseo", "hub.yml")), { code: "ENOENT" }); }); it("prints exact actionable resume commands for login continuation declines", async () => { @@ -95,22 +65,16 @@ describe("Hub guided setup continuation", () => { setupEnvironment(cwd, credentials, daemon, connectDeclined, []), ); assert.deepEqual(connectDeclined.messages, [ - "Skipped daemon connection. Run: paseo hub connect https://hub.test; then paseo hub init", + "Skipped daemon connection. Connect later with: paseo hub connect https://hub.test", + "Configure triggers in Hub: https://hub.test/triggers\nOr scaffold triggers as code: 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 login successful when no Hub app connection can initialize a workflow", async () => { + it("does not require a Hub app connection during login", async () => { const cwd = await temporaryDirectory(); const credentials = new MemoryCredentials(); const daemon = new SetupDaemon(); - const prompts = new PromptAnswers([true, true], [], []); + const prompts = new PromptAnswers([false], [], []); await runHubLogin( "https://hub.test", @@ -132,7 +96,8 @@ describe("Hub guided setup continuation", () => { ); 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.", + "Skipped daemon connection. Connect later with: paseo hub connect https://hub.test", + "Configure triggers in Hub: https://hub.test/triggers\nOr scaffold triggers as code: paseo hub init", ]); }); diff --git a/packages/cli/src/commands/hub/init.ts b/packages/cli/src/commands/hub/init.ts index 15c86d5238..16175e491a 100644 --- a/packages/cli/src/commands/hub/init.ts +++ b/packages/cli/src/commands/hub/init.ts @@ -191,35 +191,18 @@ export async function continueHubGuidedSetup( origin: string, environment: HubGuidedSetupEnvironment, ): Promise { - if (!(await requiredConfirm(environment, "Connect this daemon to this Hub?", true))) { + if (await requiredConfirm(environment, "Connect this daemon to this Hub?", true)) { + await ensureDaemonConnection(origin, environment, true); + } else { reportMessage( environment, - `Skipped daemon connection. Run: ${hubLoginResumeCommand("connect", origin)}; then paseo hub init`, + `Skipped daemon connection. Connect later with: ${hubLoginResumeCommand("connect", origin)}`, ); - 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; - } - 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; } + reportMessage( + environment, + `Configure triggers in Hub: ${new URL("/triggers", origin).toString()}\nOr scaffold triggers as code: ${hubLoginResumeCommand("init", origin)}`, + ); } async function ensureLogin(