mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 01:45:00 +00:00
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:
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 } } }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user