Deploy Hub configurations from the CLI (#2955)

* feat(cli): add Hub configuration deploy

Deploy configuration bytes directly to an explicitly targeted Hub project using only caller-supplied origin and organization API key authority.

* refactor(cli): name Hub deploy contracts

* docs(hub): complete deploy CLI guidance

* docs(hub): link deployed API references

* feat(cli): bundle referenced Hub partials

Send exact referenced prompt-partial content alongside YAML so Hub can resolve local includes while inline-only configurations keep the existing request shape.

* fix(cli): match Hub YAML size semantics

Apply the finalized Hub request limit to decoded YAML length and cover multibyte configuration payloads without changing the wire body.
This commit is contained in:
Mohamed Boudra
2026-08-07 09:40:36 +02:00
committed by GitHub
parent 3a6d6db9a5
commit 64058722e1
11 changed files with 1468 additions and 24 deletions
+158
View File
@@ -0,0 +1,158 @@
import { z } from "zod";
import type { HubDeployPartial } from "./deploy-input.js";
import { HubDeployError } from "./error.js";
const installResponseSchema = z
.object({
projectSlug: z.string().min(1),
version: z.number().int().positive(),
versionId: z.string().uuid(),
active: z.boolean(),
})
.strict();
const issuePathSchema = z.union([z.string(), z.array(z.union([z.string(), z.number()]))]);
const fieldIssueSchema = z.object({
field: z.string().optional(),
path: issuePathSchema.optional(),
message: z.string(),
});
const problemSchema = z.object({
type: z.string().optional(),
title: z.string().optional(),
status: z.number().int().optional(),
detail: z.string().optional(),
instance: z.string().optional(),
errors: z
.union([z.array(fieldIssueSchema), z.record(z.string(), z.array(z.string()))])
.optional(),
issues: z.array(fieldIssueSchema).optional(),
});
export type HubInstallResult = z.infer<typeof installResponseSchema>;
interface InstallHubConfigurationInput {
origin: string;
apiKey: string;
projectSlug: string;
yaml: string;
partials?: readonly HubDeployPartial[];
}
export async function installHubConfiguration(
input: InstallHubConfigurationInput,
): Promise<HubInstallResult> {
let response: Response;
try {
response = await fetch(`${input.origin}/api/v1/configurations/install`, {
method: "POST",
headers: {
authorization: `Bearer ${input.apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify({
projectSlug: input.projectSlug,
yaml: input.yaml,
...(input.partials === undefined || input.partials.length === 0
? {}
: { partials: input.partials }),
}),
});
} catch {
throw new HubDeployError(
"HUB_NETWORK_ERROR",
`Could not reach Paseo Hub at ${input.origin}. Check the Hub URL and network connection.`,
);
}
if (response.status !== 201) {
throw await deploymentFailure(response, input.apiKey);
}
try {
return installResponseSchema.parse(await response.json());
} catch {
throw new HubDeployError(
"HUB_INVALID_RESPONSE",
"Hub returned a malformed deployment response.",
);
}
}
async function deploymentFailure(response: Response, apiKey: string): Promise<HubDeployError> {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/problem+json")) {
return new HubDeployError(
"HUB_REQUEST_FAILED",
`Hub deployment failed with HTTP ${response.status}.`,
);
}
let body: unknown;
try {
body = await response.json();
} catch {
return new HubDeployError(
"HUB_INVALID_RESPONSE",
`Hub returned malformed problem details for HTTP ${response.status}.`,
);
}
const parsed = problemSchema.safeParse(body);
if (
!parsed.success ||
(parsed.data.status !== undefined && parsed.data.status !== response.status)
) {
return new HubDeployError(
"HUB_INVALID_RESPONSE",
`Hub returned nonconforming problem details for HTTP ${response.status}.`,
);
}
const title = parsed.data.title ?? `Hub deployment failed with HTTP ${response.status}`;
const detail = parsed.data.detail;
const message = detail === undefined ? title : `${title}: ${detail}`;
const details = formatFieldIssues(parsed.data.errors, parsed.data.issues);
const code = response.status === 422 ? "HUB_VALIDATION_FAILED" : "HUB_REQUEST_FAILED";
return new HubDeployError(
code,
redactSecret(message, apiKey),
details === undefined ? undefined : redactSecret(details, apiKey),
);
}
function formatFieldIssues(
errors: z.infer<typeof problemSchema>["errors"],
issues: z.infer<typeof problemSchema>["issues"],
): string | undefined {
const fieldIssues = Array.isArray(errors) ? errors : issues;
if (fieldIssues !== undefined) {
const lines = fieldIssues.map((issue) => {
const field = issue.field ?? formatIssuePath(issue.path);
return field === undefined ? issue.message : `${field}: ${issue.message}`;
});
return lines.length === 0 ? undefined : lines.join("\n");
}
if (errors === undefined) return undefined;
const lines = Object.entries(errors).flatMap(([field, messages]) =>
messages.map((message: string) => `${field}: ${message}`),
);
return lines.length === 0 ? undefined : lines.join("\n");
}
function formatIssuePath(path: z.infer<typeof issuePathSchema> | undefined): string | undefined {
if (path === undefined || typeof path === "string") return path;
let formatted = "";
for (const segment of path) {
if (typeof segment === "number") {
formatted += `[${segment}]`;
} else {
formatted += formatted.length === 0 ? segment : `.${segment}`;
}
}
return formatted || undefined;
}
function redactSecret(value: string, secret: string): string {
return value.split(secret).join("[redacted]");
}
@@ -0,0 +1,397 @@
import { lstat, readFile } from "node:fs/promises";
import path from "node:path";
import YAML from "yaml";
import { HubDeployError } from "./error.js";
const DEFAULT_CONFIGURATION_PATH = ".paseo/hub.yml";
const PROMPT_PARTIAL_ROOT = ".paseo/partials";
const PROMPT_PARTIAL_ROOT_PREFIX = `${PROMPT_PARTIAL_ROOT}/`;
const PROJECT_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
const MAX_CONFIGURATION_LENGTH = 1_000_000;
const MAX_PROMPT_PARTIAL_COUNT = 100;
const MAX_PROMPT_PARTIAL_PATH_LENGTH = 512;
const MAX_PROMPT_PARTIAL_CONTENT_BYTES = 1_000_000;
const MAX_PROMPT_PARTIAL_BUNDLE_BYTES = 5_000_000;
export interface HubDeployPartial {
path: string;
content: string;
}
export interface HubDeployInput {
projectSlug: string;
yaml: string;
partials?: readonly HubDeployPartial[];
}
interface ResolveHubDeployInput {
cwd: string;
file?: string;
project?: string;
}
export async function resolveHubDeployInput(input: ResolveHubDeployInput): Promise<HubDeployInput> {
const file = input.file ?? DEFAULT_CONFIGURATION_PATH;
const projectRoot = path.resolve(input.cwd);
const configurationPath = resolveConfigurationPath(projectRoot, file);
const yaml = await readConfiguration(projectRoot, configurationPath, file);
const configuration = parseConfiguration(yaml);
const projectSlug = input.project ?? projectFromConfiguration(configuration);
if (projectSlug === undefined) {
throw new HubDeployError(
"HUB_PROJECT_REQUIRED",
"Project is required. Pass --project <slug> or add top-level project to the YAML.",
);
}
if (!PROJECT_SLUG_PATTERN.test(projectSlug)) {
throw new HubDeployError(
"HUB_INVALID_PROJECT",
"Project must be a bare slug such as my-project.",
);
}
const partials = await resolvePromptPartials(projectRoot, configuration);
return {
projectSlug,
yaml,
...(partials.length === 0 ? {} : { partials }),
};
}
function resolveConfigurationPath(cwd: string, file: string): string {
if (file.length === 0 || file.includes("\0")) {
throw invalidConfigurationPath();
}
const segments = file.replaceAll("\\", "/").split("/");
if (segments.some((segment) => segment === "..")) {
throw invalidConfigurationPath();
}
const resolved = path.resolve(cwd, file);
const relative = path.relative(cwd, resolved);
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw invalidConfigurationPath();
}
return resolved;
}
async function readConfiguration(
projectRoot: string,
configurationPath: string,
displayPath: string,
): Promise<string> {
const unsafePath = () =>
new HubDeployError(
"HUB_CONFIGURATION_UNSAFE_PATH",
`Hub configuration at ${displayPath} must not use a symlink.`,
);
await rejectSymlinkComponents(projectRoot, configurationPath, unsafePath);
let stats: Awaited<ReturnType<typeof lstat>>;
try {
stats = await lstat(configurationPath);
} catch (error) {
throw configurationReadError(displayPath, error);
}
if (stats.isSymbolicLink()) {
throw unsafePath();
}
if (!stats.isFile()) {
throw new HubDeployError(
"HUB_CONFIGURATION_NOT_FILE",
`Hub configuration at ${displayPath} must be a regular file.`,
);
}
if (!hasReadPermission(stats.mode)) {
throw new HubDeployError(
"HUB_CONFIGURATION_UNREADABLE",
`Could not read Hub configuration at ${displayPath}. Check file permissions.`,
);
}
let bytes: Buffer;
try {
bytes = await readFile(configurationPath);
} catch (error) {
throw configurationReadError(displayPath, error);
}
const yaml = bytes.toString("utf8");
if (yaml.length > MAX_CONFIGURATION_LENGTH) {
throw new HubDeployError(
"HUB_CONFIGURATION_TOO_LARGE",
`Hub configuration at ${displayPath} exceeds the ${MAX_CONFIGURATION_LENGTH}-character limit.`,
);
}
return yaml;
}
function parseConfiguration(yaml: string): Record<string, unknown> {
let configuration: unknown;
try {
configuration = YAML.parse(yaml);
} catch {
throw new HubDeployError("HUB_INVALID_CONFIGURATION", "Hub configuration is not valid YAML.");
}
if (!isRecord(configuration)) {
throw new HubDeployError(
"HUB_INVALID_CONFIGURATION",
"Hub configuration must be a YAML mapping.",
);
}
return configuration;
}
function projectFromConfiguration(configuration: Record<string, unknown>): string | undefined {
const project: unknown = configuration["project"];
if (project === undefined) return undefined;
if (typeof project !== "string") {
throw new HubDeployError(
"HUB_INVALID_PROJECT",
"Top-level project must be a bare project slug.",
);
}
return project;
}
async function resolvePromptPartials(
projectRoot: string,
configuration: Record<string, unknown>,
): Promise<HubDeployPartial[]> {
const references = collectPromptPartialReferences(configuration);
if (references.length > MAX_PROMPT_PARTIAL_COUNT) {
throw new HubDeployError(
"HUB_PARTIAL_LIMIT_EXCEEDED",
`Hub configuration references ${references.length} partials; the limit is ${MAX_PROMPT_PARTIAL_COUNT}.`,
);
}
const partials: HubDeployPartial[] = [];
let bundleBytes = 0;
for (const reference of references) {
const partialPath = path.resolve(
projectRoot,
PROMPT_PARTIAL_ROOT,
...reference.path.split("/"),
);
const content = await readPartial(projectRoot, partialPath, reference.path);
const contentBytes = Buffer.byteLength(content, "utf8");
if (contentBytes > MAX_PROMPT_PARTIAL_CONTENT_BYTES) {
throw new HubDeployError(
"HUB_PARTIAL_TOO_LARGE",
`Referenced Hub partial ${reference.path} exceeds the ${MAX_PROMPT_PARTIAL_CONTENT_BYTES}-byte limit.`,
);
}
bundleBytes += contentBytes;
if (bundleBytes > MAX_PROMPT_PARTIAL_BUNDLE_BYTES) {
throw new HubDeployError(
"HUB_PARTIAL_BUNDLE_TOO_LARGE",
`Referenced Hub partials exceed the ${MAX_PROMPT_PARTIAL_BUNDLE_BYTES}-byte combined limit.`,
);
}
partials.push({ path: reference.path, content });
}
return partials;
}
interface PromptPartialReference {
path: string;
}
function collectPromptPartialReferences(
configuration: Record<string, unknown>,
): PromptPartialReference[] {
const references: PromptPartialReference[] = [];
const seen = new Set<string>();
const triggers = configuration["triggers"];
if (!Array.isArray(triggers)) return references;
for (const trigger of triggers) {
if (!isRecord(trigger) || !Array.isArray(trigger["steps"])) continue;
for (const step of trigger["steps"]) {
if (!isRecord(step) || !Array.isArray(step["prompt"])) continue;
for (const block of step["prompt"]) {
if (!isRecord(block) || !Object.hasOwn(block, "include")) continue;
const include = block["include"];
if (typeof include !== "string") {
throw new HubDeployError(
"HUB_PARTIAL_PATH_INVALID",
"Hub partial include path must be a string.",
);
}
const normalizedPath = normalizePromptPartialPath(include);
if (seen.has(normalizedPath)) {
throw new HubDeployError(
"HUB_PARTIAL_DUPLICATE",
`Hub partial ${normalizedPath} is referenced more than once. Remove the duplicate include.`,
);
}
seen.add(normalizedPath);
references.push({ path: normalizedPath });
}
}
}
return references;
}
function normalizePromptPartialPath(value: string): string {
const decoded = decodePromptPartialPath(value);
if (decoded.length === 0) throw invalidPromptPartialPath(value);
if (/^(?:[a-zA-Z]:[\\/]|[\\/]{1,2})/u.test(decoded)) {
throw invalidPromptPartialPath(value);
}
const segments = decoded.replaceAll("\\", "/").split("/");
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
throw invalidPromptPartialPath(value);
}
const canonical = `${PROMPT_PARTIAL_ROOT}/${segments.join("/")}`;
if (!canonical.startsWith(PROMPT_PARTIAL_ROOT_PREFIX)) {
throw invalidPromptPartialPath(value);
}
if (canonical.length > MAX_PROMPT_PARTIAL_PATH_LENGTH) {
throw new HubDeployError(
"HUB_PARTIAL_PATH_TOO_LONG",
`Hub partial path ${value} exceeds the ${MAX_PROMPT_PARTIAL_PATH_LENGTH}-character limit.`,
);
}
return canonical.slice(PROMPT_PARTIAL_ROOT_PREFIX.length);
}
function decodePromptPartialPath(value: string): string {
let decoded = value;
for (let attempt = 0; attempt < 8; attempt += 1) {
let next: string;
try {
next = decodeURIComponent(decoded);
} catch {
throw invalidPromptPartialPath(value);
}
if (next === decoded) break;
decoded = next;
}
if (decoded.includes("\0") || /%[0-9a-f]{2}/iu.test(decoded)) {
throw invalidPromptPartialPath(value);
}
return decoded;
}
async function readPartial(
projectRoot: string,
partialPath: string,
displayPath: string,
): Promise<string> {
await rejectSymlinkComponents(
projectRoot,
partialPath,
() =>
new HubDeployError(
"HUB_PARTIAL_UNSAFE_PATH",
`Referenced Hub partial ${displayPath} must not use a symlink.`,
),
);
let stats: Awaited<ReturnType<typeof lstat>>;
try {
stats = await lstat(partialPath);
} catch (error) {
if (errorCode(error) === "ENOENT") {
throw new HubDeployError(
"HUB_PARTIAL_MISSING",
`Referenced Hub partial ${displayPath} does not exist.`,
);
}
throw new HubDeployError(
"HUB_PARTIAL_UNREADABLE",
`Could not read referenced Hub partial ${displayPath}. Check the file and permissions.`,
);
}
if (stats.isSymbolicLink()) {
throw new HubDeployError(
"HUB_PARTIAL_UNSAFE_PATH",
`Referenced Hub partial ${displayPath} must not be a symlink.`,
);
}
if (!stats.isFile()) {
throw new HubDeployError(
"HUB_PARTIAL_NOT_FILE",
`Referenced Hub partial ${displayPath} must be a regular file.`,
);
}
if (!hasReadPermission(stats.mode)) {
throw new HubDeployError(
"HUB_PARTIAL_UNREADABLE",
`Could not read referenced Hub partial ${displayPath}. Check the file and permissions.`,
);
}
try {
return (await readFile(partialPath)).toString("utf8");
} catch {
throw new HubDeployError(
"HUB_PARTIAL_UNREADABLE",
`Could not read referenced Hub partial ${displayPath}. Check the file and permissions.`,
);
}
}
async function rejectSymlinkComponents(
root: string,
target: string,
error: () => HubDeployError,
): Promise<void> {
const relative = path.relative(root, target);
let current = root;
for (const segment of relative.split(path.sep).filter(Boolean)) {
current = path.join(current, segment);
try {
if ((await lstat(current)).isSymbolicLink()) throw error();
} catch (failure) {
if (failure instanceof HubDeployError) throw failure;
if (errorCode(failure) === "ENOENT") return;
throw error();
}
}
}
function configurationReadError(displayPath: string, error: unknown): HubDeployError {
if (errorCode(error) === "ENOENT") {
return new HubDeployError(
"HUB_CONFIGURATION_UNREADABLE",
`Could not read Hub configuration at ${displayPath}. Pass an existing YAML file.`,
);
}
return new HubDeployError(
"HUB_CONFIGURATION_UNREADABLE",
`Could not read Hub configuration at ${displayPath}. Check the file and permissions.`,
);
}
function invalidConfigurationPath(): HubDeployError {
return new HubDeployError(
"HUB_CONFIGURATION_PATH_INVALID",
"Hub configuration path must stay within the current project root; parent-directory paths are not allowed.",
);
}
function invalidPromptPartialPath(value: string): HubDeployError {
return new HubDeployError(
"HUB_PARTIAL_PATH_INVALID",
`Hub partial path must be a safe relative path under .paseo/partials/: ${value}`,
);
}
function hasReadPermission(mode: number): boolean {
return (mode & 0o444) !== 0;
}
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 isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,707 @@
import { createServer } from "node:http";
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { renderError, toCommandError } from "../../output/index.js";
import { createHubCommand } from "./index.js";
import { runHubDeploy } from "./deploy.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
describe("hub deploy", () => {
it("deploys the default file with its project metadata and exact YAML through env credentials", async () => {
const cwd = await temporaryDirectory();
const configurationDirectory = path.join(cwd, ".paseo");
const yaml = "project: studio-api\r\nenvironments: []\r\ntriggers: []\r\n";
await mkdir(configurationDirectory);
await writeFile(path.join(configurationDirectory, "hub.yml"), yaml);
const hub = await startHub();
try {
const result = await runHubDeploy(
{},
{
cwd,
env: { PASEO_HUB_URL: hub.origin, PASEO_HUB_API_KEY: "test-operator-secret" },
},
);
expect(await hub.received).toEqual({
method: "POST",
url: "/api/v1/configurations/install",
authorization: "Bearer test-operator-secret",
body: JSON.stringify({ projectSlug: "studio-api", yaml }),
});
expect(result.data).toEqual({
projectSlug: "studio-api",
version: 7,
versionId: "2de5b143-1c88-42db-8a8d-71ca2af97830",
active: true,
});
} finally {
await hub.close();
}
});
it("uses an explicit file and lets -p override project metadata without rewriting YAML", async () => {
const cwd = await temporaryDirectory();
const yaml = "project: file-project\nenvironments: []\ntriggers: []\n";
await writeFile(path.join(cwd, "production.yml"), yaml);
const hub = await startHub();
try {
await runHubDeploy(
{
file: "production.yml",
project: "flag-project",
hub: hub.origin,
apiKey: "flag-secret",
},
{ cwd, env: {} },
);
expect(await hub.received).toMatchObject({
authorization: "Bearer flag-secret",
body: JSON.stringify({ projectSlug: "flag-project", yaml }),
});
} finally {
await hub.close();
}
});
it("deploys mixed inline and partial prompts with only the referenced partial files", async () => {
const cwd = await temporaryDirectory();
const configurationDirectory = path.join(cwd, ".paseo");
const partialDirectory = path.join(configurationDirectory, "partials", "docs");
const yaml = [
"project: studio-api",
"triggers:",
" - steps:",
" - prompt:",
" - text: Inline request",
" - include: docs/safety.md",
" - include: docs/release.md",
"unknownNestedInclude: ignored.md",
"",
].join("\n");
const safety = "Follow the safety checklist.\n{ include: nested.md }";
const release = "Use the release checklist.";
await mkdir(partialDirectory, { recursive: true });
await writeFile(path.join(configurationDirectory, "hub.yml"), yaml);
await writeFile(path.join(partialDirectory, "safety.md"), safety);
await writeFile(path.join(partialDirectory, "release.md"), release);
const hub = await startHub();
try {
await runHubDeploy(
{},
{ cwd, env: { PASEO_HUB_URL: hub.origin, PASEO_HUB_API_KEY: "bundle-secret" } },
);
expect(await hub.received).toEqual({
method: "POST",
url: "/api/v1/configurations/install",
authorization: "Bearer bundle-secret",
body: JSON.stringify({
projectSlug: "studio-api",
yaml,
partials: [
{ path: "docs/safety.md", content: safety },
{ path: "docs/release.md", content: release },
],
}),
});
} finally {
await hub.close();
}
});
it("anchors explicit configuration files and their partial bundle at the current project root", async () => {
const cwd = await temporaryDirectory();
const configurationDirectory = path.join(cwd, "configs");
const partialDirectory = path.join(cwd, ".paseo", "partials");
const yaml =
"project: explicit-project\ntriggers:\n - steps:\n - prompt:\n - include: instructions.md\n";
const partial = "Explicit project-root partial";
await mkdir(configurationDirectory, { recursive: true });
await mkdir(partialDirectory, { recursive: true });
await writeFile(path.join(configurationDirectory, "production.yml"), yaml);
await writeFile(path.join(partialDirectory, "instructions.md"), partial);
const hub = await startHub();
try {
await runHubDeploy(
{ file: "configs/production.yml", hub: hub.origin, apiKey: "explicit-secret" },
{ cwd, env: {} },
);
expect(await hub.received).toMatchObject({
body: JSON.stringify({
projectSlug: "explicit-project",
yaml,
partials: [{ path: "instructions.md", content: partial }],
}),
});
} finally {
await hub.close();
}
});
it("does not search parent directories or alternate default filenames", async () => {
const parent = await temporaryDirectory();
const cwd = path.join(parent, "child");
await mkdir(path.join(parent, ".paseo"));
await mkdir(path.join(cwd, ".paseo"), { recursive: true });
await writeFile(path.join(parent, ".paseo", "hub.yml"), "project: parent-project\n");
await writeFile(path.join(cwd, ".paseo", "hub.yaml"), "project: alternate-project\n");
await expect(
runHubDeploy({ hub: "https://hub.example.com", apiKey: "unused-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_CONFIGURATION_UNREADABLE",
message: "Could not read Hub configuration at .paseo/hub.yml. Pass an existing YAML file.",
});
});
it("requires an explicit project when the YAML omits project metadata", async () => {
const cwd = await temporaryDirectory();
await writeFile(path.join(cwd, "hub.yml"), "environments: []\ntriggers: []\n");
await expect(
runHubDeploy(
{ file: "hub.yml", hub: "https://hub.example.com", apiKey: "unused-secret" },
{ cwd, env: {} },
),
).rejects.toMatchObject({
message: "Project is required. Pass --project <slug> or add top-level project to the YAML.",
});
});
it("fails locally for a missing partial without sending a request", async () => {
const cwd = await projectFile(
"project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: missing.md\n",
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "missing-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_MISSING",
message: "Referenced Hub partial missing.md does not exist.",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it.each([
"../secret.md",
"%2e%2e/secret.md",
"/etc/passwd",
"\\\\server\\share\\secret.md",
"C:\\secret.md",
])("fails locally for an unsafe partial path %s without sending a request", async (include) => {
const cwd = await projectFile(
`project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: ${JSON.stringify(include)}\n`,
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "unsafe-path-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({ code: "HUB_PARTIAL_PATH_INVALID" });
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when a referenced partial is a directory", async () => {
const cwd = await projectFile(
"project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: docs\n",
);
await mkdir(path.join(cwd, ".paseo", "partials", "docs"), { recursive: true });
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "directory-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_NOT_FILE",
message: "Referenced Hub partial docs must be a regular file.",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when a referenced partial cannot be read", async () => {
const cwd = await projectFile(
"project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: unreadable.md\n",
);
const partial = path.join(cwd, ".paseo", "partials", "unreadable.md");
await mkdir(path.dirname(partial), { recursive: true });
await writeFile(partial, "secret instructions");
await chmod(partial, 0o000);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "unreadable-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_UNREADABLE",
message:
"Could not read referenced Hub partial unreadable.md. Check the file and permissions.",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally for duplicate normalized partial references", async () => {
const cwd = await projectFile(
"project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: docs/safety.md\n - include: docs\\safety.md\n",
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "duplicate-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_DUPLICATE",
message:
"Hub partial docs/safety.md is referenced more than once. Remove the duplicate include.",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when the configuration exceeds the YAML size limit", async () => {
const cwd = await temporaryDirectory();
await mkdir(path.join(cwd, ".paseo"));
await writeFile(
path.join(cwd, ".paseo", "hub.yml"),
`project: studio-api\n${"#".repeat(1_000_000)}`,
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "large-yaml-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_CONFIGURATION_TOO_LARGE",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("uses the Hub YAML string limit rather than its UTF-8 byte length", async () => {
const cwd = await temporaryDirectory();
const yaml = `project: studio-api\n# ${"😀".repeat(400_000)}\n`;
await mkdir(path.join(cwd, ".paseo"));
await writeFile(path.join(cwd, ".paseo", "hub.yml"), yaml);
expect(yaml.length).toBeLessThan(1_000_000);
expect(Buffer.byteLength(yaml, "utf8")).toBeGreaterThan(1_000_000);
const hub = await startHub();
try {
await runHubDeploy(
{},
{ cwd, env: { PASEO_HUB_URL: hub.origin, PASEO_HUB_API_KEY: "unicode-yaml-secret" } },
);
expect(JSON.parse((await hub.received).body)).toEqual({ projectSlug: "studio-api", yaml });
} finally {
await hub.close();
}
});
it("fails locally when a partial exceeds its content size limit", async () => {
const cwd = await projectFile(
"project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: large.md\n",
);
await writeFile(path.join(cwd, ".paseo", "partials", "large.md"), "x".repeat(1_000_001));
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "large-partial-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_TOO_LARGE",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when the referenced partial bundle exceeds its combined size limit", async () => {
const cwd = await projectFile(
[
"project: studio-api",
"triggers:",
" - steps:",
" - prompt:",
...Array.from({ length: 6 }, (_, index) => ` - include: part-${index}.md`),
"",
].join("\n"),
);
await Promise.all(
Array.from({ length: 6 }, (_, index) =>
writeFile(path.join(cwd, ".paseo", "partials", `part-${index}.md`), "x".repeat(900_000)),
),
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "large-bundle-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_BUNDLE_TOO_LARGE",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally for an explicit configuration path outside the project root", async () => {
const cwd = await temporaryDirectory();
const hub = await startHub();
try {
await expect(
runHubDeploy(
{ file: "../hub.yml", hub: hub.origin, apiKey: "traversal-secret" },
{ cwd, env: {} },
),
).rejects.toMatchObject({
code: "HUB_CONFIGURATION_PATH_INVALID",
message:
"Hub configuration path must stay within the current project root; parent-directory paths are not allowed.",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when the configuration is a directory", async () => {
const cwd = await temporaryDirectory();
await mkdir(path.join(cwd, ".paseo", "hub.yml"), { recursive: true });
const hub = await startHub();
try {
await expect(
runHubDeploy(
{ hub: hub.origin, apiKey: "configuration-directory-secret" },
{ cwd, env: {} },
),
).rejects.toMatchObject({
code: "HUB_CONFIGURATION_NOT_FILE",
message: "Hub configuration at .paseo/hub.yml must be a regular file.",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when the configuration cannot be read", async () => {
const cwd = await temporaryDirectory();
await mkdir(path.join(cwd, ".paseo"));
const configuration = path.join(cwd, ".paseo", "hub.yml");
await writeFile(configuration, "project: studio-api\n");
await chmod(configuration, 0o000);
const hub = await startHub();
try {
await expect(
runHubDeploy(
{ hub: hub.origin, apiKey: "configuration-unreadable-secret" },
{ cwd, env: {} },
),
).rejects.toMatchObject({ code: "HUB_CONFIGURATION_UNREADABLE" });
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when the partial count exceeds the bundle limit", async () => {
const cwd = await projectFile(
[
"project: studio-api",
"triggers:",
" - steps:",
" - prompt:",
...Array.from({ length: 101 }, (_, index) => ` - include: part-${index}.md`),
"",
].join("\n"),
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "partial-count-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_LIMIT_EXCEEDED",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when a canonical partial path exceeds its length limit", async () => {
const include = `${"a".repeat(497)}.md`;
const cwd = await projectFile(
`project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: ${JSON.stringify(include)}\n`,
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "partial-path-length-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_PATH_TOO_LONG",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("requires Hub origin and API key with actionable flag and env guidance", async () => {
await expect(runHubDeploy({}, { cwd: "/unused", env: {} })).rejects.toMatchObject({
message: "Hub origin is required. Pass --hub <origin> or set PASEO_HUB_URL.",
});
await expect(
runHubDeploy({ hub: "https://hub.example.com" }, { cwd: "/unused", env: {} }),
).rejects.toMatchObject({
message: "Hub API key is required. Pass --api-key <secret> or set PASEO_HUB_API_KEY.",
});
});
it.each([
"ftp://hub.example.com",
"https://user:password@hub.example.com",
"https://hub.example.com/path",
"https://hub.example.com?debug=true",
"https://hub.example.com#debug",
])("rejects a non-origin Hub URL: %s", async (hub) => {
await expect(
runHubDeploy({ hub, apiKey: "unused-secret" }, { cwd: "/unused", env: {} }),
).rejects.toMatchObject({
message: "Hub URL must be an HTTP or HTTPS origin without credentials, path, query, or hash.",
});
});
it("shows RFC 9457 detail and field issues without raw server extensions", async () => {
const cwd = await projectFile("project: studio-api\n");
const hub = await startHub({
status: 422,
contentType: "application/problem+json",
body: {
type: "https://hub.example.com/problems/configuration-invalid",
title: "Configuration validation failed",
status: 422,
detail: "Fix the invalid configuration fields.",
errors: [{ field: "triggers[0].steps[0].agent.provider", message: "Unknown provider" }],
internalTrace: "do-not-expose-this-trace",
},
});
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "validation-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_VALIDATION_FAILED",
message: "Configuration validation failed: Fix the invalid configuration fields.",
details: "triggers[0].steps[0].agent.provider: Unknown provider",
});
} finally {
await hub.close();
}
});
it("reports malformed successful responses without exposing their body", async () => {
const cwd = await projectFile("project: studio-api\n");
const hub = await startHub({ status: 201, body: { projectSlug: "studio-api", version: "7" } });
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "response-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_INVALID_RESPONSE",
message: "Hub returned a malformed deployment response.",
});
} finally {
await hub.close();
}
});
it("reports network failures honestly", async () => {
const cwd = await projectFile("project: studio-api\n");
const hub = await startHub();
await hub.close();
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "network-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_NETWORK_ERROR",
message: `Could not reach Paseo Hub at ${hub.origin}. Check the Hub URL and network connection.`,
});
});
it("redacts the operator key from problem detail, field issues, and serialized errors", async () => {
const secret = "operator-secret-must-not-leak";
const cwd = await projectFile("project: studio-api\n");
const hub = await startHub({
status: 401,
contentType: "application/problem+json",
body: {
title: `Rejected key ${secret}`,
status: 401,
detail: `Authorization Bearer ${secret} is invalid`,
errors: [{ field: "apiKey", message: secret }],
},
});
try {
const error = await runHubDeploy({ hub: hub.origin, apiKey: secret }, { cwd, env: {} }).catch(
(failure: unknown) => failure,
);
const jsonOutput = renderError(toCommandError(error), { format: "json" });
expect(jsonOutput).not.toContain(secret);
expect(error).toMatchObject({
message: "Rejected key [redacted]: Authorization Bearer [redacted] is invalid",
details: "apiKey: [redacted]",
});
} finally {
await hub.close();
}
});
it("wires deploy with its exact default file and explicit target options", () => {
const deploy = createHubCommand().commands.find((command) => command.name() === "deploy");
const help = deploy?.helpInformation();
expect(deploy?.registeredArguments[0]?.name()).toBe("file");
expect(deploy?.registeredArguments[0]?.defaultValue).toBe(".paseo/hub.yml");
expect(help).toContain("-p, --project <slug>");
expect(help).toContain("--hub <origin>");
expect(help).toContain("--api-key <secret>");
expect(help).not.toContain("organization");
});
});
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-deploy-"));
temporaryDirectories.push(directory);
return directory;
}
async function projectFile(yaml: string): Promise<string> {
const cwd = await temporaryDirectory();
await mkdir(path.join(cwd, ".paseo"));
await mkdir(path.join(cwd, ".paseo", "partials"));
await writeFile(path.join(cwd, ".paseo", "hub.yml"), yaml);
return cwd;
}
interface HubResponse {
status: number;
contentType?: string;
body: unknown;
}
interface ReceivedRequest {
method: string | undefined;
url: string | undefined;
authorization: string | undefined;
body: string;
}
interface TestHub {
origin: string;
received: Promise<ReceivedRequest>;
requestCount(): number;
close(): Promise<void>;
}
async function startHub(
configuredResponse: HubResponse = {
status: 201,
body: {
projectSlug: "studio-api",
version: 7,
versionId: "2de5b143-1c88-42db-8a8d-71ca2af97830",
active: true,
},
},
): Promise<TestHub> {
let requestCount = 0;
let resolveRequest: (request: ReceivedRequest) => void;
const received = new Promise<ReceivedRequest>((resolve) => {
resolveRequest = resolve;
});
const server = createServer((request, response) => {
requestCount += 1;
let body = "";
request.setEncoding("utf8");
request.on("data", (chunk: string) => {
body += chunk;
});
request.on("end", () => {
resolveRequest({
method: request.method,
url: request.url,
authorization: request.headers.authorization,
body,
});
response.writeHead(configuredResponse.status, {
"content-type": configuredResponse.contentType ?? "application/json",
});
response.end(JSON.stringify(configuredResponse.body));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (address === null || typeof address === "string") throw new Error("Test server did not bind");
return {
origin: `http://127.0.0.1:${address.port}`,
received,
requestCount: () => requestCount,
close: () => closeServer(server),
};
}
function closeServer(server: ReturnType<typeof createServer>): Promise<void> {
return new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
+107
View File
@@ -0,0 +1,107 @@
import type { Command } from "commander";
import { withOutput, type OutputSchema, type SingleResult } from "../../output/index.js";
import { addJsonOption } from "../../utils/command-options.js";
import { installHubConfiguration, type HubInstallResult } from "./client.js";
import { resolveHubDeployInput } from "./deploy-input.js";
import { HubDeployError } from "./error.js";
export interface HubDeployOptions {
file?: string;
project?: string;
hub?: string;
apiKey?: string;
}
interface HubDeployEnvironment {
cwd: string;
env: Readonly<Record<string, string | undefined>>;
}
const resultSchema: OutputSchema<HubInstallResult> = {
idField: "versionId",
columns: [
{ header: "PROJECT", field: "projectSlug" },
{ header: "VERSION", field: "version" },
{ header: "VERSION ID", field: "versionId" },
{ header: "ACTIVE", field: "active" },
],
};
export async function runHubDeploy(
options: HubDeployOptions,
environment: HubDeployEnvironment = { cwd: process.cwd(), env: process.env },
): Promise<SingleResult<HubInstallResult>> {
const origin = options.hub ?? environment.env.PASEO_HUB_URL;
const apiKey = options.apiKey ?? environment.env.PASEO_HUB_API_KEY;
if (!origin) {
throw new HubDeployError(
"HUB_ORIGIN_REQUIRED",
"Hub origin is required. Pass --hub <origin> or set PASEO_HUB_URL.",
);
}
if (!apiKey) {
throw new HubDeployError(
"HUB_API_KEY_REQUIRED",
"Hub API key is required. Pass --api-key <secret> or set PASEO_HUB_API_KEY.",
);
}
const normalizedOrigin = parseHubOrigin(origin);
const deployInput = await resolveHubDeployInput({
cwd: environment.cwd,
...(options.file === undefined ? {} : { file: options.file }),
...(options.project === undefined ? {} : { project: options.project }),
});
const deployed = await installHubConfiguration({
origin: normalizedOrigin,
apiKey,
...deployInput,
});
return { type: "single", data: deployed, schema: resultSchema };
}
export function addHubDeployCommand(hub: Command): void {
addJsonOption(
hub
.command("deploy")
.description("Install and activate a Hub configuration")
.argument("[file]", "Hub configuration YAML", ".paseo/hub.yml")
.option("-p, --project <slug>", "Target project slug")
.option("--hub <origin>", "Paseo Hub origin")
.option("--api-key <secret>", "Organization API key"),
).action(
withOutput(async (...args) => {
const file = args[0] as string;
const options = args.at(-2) as HubDeployOptions;
return runHubDeploy({ ...options, file });
}),
);
}
function parseHubOrigin(value: string): string {
let url: URL;
try {
url = new URL(value);
} catch {
throw invalidHubOrigin();
}
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password ||
url.pathname !== "/" ||
url.search ||
url.hash
) {
throw invalidHubOrigin();
}
return url.origin;
}
function invalidHubOrigin(): HubDeployError {
return new HubDeployError(
"HUB_INVALID_ORIGIN",
"Hub URL must be an HTTP or HTTPS origin without credentials, path, query, or hash.",
);
}
+10
View File
@@ -0,0 +1,10 @@
export class HubDeployError extends Error {
constructor(
readonly code: string,
message: string,
readonly details?: string,
) {
super(message);
this.name = "HubDeployError";
}
}
+3 -1
View File
@@ -4,6 +4,7 @@ import { withOutput, type ListResult, type OutputSchema } from "../../output/ind
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { connectToDaemon } from "../../utils/client.js";
import { createDeviceAuthorizationWorkflow } from "./device-authorization.js";
import { addHubDeployCommand } from "./deploy.js";
interface HubCommandClient {
connectHub(url: string, token: string): Promise<{ status: HubStatus }>;
@@ -100,7 +101,7 @@ async function withClient<T>(
export function createHubCommand(
environment: HubCommandEnvironment = productionEnvironment,
): Command {
const hub = new Command("hub").description("Manage this daemon's Paseo Hub relationship");
const hub = new Command("hub").description("Manage Paseo Hub");
addJsonAndDaemonHostOptions(
hub.command("connect").argument("<url>").option("--token <token>"),
).action(
@@ -144,6 +145,7 @@ export function createHubCommand(
});
}),
);
addHubDeployCommand(hub);
return hub;
}
+10 -1
View File
@@ -199,9 +199,18 @@ Use `PASEO_HOME` to run multiple isolated daemon instances.
paseo hub connect <url> # Enroll this daemon with a Paseo Hub
paseo hub status # Show the current Hub relationship
paseo hub disconnect # End it
paseo hub deploy [file] # Install and activate a Hub configuration
```
See [Daemons in Hub](/docs/hub/daemons).
`file` defaults exactly to `.paseo/hub.yml` relative to the current directory. Pass a file to use another path. The CLI does not search parent directories or alternate filenames.
Pass `-p, --project <slug>` to select the project, or add optional top-level `project` metadata to the YAML. The flag wins when both are present. Project is deployment metadata, not workflow configuration, and the YAML is sent unchanged.
Prompt `include` blocks are read from `.paseo/partials/` under the current directory, even when you pass an explicit configuration file. The CLI sends only the files referenced by the main YAML. Nested include-looking text inside a partial is content and is not resolved recursively. Inline-only configurations omit the partial bundle.
Deployment requires an explicit Hub origin and organization API key. `--hub <origin>` overrides `PASEO_HUB_URL`; `--api-key <secret>` overrides `PASEO_HUB_API_KEY`. The key's organization supplies organization scope. Durable Hub login and credential persistence are not implemented.
See [Daemons in Hub](/docs/hub/daemons), [Hub configuration](/docs/hub/configuration), and the [Hub public API](/docs/hub/api).
## Connecting to a remote daemon
+30 -19
View File
@@ -9,9 +9,16 @@ category: Hub
# Hub public API
The Hub public API lets automation operate on projects and daemons in one
organization. Set the Hub origin in `HUB_URL` below, for example
organization. Set the Hub origin in `PASEO_HUB_URL` below, for example
`https://hub.example.com`.
## API reference
- [Interactive API reference](https://hub.paseo.sh/api/reference)
- [OpenAPI 3.1 document](https://hub.paseo.sh/api/openapi.json)
These are the canonical reference endpoints for the hosted Paseo Hub. A self-hosted Hub exposes the same `/api/reference` and `/api/openapi.json` paths on its own origin.
## Authentication
Create an organization API key from the Hub dashboard under **API keys**. Send
@@ -38,17 +45,18 @@ Each key has one or more selectable scopes:
API keys do not grant dashboard access. They cannot manage connections,
projects, or organization members.
Missing, invalid, or revoked credentials return `401`:
API failures use RFC 9457 problem details. Missing, invalid, or revoked credentials return `401` with `application/problem+json`:
```json
{ "error": "unauthorized" }
{
"type": "https://hub.example.com/problems/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Provide a valid organization API key."
}
```
A valid key without the scope required by an endpoint returns `403`:
```json
{ "error": "forbidden" }
```
A valid key without the scope required by an endpoint returns `403` in the same format.
## Configuration install
@@ -57,7 +65,7 @@ supplied YAML after Hub validates and compiles it, then activates the new
revision.
```http
POST /api/configurations/install
POST /api/v1/configurations/install
```
Request body:
@@ -65,12 +73,17 @@ Request body:
```json
{
"projectSlug": "my-project",
"yaml": "environments:\n - name: production\n kind: daemon\n daemon: build-server\n cwd: /workspace\ntriggers:\n - name: deploy\n on: manual.run\n max_runtime: 2h\n filters:\n from_users: [automation]\n steps:\n - id: deploy\n environment: production\n max_runtime: 90m\n idle_timeout: 10m\n agent:\n provider: opencode\n mode: full-access\n prompt:\n - text: Deploy the project"
"yaml": "environments:\n - name: production\n kind: daemon\n daemon: build-server\n cwd: /workspace\ntriggers:\n - name: deploy\n on: manual.run\n max_runtime: 2h\n filters:\n from_users: [automation]\n steps:\n - id: deploy\n environment: production\n max_runtime: 90m\n idle_timeout: 10m\n agent:\n provider: opencode\n mode: full-access\n prompt:\n - text: Deploy the project",
"partials": [
{
"path": "docs/safety.md",
"content": "Follow the safety checklist."
}
]
}
```
The YAML must describe a valid Hub configuration. Replace the example daemon,
working directory, and trigger values with resources in your organization.
The YAML must describe a valid Hub configuration and its string value is limited to 1,000,000 characters. `projectSlug` is deployment metadata and determines the target project; the API key determines its organization. `partials` is optional for inline-only configurations. When the YAML uses prompt `include` blocks, send exactly one entry for each referenced file, with a path relative to `.paseo/partials/` and the file's UTF-8 content. The bundle accepts at most 100 files, each with a canonical path no longer than 512 characters and content no larger than 1,000,000 bytes; combined partial content may not exceed 5,000,000 bytes. Hub rejects missing, extra, duplicate, unsafe, or oversized entries. Replace the example daemon, working directory, and trigger values with resources in your organization.
On success, Hub returns `201`:
@@ -83,21 +96,19 @@ On success, Hub returns `201`:
}
```
Common responses are `400 {"error":"invalid_request"}` for a missing or
malformed body, `404 {"error":"project_not_found"}` for an inactive or
unknown project in the key's organization, and `422` for invalid YAML or an
invalid configuration. An invalid configuration includes the provisional
`versionId` in its response.
Common responses are `400` for a missing or malformed body, `404` for an inactive or unknown project in the key's organization, and `422` for invalid YAML or an invalid configuration. Validation problem details include field issues. A failed install does not replace the active revision.
Example:
```bash
curl --fail-with-body -sS -X POST "$HUB_URL/api/configurations/install" \
-H "Authorization: Bearer $PASEO_API_KEY" \
curl --fail-with-body -sS -X POST "$PASEO_HUB_URL/api/v1/configurations/install" \
-H "Authorization: Bearer $PASEO_HUB_API_KEY" \
-H "Content-Type: application/json" \
--data @configuration-install.json
```
For a local YAML file, `paseo hub deploy [file]` calls this endpoint and preserves the file contents. See [Deploy from the CLI](/docs/hub/configuration#deploy-from-the-cli) for project precedence, flags, environment variables, and the current authentication limits.
## Manual run dispatch
`runs:dispatch` dispatches a configured `manual.run` trigger for a project.
+6 -2
View File
@@ -8,9 +8,11 @@ category: Hub
# `hub.yml` reference
A configuration has `environments` and `triggers`. Execution fields belong to each trigger's `steps`.
A configuration has `environments` and `triggers`. It may also include top-level `project` deployment metadata for `paseo hub deploy`. Execution fields belong to each trigger's `steps`.
```yaml
project: my-project
environments:
- name: development
kind: daemon
@@ -35,6 +37,8 @@ triggers:
- text: ${{ paseo.prompt }}
```
`project` is an optional bare project slug. The deploy CLI uses it to choose the target project when `-p, --project` is absent. The flag takes precedence over this metadata without rewriting the YAML. `project` is not available to triggers, expressions, or agents.
## Environments
| Field | Required | Notes |
@@ -131,7 +135,7 @@ Hub counts an actual capability emission; ordinary assistant text does not satis
## Prompt partials
`include` paths are relative to `.paseo/partials/` and are resolved at the exact GitHub configuration commit. Hub stores the resolved content and SHA-256 hash in the immutable revision. Missing files, unsafe paths, symlinks, submodules, directories, and nested includes are rejected. Manual configurations cannot use repository partials.
`include` paths are relative to `.paseo/partials/`. For GitHub configuration, Hub reads them at the exact configuration commit and stores the resolved content and SHA-256 hash in the immutable revision. For `paseo hub deploy`, the CLI reads the referenced files from the local project root and sends them in the optional `partials` bundle; the bundle path omits the `.paseo/partials/` prefix. Missing files, unsafe paths, symlinks, submodules, directories, duplicate or unexpected bundle entries, and nested includes are rejected. Manual configurations cannot use repository partials.
## Deadlines
+25
View File
@@ -16,11 +16,36 @@ A configuration comes from exactly one source:
- **GitHub source**: one repository, the file `.paseo/hub.yml`, on the repository's current default branch.
- **Manual source**: edited in the dashboard and saved with **Save and activate**.
- **CLI/API install**: YAML sent explicitly with an organization API key.
Pick a GitHub source by choosing a repository and clicking **Use for configuration**. That syncs immediately and enables automatic deployment.
The path and the branch are fixed. There is no setting for either.
## Deploy from the CLI
From a project checkout, add the target project slug as optional deployment metadata:
```yaml
project: my-project
```
Then deploy:
```sh
PASEO_HUB_URL=https://hub.example.com \
PASEO_HUB_API_KEY=paseo_pk_... \
paseo hub deploy
```
The default path is exactly `.paseo/hub.yml` relative to the current directory. The CLI does not search parent directories or alternate filenames. Use `paseo hub deploy path/to/config.yml` for another file. The bundle root remains the current directory, so partials are always read from `.paseo/partials/` under that directory. `-p, --project <slug>` overrides the file's `project` value without changing the YAML sent to Hub.
For each prompt `include`, the CLI sends one `{ path, content }` entry whose path is relative to `.paseo/partials/`. It sends only files referenced by the main YAML; nested include-looking text inside a partial is not scanned. Missing, unsafe, duplicate, unreadable, non-file, or oversized inputs fail locally before the Hub request. A configuration with only inline prompt blocks sends no `partials` field.
Use `--hub <origin>` or `PASEO_HUB_URL` for the Hub origin, and `--api-key <secret>` or `PASEO_HUB_API_KEY` for the organization API key. A flag takes precedence over its environment variable. The key supplies organization scope and needs `configuration:install`. `project` only selects the deployment target; workflows cannot reference it.
Durable Hub login and credential persistence are not implemented. Supply the origin and API key for each deployment through flags or the current process environment.
## Sync
A push to the default branch of the configuration repository triggers a sync:
+15 -1
View File
@@ -39,6 +39,8 @@ Open **Projects → New project**. On its **Configuration** tab, pick a reposito
Add `.paseo/hub.yml` to that repository:
```yaml
project: your-project
environments:
- name: dev
kind: daemon
@@ -67,7 +69,9 @@ triggers:
${{ paseo.prompt }}
```
`daemon` is the normalized slug from step 3. `cwd` is a directory on that machine.
`project` is the project slug from step 4. The deploy CLI reads it as deployment metadata; it does not affect workflow behavior. `daemon` is the normalized slug from step 3. `cwd` is a directory on that machine.
If a prompt uses an `include` block, store the file below `.paseo/partials/`. The deploy CLI bundles only the files referenced by `.paseo/hub.yml`; nested include-looking text inside a partial is not resolved.
## 6. Push
@@ -75,6 +79,16 @@ Push to the default branch. Hub fetches the file at that commit, validates it, a
If the file is invalid, Hub records the failure and keeps the previous revision active.
To deploy the file directly instead, create an organization API key with the `configuration:install` scope, then run:
```sh
export PASEO_HUB_URL=https://your-hub.example.com
export PASEO_HUB_API_KEY=paseo_pk_...
paseo hub deploy
```
The command reads exactly `.paseo/hub.yml` from the current directory. It does not search parent directories. Use `paseo hub deploy path/to/config.yml` for another file. `-p, --project <slug>` overrides the file's `project` metadata. See [Hub configuration](/docs/hub/configuration#deploy-from-the-cli) for every deploy option and the current authentication limits.
## 7. Trigger it
Comment `@paseo have a look at this` on an issue in that repository, from the account you listed in `from_users`.