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(); const progress: string[] = []; try { const result = await runHubDeploy( {}, { cwd, env: { PASEO_HUB_URL: hub.origin, PASEO_HUB_API_KEY: "test-operator-secret" }, reporter: { progress: (message) => progress.push(message) }, }, ); 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, origin: hub.origin, }); expect(progress).toEqual([`Deploying studio-api to ${hub.origin}`]); } 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("dry-run sends the identical resolved bundle to validation and never installs", async () => { const cwd = await projectFile( "project: file-project\ntriggers:\n - steps:\n - prompt:\n - include: safety.md\n", ); await writeFile(path.join(cwd, ".paseo", "partials", "safety.md"), "Stay safe."); const hub = await startHub({ status: 200, body: { projectSlug: "flag-project", valid: true }, }); const progress: string[] = []; try { const result = await runHubDeploy( { project: "flag-project", hub: hub.origin, apiKey: "validation-secret", dryRun: true, json: true, }, { cwd, env: {}, reporter: { progress: (message) => progress.push(message) } }, ); expect(await hub.received).toEqual({ method: "POST", url: "/api/v1/configurations/validate", authorization: "Bearer validation-secret", body: JSON.stringify({ projectSlug: "flag-project", yaml: "project: file-project\ntriggers:\n - steps:\n - prompt:\n - include: safety.md\n", partials: [{ path: "safety.md", content: "Stay safe." }], }), }); expect(result.data).toEqual({ projectSlug: "flag-project", valid: true, origin: hub.origin }); expect(hub.requestCount()).toBe(1); expect(progress).toEqual([]); } 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 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("uses the hosted default and requires authority with actionable guidance", async () => { const cwd = await projectFile("project: paseo\n"); const progress: string[] = []; await expect( runHubDeploy( {}, { cwd, env: {}, reporter: { progress: (message) => progress.push(message) } }, ), ).rejects.toMatchObject({ message: "No stored Hub login matches https://hub.paseo.sh. Run `paseo hub login https://hub.paseo.sh`, pass --api-key , or set PASEO_HUB_API_KEY.", }); expect(progress).toEqual(["Deploying paseo to https://hub.paseo.sh"]); await expect( runHubDeploy({ hub: "https://hub.example.com" }, { cwd, env: {} }), ).rejects.toMatchObject({ message: "No stored Hub login matches https://hub.example.com. Run `paseo hub login https://hub.example.com`, pass --api-key , or set PASEO_HUB_API_KEY.", }); }); it.each([ "ftp://hub.example.com", "http://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 HTTPS origin without credentials, path, query, or hash. HTTP is allowed only for localhost, 127.0.0.1, or [::1].", }); }); 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 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 "); expect(help).toContain("--hub "); expect(help).toContain("--api-key "); expect(help).toContain("--dry-run"); expect(help).not.toContain("organization"); }); }); async function temporaryDirectory(): Promise { const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-deploy-")); temporaryDirectories.push(directory); return directory; } async function projectFile(yaml: string): Promise { 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; requestCount(): number; close(): Promise; } async function startHub( configuredResponse: HubResponse = { status: 201, body: { projectSlug: "studio-api", version: 7, versionId: "2de5b143-1c88-42db-8a8d-71ca2af97830", active: true, }, }, ): Promise { let requestCount = 0; let resolveRequest: (request: ReceivedRequest) => void; const received = new Promise((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((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): Promise { return new Promise((resolve, reject) => { server.close((error) => { if (error) { reject(error); return; } resolve(); }); }); }