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
This commit is contained in:
Mohamed Boudra
2026-09-09 09:15:28 +02:00
committed by GitHub
parent 613cbbe9ef
commit c172076bff
20 changed files with 1110 additions and 655 deletions
+36 -46
View File
@@ -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(
@@ -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 <slug>");
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<string> {
return cwd;
}
async function triggerProject(): Promise<string> {
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<unknown>((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<unknown[]>((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<void>((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<typeof createServer>): Promise<void> {
server.closeAllConnections();
return new Promise<void>((resolve, reject) => {
@@ -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 <slug> to deploy it.",
});
});
});
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-triggers-"));
temporaryDirectories.push(directory);
return directory;
}
@@ -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<HubDeployTrigger[]> {
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<Awaited<ReturnType<typeof lstat>>> {
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 <slug> 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<boolean> {
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<Awaited<ReturnType<typeof lstat>>> {
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"
);
}
+128 -8
View File
@@ -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<Record<string, string | undefined>>;
credentials?: HubCredentialStore;
hub?: Pick<HubHttpClient, "installConfiguration" | "validateConfiguration">;
hub?: Pick<
HubHttpClient,
"installConfiguration" | "validateConfiguration" | "installTrigger" | "validateTrigger"
>;
reporter?: HubReporter;
}
export interface HubDeployCommandDependencies {
env: Readonly<Record<string, string | undefined>>;
credentials: HubCredentialStore;
hub: Pick<HubHttpClient, "installConfiguration" | "validateConfiguration">;
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<HubDeployResult> = {
idField: "versionId",
columns: [
@@ -68,6 +93,28 @@ const validationSchema: OutputSchema<HubDryRunResult> = {
],
};
const triggerResultSchema: OutputSchema<HubTriggerDeployResult> = {
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<HubTriggerDryRunResult> = {
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<HubDeployResult> | SingleResult<HubDryRunResult>> {
): Promise<
| SingleResult<HubDeployResult>
| SingleResult<HubDryRunResult>
| ListResult<HubTriggerDeployResult>
| ListResult<HubTriggerDryRunResult>
> {
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<HubTriggerDeployResult> | ListResult<HubTriggerDryRunResult>> {
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 <slug>", "Target project slug")
.description("Deploy .paseo organization triggers or a legacy project bundle")
.option("-p, --project <slug>", "Deploy a legacy bundle to this project slug")
.option("--hub <origin>", "Paseo Hub origin")
.option("--api-key <secret>", "Organization API key")
.option("--dry-run", "Validate without installing or activating"),
),
).action(
withOutput<HubDeployResult | HubDryRunResult, unknown[]>(async (...args) => {
withOutput<
HubDeployResult | HubDryRunResult | HubTriggerDeployResult | HubTriggerDryRunResult,
unknown[]
>(async (...args) => {
const options = args.at(-2) as HubDeployOptions;
return runHubDeploy(options, {
cwd: dependencies.cwd(),
@@ -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<HubTriggerValidationResult> {
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<HubTriggerInstallationResult> {
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<HubConfigurationResources> {
return requestHub({
origin,
@@ -114,18 +151,6 @@ export class HubHttpClient {
});
}
listSetupResources(origin: string, apiKey: string): Promise<HubSetupResources> {
return requestHub({
origin,
path: "/api/v1/setup-resources",
method: "GET",
apiKey,
successStatus: 200,
schema: setupResourcesSchema,
failureMessage: "Hub guided setup resource listing failed",
});
}
installConfiguration(input: HubConfigurationInput): Promise<HubInstallResult> {
return requestHub({
origin: input.origin,
@@ -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<typeof authorizationSchema>;
export type CliAuthorizationPoll = z.infer<typeof authorizationPollSchema>;
export type HubProject = z.infer<typeof projectSchema>;
export type HubTrigger = z.infer<typeof triggerSchema>;
export type HubTriggerValidationResult = z.infer<typeof triggerValidationResponseSchema>;
export type HubTriggerInstallationResult = z.infer<typeof triggerInstallationResponseSchema>;
export type HubConfigurationResources = z.infer<typeof configurationResourcesSchema>;
export type HubSetupResources = z.infer<typeof setupResourcesSchema>;
export type HubInstallResult = z.infer<typeof installResponseSchema>;
export type HubValidationResult = z.infer<typeof validationResponseSchema>;
+136 -41
View File
@@ -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<ReturnType<HubHttpClient["listSetupResources"]>>;
configurationResources?: Awaited<ReturnType<HubHttpClient["listConfigurationResources"]>>;
} = {},
): 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,
};
},
+46 -116
View File
@@ -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<string, string>][])(
"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<string, { connection?: string; filters: Record<string, unknown> }>;
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<string, { max: number; required: boolean }>;
};
};
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<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-init-"));
directories.push(directory);
return directory;
}
+56 -114
View File
@@ -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<Record<string, string>>;
}
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<Record<string, string>>,
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<user-prompt>\n\${{ paseo.prompt }}\n</user-prompt>\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<user-prompt>\n\${{ paseo.prompt }}\n</user-prompt>\n`,
...(input.reply === undefined
? {}
: { outputs: { [input.reply]: { max: 1, required: true } } }),
},
};
}
+96 -151
View File
@@ -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<HubProject> {
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<HubStarterTriggerConnection[]> {
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<HubStarterAgentRuntime> {
): Promise<HubStarterAgentRuntime & { mode: string }> {
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<HubSetupResources> {
): Promise<HubConfigurationResources> {
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<HubConfigurationResources["daemons"][number]> {
const resources = await environment.hub.listConfigurationResources(
origin,
resolveHubCredential({
options: { origin },
env: environment.env,
credentials: environment.credentials,
origin,
}),
);
const daemon = resources.daemons.find(({ id }) => id === daemonId);
if (daemon === undefined) {
throw new HubCommandError(
"HUB_DAEMON_RESOURCE_MISSING",
"The connected daemon is not available in this Hub organization. Reconnect it and try again.",
);
}
return daemon;
}
async function writeScaffold(
cwd: string,
scaffold: ReturnType<typeof createHubInitScaffold>,
replaceExisting: boolean,
): Promise<void> {
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<boolean> {
async function prepareScaffoldDestination(cwd: string, triggerPath: string): Promise<boolean> {
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<boolean> {
}
}
async function requireSafeScaffoldDirectory(target: string, displayPath: string): Promise<void> {
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
@@ -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",
),
@@ -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 },
})),
];
}