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:
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export class HubDeployError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly details?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "HubDeployError";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user