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
@@ -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();
});
});
}