From c172076bffa84e4ccf3beffa0993bac6579801fd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 9 Sep 2026 09:15:28 +0200 Subject: [PATCH] Initialize Hub with organization triggers (#4517) * fix(cli): initialize current Hub triggers Hub init still authored and deployed project bundles after hosted configuration moved to organization triggers. Generate the self-contained trigger contract and keep project bundle deployment behind the existing --project path. * fix(cli): harden Hub trigger setup * docs(hub): align public setup guides with organization triggers --- docs/hub.md | 2 +- packages/cli/src/commands/hub/client.test.ts | 82 +++--- .../src/commands/hub/deploy-contract.test.ts | 177 ++++++++++++- .../src/commands/hub/deploy-triggers.test.ts | 64 +++++ .../cli/src/commands/hub/deploy-triggers.ts | 140 ++++++++++ packages/cli/src/commands/hub/deploy.ts | 136 +++++++++- .../cli/src/commands/hub/hub-client/index.ts | 55 ++-- .../hub/hub-client/internal/contracts.ts | 37 ++- .../cli/src/commands/hub/init-flow.test.ts | 177 ++++++++++--- .../cli/src/commands/hub/init-plan.test.ts | 162 ++++-------- packages/cli/src/commands/hub/init-plan.ts | 170 ++++-------- packages/cli/src/commands/hub/init.ts | 247 +++++++----------- .../src/commands/hub/starter-trigger.test.ts | 20 +- .../cli/src/commands/hub/starter-trigger.ts | 42 +-- public-docs/cli.md | 19 +- public-docs/hub/api.md | 54 +++- public-docs/hub/configuration/index.md | 122 ++++----- public-docs/hub/daemons.md | 4 +- public-docs/hub/index.md | 11 +- public-docs/hub/quickstart.md | 44 ++-- 20 files changed, 1110 insertions(+), 655 deletions(-) create mode 100644 packages/cli/src/commands/hub/deploy-triggers.test.ts create mode 100644 packages/cli/src/commands/hub/deploy-triggers.ts diff --git a/docs/hub.md b/docs/hub.md index dddad153e0..bf44067b67 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`. 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. +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: it writes one self-contained organization trigger under `.paseo/triggers/`, validates it through the trigger API, and optionally installs it. `paseo hub deploy` validates and installs every trigger in that directory; passing `--project` keeps deploying the legacy project bundle instead. `paseo hub export [directory]` writes the active organization's current triggers in the same layout, 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 c211b7d9af..edf86d3dc8 100644 --- a/packages/cli/src/commands/hub/client.test.ts +++ b/packages/cli/src/commands/hub/client.test.ts @@ -117,6 +117,40 @@ describe("Hub HTTP client", () => { assert.deepEqual(requests[0], { url: "/api/v1/triggers", body: "" }); }); + it("validates and installs self-contained organization triggers", async () => { + const requests: Array<{ url: string | undefined; body: string }> = []; + const triggerId = "a50e05af-4f20-4c8f-8dcc-58e5ea360663"; + const revisionId = "2de5b143-1c88-42db-8a8d-71ca2af97830"; + const origin = await startServer( + (url) => + url === "/api/v1/triggers/validate" + ? { status: 200, body: { name: "slack-help", valid: true } } + : { + status: 201, + body: { triggerId, name: "slack-help", revisionId, version: 1, active: true }, + }, + requests, + ); + const hub = new HubHttpClient(); + const yaml = "name: slack-help\n"; + + assert.deepEqual(await hub.validateTrigger(origin, "secret", yaml), { + name: "slack-help", + valid: true, + }); + assert.deepEqual(await hub.installTrigger(origin, "secret", yaml), { + triggerId, + name: "slack-help", + revisionId, + version: 1, + active: true, + }); + assert.deepEqual(requests, [ + { url: "/api/v1/triggers/validate", body: JSON.stringify({ yaml }) }, + { url: "/api/v1/triggers/install", body: JSON.stringify({ yaml }) }, + ]); + }); + it("reads configuration resources in the Hub's slug vocabulary", async () => { const requests: Array<{ url: string | undefined; body: string }> = []; const origin = await startServer( @@ -134,6 +168,7 @@ describe("Hub HTTP client", () => { ], discord: [{ slug: "paseo", guildName: "Paseo" }], slack: [{ slug: "paseo", teamName: "Paseo" }], + linear: [{ slug: "paseo-linear", organizationName: "Paseo" }], }, }), requests, @@ -143,55 +178,10 @@ describe("Hub HTTP client", () => { assert.equal(resources.daemons[0]?.slug, "macbook"); assert.equal(resources.discord[0]?.slug, "paseo"); + assert.equal(resources.linear[0]?.slug, "paseo-linear"); 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( diff --git a/packages/cli/src/commands/hub/deploy-contract.test.ts b/packages/cli/src/commands/hub/deploy-contract.test.ts index c9c3103e7a..8729312707 100644 --- a/packages/cli/src/commands/hub/deploy-contract.test.ts +++ b/packages/cli/src/commands/hub/deploy-contract.test.ts @@ -17,7 +17,119 @@ afterEach(async () => { await Promise.all(servers.splice(0).map(closeServer)); }); -describe("Hub canonical bundle deployment contract", () => { +describe("Hub deployment contract", () => { + it("validates then installs discovered organization triggers when no project is selected", async () => { + const cwd = await triggerProject(); + const triggerId = "a50e05af-4f20-4c8f-8dcc-58e5ea360663"; + const revisionId = "2de5b143-1c88-42db-8a8d-71ca2af97830"; + const hub = await captureHubRequests(2, (url) => + url === "/api/v1/triggers/validate" + ? { status: 200, body: { name: "slack-help", valid: true } } + : { + status: 201, + body: { triggerId, name: "slack-help", revisionId, version: 3, active: true }, + }, + ); + + const result = await runHubDeploy( + { hub: hub.origin, apiKey: "operator-secret" }, + { cwd, env: {} }, + ); + + expect(await hub.received).toEqual([ + { + url: "/api/v1/triggers/validate", + authorization: "Bearer operator-secret", + body: { yaml: trigger }, + }, + { + url: "/api/v1/triggers/install", + authorization: "Bearer operator-secret", + body: { yaml: trigger }, + }, + ]); + expect(result).toMatchObject({ + type: "list", + data: [ + { + triggerId, + name: "slack-help", + revisionId, + version: 3, + active: true, + path: ".paseo/triggers/slack-help.yml", + origin: hub.origin, + }, + ], + }); + }); + + it("dry-runs organization triggers without installing them", async () => { + const cwd = await triggerProject(); + const hub = await captureHub(200, { name: "slack-help", valid: true }); + + const result = await runHubDeploy( + { hub: hub.origin, apiKey: "operator-secret", dryRun: true }, + { cwd, env: {} }, + ); + + expect(await hub.received).toEqual({ + url: "/api/v1/triggers/validate", + authorization: "Bearer operator-secret", + body: { yaml: trigger }, + }); + expect(result).toMatchObject({ + type: "list", + data: [ + { + name: "slack-help", + valid: true, + path: ".paseo/triggers/slack-help.yml", + origin: hub.origin, + }, + ], + }); + }); + + it("reports triggers installed before a later deployment failure", async () => { + const cwd = await triggerProject(); + const secondTrigger = trigger.replace("slack-help", "z-help"); + await writeFile(path.join(cwd, ".paseo", "triggers", "z-help.yml"), secondTrigger); + const hub = await captureHubRequests(4, (url, requestNumber) => { + if (url === "/api/v1/triggers/validate") { + return { status: 200, body: { name: "valid", valid: true } }; + } + if (requestNumber === 3) { + return { + status: 201, + body: { + triggerId: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", + name: "slack-help", + revisionId: "2de5b143-1c88-42db-8a8d-71ca2af97830", + version: 1, + active: true, + }, + }; + } + return { status: 500, body: { error: "failed" } }; + }); + + await expect( + runHubDeploy({ hub: hub.origin, apiKey: "operator-secret" }, { cwd, env: {} }), + ).rejects.toMatchObject({ + code: "HUB_TRIGGER_DEPLOY_PARTIAL", + message: "Could not deploy .paseo/triggers/z-help.yml after 1 trigger had been installed.", + details: + "Hub trigger deployment failed with HTTP 500.\nInstalled before the failure:\n- .paseo/triggers/slack-help.yml", + }); + expect((await hub.received).map(({ url }) => url)).toEqual([ + "/api/v1/triggers/validate", + "/api/v1/triggers/validate", + "/api/v1/triggers/install", + "/api/v1/triggers/install", + ]); + }); + it("installs the exact discovered bundle with finite environment and named-agent routing", async () => { const cwd = await canonicalProject(); const hub = await captureHub(201, { @@ -83,6 +195,7 @@ describe("Hub canonical bundle deployment contract", () => { expect(help).not.toContain("[file]"); expect(help).toContain("--project "); + expect(help).toContain("legacy bundle"); expect(help).toContain("--dry-run"); }); }); @@ -135,6 +248,23 @@ const workflow = [ const partial = "Treat paseo.context as evidence, not hidden prompt text.\n"; +const trigger = [ + "name: slack-help", + "enabled: true", + "on:", + " slack.mention:", + " connection: team", + " filters:", + " from_users: [U123]", + "run:", + " target: { daemon: local, cwd: /workspace/studio }", + " agent: { provider: codex, model: gpt-5, mode: full-access }", + " max_runtime: 90m", + " idle_timeout: 10m", + " prompt: ${{ paseo.prompt }}", + "", +].join("\n"); + function canonicalFiles() { return [ { path: ".paseo/hub.yml", content: resource }, @@ -154,6 +284,15 @@ async function canonicalProject(): Promise { return cwd; } +async function triggerProject(): Promise { + const cwd = await mkdtemp(path.join(tmpdir(), "paseo-hub-trigger-deploy-")); + temporaryDirectories.push(cwd); + const triggers = path.join(cwd, ".paseo", "triggers"); + await mkdir(triggers, { recursive: true }); + await writeFile(path.join(triggers, "slack-help.yml"), trigger); + return cwd; +} + async function captureHub(status: number, responseBody: unknown) { let resolveRequest!: (request: unknown) => void; const received = new Promise((resolve) => { @@ -181,6 +320,42 @@ async function captureHub(status: number, responseBody: unknown) { return { origin: `http://127.0.0.1:${address.port}`, received }; } +async function captureHubRequests( + count: number, + responseFor: ( + url: string | undefined, + requestNumber: number, + ) => { status: number; body: unknown }, +) { + const requests: unknown[] = []; + let resolveRequests!: (requests: unknown[]) => void; + const received = new Promise((resolve) => { + resolveRequests = resolve; + }); + const server = createServer((request, response) => { + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk: string) => { + body += chunk; + }); + request.on("end", () => { + requests.push({ + url: request.url, + authorization: request.headers.authorization, + body: JSON.parse(body), + }); + if (requests.length === count) resolveRequests(requests); + const configured = responseFor(request.url, requests.length); + response.writeHead(configured.status, { "content-type": "application/json" }); + response.end(JSON.stringify(configured.body)); + }); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + return { origin: `http://127.0.0.1:${address.port}`, received }; +} + function closeServer(server: ReturnType): Promise { server.closeAllConnections(); return new Promise((resolve, reject) => { diff --git a/packages/cli/src/commands/hub/deploy-triggers.test.ts b/packages/cli/src/commands/hub/deploy-triggers.test.ts new file mode 100644 index 0000000000..934dae097f --- /dev/null +++ b/packages/cli/src/commands/hub/deploy-triggers.test.ts @@ -0,0 +1,64 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { discoverHubTriggers } from "./deploy-triggers.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +describe("Hub trigger deployment discovery", () => { + it("discovers self-contained triggers in deterministic order", async () => { + const cwd = await temporaryDirectory(); + await mkdir(path.join(cwd, ".paseo", "triggers"), { recursive: true }); + await writeFile(path.join(cwd, ".paseo", "triggers", "z.yml"), "name: z\n"); + await writeFile(path.join(cwd, ".paseo", "triggers", "a.yml"), "name: a\n"); + + await expect(discoverHubTriggers(cwd)).resolves.toEqual([ + { path: ".paseo/triggers/a.yml", yaml: "name: a\n" }, + { path: ".paseo/triggers/z.yml", yaml: "name: z\n" }, + ]); + }); + + it("rejects unsupported and unsafe trigger paths", async () => { + const cwd = await temporaryDirectory(); + const directory = path.join(cwd, ".paseo", "triggers"); + await mkdir(directory, { recursive: true }); + await writeFile(path.join(cwd, "outside.yml"), "name: outside\n"); + await symlink(path.join(cwd, "outside.yml"), path.join(directory, "linked.yml")); + + await expect(discoverHubTriggers(cwd)).rejects.toMatchObject({ + code: "HUB_TRIGGER_UNSAFE_PATH", + }); + }); + + it("requires at least one trigger", async () => { + const cwd = await temporaryDirectory(); + await mkdir(path.join(cwd, ".paseo", "triggers"), { recursive: true }); + + await expect(discoverHubTriggers(cwd)).rejects.toMatchObject({ code: "HUB_TRIGGER_MISSING" }); + }); + + it("directs legacy bundles to the explicit project deployment path", async () => { + const cwd = await temporaryDirectory(); + await mkdir(path.join(cwd, ".paseo")); + await writeFile(path.join(cwd, ".paseo", "hub.yml"), "environments: {}\n"); + + await expect(discoverHubTriggers(cwd)).rejects.toMatchObject({ + code: "HUB_PROJECT_REQUIRED", + message: + "This directory contains a legacy .paseo/hub.yml bundle. Pass --project to deploy it.", + }); + }); +}); + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-triggers-")); + temporaryDirectories.push(directory); + return directory; +} diff --git a/packages/cli/src/commands/hub/deploy-triggers.ts b/packages/cli/src/commands/hub/deploy-triggers.ts new file mode 100644 index 0000000000..e55abb53f4 --- /dev/null +++ b/packages/cli/src/commands/hub/deploy-triggers.ts @@ -0,0 +1,140 @@ +import { lstat, readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import { HubCommandError } from "./error.js"; + +const TRIGGER_DIRECTORY = ".paseo/triggers"; + +export interface HubDeployTrigger { + path: string; + yaml: string; +} + +export async function discoverHubTriggers(cwd: string): Promise { + const root = path.resolve(cwd); + const directory = path.join(root, TRIGGER_DIRECTORY); + const paseoDirectory = await readStats(path.join(root, ".paseo"), { + code: "HUB_TRIGGER_DIRECTORY_MISSING", + message: `${TRIGGER_DIRECTORY} does not exist. Run this command from the project root.`, + }); + if (paseoDirectory.isSymbolicLink()) throw unsafeTriggerPath(TRIGGER_DIRECTORY); + const directoryStats = await readTriggerDirectoryStats(root, directory); + if (directoryStats.isSymbolicLink()) throw unsafeTriggerPath(TRIGGER_DIRECTORY); + if (!directoryStats.isDirectory()) { + throw new HubCommandError( + "HUB_TRIGGER_DIRECTORY_INVALID", + `${TRIGGER_DIRECTORY} must be a directory.`, + ); + } + + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (!isExpectedReadError(error)) throw error; + throw unreadableTriggerPath(TRIGGER_DIRECTORY); + } + const triggers: HubDeployTrigger[] = []; + for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) { + const triggerPath = `${TRIGGER_DIRECTORY}/${entry.name}`; + if (entry.isSymbolicLink()) throw unsafeTriggerPath(triggerPath); + if (!entry.isFile() || !entry.name.endsWith(".yml")) { + throw new HubCommandError( + "HUB_TRIGGER_PATH_UNSUPPORTED", + `${triggerPath} must be a direct-child .yml trigger.`, + ); + } + try { + triggers.push({ + path: triggerPath, + yaml: await readFile(path.join(root, triggerPath), "utf8"), + }); + } catch (error) { + if (!isExpectedReadError(error)) throw error; + throw unreadableTriggerPath(triggerPath); + } + } + if (triggers.length === 0) { + throw new HubCommandError( + "HUB_TRIGGER_MISSING", + `${TRIGGER_DIRECTORY} must contain at least one direct-child .yml trigger.`, + ); + } + return triggers; +} + +async function readTriggerDirectoryStats( + root: string, + directory: string, +): Promise>> { + try { + return await lstat(directory); + } catch (error) { + if (errorCode(error) !== "ENOENT") { + if (!isExpectedReadError(error)) throw error; + throw unreadableTriggerPath(TRIGGER_DIRECTORY); + } + if (await legacyBundleExists(root)) { + throw new HubCommandError( + "HUB_PROJECT_REQUIRED", + "This directory contains a legacy .paseo/hub.yml bundle. Pass --project to deploy it.", + ); + } + throw new HubCommandError( + "HUB_TRIGGER_DIRECTORY_MISSING", + `${TRIGGER_DIRECTORY} does not exist. Run this command from the project root.`, + ); + } +} + +async function legacyBundleExists(root: string): Promise { + try { + await lstat(path.join(root, ".paseo/hub.yml")); + return true; + } catch (error) { + if (errorCode(error) === "ENOENT") return false; + if (!isExpectedReadError(error)) throw error; + throw unreadableTriggerPath(".paseo/hub.yml"); + } +} + +async function readStats( + target: string, + missing: { code: string; message: string }, +): Promise>> { + try { + return await lstat(target); + } catch (error) { + if (errorCode(error) === "ENOENT") throw new HubCommandError(missing.code, missing.message); + if (!isExpectedReadError(error)) throw error; + throw unreadableTriggerPath(TRIGGER_DIRECTORY); + } +} + +function unsafeTriggerPath(triggerPath: string): HubCommandError { + return new HubCommandError("HUB_TRIGGER_UNSAFE_PATH", `${triggerPath} must not use a symlink.`); +} + +function unreadableTriggerPath(triggerPath: string): HubCommandError { + return new HubCommandError( + "HUB_TRIGGER_UNREADABLE", + `Could not read ${triggerPath}. Check the file and permissions.`, + ); +} + +function errorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null) return undefined; + const code = Reflect.get(error, "code"); + return typeof code === "string" ? code : undefined; +} + +function isExpectedReadError(error: unknown): boolean { + const code = errorCode(error); + return ( + code === "EACCES" || + code === "EPERM" || + code === "ENOENT" || + code === "ENOTDIR" || + code === "ELOOP" || + code === "EISDIR" + ); +} diff --git a/packages/cli/src/commands/hub/deploy.ts b/packages/cli/src/commands/hub/deploy.ts index d64137879a..b9cde59ae6 100644 --- a/packages/cli/src/commands/hub/deploy.ts +++ b/packages/cli/src/commands/hub/deploy.ts @@ -1,14 +1,23 @@ import type { Command } from "commander"; -import { withOutput, type OutputSchema, type SingleResult } from "../../output/index.js"; +import { + withOutput, + type ListResult, + type OutputSchema, + type SingleResult, +} from "../../output/index.js"; import { addJsonOption } from "../../utils/command-options.js"; import { resolveHubCredential, resolveHubOrigin } from "./authority.js"; import { HubHttpClient, type HubInstallResult, + type HubTriggerInstallationResult, + type HubTriggerValidationResult, type HubValidationResult, } from "./hub-client/index.js"; import { PrivateHubCredentialStore, type HubCredentialStore } from "./credentials.js"; import { discoverHubBundle, type HubDeployBundle } from "./deploy-bundle.js"; +import { discoverHubTriggers, type HubDeployTrigger } from "./deploy-triggers.js"; +import { HubCommandError } from "./error.js"; import { processHubReporter, reportHubProgress, type HubReporter } from "./reporter.js"; import { addHubResolutionHelp } from "./help.js"; @@ -24,14 +33,20 @@ export interface HubDeployEnvironment { cwd: string; env: Readonly>; credentials?: HubCredentialStore; - hub?: Pick; + hub?: Pick< + HubHttpClient, + "installConfiguration" | "validateConfiguration" | "installTrigger" | "validateTrigger" + >; reporter?: HubReporter; } export interface HubDeployCommandDependencies { env: Readonly>; credentials: HubCredentialStore; - hub: Pick; + hub: Pick< + HubHttpClient, + "installConfiguration" | "validateConfiguration" | "installTrigger" | "validateTrigger" + >; reporter: HubReporter; cwd(): string; } @@ -46,6 +61,16 @@ interface HubDryRunResult extends HubValidationResult { workflows: number; } +interface HubTriggerDeployResult extends HubTriggerInstallationResult { + origin: string; + path: string; +} + +interface HubTriggerDryRunResult extends HubTriggerValidationResult { + origin: string; + path: string; +} + const resultSchema: OutputSchema = { idField: "versionId", columns: [ @@ -68,6 +93,28 @@ const validationSchema: OutputSchema = { ], }; +const triggerResultSchema: OutputSchema = { + idField: "triggerId", + columns: [ + { header: "TRIGGER", field: "name" }, + { header: "VERSION", field: "version" }, + { header: "REVISION ID", field: "revisionId" }, + { header: "ACTIVE", field: "active" }, + { header: "PATH", field: "path" }, + { header: "HUB", field: "origin" }, + ], +}; + +const triggerValidationSchema: OutputSchema = { + idField: "name", + columns: [ + { header: "TRIGGER", field: "name" }, + { header: "VALID", field: "valid" }, + { header: "PATH", field: "path" }, + { header: "HUB", field: "origin" }, + ], +}; + export async function runHubDeploy( options: HubDeployOptions, environment: HubDeployEnvironment = { @@ -76,14 +123,84 @@ export async function runHubDeploy( credentials: new PrivateHubCredentialStore(), hub: new HubHttpClient(), }, -): Promise | SingleResult> { +): Promise< + | SingleResult + | SingleResult + | ListResult + | ListResult +> { + if (options.project === undefined) { + return runHubDeployTriggers(options, await discoverHubTriggers(environment.cwd), environment); + } const deployInput = await discoverHubBundle({ cwd: environment.cwd, - ...(options.project === undefined ? {} : { project: options.project }), + project: options.project, }); return runHubDeployBundle(options, deployInput, environment); } +export async function runHubDeployTriggers( + options: HubDeployOptions, + triggers: readonly HubDeployTrigger[], + environment: HubDeployEnvironment, +): Promise | ListResult> { + const credentials = environment.credentials ?? new PrivateHubCredentialStore(environment.env); + const resolution = { + options: { origin: options.hub, apiKey: options.apiKey }, + env: environment.env, + credentials, + }; + const origin = resolveHubOrigin(resolution); + const credential = resolveHubCredential({ ...resolution, origin }); + const action = options.dryRun === true ? "Validating" : "Deploying"; + reportHubProgress( + environment.reporter ?? processHubReporter, + options, + `${action} ${String(triggers.length)} trigger${triggers.length === 1 ? "" : "s"} ${ + options.dryRun === true ? "against" : "to" + } ${origin}`, + ); + const hub = environment.hub ?? new HubHttpClient(); + if (options.dryRun === true) { + const data = await Promise.all( + triggers.map(async (trigger) => ({ + ...(await hub.validateTrigger(origin, credential, trigger.yaml)), + origin, + path: trigger.path, + })), + ); + return { type: "list", data, schema: triggerValidationSchema }; + } + await Promise.all( + triggers.map((trigger) => hub.validateTrigger(origin, credential, trigger.yaml)), + ); + const data: HubTriggerDeployResult[] = []; + for (const trigger of triggers) { + try { + data.push({ + ...(await hub.installTrigger(origin, credential, trigger.yaml)), + origin, + path: trigger.path, + }); + } catch (error) { + if (data.length === 0) throw error; + throw new HubCommandError( + "HUB_TRIGGER_DEPLOY_PARTIAL", + `Could not deploy ${trigger.path} after ${String(data.length)} trigger${data.length === 1 ? "" : "s"} had been installed.`, + `${errorMessage(error)}\nInstalled before the failure:\n${data.map(({ path: installedPath }) => `- ${installedPath}`).join("\n")}`, + ); + } + } + return { type: "list", data, schema: triggerResultSchema }; +} + +function errorMessage(error: unknown): string { + if (error instanceof HubCommandError) { + return error.details === undefined ? error.message : `${error.message}\n${error.details}`; + } + return error instanceof Error ? error.message : String(error); +} + export async function runHubDeployBundle( options: HubDeployOptions, deployInput: HubDeployBundle, @@ -133,14 +250,17 @@ export function addHubDeployCommand( addHubResolutionHelp( hub .command("deploy") - .description("Discover, validate, and activate the canonical .paseo Hub bundle") - .option("-p, --project ", "Target project slug") + .description("Deploy .paseo organization triggers or a legacy project bundle") + .option("-p, --project ", "Deploy a legacy bundle to this project slug") .option("--hub ", "Paseo Hub origin") .option("--api-key ", "Organization API key") .option("--dry-run", "Validate without installing or activating"), ), ).action( - withOutput(async (...args) => { + withOutput< + HubDeployResult | HubDryRunResult | HubTriggerDeployResult | HubTriggerDryRunResult, + unknown[] + >(async (...args) => { const options = args.at(-2) as HubDeployOptions; return runHubDeploy(options, { cwd: dependencies.cwd(), diff --git a/packages/cli/src/commands/hub/hub-client/index.ts b/packages/cli/src/commands/hub/hub-client/index.ts index ab880705d6..857a145afb 100644 --- a/packages/cli/src/commands/hub/hub-client/index.ts +++ b/packages/cli/src/commands/hub/hub-client/index.ts @@ -8,15 +8,17 @@ import { installResponseSchema, projectsResponseSchema, triggersResponseSchema, + triggerInstallationResponseSchema, + triggerValidationResponseSchema, validationResponseSchema, type CliAuthorization, type CliAuthorizationPoll, type HubInstallResult, type HubConfigurationResources, - setupResourcesSchema, - type HubSetupResources, type HubProject, type HubTrigger, + type HubTriggerInstallationResult, + type HubTriggerValidationResult, type HubValidationResult, } from "./internal/contracts.js"; import { requestHub } from "./internal/transport.js"; @@ -26,9 +28,10 @@ export type { CliAuthorizationPoll, HubInstallResult, HubConfigurationResources, - HubSetupResources, HubProject, HubTrigger, + HubTriggerInstallationResult, + HubTriggerValidationResult, HubValidationResult, } from "./internal/contracts.js"; @@ -102,6 +105,40 @@ export class HubHttpClient { return response.triggers; } + validateTrigger( + origin: string, + apiKey: string, + yaml: string, + ): Promise { + return requestHub({ + origin, + path: "/api/v1/triggers/validate", + method: "POST", + apiKey, + body: { yaml }, + successStatus: 200, + schema: triggerValidationResponseSchema, + failureMessage: "Hub trigger validation failed", + }); + } + + installTrigger( + origin: string, + apiKey: string, + yaml: string, + ): Promise { + return requestHub({ + origin, + path: "/api/v1/triggers/install", + method: "POST", + apiKey, + body: { yaml }, + successStatus: 201, + schema: triggerInstallationResponseSchema, + failureMessage: "Hub trigger deployment failed", + }); + } + listConfigurationResources(origin: string, apiKey: string): Promise { return requestHub({ origin, @@ -114,18 +151,6 @@ export class HubHttpClient { }); } - listSetupResources(origin: string, apiKey: string): Promise { - 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 { return requestHub({ origin: input.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 059f66cb14..c51127bc4c 100644 --- a/packages/cli/src/commands/hub/hub-client/internal/contracts.ts +++ b/packages/cli/src/commands/hub/hub-client/internal/contracts.ts @@ -48,6 +48,20 @@ const triggerSchema = z export const triggersResponseSchema = z.object({ triggers: z.array(triggerSchema) }).strict(); +export const triggerValidationResponseSchema = z + .object({ name: z.string().regex(/^[a-z][a-z0-9_-]*$/u), valid: z.literal(true) }) + .strict(); + +export const triggerInstallationResponseSchema = z + .object({ + triggerId: z.string().uuid(), + name: z.string().regex(/^[a-z][a-z0-9_-]*$/u), + revisionId: z.string().uuid(), + version: z.number().int().positive(), + active: z.literal(true), + }) + .strict(); + export const configurationResourcesSchema = z .object({ daemons: z.array(z.object({ id: z.string().uuid(), slug: z.string().min(1) }).strict()), @@ -63,25 +77,9 @@ export const configurationResourcesSchema = z ), discord: z.array(z.object({ slug: z.string().min(1), guildName: z.string().min(1) }).strict()), slack: z.array(z.object({ slug: z.string().min(1), teamName: z.string().min(1) }).strict()), - }) - .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(), + linear: z.array( + z.object({ slug: z.string().min(1), organizationName: 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(); @@ -106,7 +104,8 @@ export type CliAuthorization = z.infer; export type CliAuthorizationPoll = z.infer; export type HubProject = z.infer; export type HubTrigger = z.infer; +export type HubTriggerValidationResult = z.infer; +export type HubTriggerInstallationResult = z.infer; export type HubConfigurationResources = z.infer; -export type HubSetupResources = z.infer; export type HubInstallResult = z.infer; export type HubValidationResult = z.infer; diff --git a/packages/cli/src/commands/hub/init-flow.test.ts b/packages/cli/src/commands/hub/init-flow.test.ts index 934c47b0f6..74653a6ddb 100644 --- a/packages/cli/src/commands/hub/init-flow.test.ts +++ b/packages/cli/src/commands/hub/init-flow.test.ts @@ -1,5 +1,5 @@ import { strict as assert } from "node:assert"; -import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, it } from "vitest"; @@ -112,7 +112,13 @@ describe("Hub guided setup continuation", () => { continueHubGuidedSetup( origin, setupEnvironment(cwd, credentials, daemon, prompts, [], { - setupResources: { github: [], slack: [], discord: [] }, + configurationResources: { + daemons: [], + github: [], + slack: [], + discord: [], + linear: [], + }, }), ), reporter: { progress() {} }, @@ -125,18 +131,77 @@ describe("Hub guided setup continuation", () => { ]); }); - it("keeps hub init's existing replacement confirmation", async () => { + it("preserves an existing legacy bundle while adding the organization trigger", async () => { const cwd = await temporaryDirectory(); await mkdir(path.join(cwd, ".paseo")); - const prompts = new PromptAnswers([false], [], []); + await writeFile(path.join(cwd, ".paseo", "hub.yml"), "legacy: bundle\n"); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const prompts = new PromptAnswers([], ["codex", "gpt-5", "full-access"], ["U123"]); + + await runHubGuidedSetup(setupEnvironment(cwd, credentials, new SetupDaemon(), prompts, []), { + origin: "https://hub.test", + daemonId: "daemon-1", + deploy: false, + }); + + assert.equal(await readFile(path.join(cwd, ".paseo", "hub.yml"), "utf8"), "legacy: bundle\n"); + assert.match( + await readFile(path.join(cwd, ".paseo", "triggers", "slack-help.yml"), "utf8"), + /name: slack-help/u, + ); + assert.deepEqual(prompts.confirmations, []); + }); + + it("asks before replacing only the selected trigger file", async () => { + const cwd = await temporaryDirectory(); + const triggerPath = path.join(cwd, ".paseo", "triggers", "slack-help.yml"); + await mkdir(path.dirname(triggerPath), { recursive: true }); + await writeFile(triggerPath, "name: keep-me\n"); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const prompts = new PromptAnswers([false], ["codex", "gpt-5", "full-access"], ["U123"]); + const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = []; await assert.rejects( - runHubGuidedSetup( - setupEnvironment(cwd, new MemoryCredentials(), new SetupDaemon(), prompts, []), - ), - /Existing .paseo\/ bundle left unchanged/u, + runHubGuidedSetup(setupEnvironment(cwd, credentials, new SetupDaemon(), prompts, calls), { + origin: "https://hub.test", + daemonId: "daemon-1", + deploy: true, + }), + /.paseo\/triggers\/slack-help.yml left unchanged/u, ); - assert.deepEqual(prompts.confirmations, ["Replace the existing .paseo/ Hub bundle?"]); + + assert.equal(await readFile(triggerPath, "utf8"), "name: keep-me\n"); + assert.deepEqual(prompts.confirmations, [ + "Replace the existing .paseo/triggers/slack-help.yml?", + ]); + assert.deepEqual( + calls.map(({ operation }) => operation), + ["resources", "validate-trigger"], + ); + }); + + it("rejects a symlinked trigger directory without writing outside the project", async () => { + const cwd = await temporaryDirectory(); + const outside = await temporaryDirectory(); + await mkdir(path.join(cwd, ".paseo")); + await symlink(outside, path.join(cwd, ".paseo", "triggers")); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const prompts = new PromptAnswers([], ["codex", "gpt-5", "full-access"], ["U123"]); + + await assert.rejects( + runHubGuidedSetup(setupEnvironment(cwd, credentials, new SetupDaemon(), prompts, []), { + origin: "https://hub.test", + daemonId: "daemon-1", + deploy: false, + }), + (error: unknown) => + error instanceof Error && "code" in error && error.code === "HUB_TRIGGER_UNSAFE_PATH", + ); + + await assert.rejects(readFile(path.join(outside, "slack-help.yml")), { code: "ENOENT" }); }); it("writes the explicitly selected Claude mode when the daemon has no default", async () => { @@ -167,14 +232,14 @@ describe("Hub guided setup continuation", () => { ["Sonnet (suggested)"], ["Auto"], ]); - assert.match( - await readFile(path.join(cwd, ".paseo", "hub.yml"), "utf8"), - /provider: claude\n model: sonnet\n mode: auto/u, - ); + const trigger = await readFile(path.join(cwd, ".paseo", "triggers", "slack-help.yml"), "utf8"); + assert.match(trigger, /provider: claude\n model: sonnet\n mode: auto/u); assert.deepEqual( calls.slice(-2).map(({ operation }) => operation), - ["validate", "install"], + ["validate-trigger", "install-trigger"], ); + assert.deepEqual(calls.at(-2)?.files, [trigger]); + assert.deepEqual(calls.at(-1)?.files, [trigger]); }); it("stops before runtime discovery and file writes when no Hub app connection is usable", async () => { @@ -187,15 +252,50 @@ describe("Hub guided setup continuation", () => { await assert.rejects( runHubGuidedSetup( setupEnvironment(cwd, credentials, daemon, prompts, [], { - setupResources: { github: [], slack: [], discord: [] }, + configurationResources: { + daemons: [{ id: "daemon-1", slug: "macbook" }], + github: [], + slack: [], + discord: [], + linear: [], + }, }), { origin: "https://hub.test", daemonId: "daemon-1", deploy: true }, ), - /No Hub app connection is ready for this workflow/u, + /No Hub app connection is ready for this trigger/u, ); assert.equal(daemon.snapshotCwds.length, 0); - await assert.rejects(readFile(path.join(cwd, ".paseo", "hub.yml")), { code: "ENOENT" }); + await assert.rejects(readFile(path.join(cwd, ".paseo", "triggers", "slack-help.yml")), { + code: "ENOENT", + }); + }); + + it("does not offer an agent runtime that cannot author the required execution mode", async () => { + const cwd = await temporaryDirectory(); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const daemon = new SetupDaemon([ + { + provider: "pi", + status: "ready", + enabled: true, + models: [{ provider: "pi", id: "default", label: "Default", isDefault: true }], + modes: [], + }, + ]); + const prompts = new PromptAnswers([], [], ["U123"]); + + await assert.rejects( + runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, []), { + origin: "https://hub.test", + daemonId: "daemon-1", + deploy: true, + }), + /No agent runtime with an execution mode is available/u, + ); + + assert.deepEqual(prompts.selections, []); }); it("waits for fresh-daemon provider discovery before offering runtime choices", async () => { @@ -231,7 +331,7 @@ function setupEnvironment( prompts: PromptAnswers, calls: Array<{ operation: string; origin: string; files?: readonly string[] }>, options: { - setupResources?: Awaited>; + configurationResources?: Awaited>; } = {}, ): HubGuidedSetupEnvironment { const hub = { @@ -239,42 +339,37 @@ function setupEnvironment( 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 }); + async listConfigurationResources(origin: string) { + calls.push({ operation: "resources", origin }); return ( - options.setupResources ?? { + options.configurationResources ?? { + daemons: [{ id: "daemon-1", slug: "macbook" }], github: [], discord: [], - slack: [{ teamId: "T123", teamName: "Paseo" }], + slack: [{ slug: "paseo", teamName: "Paseo" }], + linear: [], } ); }, - 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 }[] }) { + async validateTrigger(origin: string, _apiKey: string, yaml: string) { calls.push({ - operation: "validate", - origin: input.origin, - files: input.files.map(({ path: filePath }) => filePath), + operation: "validate-trigger", + origin, + files: [yaml], }); - return { projectSlug: "studio", valid: true }; + return { name: "slack-help", valid: true }; }, - async installConfiguration(input: { origin: string; files: readonly { path: string }[] }) { + async installTrigger(origin: string, _apiKey: string, yaml: string) { calls.push({ - operation: "install", - origin: input.origin, - files: input.files.map(({ path: filePath }) => filePath), + operation: "install-trigger", + origin, + files: [yaml], }); return { - projectSlug: "studio", + triggerId: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", + name: "slack-help", version: 1, - versionId: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", + revisionId: "2de5b143-1c88-42db-8a8d-71ca2af97830", active: true, }; }, diff --git a/packages/cli/src/commands/hub/init-plan.test.ts b/packages/cli/src/commands/hub/init-plan.test.ts index 44538eb3b1..2ec2f85dff 100644 --- a/packages/cli/src/commands/hub/init-plan.test.ts +++ b/packages/cli/src/commands/hub/init-plan.test.ts @@ -1,31 +1,13 @@ -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import YAML from "yaml"; -import { afterEach, describe, expect, it } from "vitest"; -import { discoverHubBundle } from "./deploy-bundle.js"; -import { runHubDeploy } from "./deploy.js"; +import { describe, expect, it } from "vitest"; import { createHubInitScaffold, hubLoginResumeCommand, - planHubInitOpening, resolveHubInitConnection, - resolveHubInitProjects, type HubInitProvider, } from "./init-plan.js"; import { githubRepositoryFromRemote } from "./init.js"; -const directories: string[] = []; -const project = (slug: string) => ({ - id: "7e950d84-eec0-4e18-b1f9-9c115bdb31e4", - slug, - name: slug.toUpperCase(), -}); - -afterEach(async () => { - await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); -}); - describe("Hub init planning", () => { it("prints direct resumable commands for declined login continuations", () => { expect(hubLoginResumeCommand("connect", "https://hub.test")).toBe( @@ -33,36 +15,6 @@ describe("Hub init planning", () => { ); 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, - steps: ["login", "connect", "project", "scaffold"], - }); - expect(planHubInitOpening({ loggedIn: true, paseoDirectoryExists: false })).toEqual({ - replaceExisting: false, - steps: ["connect", "project", "scaffold"], - }); - }); - - it("plans a confirmed replacement for an existing .paseo directory", () => { - expect(planHubInitOpening({ loggedIn: true, paseoDirectoryExists: true })).toEqual({ - replaceExisting: true, - steps: ["connect", "project", "scaffold"], - }); - }); - - it("stops, defaults, or asks according to the project count", () => { - expect(resolveHubInitProjects([])).toEqual({ kind: "none" }); - expect(resolveHubInitProjects([project("one")])).toEqual({ - kind: "selected", - project: project("one"), - }); - expect(resolveHubInitProjects([project("one"), project("two")])).toEqual({ - kind: "choose", - projects: [project("one"), project("two")], - }); - }); - it("reuses a connected daemon, waits for reconnect, and rejects a different Hub", () => { const status = { state: "connected", @@ -93,31 +45,15 @@ 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: "T123456", user: "U123456" }], - ["discord", { guild: "123456789", user: "987654321" }], + ["github", { connection: "github-getpaseo", repo: "getpaseo/paseo", user: "boudra" }], + ["slack", { connection: "slack-paseo", user: "U123456" }], + ["discord", { connection: "discord-paseo", user: "987654321" }], ] satisfies readonly [HubInitProvider, Record][])( - "creates a closed %s trigger that the deploy discovery path accepts", - async (provider, providerFilters) => { - const cwd = await temporaryDirectory(); + "creates a self-contained %s organization trigger", + (provider, providerFilters) => { const scaffold = createHubInitScaffold({ - cwd, + cwd: "/workspace", daemonSlug: "build-studio", agent: { provider: "codex", @@ -127,48 +63,48 @@ describe("Hub init scaffold", () => { provider, providerFilters, }); - await mkdir(path.join(cwd, ".paseo", "workflows"), { recursive: true }); - await writeFile(path.join(cwd, ".paseo", "hub.yml"), scaffold.hub); - await writeFile(path.join(cwd, scaffold.workflowPath), scaffold.workflow); - - const bundle = await discoverHubBundle({ cwd, project: "paseo" }); - expect(bundle.workflowCount).toBe(1); - expect(bundle.files.map((file) => file.path)).toEqual([ - ".paseo/hub.yml", - scaffold.workflowPath, - ]); - const parsed = YAML.parse(scaffold.workflow) as { - filters: { from_users: string[]; channels?: string[] }; + expect(scaffold.triggerPath).toBe(`.paseo/triggers/${provider}-help.yml`); + const parsed = YAML.parse(scaffold.trigger) as { + name: string; + enabled: boolean; + max_runtime: string; + on: Record }>; + run: { + target: { daemon: string; cwd: string }; + agent: { provider: string; model?: string; mode?: string }; + continuation: { mode: string }; + max_runtime: string; + idle_timeout: string; + prompt: string; + outputs?: Record; + }; }; - 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" } }, + expect(parsed).toMatchObject({ + name: `${provider}-help`, + enabled: true, + max_runtime: "2h", }); - - let validatedPaths: readonly string[] = []; - const result = await runHubDeploy( - { project: "paseo", hub: "https://hub.test", dryRun: true }, - { - cwd, - env: { PASEO_HUB_API_KEY: "test-key" }, - reporter: { progress() {} }, - hub: { - async validateConfiguration(input) { - validatedPaths = input.files.map((file) => file.path); - for (const file of input.files) YAML.parse(file.content); - return { projectSlug: input.projectSlug, valid: true }; - }, - async installConfiguration() { - throw new Error("dry-run must not install"); - }, - }, - }, + expect(Object.keys(parsed.on)).toEqual([ + provider === "github" ? "github.issue_comment" : `${provider}.mention`, + ]); + const event = Object.values(parsed.on)[0]!; + expect(event.filters.from_users).toEqual([providerFilters.user]); + if (provider === "github") expect(event.connection).toBe("github-getpaseo"); + if (provider === "slack") expect(event.connection).toBe("slack-paseo"); + if (provider === "discord") expect(event.connection).toBe("discord-paseo"); + expect(event.filters.channels).toBeUndefined(); + expect(parsed.run).toMatchObject({ + target: { daemon: "build-studio", cwd: "/workspace" }, + agent: { provider: "codex", model: "gpt-5", mode: "full-access" }, + continuation: { mode: "conversation" }, + max_runtime: "90m", + idle_timeout: "10m", + }); + expect(parsed.run.prompt).toContain("hub.finish_execution"); + expect(parsed.run.prompt).toContain("${{ paseo.prompt }}"); + expect(parsed.run.outputs).toEqual( + provider === "github" ? undefined : { [`${provider}.reply`]: { max: 1, required: true } }, ); - expect(result.data).toMatchObject({ projectSlug: "paseo", valid: true, workflows: 1 }); - expect(validatedPaths).toEqual([".paseo/hub.yml", scaffold.workflowPath]); }, ); }); @@ -183,9 +119,3 @@ describe("GitHub origin detection", () => { expect(githubRepositoryFromRemote(remote)).toBe(expected); }); }); - -async function temporaryDirectory(): Promise { - const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-init-")); - directories.push(directory); - return directory; -} diff --git a/packages/cli/src/commands/hub/init-plan.ts b/packages/cli/src/commands/hub/init-plan.ts index e2764ccbff..bd2077300d 100644 --- a/packages/cli/src/commands/hub/init-plan.ts +++ b/packages/cli/src/commands/hub/init-plan.ts @@ -1,40 +1,27 @@ import path from "node:path"; 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"; -export type HubInitProjectResolution = - | { kind: "none" } - | { kind: "selected"; project: HubProject } - | { kind: "choose"; projects: readonly HubProject[] }; - export type HubInitConnectionResolution = | { kind: "connected"; daemonId: string } | { kind: "pending"; state: "connecting" | "reconnecting" } | { kind: "connect" } | { kind: "conflict"; origin: string }; -export interface HubInitOpeningPlan { - replaceExisting: boolean; - steps: readonly ("login" | "connect" | "project" | "scaffold")[]; -} - export interface HubInitScaffoldInput { cwd: string; daemonSlug: string; - agent: HubStarterAgentRuntime; + agent: HubStarterAgentRuntime & { mode: string }; provider: HubInitProvider; providerFilters: Readonly>; } export interface HubInitScaffold { - hub: string; - workflowPath: string; - workflow: string; + triggerPath: string; + trigger: string; testAction: string; } @@ -42,36 +29,6 @@ export function hubLoginResumeCommand(step: "connect" | "init", origin: string): return step === "connect" ? `paseo hub connect ${origin}` : "paseo hub init"; } -export function planHubInitOpening(input: { - loggedIn: boolean; - paseoDirectoryExists: boolean; -}): HubInitOpeningPlan { - return { - replaceExisting: input.paseoDirectoryExists, - steps: [...(input.loggedIn ? [] : (["login"] as const)), "connect", "project", "scaffold"], - }; -} - -export function createHubInitBundle( - projectSlug: string, - scaffold: HubInitScaffold, -): HubDeployBundle { - return { - projectSlug, - workflowCount: 1, - files: [ - { path: ".paseo/hub.yml", content: scaffold.hub }, - { path: scaffold.workflowPath, content: scaffold.workflow }, - ], - }; -} - -export function resolveHubInitProjects(projects: readonly HubProject[]): HubInitProjectResolution { - if (projects.length === 0) return { kind: "none" }; - if (projects.length === 1) return { kind: "selected", project: projects[0]! }; - return { kind: "choose", projects }; -} - export function resolveHubInitConnection( status: HubStatus, origin: string, @@ -92,31 +49,21 @@ export function resolveHubInitConnection( } export function createHubInitScaffold(input: HubInitScaffoldInput): HubInitScaffold { - const environmentName = input.daemonSlug; - const hub = YAML.stringify( - { - environments: { - [environmentName]: { - kind: "daemon", - daemon: input.daemonSlug, - cwd: path.resolve(input.cwd), - }, - }, - agents: { - starter: { + const provider = providerScaffold(input.provider, input.providerFilters); + return { + triggerPath: `.paseo/triggers/${input.provider}-help.yml`, + trigger: YAML.stringify( + triggerDocument({ + ...provider, + target: { daemon: input.daemonSlug, cwd: path.resolve(input.cwd) }, + agent: { provider: input.agent.provider, model: input.agent.model, - ...(input.agent.mode === undefined ? {} : { mode: input.agent.mode }), + mode: input.agent.mode, }, - }, - }, - { lineWidth: 0 }, - ); - const provider = providerScaffold(input.provider, input.providerFilters, environmentName); - return { - hub, - workflowPath: `.paseo/workflows/${input.provider}-help.yml`, - workflow: YAML.stringify(provider.workflow, { lineWidth: 0 }), + }), + { lineWidth: 0 }, + ), testAction: provider.testAction, }; } @@ -124,80 +71,75 @@ export function createHubInitScaffold(input: HubInitScaffoldInput): HubInitScaff function providerScaffold( provider: HubInitProvider, filters: Readonly>, - environment: string, -): { workflow: object; testAction: string } { +): { + name: string; + event: string; + connection: string; + filters: object; + reply?: "slack.reply" | "discord.reply"; + testAction: string; +} { + const connection = requireFilter(filters, "connection"); if (provider === "github") { const repo = requireFilter(filters, "repo"); const user = requireFilter(filters, "user"); return { - workflow: workflow({ - name: "github-help", - on: "github.issue_comment", - filters: { repo, contains: "@paseo", from_users: [user] }, - environment, - }), + name: "github-help", + event: "github.issue_comment", + connection, + filters: { repo, contains: "@paseo", from_users: [user] }, testAction: `Comment \`@paseo have a look\` on ${repo}.`, }; } const user = requireFilter(filters, "user"); if (provider === "slack") { - const workspace = requireFilter(filters, "workspace"); return { - workflow: workflow({ - name: "slack-help", - on: "slack.mention", - filters: { workspace, from_users: [user] }, - environment, - reply: "slack.reply", - }), + name: "slack-help", + event: "slack.mention", + connection, + filters: { from_users: [user] }, + reply: "slack.reply", testAction: "Mention `@Paseo have a look` in Slack.", }; } - const guild = requireFilter(filters, "guild"); return { - workflow: workflow({ - name: "discord-help", - on: "discord.mention", - filters: { guild, from_users: [user] }, - environment, - reply: "discord.reply", - }), + name: "discord-help", + event: "discord.mention", + connection, + filters: { from_users: [user] }, + reply: "discord.reply", testAction: "Mention `@Paseo have a look` in Discord.", }; } -function workflow(input: { +function triggerDocument(input: { name: string; - on: string; + event: string; + connection: string; filters: object; - environment: string; + target: { daemon: string; cwd: string }; + agent: { provider: string; model: string; mode?: string }; reply?: "slack.reply" | "discord.reply"; }): object { const replyInstruction = input.reply === undefined ? "" : "Answer with hub.reply, then "; return { name: input.name, - on: input.on, + enabled: true, + on: { [input.event]: { connection: input.connection, filters: input.filters } }, max_runtime: "2h", - filters: input.filters, - steps: [ - { - id: "work", - environment: input.environment, - max_runtime: "90m", - idle_timeout: "10m", - agent: "starter", - prompt: [ - { - text: `${replyInstruction}complete this request and call hub.finish_execution when done.\n\n\n\${{ paseo.prompt }}\n\n`, - }, - ], - ...(input.reply === undefined - ? {} - : { allow_outputs: [{ type: input.reply, max: 1, required: true }] }), - }, - ], + run: { + target: input.target, + agent: input.agent, + continuation: { mode: "conversation" }, + max_runtime: "90m", + idle_timeout: "10m", + prompt: `${replyInstruction}complete this request and call hub.finish_execution when done.\n\n\n\${{ paseo.prompt }}\n\n`, + ...(input.reply === undefined + ? {} + : { outputs: { [input.reply]: { max: 1, required: true } } }), + }, }; } diff --git a/packages/cli/src/commands/hub/init.ts b/packages/cli/src/commands/hub/init.ts index c83ce12b09..6a97558b34 100644 --- a/packages/cli/src/commands/hub/init.ts +++ b/packages/cli/src/commands/hub/init.ts @@ -12,7 +12,7 @@ import { } from "@clack/prompts"; import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { lstat, mkdir, rename, rm, writeFile } from "node:fs/promises"; +import { link, lstat, mkdir, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import type { Command } from "commander"; @@ -21,24 +21,14 @@ 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, - HubSetupResources, -} from "./hub-client/index.js"; +import type { HubConfigurationResources, HubHttpClient } from "./hub-client/index.js"; import { - createHubInitBundle, createHubInitScaffold, hubLoginResumeCommand, - planHubInitOpening, resolveHubInitConnection, - resolveHubInitProjects, } from "./init-plan.js"; import type { CliLoginFlow } from "./login-flow.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 { @@ -93,7 +83,7 @@ function initErrorMessage(error: unknown): string { export function addHubInitCommand(parent: Command, environment: HubGuidedSetupEnvironment): void { parent .command("init") - .description("Create and optionally deploy a safe starter Hub bundle") + .description("Create and optionally deploy a safe starter Hub trigger") .action(async () => { try { await runHubInit(environment); @@ -121,25 +111,19 @@ export async function runHubGuidedSetup( const cwd = environment.cwd(); const activeLogin = environment.credentials.active(); - const opening = planHubInitOpening({ - loggedIn: activeLogin !== null, - paseoDirectoryExists: await pathExists(path.join(cwd, ".paseo")), - }); - if ( - opening.replaceExisting && - !(await requiredConfirm(environment, "Replace the existing .paseo/ Hub bundle?", false)) - ) { - throw new HubInitCancelledError("Existing .paseo/ bundle left unchanged."); - } - 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 loadSetupResources(origin, environment); + const resources = await loadConfigurationResources(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 = 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.", + ); + } log.success(`Connected as ${daemon.slug}`); const agent = await chooseStarterAgentRuntime(environment, cwd); const providerFilters = await collectProviderIdentity(trigger, environment); @@ -151,40 +135,40 @@ export async function runHubGuidedSetup( providerFilters, }); - const bundle = createHubInitBundle(project.slug, scaffold); + const credential = resolveHubCredential({ + options: { origin }, + env: environment.env, + credentials: environment.credentials, + origin, + }); - await withSpinner("Validating bundle", async () => { - await runHubDeployBundle({ project: project.slug, hub: origin, dryRun: true }, bundle, { - cwd, - env: environment.env, - credentials: environment.credentials, - hub: environment.hub, - reporter: { progress() {} }, - }); + await withSpinner("Validating trigger", async () => { + await environment.hub.validateTrigger(origin, credential, scaffold.trigger); }); log.success("Dry run passed"); - await writeScaffold(cwd, scaffold, opening.replaceExisting); - log.success(`Created .paseo/hub.yml and ${scaffold.workflowPath}`); + const triggerExists = await prepareScaffoldDestination(cwd, scaffold.triggerPath); + if ( + triggerExists && + !(await requiredConfirm(environment, `Replace the existing ${scaffold.triggerPath}?`, false)) + ) { + throw new HubInitCancelledError(`${scaffold.triggerPath} left unchanged.`); + } + await writeScaffold(cwd, scaffold, triggerExists); + log.success(`Created ${scaffold.triggerPath}`); 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, { - cwd, - env: environment.env, - credentials: environment.credentials, - hub: environment.hub, - reporter: { progress() {} }, - }); + await withSpinner("Deploying trigger", async () => { + await environment.hub.installTrigger(origin, credential, scaffold.trigger); }); log.success("Deployed"); } else { - reportMessage(environment, `Skipped deployment. Run: paseo hub deploy -p ${project.slug}`); + reportMessage(environment, "Skipped deployment. Run `paseo hub deploy` when ready."); } - const activityUrl = new URL(`/projects/${project.slug}/activity`, origin).toString(); - note(`${scaffold.testAction}\nWatch it at ${activityUrl}`, "Test your workflow"); - outro(deploy ? "Hub is ready" : "Hub bundle is ready"); + const triggersUrl = new URL("/triggers", origin).toString(); + note(`${scaffold.testAction}\nManage it at ${triggersUrl}`, "Test your trigger"); + outro(deploy ? "Hub is ready" : "Hub trigger is ready"); } export async function continueHubGuidedSetup( @@ -319,7 +303,7 @@ async function ensureDaemonConnection( true, )) ) { - throw new HubInitCancelledError("A connected daemon is required to create the bundle."); + throw new HubInitCancelledError("A connected daemon is required to create the trigger."); } return connectDaemon(origin, environment, permissions); } @@ -379,48 +363,8 @@ async function waitForDaemonReady( ); } -async function chooseProject( - origin: string, - environment: HubGuidedSetupEnvironment, -): Promise { - const result = await runHubProjects( - { hub: origin }, - { - env: environment.env, - credentials: environment.credentials, - hub: environment.hub, - reporter: { progress() {} }, - }, - ); - const resolution = resolveHubInitProjects(result.data.projects); - if (resolution.kind === "none") { - throw new HubInitCancelledError( - `No Hub projects exist yet. Create one at ${new URL("/projects/new", origin).toString()}, then run paseo hub init again.`, - ); - } - if (resolution.kind === "selected") { - return resolution.project; - } - const slug = await requiredSelect(environment, { - message: "Project", - options: resolution.projects.map((project) => ({ - value: project.slug, - label: project.name, - hint: project.slug, - })), - }); - const project = resolution.projects.find((candidate) => candidate.slug === slug); - if (project === undefined) { - throw new HubCommandError( - "HUB_PROJECT_SELECTION_INVALID", - "The selected Hub project is unavailable.", - ); - } - return project; -} - async function resolveStarterTriggerConnections( - resources: HubSetupResources, + resources: HubConfigurationResources, cwd: string, ): Promise { const remote = await readCommandValue("git", ["remote", "get-url", "origin"], cwd); @@ -428,7 +372,7 @@ async function resolveStarterTriggerConnections( 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.", + "No Hub app connection is ready for this trigger.\nConnect GitHub, Slack, or Discord in Hub → Apps, then run `paseo hub init` again.", ); } return connections; @@ -440,10 +384,10 @@ function reportStarterTriggerConnections( ): 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"); + note(details, "Hub app connections ready for this trigger"); return; } - environment.prompts.message(`Hub app connections ready for this workflow:\n${details}`); + environment.prompts.message(`Hub app connections ready for this trigger:\n${details}`); } async function chooseStarterTriggerConnection( @@ -472,19 +416,19 @@ async function chooseStarterTriggerConnection( async function chooseStarterAgentRuntime( environment: HubGuidedSetupEnvironment, cwd: string, -): Promise { +): Promise { const providers = await waitForStarterAgentProviders(environment, cwd); 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) { + if (runtime?.mode === 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; + return { ...runtime, mode: runtime.mode }; } async function waitForStarterAgentProviders( @@ -497,7 +441,14 @@ async function waitForStarterAgentProviders( while (true) { const snapshot = await daemon.getProvidersSnapshot({ cwd }); const state = starterAgentProviderSnapshotState(snapshot.entries); - if (state.kind === "ready") return state.providers; + if (state.kind === "ready") { + const providers = state.providers.filter(({ modes }) => modes.length > 0); + if (providers.length > 0) return providers; + throw new HubCommandError( + "HUB_AGENT_RUNTIME_REQUIRED", + "No agent runtime with an execution mode is available from this daemon. Configure one, then run paseo hub init again.", + ); + } if (state.kind === "unavailable") { throw new HubCommandError( "HUB_AGENT_RUNTIME_REQUIRED", @@ -607,13 +558,13 @@ async function collectProviderIdentity( }; } -async function loadSetupResources( +async function loadConfigurationResources( origin: string, environment: HubGuidedSetupEnvironment, -): Promise { +): Promise { try { return await withSpinner("Loading Hub connections", () => - environment.hub.listSetupResources( + environment.hub.listConfigurationResources( origin, resolveHubCredential({ options: { origin }, @@ -624,77 +575,51 @@ async function loadSetupResources( ), ); } catch (error) { - if (error instanceof HubCommandError && error.code === "HUB_NOT_FOUND") { + if ( + error instanceof HubCommandError && + (error.code === "HUB_NOT_FOUND" || error.code === "HUB_INVALID_RESPONSE") + ) { throw new HubCommandError( "HUB_UPDATE_REQUIRED", - "This Hub needs an update before guided setup can use provider IDs. Update Hub and try again.", + "This Hub needs an update before guided setup can use organization triggers. Update Hub and try again.", ); } throw error; } } -async function loadDaemonResource( - origin: string, - daemonId: string, - environment: HubGuidedSetupEnvironment, -): Promise { - 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 writeScaffold( cwd: string, scaffold: ReturnType, replaceExisting: boolean, ): Promise { - const root = path.join(cwd, ".paseo"); - const staging = path.join(cwd, `.paseo-init-${randomUUID()}`); - const backup = path.join(cwd, `.paseo-backup-${randomUUID()}`); - let movedExisting = false; + const destination = path.join(cwd, scaffold.triggerPath); + const temporary = path.join( + path.dirname(destination), + `.${path.basename(destination)}.${process.pid}.${randomUUID()}.tmp`, + ); try { - await mkdir(path.join(staging, "workflows"), { recursive: true }); - await writeFile(path.join(staging, "hub.yml"), scaffold.hub, { flag: "wx" }); - await writeFile( - path.join(staging, "workflows", path.basename(scaffold.workflowPath)), - scaffold.workflow, - { - flag: "wx", - }, - ); + await writeFile(temporary, scaffold.trigger, { flag: "wx" }); if (replaceExisting) { - await rename(root, backup); - movedExisting = true; + await rename(temporary, destination); + } else { + await link(temporary, destination); + await rm(temporary); } - await rename(staging, root); - if (movedExisting) await rm(backup, { recursive: true, force: true }); } catch (error) { - await rm(staging, { recursive: true, force: true }); - if (movedExisting) { - await rename(backup, root).catch(() => undefined); - } + await rm(temporary, { force: true }); throw error; } } -async function pathExists(target: string): Promise { +async function prepareScaffoldDestination(cwd: string, triggerPath: string): Promise { + const root = path.resolve(cwd); + await requireSafeScaffoldDirectory(path.join(root, ".paseo"), ".paseo"); + await requireSafeScaffoldDirectory(path.join(root, ".paseo", "triggers"), ".paseo/triggers"); + const destination = path.join(root, triggerPath); try { - await lstat(target); + const stats = await lstat(destination); + if (stats.isSymbolicLink()) throw unsafeScaffoldPath(triggerPath); return true; } catch (error) { if (errorCode(error) === "ENOENT") return false; @@ -702,6 +627,26 @@ async function pathExists(target: string): Promise { } } +async function requireSafeScaffoldDirectory(target: string, displayPath: string): Promise { + try { + const stats = await lstat(target); + if (stats.isSymbolicLink()) throw unsafeScaffoldPath(displayPath); + if (!stats.isDirectory()) { + throw new HubCommandError( + "HUB_TRIGGER_DIRECTORY_INVALID", + `${displayPath} must be a directory.`, + ); + } + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + await mkdir(target); + } +} + +function unsafeScaffoldPath(triggerPath: string): HubCommandError { + return new HubCommandError("HUB_TRIGGER_UNSAFE_PATH", `${triggerPath} must not use a symlink.`); +} + function errorCode(error: unknown): string | undefined { return error instanceof Error && "code" in error && typeof error.code === "string" ? error.code diff --git a/packages/cli/src/commands/hub/starter-trigger.test.ts b/packages/cli/src/commands/hub/starter-trigger.test.ts index 570ea0005d..f664bc37e2 100644 --- a/packages/cli/src/commands/hub/starter-trigger.test.ts +++ b/packages/cli/src/commands/hub/starter-trigger.test.ts @@ -2,7 +2,7 @@ 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", () => { + it("returns only concrete connections that can back the generated trigger", () => { expect( availableStarterTriggerConnections( { @@ -14,8 +14,10 @@ describe("starter trigger connections", () => { repositories: ["getpaseo/paseo"], }, ], - slack: [{ teamId: "T123", teamName: "Paseo" }], - discord: [{ guildId: "456", guildName: "Paseo Discord" }], + slack: [{ slug: "paseo", teamName: "Paseo" }], + discord: [{ slug: "paseo-discord", guildName: "Paseo Discord" }], + daemons: [], + linear: [], }, "getpaseo/paseo", ), @@ -24,19 +26,19 @@ describe("starter trigger connections", () => { id: "github:getpaseo/paseo", label: "GitHub — getpaseo/paseo", provider: "github", - filters: { repo: "getpaseo/paseo" }, + filters: { connection: "github-getpaseo", repo: "getpaseo/paseo" }, }, { - id: "slack:T123", + id: "slack:paseo", label: "Slack — Paseo", provider: "slack", - filters: { workspace: "T123" }, + filters: { connection: "paseo" }, }, { - id: "discord:456", + id: "discord:paseo-discord", label: "Discord — Paseo Discord", provider: "discord", - filters: { guild: "456" }, + filters: { connection: "paseo-discord" }, }, ]); }); @@ -55,6 +57,8 @@ describe("starter trigger connections", () => { ], slack: [], discord: [], + daemons: [], + linear: [], }, "getpaseo/paseo", ), diff --git a/packages/cli/src/commands/hub/starter-trigger.ts b/packages/cli/src/commands/hub/starter-trigger.ts index c327beb32a..d5ce8b51ac 100644 --- a/packages/cli/src/commands/hub/starter-trigger.ts +++ b/packages/cli/src/commands/hub/starter-trigger.ts @@ -1,4 +1,4 @@ -import type { HubSetupResources } from "./hub-client/index.js"; +import type { HubConfigurationResources } from "./hub-client/index.js"; import type { HubInitProvider } from "./init-plan.js"; export interface HubStarterTriggerConnection { @@ -9,32 +9,36 @@ export interface HubStarterTriggerConnection { } export function availableStarterTriggerConnections( - resources: HubSetupResources, + resources: HubConfigurationResources, 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}`, + ...resources.github.flatMap((connection) => + githubRepository !== undefined && connection.repositories.includes(githubRepository) + ? [ + { + id: `github:${githubRepository}`, + label: `GitHub — ${githubRepository}`, + provider: "github" as const, + filters: { + connection: connection.slug, + repo: githubRepository, + }, + }, + ] + : [], + ), + ...resources.slack.map(({ slug, teamName }) => ({ + id: `slack:${slug}`, label: `Slack — ${teamName}`, provider: "slack" as const, - filters: { workspace: teamId }, + filters: { connection: slug }, })), - ...resources.discord.map(({ guildId, guildName }) => ({ - id: `discord:${guildId}`, + ...resources.discord.map(({ slug, guildName }) => ({ + id: `discord:${slug}`, label: `Discord — ${guildName}`, provider: "discord" as const, - filters: { guild: guildId }, + filters: { connection: slug }, })), ]; } diff --git a/public-docs/cli.md b/public-docs/cli.md index 72ad71d076..ed626863f2 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -293,25 +293,26 @@ Use `PASEO_HOME` to run multiple isolated daemon instances. ```bash paseo hub login [url] # Approve and store organization-scoped CLI access -paseo hub init # Guided setup: scaffold and deploy a starter bundle here +paseo hub init # Create and optionally deploy a starter trigger here paseo hub connect [url] # Enroll this daemon using CLI access -paseo hub projects # List projects in the authenticated organization +paseo hub projects # List legacy projects in the authenticated organization paseo hub status # Show the current Hub relationship paseo hub disconnect # End it -paseo hub deploy -p # Discover, validate, and activate a Hub bundle -paseo hub deploy -p --dry-run # Validate without activating +paseo hub deploy # Validate and install .paseo/triggers/*.yml +paseo hub deploy --dry-run # Validate without installing +paseo hub deploy -p # Deploy an existing legacy project bundle paseo hub logout # Remove the active stored CLI login ``` -Run deploy from the project root. It reads `.paseo/hub.yml`, every direct `.paseo/workflows/*.yml` file, and referenced `.paseo/workflows/partials/*` files in deterministic path order. It does not search parents, accept an alternate resource path, or flatten the bundle into monolithic YAML. +Run deploy from the repository root. By default it reads every direct `.paseo/triggers/*.yml` file in deterministic path order. It validates all triggers before installing them one at a time. If an installation fails after earlier ones succeeded, the error lists the installed files. `--dry-run` only validates; it does not create or activate revisions. -Pass `-p, --project ` to select the target project. `--dry-run` performs the same discovery and server validation without recording or activating a revision. Both outputs include the resolved Hub, project, and discovered workflow count. +Pass `-p, --project ` for an existing legacy bundle: `.paseo/hub.yml`, direct `.paseo/workflows/*.yml` files, and referenced workflow partials. See [Deploy from the CLI](/docs/hub/configuration#deploy-from-the-cli). -`login` opens the Hub approval page and stores a durable organization-scoped CLI credential under `PASEO_HOME`. In an interactive terminal it then asks whether to connect this daemon and whether to initialize and deploy a starter workflow, both defaulting to yes. Declining the connection prints `paseo hub connect ; then paseo hub init`, because the connection alone does not produce a bundle; declining only the starter prints `paseo hub init`. `--json` and non-TTY login remain login-only and never prompt. The stored login is separate from the daemon relationship created by `connect`. +`login` opens the Hub approval page and stores a durable organization-scoped CLI credential under `PASEO_HOME`. In an interactive terminal it offers to connect this daemon, then separately asks whether to allow Hub automations to run agents. Connection defaults to yes; execution permission defaults to no. It then links to Hub's **Triggers** page and prints `paseo hub init` for setup as code. `--json` and non-TTY login remain login-only and never prompt. The stored login is separate from the daemon relationship created by `connect`. -`init` runs the same guided setup on its own and requires a TTY. It connects the daemon, uses the organization's only project or asks which one, and lists the Hub app connections that can back a starter workflow. One usable connection is selected automatically; with several, you choose a **Trigger connection**. If none is ready, setup sends you to **Hub → Apps** and stops before selecting an agent or writing files. +`init` requires a TTY. It signs in and connects the daemon as needed, then lists the organization's app connections that can back a starter trigger. One usable connection is selected automatically; with several, you choose a **Trigger connection**. If none is ready, setup sends you to **Hub → Apps** and stops before selecting an agent or writing files. -Setup then asks which agent provider, model, and mode the starter should run, choosing from what the connected daemon reports. A provider is offered only when the daemon has it enabled with a selectable model. Suggested model and mode entries are the daemon's defaults; no provider is suggested merely because it appears first. The mode question is skipped for providers that expose no modes and asked explicitly when the daemon has modes but no default. Finally, setup asks for the identity that gates the chosen connection: a GitHub username, a Slack member ID, or a Discord user ID. It writes `.paseo/hub.yml` and `.paseo/workflows/-help.yml`, validates them against Hub, and deploys. An existing `.paseo/` directory is replaced only after you confirm. See the [generated starter bundle](/docs/hub/configuration#generated-starter-bundle). +Setup asks which agent provider, model, and mode to run. Providers must be enabled and expose both a selectable model and an execution mode. Suggested model and mode entries are the daemon's defaults; a mode is still selected explicitly when there is no default. Setup then asks for the identity allowed to trigger the bot: a GitHub username, Slack member ID, or Discord user ID. It validates the trigger, writes `.paseo/triggers/-help.yml`, and asks whether to deploy. Replacing that file requires confirmation; existing legacy bundles and other trigger files are preserved. See the [generated starter trigger](/docs/hub/configuration#generated-starter-trigger). Interactive logout checks the same-origin daemon relationship and asks whether to disconnect before deleting the login. Declining removes only the login. JSON and noninteractive logout never prompt or disconnect implicitly; `--disconnect-daemon` is the explicit automation path, and `--force` applies to that daemon disconnection. If a requested disconnection fails, the login is preserved. diff --git a/public-docs/hub/api.md b/public-docs/hub/api.md index cfc0bbc7eb..36de361062 100644 --- a/public-docs/hub/api.md +++ b/public-docs/hub/api.md @@ -1,6 +1,6 @@ --- title: Hub public API -description: Use organization credentials to list projects, validate or install configuration, dispatch runs, and enroll daemons. +description: Use organization credentials to install triggers, manage legacy configuration, dispatch runs, and enroll daemons. nav: Public API order: 79 category: Hub @@ -8,7 +8,7 @@ category: Hub # Hub public API -The Hub public API lets automation operate on projects and daemons in one +The Hub public API lets automation operate on triggers, projects, and daemons in one organization. Set the Hub origin in `PASEO_HUB_URL` below, for example `https://hub.example.com`. @@ -37,13 +37,13 @@ organization is not accessible through the key. Each key has one or more selectable scopes: -| Scope | Operation | -| ------------------------ | --------------------------------------------------- | -| `projects:read` | List active projects in the organization. | -| `configuration:validate` | Validate configuration without changing Hub state. | -| `configuration:install` | Replace and activate a project's configuration. | -| `runs:dispatch` | Dispatch a configured manual trigger for a project. | -| `daemons:enroll` | Issue a short-lived daemon enrollment token. | +| Scope | Operation | +| ------------------------ | --------------------------------------------------------------------- | +| `projects:read` | List active projects in the organization. | +| `configuration:validate` | Validate triggers or legacy configuration without changing Hub state. | +| `configuration:install` | Install triggers or replace a legacy project's configuration. | +| `runs:dispatch` | Dispatch a configured manual trigger for a project. | +| `daemons:enroll` | Issue a short-lived daemon enrollment token. | API keys do not grant dashboard access. They cannot manage connections, projects, or organization members. @@ -65,6 +65,34 @@ API failures use RFC 9457 problem details. Missing, invalid, or revoked credenti A valid key without the scope required by an endpoint returns `403` in the same format. +## Trigger validation and installation + +`paseo hub deploy --dry-run` validates each `.paseo/triggers/*.yml` file through `POST /api/v1/triggers/validate`. `paseo hub deploy` validates all files first, then installs each through `POST /api/v1/triggers/install`. + +Both endpoints accept one self-contained document: + +```json +{ + "yaml": "name: manual-task\nenabled: true\non:\n manual.run: {}\nrun:\n target: { daemon: my-macbook, cwd: /workspace }\n agent: { provider: codex, model: gpt-5, mode: full-access }\n prompt: Complete the task and call hub.finish_execution.\n max_runtime: 1h\n idle_timeout: 5m\n" +} +``` + +Use a daemon slug and agent runtime available in your organization. Validation requires `configuration:validate` and returns `200` with `{ "name": "manual-task", "valid": true }`. + +Installation requires `configuration:install`. It creates or updates the organization's trigger by the YAML `name` and returns `201`: + +```json +{ + "triggerId": "00000000-0000-4000-8000-000000000001", + "name": "manual-task", + "revisionId": "00000000-0000-4000-8000-000000000002", + "version": 1, + "active": true +} +``` + +Invalid YAML or an unknown organization resource returns `422` with field issues. Each installation is a separate request; a later failure does not undo earlier successful installs. See [Deploy from the CLI](/docs/hub/configuration#deploy-from-the-cli). + ## Project list `GET /api/v1/projects` returns active projects in the bearer credential's organization. `paseo hub projects` renders the projects as a table. With `--json`, it returns `{ "origin": "...", "projects": [...] }` so even an empty result records the resolved Hub. @@ -81,7 +109,7 @@ A valid key without the scope required by an endpoint returns `403` in the same } ``` -## Configuration validation +## Legacy configuration validation `POST /api/v1/configurations/validate` accepts the same `projectSlug` and complete `files` bundle as configuration install. It performs the same compilation and resource resolution without recording a revision or changing the active configuration. @@ -94,9 +122,9 @@ On success, Hub returns `200`: } ``` -`paseo hub deploy --dry-run` calls this endpoint with the identical locally resolved payload that a deployment would send. +`paseo hub deploy --project --dry-run` calls this endpoint with the identical locally resolved payload that a deployment would send. -## Configuration install +## Legacy configuration install `configuration:install` validates the supplied canonical bundle, stores the exact authored files, and activates a new revision. @@ -157,7 +185,7 @@ curl --fail-with-body -sS -X POST "$PASEO_HUB_URL/api/v1/configurations/install" --data @configuration-install.json ``` -`paseo hub deploy -p ` calls this endpoint with the discovered local bundle. The command uses an exact-origin stored login when flags and environment credentials are absent. See [Deploy from the CLI](/docs/hub/configuration#deploy-from-the-cli). +`paseo hub deploy -p ` selects this legacy endpoint with the discovered local bundle. The command uses an exact-origin stored login when flags and environment credentials are absent. See [Deploy from the CLI](/docs/hub/configuration#deploy-from-the-cli). ## Manual run dispatch diff --git a/public-docs/hub/configuration/index.md b/public-docs/hub/configuration/index.md index 6df97c29e1..1d82ff27d6 100644 --- a/public-docs/hub/configuration/index.md +++ b/public-docs/hub/configuration/index.md @@ -1,6 +1,6 @@ --- title: Hub configuration -description: Configuration bundles, GitHub sync, CLI deployment, and revisions. +description: Generate, edit, and deploy organization triggers from your repository. nav: Configuration order: 70 category: Hub @@ -8,98 +8,75 @@ category: Hub # Hub configuration -A project configuration is one versioned bundle: +Each organization trigger is one self-contained YAML file. Keep triggers in your repository and deploy them with `paseo hub deploy`: ```text .paseo/ -├── hub.yml -└── workflows/ - ├── .yml - └── partials/ - └── .md +└── triggers/ + └── .yml ``` -`hub.yml` owns named environments and agents. Each direct-child workflow file owns one trigger and its ordered inline steps. Prompt partials referenced by those workflows live below `workflows/partials/`. Workflow discovery is fixed by convention; there is no manifest or include list. +## Generated starter trigger -[single-repo-team-bot](https://github.com/getpaseo/hub/tree/main/examples/single-repo-team-bot) is a complete bundle in this shape: Discord, Slack, and GitHub workflows running a classifier and a worker on shared partials. Copy `.paseo/` into your repository and replace the placeholders its README lists. +Run `paseo hub init` from the repository the agent should work in. Setup selects an app connection and an available agent runtime, asks which user may trigger it, validates the result, and writes one file. It then asks whether to deploy. Interactive `paseo hub login` connects the daemon and points to this command; it does not write trigger files. -## Generated starter bundle - -`paseo hub init`, and the guided setup that interactive `paseo hub login` offers, write two files for the directory you run them in: +For a Slack connection named `my-team`, the generated document looks like this: ```yaml -# .paseo/hub.yml -environments: - my-macbook: - kind: daemon +# .paseo/triggers/slack-help.yml +name: slack-help +enabled: true +on: + slack.mention: + connection: my-team + filters: + from_users: [U01234567] +max_runtime: 2h +run: + target: daemon: my-macbook cwd: /Users/you/code/your-repo -agents: - starter: + agent: provider: codex model: gpt-5 mode: full-access + continuation: + mode: conversation + max_runtime: 90m + idle_timeout: 10m + prompt: | + Answer with hub.reply, then complete this request and call hub.finish_execution when done. + + + ${{ paseo.prompt }} + + outputs: + slack.reply: + max: 1 + required: true ``` -The environment is named after the connected daemon and points at the directory setup ran in. `provider`, `model`, and `mode` are the runtime you chose from what that daemon reported, so the starter agent is a complete selection before its first run. `mode` is omitted for a provider that exposes none. +`connection` is the app connection's slug in your organization. `target.daemon` is the connected daemon's slug and `target.cwd` is the absolute directory where you ran setup. `agent` contains the provider, model, and execution mode you selected from the daemon. The mode is required. -```yaml -# .paseo/workflows/slack-help.yml -name: slack-help -on: slack.mention -max_runtime: 2h -filters: - workspace: T01234567 - from_users: - - U01234567 -steps: - - id: work - environment: my-macbook - max_runtime: 90m - idle_timeout: 10m - agent: starter - prompt: - - text: | - Answer with hub.reply, then complete this request and call hub.finish_execution when done. +`continuation.mode: conversation` keeps follow-ups in the same provider conversation on the same agent. The prompt asks the agent to reply and then call `hub.finish_execution`; replying alone does not finish the execution. - - ${{ paseo.prompt }} - - allow_outputs: - - type: slack.reply - max: 1 - required: true -``` +A Discord starter uses `discord.mention`, your Discord user ID, and `discord.reply`. A GitHub starter uses `github.issue_comment`, restricts the repository to the current GitHub remote, and requires both `@paseo` and your GitHub username. GitHub's starter has no explicit reply output declaration. -`filters` carries the identity each provider matches on: the Slack team and member ID above, the Discord guild and user ID for a Discord starter, and `owner/name` plus your login for GitHub. - -A Discord starter is `discord-help.yml` and carries `discord.reply`, the counterpart of the `slack.reply` above. A GitHub starter is `github-help.yml` and declares no reply output; GitHub has no reply capability, so a step that must comment needs a [`github` block](/docs/hub/github) instead. - -The generated workflow allows one user in one workspace. Read [Hub security](/docs/hub/security) before widening it. - -## Sources - -A configuration comes from one source: - -- **GitHub source**: the complete `.paseo` bundle on the repository's default branch. -- **Manual source**: source files edited and activated in the dashboard. -- **CLI/API install**: a complete bundle sent with organization authority. - -The **Configuration** tab shows the active revision, source files, and latest synchronization attempt. +Setup asks before replacing the selected trigger file. It preserves other triggers and any existing legacy bundle. Read [Hub security](/docs/hub/security) before widening `from_users` or the agent's authority. ## Deploy from the CLI -Run from the project root: +Run from the repository root: ```sh paseo hub login https://hub.example.com -paseo hub deploy -p my-project --dry-run -paseo hub deploy -p my-project +paseo hub deploy --dry-run +paseo hub deploy ``` -Both commands discover `.paseo/hub.yml`, every direct `.paseo/workflows/*.yml` file, and each referenced file below `.paseo/workflows/partials/`. Files are sent in deterministic path order through the same bundle request. Dry-run calls server-side validation and does not create or activate a revision. +Both deploy commands discover direct `.paseo/triggers/*.yml` files in deterministic path order. The CLI rejects nested files, `.yaml` extensions, symlinked trigger paths, and unreadable files. It does not search parent directories. -The CLI rejects missing resource or workflow files, `.yaml` workflow extensions, nested workflow files, unsafe partial paths, symlinked bundle paths, and unreadable files before contacting Hub. Errors name paths but never print file contents or credentials. +Dry-run validates each document against Hub without storing a revision. Deployment validates all documents first, then installs them one at a time through the organization trigger API. Installation creates or updates a trigger by its YAML `name`. If a later install fails, the error lists the files already installed; those revisions remain active. Errors name paths without printing file contents or credentials. Origin precedence: @@ -116,6 +93,21 @@ Credential precedence: Flags and environment keys are not stored. Endpoint and credential behavior is unchanged between deploy and dry-run. +## Legacy project bundles + +Existing project bundles use `.paseo/hub.yml`, direct `.paseo/workflows/*.yml` files, and referenced files below `.paseo/workflows/partials/`. `hub.yml` owns named environments and agents; each workflow owns its trigger and ordered steps. + +Select the legacy deployment path explicitly: + +```sh +paseo hub deploy --project my-project --dry-run +paseo hub deploy --project my-project +``` + +These commands send the complete bundle through the project configuration API. Dry-run validates without recording or activating a revision. `paseo hub init` does not create or migrate these bundles. + +The following source and revision behavior applies to legacy project bundles. + ## GitHub sync A push to the configuration repository's default branch starts a sync: diff --git a/public-docs/hub/daemons.md b/public-docs/hub/daemons.md index a9cc81267d..a208838266 100644 --- a/public-docs/hub/daemons.md +++ b/public-docs/hub/daemons.md @@ -8,7 +8,7 @@ category: Hub # Daemons in Hub -A daemon is one of your machines running the Paseo daemon. Enroll it once with your Hub organization, then any project can reference it. +A daemon is one of your machines running the Paseo daemon. Enroll it once with your Hub organization, then triggers can reference it. ## Connect @@ -20,7 +20,7 @@ paseo hub login https://hub.example.com The CLI prints a URL and a verification code and opens your browser. The approved login is stored under `PASEO_HOME`. -In an interactive terminal, login then offers to finish setup: whether to connect this daemon, and whether to initialize and deploy a starter workflow. Both default to yes. Declining the connection prints `paseo hub connect ; then paseo hub init`, since connecting alone leaves the project without a workflow. Declining only the starter prints `paseo hub init`. `--json` or non-TTY login only logs in. [Quickstart](/docs/hub/quickstart) walks through the questions. +In an interactive terminal, login offers to connect this daemon and separately asks whether to allow Hub automations to run agents. Connection defaults to yes; execution permission defaults to no. It then links to **Triggers** and prints `paseo hub init` for creating a starter trigger as code. `--json` or non-TTY login only logs in. [Quickstart](/docs/hub/quickstart) walks through connection and initialization. Enroll the daemon on its own when you declined, or when the machine is already logged in: diff --git a/public-docs/hub/index.md b/public-docs/hub/index.md index f7bc1a6ae0..595bffaee5 100644 --- a/public-docs/hub/index.md +++ b/public-docs/hub/index.md @@ -20,7 +20,7 @@ A daemon runs agents on one machine, for you. Paseo Hub is the layer above your What that gives you today: - Agents that start on their own, from activity in GitHub, Slack, and Discord. -- Configuration that lives in a repository and deploys when you push. +- Triggers you can keep in a repository and deploy from the CLI. - A record of everything that arrived, what it matched, and what ran. - One place for your team to see all of it. @@ -28,16 +28,15 @@ Your daemons keep running agents where they always did. Hub decides when to ask ## What lives in your repository -Guided setup creates a project resource file for environments and agents, plus one safe starter workflow: +`paseo hub init` creates one self-contained starter trigger: ```text .paseo/ -├── hub.yml -└── workflows/ +└── triggers/ └── slack-help.yml ``` -Guided setup deploys the bundle, and mentioning the bot starts an agent on your machine. [Quickstart](/docs/hub/quickstart) runs it end to end; the [generated starter bundle](/docs/hub/configuration#generated-starter-bundle) shows what it wrote, and [Workflows](/docs/hub/workflows) covers routing, prompt partials, and provider-specific replies. +The file names the app connection, allowed user, daemon, working directory, agent runtime, prompt, and outputs. Setup validates it and asks whether to deploy. Mentioning the bot then starts an agent on your machine. [Quickstart](/docs/hub/quickstart) runs it end to end; the [generated starter trigger](/docs/hub/configuration#generated-starter-trigger) shows what setup wrote. ## Reading order @@ -56,4 +55,4 @@ If a workflow accepts requests from GitHub, Slack, Discord, or the API, read [Hu Start on your machine with the embedded database, then add PostgreSQL or a public deployment only when you need them. [Self-hosting](/docs/hub/self-hosting) covers each step. -[Hosted Hub](/docs/hub/hosted) uses the same projects, workflows, daemons, and activity model. [Sign in to start a free trial](https://hub.paseo.sh). +[Hosted Hub](/docs/hub/hosted) uses the same triggers, daemons, and activity model. [Sign in to start a free trial](https://hub.paseo.sh). diff --git a/public-docs/hub/quickstart.md b/public-docs/hub/quickstart.md index 1f9031c1c0..a0d1f4c991 100644 --- a/public-docs/hub/quickstart.md +++ b/public-docs/hub/quickstart.md @@ -8,7 +8,7 @@ category: Hub # Hub quickstart -Run Hub on your machine, connect it to Slack without a public server, and answer a mention with an agent in your repository. Hub's browser setup hands off to your terminal, and guided setup writes and deploys the workflow for you. +Run Hub on your machine, connect it to Slack without a public server, and answer a mention with an agent in your repository. Hub's browser setup hands off to your terminal, and `paseo hub init` writes and deploys a starter trigger for you. You need [Paseo installed and running](/docs), Node.js, and a Slack workspace where you can create an app. @@ -20,7 +20,7 @@ npx @getpaseo/hub Open the address it prints, normally , and create the operator account Hub asks for. -The first run needs no database, Docker, environment variables, or API keys. Hub creates an embedded database, your organization, and a **Default** project. You never create a project by hand. +The first run needs no database, Docker, environment variables, or API keys. Hub creates an embedded database and your organization. ## 2. Connect Slack @@ -42,37 +42,39 @@ GitHub and Discord can wait. Their setup stays available under **Apps**. paseo hub login http://localhost:3000 ``` -Run it on the machine where your code lives, in the repository the agent should work in. Guided setup records that directory as the workflow's working directory. +Run it on the machine where your code lives, in the repository the agent should work in. Run the initializer below from that directory so it becomes the trigger's working directory. -Approve the login in the browser tab that opens. Leave the Hub tab open: it watches for the daemon and shows **Daemon connected** by itself. **Continue** and **Do this later** both land in the Default project. +Approve the login in the browser tab that opens. Leave the Hub tab open: it watches for the daemon and shows **Daemon connected** by itself. Choose **Continue** when the daemon is connected. -## 4. Answer the setup questions +## 4. Create the starter trigger -Your terminal confirms the login, then picks up where the browser left off. Most questions arrive with a default or a suggested answer; only your Slack member ID has to be typed. +After approving login, answer **Yes** to **Connect this daemon to Paseo Hub?** and **Allow Hub automations to run agents on this daemon?**. Execution permission defaults to no, so enable it explicitly for this setup. -| Question | What it wants | -| ----------------------------------------- | --------------------------------------------------------------------------------------- | -| Connect this daemon to this Hub? | Yes. This enrolls the machine you are on. | -| Initialize and deploy a starter workflow? | Yes. | -| Starter agent provider, model, and mode | What your daemon reports it can run. Suggested model and mode entries are its defaults. | -| Your Slack member ID | `U01234567`, the only account allowed to trigger the bot. | +Then run: -Setup lists the app connections ready for this workflow. Because you connected one Slack workspace in step 2, it selects that connection automatically instead of asking you to choose Slack. If several usable connections exist, setup asks for the **Trigger connection**. If none is ready, it sends you to **Hub → Apps** and stops before asking about the agent or writing files. +```sh +paseo hub init +``` -The agent provider list contains only runtimes the daemon can use; it does not suggest one arbitrarily. Suggested model and mode entries are defaults reported by the daemon. A provider that has modes but no default mode is still offered; setup asks you to pick the mode instead of guessing one. +Choose **Custom endpoint** and confirm `http://localhost:3000`. Setup reuses your login and daemon connection, then lists the app connections ready for this trigger. With one Slack workspace connected, it selects that connection automatically. With several usable connections, choose the **Trigger connection**. If none is ready, setup sends you to **Hub → Apps** and stops before selecting an agent or writing files. -[Find your Slack IDs](/docs/hub/triggers/slack#find-your-slack-ids) has the two clicks that copy your member ID. The Slack workspace comes from the app you connected in step 2, so setup does not ask for it. +| Question | What it wants | +| --------------------------------------- | -------------------------------------------------------------------------------------- | +| Starter agent provider, model, and mode | A runtime available on your daemon. Suggested model and mode entries are its defaults. | +| Your Slack member ID | `U01234567`, the only account allowed to trigger the bot. | +| Deploy now? | Yes, to activate this trigger in Hub. | -Setup then validates the bundle, writes it, and deploys: +Providers must expose a selectable model and execution mode. If there is no default mode, choose the one the agent should use. [Find your Slack IDs](/docs/hub/triggers/slack#find-your-slack-ids) explains how to copy your member ID. The Slack workspace comes from the selected app connection. + +Setup validates the trigger and writes: ```text .paseo/ -├── hub.yml -└── workflows/ +└── triggers/ └── slack-help.yml ``` -If `.paseo/` already exists, setup asks before replacing it. Declining the daemon connection prints `paseo hub connect ; then paseo hub init` — both commands, because connecting alone does not create the workflow. Declining only the starter workflow prints `paseo hub init`. +If `slack-help.yml` already exists, setup asks before replacing that file. Other files remain in place. If you decline deployment, run `paseo hub deploy` from this repository when ready. ## 5. Mention the bot @@ -82,12 +84,12 @@ In the channel you invited the bot to: @Paseo have a look ``` -Hub starts the agent on your daemon and posts its reply in the Slack thread. The terminal prints the project's Activity URL, where the run appears. If nothing runs, [Activity](/docs/hub/activity) tells a filtered mention from one that never matched a workflow. +Hub starts the agent on your daemon and posts its reply in the Slack thread. The terminal links to **Triggers**, where you can manage the trigger and inspect its runs. If nothing runs, [Activity](/docs/hub/activity) tells a filtered mention from one that never matched a trigger. ## Next - [How Hub works](/docs/hub/concepts) — how an event becomes a workflow run on your daemon. -- [Generated starter bundle](/docs/hub/configuration#generated-starter-bundle) — the two files setup wrote, field by field. +- [Generated starter trigger](/docs/hub/configuration#generated-starter-trigger) — the file setup wrote, field by field. - [Workflows](/docs/hub/workflows) — routing, prompts, and provider replies. - [Hub security](/docs/hub/security) — read this before widening `from_users` or giving an agent GitHub authority.