feat(cli): simplify Hub login and export triggers (#4050)

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