Add durable Paseo Hub CLI login (#3022)

* feat(cli): add durable Hub login

Keep organization-scoped human credentials separate from daemon relationship authority. Connect now exchanges explicit or stored CLI authority for a one-time enrollment token, while logout removes human access without implicitly disconnecting automation.

* fix(cli): require TLS for remote Hub credentials

Durable Hub credentials are bearer secrets, so remote origins must not receive them over cleartext HTTP. Keep literal loopback HTTP available for local Hub development.

* fix(cli): make Hub destinations explicit

Resolve optional Hub origins through one shared precedence policy so active logins remain the default context and credentials stay origin-bound. Report the selected destination before human-facing work, keep structured output auditable, and preserve login authority until requested daemon disconnection succeeds.
This commit is contained in:
Mohamed Boudra
2026-08-08 15:27:43 +02:00
committed by GitHub
parent e36842b234
commit b669dad221
40 changed files with 2369 additions and 1032 deletions
@@ -0,0 +1,34 @@
import type { HubCredentialStore } from "./credentials.js";
import { HubCommandError } from "./error.js";
import { normalizeHubOrigin } from "./origin.js";
export interface HubAuthorityOptions {
origin?: string;
apiKey?: string;
}
interface ResolveHubInput {
options: HubAuthorityOptions;
env: Readonly<Record<string, string | undefined>>;
credentials: HubCredentialStore;
}
export const DEFAULT_HUB_ORIGIN = "https://hub.paseo.sh";
export function resolveHubOrigin(input: ResolveHubInput): string {
const configuredOrigin = input.options.origin ?? input.env.PASEO_HUB_URL;
const selectedOrigin =
configuredOrigin ?? input.credentials.active()?.origin ?? DEFAULT_HUB_ORIGIN;
return normalizeHubOrigin(selectedOrigin);
}
export function resolveHubCredential(input: ResolveHubInput & { origin: string }): string {
const explicitCredential = input.options.apiKey ?? input.env.PASEO_HUB_API_KEY;
if (explicitCredential !== undefined) return explicitCredential;
const stored = input.credentials.get(input.origin);
if (stored !== null) return stored.credential;
throw new HubCommandError(
"HUB_API_KEY_REQUIRED",
`No stored Hub login matches ${input.origin}. Run \`paseo hub login ${input.origin}\`, pass --api-key <secret>, or set PASEO_HUB_API_KEY.`,
);
}
@@ -0,0 +1,133 @@
import { strict as assert } from "node:assert";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, it } from "vitest";
import { HubHttpClient } from "./client.js";
const servers: Array<ReturnType<typeof createServer>> = [];
afterEach(async () => {
await Promise.all(servers.splice(0).map(closeServer));
});
describe("Hub HTTP client", () => {
it("uses the paired CLI authorization contract", async () => {
const requests: Array<{ url: string | undefined; body: string }> = [];
const origin = await startServer((url) => {
if (url === "/api/v1/cli-authorizations") {
return {
status: 201,
body: {
deviceCode: "device-code-with-more-than-thirty-two-characters",
userCode: "ABCD-EFGH",
verificationUri: `${origin}/cli-login`,
verificationUriComplete: `${origin}/cli-login?code=ABCD-EFGH`,
expiresAt: "2026-08-08T14:00:00.000Z",
interval: 5,
},
};
}
return {
status: 200,
body: {
status: "authorized",
interval: 5,
credential: "paseo_cli_prefix_durable-secret-value",
organizationId: "organization-1",
},
};
}, requests);
const hub = new HubHttpClient();
const started = await hub.startCliAuthorization(origin);
const polled = await hub.pollCliAuthorization(origin, started.deviceCode, 1_000);
assert.equal(polled.status, "authorized");
assert.deepEqual(requests, [
{ url: "/api/v1/cli-authorizations", body: "{}" },
{
url: "/api/v1/cli-authorizations/poll",
body: JSON.stringify({ deviceCode: "device-code-with-more-than-thirty-two-characters" }),
},
]);
});
it("validates projects and enrollment responses once at the HTTP boundary", async () => {
const requests: Array<{ url: string | undefined; body: string }> = [];
const origin = await startServer((url) => {
if (url === "/api/v1/projects") {
return {
status: 200,
body: {
projects: [
{
id: "a50e05af-4f20-4c8f-8dcc-58e5ea360663",
slug: "paseo",
name: "Paseo",
},
],
},
};
}
return {
status: 201,
body: {
token: "one-time-enrollment-token-with-enough-length",
expiresAt: "2026-08-08T14:00:00.000Z",
},
};
}, requests);
const hub = new HubHttpClient();
const projects = await hub.listProjects(origin, "human-secret");
const token = await hub.issueEnrollmentToken(origin, "human-secret");
assert.equal(projects[0]?.slug, "paseo");
assert.equal(token, "one-time-enrollment-token-with-enough-length");
assert.deepEqual(
requests.map((request) => request.url),
["/api/v1/projects", "/api/v1/daemons/enrollment-tokens"],
);
});
});
interface TestResponse {
status: number;
body: unknown;
}
async function startServer(
responseFor: (url: string | undefined) => TestResponse,
requests: Array<{ url: string | undefined; body: string }>,
): Promise<string> {
const server = createServer((request, response) => {
let body = "";
request.setEncoding("utf8");
request.on("data", (chunk: string) => {
body += chunk;
});
request.on("end", () => {
requests.push({ url: request.url, body });
const configured = responseFor(request.url);
response.writeHead(configured.status, { "content-type": "application/json" });
response.end(JSON.stringify(configured.body));
});
});
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
return `http://127.0.0.1:${address.port}`;
}
function closeServer(server: ReturnType<typeof createServer>): Promise<void> {
server.closeAllConnections();
return new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error !== undefined) {
reject(error);
return;
}
resolve();
});
});
}
+192 -54
View File
@@ -1,16 +1,52 @@
import { z } from "zod";
import type { HubDeployPartial } from "./deploy-input.js";
import { HubDeployError } from "./error.js";
import { HubCommandError } from "./error.js";
const activationUrlSchema = z.url({ protocol: /^https?$/u });
const authorizationSchema = z
.object({
deviceCode: z.string().min(32),
userCode: z.string().min(1),
verificationUri: activationUrlSchema,
verificationUriComplete: activationUrlSchema,
expiresAt: z.string().datetime(),
interval: z.number().int().min(1),
})
.strict();
const authorizationPollSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("pending"), interval: z.number().int().min(1) }).strict(),
z.object({ status: z.literal("slow_down"), interval: z.number().int().min(1) }).strict(),
z
.object({
status: z.literal("authorized"),
interval: z.number().int().min(1),
credential: z.string().min(32),
organizationId: z.string().min(1),
})
.strict(),
z.object({ status: z.literal("denied"), interval: z.number().int().min(1) }).strict(),
z.object({ status: z.literal("expired"), interval: z.number().int().min(1) }).strict(),
z.object({ status: z.literal("disclosed"), interval: z.number().int().min(1) }).strict(),
z.object({ status: z.literal("retry_later") }).strict(),
]);
const projectSchema = z
.object({ id: z.string().uuid(), slug: z.string().min(1), name: z.string().min(1) })
.strict();
const projectsResponseSchema = z.object({ projects: z.array(projectSchema) }).strict();
const installResponseSchema = z
.object({
projectSlug: z.string().min(1),
version: z.number().int().positive(),
versionId: z.string().uuid(),
active: z.boolean(),
active: z.literal(true),
})
.strict();
const validationResponseSchema = z
.object({ projectSlug: z.string().min(1), valid: z.literal(true) })
.strict();
const enrollmentTokenSchema = z
.object({ token: z.string().min(32), expiresAt: z.string().datetime() })
.strict();
const issuePathSchema = z.union([z.string(), z.array(z.union([z.string(), z.number()]))]);
const fieldIssueSchema = z.object({
field: z.string().optional(),
@@ -29,9 +65,13 @@ const problemSchema = z.object({
issues: z.array(fieldIssueSchema).optional(),
});
export type CliAuthorization = z.infer<typeof authorizationSchema>;
export type CliAuthorizationPoll = z.infer<typeof authorizationPollSchema>;
export type HubProject = z.infer<typeof projectSchema>;
export type HubInstallResult = z.infer<typeof installResponseSchema>;
export type HubValidationResult = z.infer<typeof validationResponseSchema>;
interface InstallHubConfigurationInput {
interface HubConfigurationInput {
origin: string;
apiKey: string;
projectSlug: string;
@@ -39,60 +79,164 @@ interface InstallHubConfigurationInput {
partials?: readonly HubDeployPartial[];
}
export async function installHubConfiguration(
input: InstallHubConfigurationInput,
): Promise<HubInstallResult> {
let response: Response;
try {
response = await fetch(`${input.origin}/api/v1/configurations/install`, {
export class HubHttpClient {
async startCliAuthorization(origin: string): Promise<CliAuthorization> {
return this.request({
origin,
path: "/api/v1/cli-authorizations",
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 }),
}),
body: {},
successStatus: 201,
schema: authorizationSchema,
failureMessage: "Hub CLI login could not be started",
});
} 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);
async pollCliAuthorization(
origin: string,
deviceCode: string,
timeoutMilliseconds: number,
): Promise<CliAuthorizationPoll> {
try {
return await this.request({
origin,
path: "/api/v1/cli-authorizations/poll",
method: "POST",
body: { deviceCode },
successStatus: 200,
schema: authorizationPollSchema,
timeoutMilliseconds,
failureMessage: "Hub CLI login polling failed",
});
} catch (error) {
if (error instanceof HubCommandError && error.code === "HUB_NETWORK_ERROR") {
return { status: "retry_later" };
}
throw error;
}
}
try {
return installResponseSchema.parse(await response.json());
} catch {
throw new HubDeployError(
"HUB_INVALID_RESPONSE",
"Hub returned a malformed deployment response.",
);
async listProjects(origin: string, apiKey: string): Promise<HubProject[]> {
const response = await this.request({
origin,
path: "/api/v1/projects",
method: "GET",
apiKey,
successStatus: 200,
schema: projectsResponseSchema,
failureMessage: "Hub project listing failed",
});
return response.projects;
}
installConfiguration(input: HubConfigurationInput): Promise<HubInstallResult> {
return this.request({
origin: input.origin,
path: "/api/v1/configurations/install",
method: "POST",
apiKey: input.apiKey,
body: configurationBody(input),
successStatus: 201,
schema: installResponseSchema,
failureMessage: "Hub deployment failed",
});
}
validateConfiguration(input: HubConfigurationInput): Promise<HubValidationResult> {
return this.request({
origin: input.origin,
path: "/api/v1/configurations/validate",
method: "POST",
apiKey: input.apiKey,
body: configurationBody(input),
successStatus: 200,
schema: validationResponseSchema,
failureMessage: "Hub configuration validation failed",
});
}
async issueEnrollmentToken(origin: string, apiKey: string): Promise<string> {
const response = await this.request({
origin,
path: "/api/v1/daemons/enrollment-tokens",
method: "POST",
apiKey,
successStatus: 201,
schema: enrollmentTokenSchema,
failureMessage: "Hub daemon enrollment authorization failed",
});
return response.token;
}
private async request<T>(input: RequestInput<T>): Promise<T> {
const signal = AbortSignal.timeout(input.timeoutMilliseconds ?? 15_000);
let response: Response;
try {
response = await fetch(`${input.origin}${input.path}`, {
method: input.method,
headers: {
...(input.apiKey === undefined ? {} : { authorization: `Bearer ${input.apiKey}` }),
...(input.body === undefined ? {} : { "content-type": "application/json" }),
},
...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }),
signal,
});
} catch {
throw new HubCommandError(
"HUB_NETWORK_ERROR",
`Could not reach Paseo Hub at ${input.origin}. Check the Hub URL and network connection.`,
);
}
if (response.status !== input.successStatus) {
throw await requestFailure(response, input.failureMessage, input.apiKey);
}
try {
return input.schema.parse(await response.json());
} catch {
throw new HubCommandError("HUB_INVALID_RESPONSE", "Hub returned a malformed response.");
}
}
}
async function deploymentFailure(response: Response, apiKey: string): Promise<HubDeployError> {
interface RequestInput<T> {
origin: string;
path: string;
method: "GET" | "POST";
apiKey?: string;
body?: unknown;
successStatus: number;
schema: z.ZodType<T>;
timeoutMilliseconds?: number;
failureMessage: string;
}
function configurationBody(input: HubConfigurationInput): object {
return {
projectSlug: input.projectSlug,
yaml: input.yaml,
...(input.partials === undefined || input.partials.length === 0
? {}
: { partials: input.partials }),
};
}
async function requestFailure(
response: Response,
failureMessage: string,
apiKey: string | undefined,
): Promise<HubCommandError> {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/problem+json")) {
return new HubDeployError(
return new HubCommandError(
"HUB_REQUEST_FAILED",
`Hub deployment failed with HTTP ${response.status}.`,
`${failureMessage} with HTTP ${response.status}.`,
);
}
let body: unknown;
try {
body = await response.json();
} catch {
return new HubDeployError(
return new HubCommandError(
"HUB_INVALID_RESPONSE",
`Hub returned malformed problem details for HTTP ${response.status}.`,
);
@@ -102,18 +246,16 @@ async function deploymentFailure(response: Response, apiKey: string): Promise<Hu
!parsed.success ||
(parsed.data.status !== undefined && parsed.data.status !== response.status)
) {
return new HubDeployError(
return new HubCommandError(
"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 title = parsed.data.title ?? `${failureMessage} with HTTP ${response.status}`;
const message = parsed.data.detail === undefined ? title : `${title}: ${parsed.data.detail}`;
const details = formatFieldIssues(parsed.data.errors, parsed.data.issues);
const code = response.status === 422 ? "HUB_VALIDATION_FAILED" : "HUB_REQUEST_FAILED";
return new HubDeployError(
return new HubCommandError(
code,
redactSecret(message, apiKey),
details === undefined ? undefined : redactSecret(details, apiKey),
@@ -133,7 +275,6 @@ function formatFieldIssues(
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}`),
);
@@ -144,15 +285,12 @@ function formatIssuePath(path: z.infer<typeof issuePathSchema> | undefined): str
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}`;
}
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]");
function redactSecret(value: string, secret: string | undefined): string {
return secret === undefined ? value : value.split(secret).join("[redacted]");
}
@@ -1,202 +0,0 @@
import assert from "node:assert/strict";
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { describe, it } from "vitest";
import { CloudDeviceAuthorizationClient } from "./cloud-device-authorization.js";
describe("Cloud device authorization", () => {
it("accepts loopback HTTP activation URLs", async () => {
const cloud = await RegistrationCloud.start("loopback-authorization");
try {
const authorization = await new CloudDeviceAuthorizationClient().start(
cloud.origin,
"Studio Mac",
);
assert.equal(authorization.verificationUri, `${cloud.origin}/activate`);
assert.equal(
authorization.verificationUriComplete,
`${cloud.origin}/activate?code=ABCD-EFGH-JKLMN`,
);
} finally {
await cloud.stop();
}
});
it("rejects a non-web activation URL at the Cloud boundary", async () => {
const cloud = await RegistrationCloud.start("non-web-authorization");
try {
await assert.rejects(new CloudDeviceAuthorizationClient().start(cloud.origin, "Studio Mac"), {
name: "ZodError",
});
assert.deepEqual(cloud.receivedPaths, ["/api/device-authorizations/"]);
} finally {
await cloud.stop();
}
});
it("fails when start headers arrive but the response body stalls", async () => {
const cloud = await RegistrationCloud.start("stalled-start-body");
try {
await assert.rejects(
new CloudDeviceAuthorizationClient(100).start(cloud.origin, "Studio Mac"),
{ message: "Cloud registration start timed out" },
);
assert.deepEqual(cloud.receivedPaths, ["/api/device-authorizations/"]);
} finally {
await cloud.stop();
}
});
it("retries when poll headers arrive but the response body stalls", async () => {
const cloud = await RegistrationCloud.start("stalled-poll-body");
try {
const outcome = await new CloudDeviceAuthorizationClient().poll(
cloud.origin,
"device-code-with-more-than-thirty-two-characters",
100,
);
assert.deepEqual(outcome, { status: "retry_later" });
assert.deepEqual(cloud.receivedPaths, ["/api/device-authorizations/poll"]);
} finally {
await cloud.stop();
}
});
it("retries when a poll response body resets after headers arrive", async () => {
const cloud = await RegistrationCloud.start("reset-poll-body");
try {
const outcome = await new CloudDeviceAuthorizationClient().poll(
cloud.origin,
"device-code-with-more-than-thirty-two-characters",
1_000,
);
assert.deepEqual(outcome, { status: "retry_later" });
assert.deepEqual(cloud.receivedPaths, ["/api/device-authorizations/poll"]);
} finally {
await cloud.stop();
}
});
it("rejects a completed malformed poll response", async () => {
const cloud = await RegistrationCloud.start("malformed-poll-body");
try {
await assert.rejects(
new CloudDeviceAuthorizationClient().poll(
cloud.origin,
"device-code-with-more-than-thirty-two-characters",
1_000,
),
{ name: "SyntaxError" },
);
} finally {
await cloud.stop();
}
});
it("rejects a completed poll response with an invalid shape", async () => {
const cloud = await RegistrationCloud.start("invalid-poll-body");
try {
await assert.rejects(
new CloudDeviceAuthorizationClient().poll(
cloud.origin,
"device-code-with-more-than-thirty-two-characters",
1_000,
),
{ name: "ZodError" },
);
} finally {
await cloud.stop();
}
});
});
type RegistrationCloudResponse =
| "loopback-authorization"
| "non-web-authorization"
| "stalled-start-body"
| "stalled-poll-body"
| "reset-poll-body"
| "malformed-poll-body"
| "invalid-poll-body";
class RegistrationCloud {
readonly receivedPaths: string[] = [];
private constructor(
readonly origin: string,
private readonly server: Server,
) {}
static async start(responseBody: RegistrationCloudResponse): Promise<RegistrationCloud> {
let cloud: RegistrationCloud;
const server = createServer((request, response) => {
if (request.url !== undefined) cloud.receivedPaths.push(request.url);
response.writeHead(200, { "content-type": "application/json" });
if (responseBody === "malformed-poll-body") {
response.end("not-json");
return;
}
if (responseBody === "invalid-poll-body") {
response.end('{"status":"pending"}');
return;
}
if (responseBody === "non-web-authorization") {
response.end(
JSON.stringify({
deviceCode: "device-code-with-more-than-thirty-two-characters",
userCode: "ABCD-EFGH-JKLMN",
verificationUri: "https://cloud.paseo.test/activate",
verificationUriComplete: "file:///tmp/paseo-activate",
expiresAt: "2026-07-18T12:10:00.000Z",
interval: 5,
}),
);
return;
}
if (responseBody === "loopback-authorization") {
response.end(
JSON.stringify({
deviceCode: "device-code-with-more-than-thirty-two-characters",
userCode: "ABCD-EFGH-JKLMN",
verificationUri: `${cloud.origin}/activate`,
verificationUriComplete: `${cloud.origin}/activate?code=ABCD-EFGH-JKLMN`,
expiresAt: "2026-07-18T12:10:00.000Z",
interval: 5,
}),
);
return;
}
if (responseBody === "stalled-start-body") {
response.write('{"deviceCode":"device-code-with-more-than-thirty-two-characters"');
return;
}
if (responseBody === "reset-poll-body") {
response.flushHeaders();
response.write('{"status":"pending"');
setImmediate(() => response.socket?.destroy());
return;
}
response.write('{"status":"pending","interval":5');
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
cloud = new RegistrationCloud(`http://127.0.0.1:${address.port}`, server);
return cloud;
}
async stop(): Promise<void> {
this.server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
this.server.close((error) => {
if (error !== undefined) {
reject(error);
return;
}
resolve();
});
});
}
}
@@ -1,108 +0,0 @@
import { z } from "zod";
const START_TIMEOUT_MS = 15_000;
const activationUrlSchema = z.url({ protocol: /^https?$/u });
const authorizationSchema = z.object({
deviceCode: z.string().min(32),
userCode: z.string().min(1),
verificationUri: activationUrlSchema,
verificationUriComplete: activationUrlSchema,
expiresAt: z.string().datetime(),
interval: z.number().int().min(5),
});
const pollSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("pending"), interval: z.number().int().min(5) }),
z.object({ status: z.literal("slow_down"), interval: z.number().int().min(5) }),
z.object({
status: z.literal("approved"),
interval: z.number().int().min(5),
enrollmentToken: z.string().min(32),
}),
z.object({ status: z.literal("denied"), interval: z.number().int().min(5) }),
z.object({ status: z.literal("expired"), interval: z.number().int().min(5) }),
z.object({ status: z.literal("enrolled"), interval: z.number().int().min(5) }),
z.object({ status: z.literal("retry_later") }),
]);
export type DeviceAuthorization = z.infer<typeof authorizationSchema>;
export type DeviceAuthorizationPoll = z.infer<typeof pollSchema>;
export interface CloudDeviceAuthorization {
start(hubUrl: string, displayName: string): Promise<DeviceAuthorization>;
poll(
hubUrl: string,
deviceCode: string,
timeoutMilliseconds: number,
): Promise<DeviceAuthorizationPoll>;
}
export class CloudDeviceAuthorizationClient implements CloudDeviceAuthorization {
constructor(private readonly startTimeoutMilliseconds = START_TIMEOUT_MS) {}
async start(hubUrl: string, displayName: string): Promise<DeviceAuthorization> {
const signal = AbortSignal.timeout(this.startTimeoutMilliseconds);
try {
const response = await fetch(endpoint(hubUrl, "/api/device-authorizations/"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ displayName }),
signal,
});
if (!response.ok) throw new Error(`Cloud registration failed (${response.status})`);
return authorizationSchema.parse(await response.json());
} catch (error) {
if (signal.aborted) {
throw new Error("Cloud registration start timed out", { cause: error });
}
throw error;
}
}
async poll(
hubUrl: string,
deviceCode: string,
timeoutMilliseconds: number,
): Promise<DeviceAuthorizationPoll> {
const signal = AbortSignal.timeout(timeoutMilliseconds);
let response: Response;
try {
response = await fetch(endpoint(hubUrl, "/api/device-authorizations/poll"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ deviceCode }),
signal,
});
} catch {
return { status: "retry_later" };
}
if ([408, 425, 429].includes(response.status) || response.status >= 500) {
return { status: "retry_later" };
}
if (!response.ok) throw new Error(`Cloud registration poll failed (${response.status})`);
let body: unknown;
try {
body = await response.json();
} catch (error) {
if (signal.aborted || error instanceof TypeError) return { status: "retry_later" };
throw error;
}
return pollSchema.parse(body);
}
}
function endpoint(hubUrl: string, pathname: string): string {
const url = new URL(hubUrl);
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash
) {
throw new Error("Hub URL must be an HTTP or HTTPS origin without credentials or a query");
}
url.pathname = `${url.pathname.replace(/\/$/u, "")}${pathname}`;
return url.toString();
}
@@ -0,0 +1,484 @@
import { strict as assert } from "node:assert";
import { describe, it } from "vitest";
import { render } from "../../output/index.js";
import { runHubConnect } from "./connect.js";
import type { HubCredentialStore, StoredHubCredential } from "./credentials.js";
import type { HubDaemonClient, HubDaemonConnection, HubStatus } from "./daemon-client.js";
import { runHubDisconnect } from "./disconnect.js";
import { runHubLogin } from "./login.js";
import { runHubLogout } from "./logout.js";
import { runHubProjects } from "./projects.js";
import { createHubCommand } from "./index.js";
import type { HubReporter } from "./reporter.js";
const quietReporter: HubReporter = { progress() {} };
describe("Hub commands", () => {
it("exposes the hard-cut Hub command surface without connect --token", () => {
const command = createHubCommand();
const names = command.commands.map((child) => child.name());
const connect = command.commands.find((child) => child.name() === "connect");
assert.deepEqual(names, [
"login",
"connect",
"status",
"disconnect",
"projects",
"deploy",
"logout",
]);
assert.match(connect?.helpInformation() ?? "", /--api-key <secret>/u);
assert.doesNotMatch(connect?.helpInformation() ?? "", /--token/u);
assert.match(connect?.helpInformation() ?? "", /\[origin\]/u);
let help = "";
connect?.configureOutput({
writeOut(value) {
help += value;
},
});
connect?.outputHelp();
assert.match(help, /active stored login.*https:\/\/hub\.paseo\.sh/u);
});
it("login stores the durable credential and marks its normalized origin active", async () => {
const credentials = new MemoryCredentials();
const result = await runHubLogin(
"https://hub.test:443",
{},
{
env: {},
credentials,
flow: { authorize: async () => "paseo_cli_prefix_durable-secret" },
reporter: quietReporter,
},
);
assert.deepEqual(credentials.active(), {
origin: "https://hub.test",
credential: "paseo_cli_prefix_durable-secret",
});
assert.deepEqual(result.data, { origin: "https://hub.test", status: "logged_in" });
assert.equal(JSON.stringify(result).includes("durable-secret"), false);
});
it("login without an origin uses the hosted default and reports it before authorization", async () => {
const credentials = new MemoryCredentials();
const events: string[] = [];
const result = await runHubLogin(
undefined,
{},
{
env: {},
credentials,
flow: {
authorize: async (origin) => {
events.push(`authorize:${origin}`);
return "paseo_cli_prefix_durable-secret";
},
},
reporter: { progress: (message) => events.push(`progress:${message}`) },
},
);
assert.deepEqual(events, [
"progress:Logging in to https://hub.paseo.sh",
"authorize:https://hub.paseo.sh",
]);
assert.equal(result.data.origin, "https://hub.paseo.sh");
});
it("connect exchanges authority once and gives only the enrollment token to the daemon", async () => {
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "stored-human-secret" });
const daemon = new FakeDaemon("https://hub.test");
const observed: Array<{ origin: string; credential: string }> = [];
const progress: string[] = [];
await runHubConnect(
"https://hub.test",
{},
{
env: {},
credentials,
hub: {
issueEnrollmentToken: async (origin, credential) => {
observed.push({ origin, credential });
return "one-time-enrollment-token-with-enough-length";
},
},
daemon: new FakeDaemonConnection(daemon),
reporter: { progress: (message) => progress.push(message) },
},
);
assert.deepEqual(observed, [{ origin: "https://hub.test", credential: "stored-human-secret" }]);
assert.deepEqual(daemon.connections, [
{ origin: "https://hub.test", token: "one-time-enrollment-token-with-enough-length" },
]);
assert.equal(daemon.connections[0]?.token.includes("stored-human-secret"), false);
assert.deepEqual(progress, ["Connecting this daemon to https://hub.test"]);
});
it("projects reports its normalized destination before listing", async () => {
const credentials = new MemoryCredentials();
const events: string[] = [];
const result = await runHubProjects(
{ hub: "https://hub.test:443", apiKey: "explicit-secret" },
{
env: {},
credentials,
hub: {
listProjects: async (origin) => {
events.push(`request:${origin}`);
return [];
},
},
reporter: { progress: (message) => events.push(`progress:${message}`) },
},
);
assert.deepEqual(events, [
"progress:Listing projects from https://hub.test",
"request:https://hub.test",
]);
assert.deepEqual(JSON.parse(render(result, { format: "json" })), {
origin: "https://hub.test",
projects: [],
});
});
it("connect without an origin uses the active login", async () => {
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://active.test", credential: "active-secret" });
const daemon = new FakeDaemon("https://active.test");
const requests: string[] = [];
await runHubConnect(
undefined,
{},
{
env: {},
credentials,
hub: {
issueEnrollmentToken: async (origin, credential) => {
requests.push(`${origin}:${credential}`);
return "one-time-enrollment-token-with-enough-length";
},
},
daemon: new FakeDaemonConnection(daemon),
reporter: quietReporter,
},
);
assert.deepEqual(requests, ["https://active.test:active-secret"]);
});
it("connect without authority reports the hosted destination and contacts nothing", async () => {
const progress: string[] = [];
const credentials = new MemoryCredentials();
const daemon = new FakeDaemonConnection(new FakeDaemon("https://hub.paseo.sh"));
let hubRequests = 0;
await assert.rejects(
runHubConnect(
undefined,
{},
{
env: {},
credentials,
hub: {
issueEnrollmentToken: async () => {
hubRequests += 1;
return "one-time-enrollment-token-with-enough-length";
},
},
daemon,
reporter: { progress: (message) => progress.push(message) },
},
),
{
code: "HUB_API_KEY_REQUIRED",
message:
"No stored Hub login matches https://hub.paseo.sh. Run `paseo hub login https://hub.paseo.sh`, pass --api-key <secret>, or set PASEO_HUB_API_KEY.",
},
);
assert.deepEqual(progress, ["Connecting this daemon to https://hub.paseo.sh"]);
assert.equal(hubRequests, 0);
assert.equal(daemon.connectionCount, 0);
});
it("projects uses explicit API-key authority and renders auditable JSON output", async () => {
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://stored.test", credential: "stored-secret" });
const requests: Array<{ origin: string; credential: string }> = [];
const progress: string[] = [];
const result = await runHubProjects(
{ hub: "https://explicit.test", apiKey: "explicit-secret", json: true },
{
env: { PASEO_HUB_URL: "https://env.test", PASEO_HUB_API_KEY: "env-secret" },
credentials,
hub: {
listProjects: async (origin, credential) => {
requests.push({ origin, credential });
return [
{
id: "a50e05af-4f20-4c8f-8dcc-58e5ea360663",
slug: "paseo",
name: "Paseo",
},
];
},
},
reporter: { progress: (message) => progress.push(message) },
},
);
assert.deepEqual(requests, [
{ origin: "https://explicit.test", credential: "explicit-secret" },
]);
assert.deepEqual(JSON.parse(render(result, { format: "json" })), {
origin: "https://explicit.test",
projects: [
{
id: "a50e05af-4f20-4c8f-8dcc-58e5ea360663",
slug: "paseo",
name: "Paseo",
},
],
});
assert.deepEqual(progress, []);
});
it("noninteractive logout removes only the human credential without daemon access", async () => {
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "human-secret" });
const connection = new FakeDaemonConnection(new FakeDaemon("https://hub.test"));
const progress: string[] = [];
const result = await runHubLogout(
{ json: true },
{
credentials,
daemon: connection,
isInteractive: () => true,
confirmDisconnect: async () => {
throw new Error("must not prompt");
},
reporter: { progress: (message) => progress.push(message) },
},
);
assert.equal(credentials.active(), null);
assert.equal(connection.connectionCount, 0);
assert.equal(result.data.daemonDisconnected, false);
assert.deepEqual(progress, []);
});
it("interactive logout offers a same-origin daemon disconnect and treats declining as normal", async () => {
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "human-secret" });
credentials.save({ origin: "https://other.test", credential: "other-secret" });
credentials.save({ origin: "https://hub.test", credential: "human-secret" });
const daemon = new FakeDaemon("https://hub.test");
const prompts: string[] = [];
const progress: string[] = [];
const result = await runHubLogout(
{},
{
credentials,
daemon: new FakeDaemonConnection(daemon),
isInteractive: () => true,
confirmDisconnect: async (origin) => {
prompts.push(origin);
return false;
},
reporter: { progress: (message) => progress.push(message) },
},
);
assert.deepEqual(prompts, ["https://hub.test"]);
assert.equal(daemon.disconnects, 0);
assert.equal(credentials.get("https://other.test")?.credential, "other-secret");
assert.equal(result.data.status, "logged_out");
assert.deepEqual(progress, ["Logging out of https://hub.test"]);
});
it("interactive logout preserves human authority when daemon inspection fails", async () => {
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "human-secret" });
await assert.rejects(
runHubLogout(
{},
{
credentials,
daemon: {
connect: async () => {
throw new Error("daemon unavailable");
},
},
isInteractive: () => true,
confirmDisconnect: async () => {
throw new Error("must not prompt without relationship status");
},
reporter: quietReporter,
},
),
/daemon unavailable/u,
);
assert.equal(credentials.active()?.credential, "human-secret");
});
it("explicit logout automation disconnects only a daemon related to the active Hub", async () => {
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "human-secret" });
const daemon = new FakeDaemon("https://hub.test");
daemon.beforeDisconnect = () => {
assert.equal(credentials.active()?.credential, "human-secret");
};
const result = await runHubLogout(
{ json: true, disconnectDaemon: true, force: true },
{
credentials,
daemon: new FakeDaemonConnection(daemon),
isInteractive: () => false,
confirmDisconnect: async () => false,
reporter: quietReporter,
},
);
assert.equal(daemon.disconnects, 1);
assert.deepEqual(daemon.disconnectForces, [true]);
assert.equal(result.data.daemonDisconnected, true);
});
it("disconnect reports the current Hub and preserves it in the final result", async () => {
const daemon = new FakeDaemon("https://hub.test");
const progress: string[] = [];
const result = await runHubDisconnect(
{},
{
daemon: new FakeDaemonConnection(daemon),
reporter: { progress: (message) => progress.push(message) },
},
);
assert.deepEqual(progress, ["Disconnecting this daemon from https://hub.test"]);
assert.equal(result.data[0]?.hub, "https://hub.test");
});
it("preserves login when an accepted daemon disconnect fails", async () => {
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "human-secret" });
const daemon = new FakeDaemon("https://hub.test");
daemon.disconnectError = new Error("Hub refused disconnect");
const progress: string[] = [];
await assert.rejects(
runHubLogout(
{},
{
credentials,
daemon: new FakeDaemonConnection(daemon),
isInteractive: () => true,
confirmDisconnect: async () => true,
reporter: { progress: (message) => progress.push(message) },
},
),
/Hub refused disconnect/u,
);
assert.equal(credentials.active()?.credential, "human-secret");
assert.deepEqual(progress, [
"Logging out of https://hub.test",
"Disconnecting this daemon from https://hub.test",
]);
});
});
class MemoryCredentials implements HubCredentialStore {
private activeOrigin: string | null = null;
private readonly records = new Map<string, StoredHubCredential>();
active(): StoredHubCredential | null {
return this.activeOrigin === null ? null : (this.records.get(this.activeOrigin) ?? null);
}
get(origin: string): StoredHubCredential | null {
return this.records.get(origin) ?? null;
}
save(credential: StoredHubCredential): void {
const origin = new URL(credential.origin).origin;
this.records.set(origin, { ...credential, origin });
this.activeOrigin = origin;
}
logoutActive(): StoredHubCredential | null {
const active = this.active();
if (active !== null) this.records.delete(active.origin);
this.activeOrigin = null;
return active;
}
}
class FakeDaemonConnection implements HubDaemonConnection {
connectionCount = 0;
constructor(private readonly daemon: FakeDaemon) {}
async connect(): Promise<HubDaemonClient> {
this.connectionCount += 1;
return this.daemon;
}
}
class FakeDaemon implements HubDaemonClient {
readonly connections: Array<{ origin: string; token: string }> = [];
disconnects = 0;
readonly disconnectForces: boolean[] = [];
disconnectError: Error | null = null;
beforeDisconnect: (() => void) | null = null;
constructor(private readonly origin: string) {}
async connectHub(origin: string, token: string) {
this.connections.push({ origin, token });
return { status: hubStatus("connected", origin) };
}
async getHubStatus() {
return { status: hubStatus("connected", this.origin) };
}
async disconnectHub(force: boolean) {
this.disconnects += 1;
this.disconnectForces.push(force);
this.beforeDisconnect?.();
if (this.disconnectError !== null) throw this.disconnectError;
return { status: hubStatus("not_connected", null) };
}
async close() {}
}
function hubStatus(state: string, origin: string | null): HubStatus {
return {
state,
daemonId: state === "connected" ? "daemon-1" : null,
hubOrigin: origin,
scopes: state === "connected" ? ["hub.execution.*"] : [],
connectedAt: null,
lastError: null,
};
}
+63
View File
@@ -0,0 +1,63 @@
import type { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { resolveHubCredential, resolveHubOrigin } from "./authority.js";
import type { HubHttpClient } from "./client.js";
import type { HubCredentialStore } from "./credentials.js";
import type { HubDaemonConnection } from "./daemon-client.js";
import { withHubDaemon } from "./daemon-client.js";
import { hubStatusResult } from "./status-output.js";
import { reportHubProgress, type HubReporter } from "./reporter.js";
import { addHubResolutionHelp } from "./help.js";
interface HubConnectOptions {
apiKey?: string;
host?: string;
json?: boolean;
}
interface HubConnectDependencies {
env: Readonly<Record<string, string | undefined>>;
credentials: HubCredentialStore;
hub: Pick<HubHttpClient, "issueEnrollmentToken">;
daemon: HubDaemonConnection;
reporter: HubReporter;
}
export async function runHubConnect(
originInput: string | undefined,
options: HubConnectOptions,
dependencies: HubConnectDependencies,
) {
const resolution = {
options: { origin: originInput, apiKey: options.apiKey },
env: dependencies.env,
credentials: dependencies.credentials,
};
const origin = resolveHubOrigin(resolution);
reportHubProgress(dependencies.reporter, options, `Connecting this daemon to ${origin}`);
const credential = resolveHubCredential({ ...resolution, origin });
const token = await dependencies.hub.issueEnrollmentToken(origin, credential);
return withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
const response = await daemon.connectHub(origin, token);
return hubStatusResult(response.status);
});
}
export function addHubConnectCommand(parent: Command, dependencies: HubConnectDependencies): void {
addJsonAndDaemonHostOptions(
addHubResolutionHelp(
parent
.command("connect")
.description("Enroll this daemon with a Paseo Hub")
.argument("[origin]", "Paseo Hub origin")
.option("--api-key <secret>", "Organization API key"),
),
).action(
withOutput(async (...args) => {
const origin = args[0] as string | undefined;
const options = args.at(-2) as HubConnectOptions;
return runHubConnect(origin, options, dependencies);
}),
);
}
@@ -0,0 +1,195 @@
import { strict as assert } from "node:assert";
import { chmodSync, mkdtempSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, it } from "vitest";
import { DEFAULT_HUB_ORIGIN, resolveHubCredential, resolveHubOrigin } from "./authority.js";
import { PrivateHubCredentialStore, type HubCredentialStore } from "./credentials.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
const { rm } = await import("node:fs/promises");
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
describe("Hub CLI credentials", () => {
it("stores multiple normalized origins privately and selects the latest login", () => {
const home = temporaryHome();
const store = new PrivateHubCredentialStore({ PASEO_HOME: home });
store.save({ origin: "https://first.example.com", credential: "first-secret" });
store.save({ origin: "https://second.example.com:443", credential: "second-secret" });
assert.deepEqual(store.get("https://first.example.com"), {
origin: "https://first.example.com",
credential: "first-secret",
});
assert.deepEqual(store.active(), {
origin: "https://second.example.com",
credential: "second-secret",
});
if (process.platform !== "win32") {
assert.equal(statSync(home).mode & 0o777, 0o700);
assert.equal(statSync(path.join(home, "hub-credentials.json")).mode & 0o777, 0o600);
}
assert.deepEqual(
readdirSync(home).filter((entry) => entry.endsWith(".tmp")),
[],
);
});
it("logs out only the active human credential and preserves other origins", () => {
const store = new PrivateHubCredentialStore({ PASEO_HOME: temporaryHome() });
store.save({ origin: "https://first.example.com", credential: "first-secret" });
store.save({ origin: "https://second.example.com", credential: "second-secret" });
assert.equal(store.logoutActive()?.origin, "https://second.example.com");
assert.equal(store.active(), null);
assert.equal(store.get("https://second.example.com"), null);
assert.equal(store.get("https://first.example.com")?.credential, "first-secret");
});
it("rejects malformed storage without exposing persisted secrets", () => {
const home = temporaryHome();
const secret = "stored-secret-must-not-leak";
writeFileSync(path.join(home, "hub-credentials.json"), `{"credential":"${secret}"}`);
chmodSync(path.join(home, "hub-credentials.json"), 0o600);
assert.throws(
() => new PrivateHubCredentialStore({ PASEO_HOME: home }).active(),
(error) => {
assert.ok(error instanceof Error);
assert.equal(error.message.includes(secret), false);
assert.equal(JSON.stringify(error).includes(secret), false);
return true;
},
);
});
it("repairs permissive persisted file modes before returning credentials", () => {
if (process.platform === "win32") return;
const home = temporaryHome();
const store = new PrivateHubCredentialStore({ PASEO_HOME: home });
store.save({ origin: "https://hub.test", credential: "stored-secret" });
const credentialPath = path.join(home, "hub-credentials.json");
chmodSync(credentialPath, 0o644);
assert.equal(store.active()?.credential, "stored-secret");
assert.equal(statSync(credentialPath).mode & 0o777, 0o600);
});
it("resolves origin from explicit, environment, active login, then hosted default", () => {
const store = new PrivateHubCredentialStore({ PASEO_HOME: temporaryHome() });
store.save({ origin: "https://stored.example.com", credential: "stored-secret" });
assert.equal(
resolveHubOrigin({
options: { origin: "https://explicit.example.com" },
env: { PASEO_HUB_URL: "https://env.example.com" },
credentials: store,
}),
"https://explicit.example.com",
);
assert.equal(
resolveHubOrigin({
options: {},
env: { PASEO_HUB_URL: "https://env.example.com" },
credentials: store,
}),
"https://env.example.com",
);
assert.equal(
resolveHubOrigin({ options: {}, env: {}, credentials: store }),
"https://stored.example.com",
);
assert.equal(
resolveHubOrigin({
options: {},
env: {},
credentials: new PrivateHubCredentialStore({ PASEO_HOME: temporaryHome() }),
}),
DEFAULT_HUB_ORIGIN,
);
});
it("resolves credential from explicit, environment, then exact-origin stored login", () => {
const store = new PrivateHubCredentialStore({ PASEO_HOME: temporaryHome() });
store.save({ origin: "https://stored.example.com", credential: "stored-secret" });
assert.equal(
resolveHubCredential({
origin: "https://stored.example.com",
options: { origin: "https://stored.example.com", apiKey: "explicit-secret" },
env: { PASEO_HUB_API_KEY: "env-secret" },
credentials: store,
}),
"explicit-secret",
);
assert.equal(
resolveHubCredential({
origin: "https://stored.example.com",
options: { origin: "https://stored.example.com" },
env: { PASEO_HUB_API_KEY: "env-secret" },
credentials: store,
}),
"env-secret",
);
assert.equal(
resolveHubCredential({
origin: "https://stored.example.com",
options: { origin: "https://stored.example.com" },
env: {},
credentials: store,
}),
"stored-secret",
);
});
it("never applies a stored credential to a different origin", () => {
const store = new PrivateHubCredentialStore({ PASEO_HOME: temporaryHome() });
store.save({ origin: "https://stored.example.com", credential: "stored-secret" });
assert.throws(
() =>
resolveHubCredential({
origin: "https://different.example.com",
options: { origin: "https://different.example.com" },
env: {},
credentials: store,
}),
{ code: "HUB_API_KEY_REQUIRED" },
);
});
it("does not access stored login when explicit origin and API key are complete", () => {
const unavailableStore: HubCredentialStore = {
active: () => {
throw new Error("must not read active login");
},
get: () => {
throw new Error("must not read stored credential");
},
save: () => {},
logoutActive: () => null,
};
assert.equal(
resolveHubCredential({
origin: "https://hub.test",
options: { origin: "https://hub.test", apiKey: "explicit-secret" },
env: {},
credentials: unavailableStore,
}),
"explicit-secret",
);
});
});
function temporaryHome(): string {
const directory = mkdtempSync(path.join(tmpdir(), "paseo-hub-credentials-"));
temporaryDirectories.push(directory);
return directory;
}
@@ -0,0 +1,170 @@
import { randomUUID } from "node:crypto";
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { z } from "zod";
import { HubCommandError } from "./error.js";
import { normalizeHubOrigin } from "./origin.js";
const PRIVATE_DIRECTORY_MODE = 0o700;
const PRIVATE_FILE_MODE = 0o600;
const credentialRecordSchema = z
.object({ origin: z.string(), credential: z.string().min(1) })
.strict();
const credentialFileSchema = z
.object({
version: z.literal(1),
activeOrigin: z.string().nullable(),
credentials: z.array(credentialRecordSchema),
})
.strict()
.superRefine((value, context) => {
const origins = new Set<string>();
for (const record of value.credentials) {
let normalized: string;
try {
normalized = normalizeHubOrigin(record.origin);
} catch {
context.addIssue({ code: "custom", message: "Credential origin is invalid" });
continue;
}
if (normalized !== record.origin || origins.has(record.origin)) {
context.addIssue({
code: "custom",
message: "Credential origins must be unique and normalized",
});
}
origins.add(record.origin);
}
if (value.activeOrigin !== null && !origins.has(value.activeOrigin)) {
context.addIssue({ code: "custom", message: "Active credential is missing" });
}
});
type CredentialFile = z.infer<typeof credentialFileSchema>;
export interface StoredHubCredential {
origin: string;
credential: string;
}
export interface HubCredentialStore {
active(): StoredHubCredential | null;
get(origin: string): StoredHubCredential | null;
save(credential: StoredHubCredential): void;
logoutActive(): StoredHubCredential | null;
}
export class PrivateHubCredentialStore implements HubCredentialStore {
private readonly filePath: string;
constructor(env: Readonly<Record<string, string | undefined>> = process.env) {
this.filePath = path.join(resolvePaseoHome(env), "hub-credentials.json");
}
active(): StoredHubCredential | null {
const data = this.read();
if (data.activeOrigin === null) return null;
return data.credentials.find((record) => record.origin === data.activeOrigin) ?? null;
}
get(origin: string): StoredHubCredential | null {
const normalizedOrigin = normalizeHubOrigin(origin);
return this.read().credentials.find((record) => record.origin === normalizedOrigin) ?? null;
}
save(credential: StoredHubCredential): void {
const origin = normalizeHubOrigin(credential.origin);
const current = this.read();
const remaining = current.credentials.filter((record) => record.origin !== origin);
this.write({
version: 1,
activeOrigin: origin,
credentials: [...remaining, { origin, credential: credential.credential }],
});
}
logoutActive(): StoredHubCredential | null {
const current = this.read();
if (current.activeOrigin === null) return null;
const removed = current.credentials.find((record) => record.origin === current.activeOrigin);
if (removed === undefined) throw invalidCredentialFile();
this.write({
version: 1,
activeOrigin: null,
credentials: current.credentials.filter((record) => record.origin !== current.activeOrigin),
});
return removed;
}
private read(): CredentialFile {
let contents: string;
try {
contents = readFileSync(this.filePath, "utf8");
} catch (error) {
if (isMissingFile(error)) return { version: 1, activeOrigin: null, credentials: [] };
throw credentialStorageError();
}
chmodPrivate(this.filePath, PRIVATE_FILE_MODE);
try {
return credentialFileSchema.parse(JSON.parse(contents));
} catch {
throw invalidCredentialFile();
}
}
private write(value: CredentialFile): void {
const parsed = credentialFileSchema.parse(value);
const directory = path.dirname(this.filePath);
mkdirSync(directory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
chmodPrivate(directory, PRIVATE_DIRECTORY_MODE);
const temporaryPath = path.join(
directory,
`.${path.basename(this.filePath)}.${process.pid}.${randomUUID()}.tmp`,
);
try {
writeFileSync(temporaryPath, `${JSON.stringify(parsed, null, 2)}\n`, {
encoding: "utf8",
mode: PRIVATE_FILE_MODE,
});
renameSync(temporaryPath, this.filePath);
chmodPrivate(this.filePath, PRIVATE_FILE_MODE);
} catch {
rmSync(temporaryPath, { force: true });
throw credentialStorageError();
}
}
}
function resolvePaseoHome(env: Readonly<Record<string, string | undefined>>): string {
const configured = env.PASEO_HOME ?? "~/.paseo";
const expanded = configured === "~" ? homedir() : configured.replace(/^~\//u, `${homedir()}/`);
return path.resolve(expanded);
}
function chmodPrivate(target: string, mode: number): void {
if (process.platform === "win32") return;
try {
chmodSync(target, mode);
} catch {
throw credentialStorageError();
}
}
function isMissingFile(error: unknown): boolean {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}
function invalidCredentialFile(): HubCommandError {
return new HubCommandError(
"HUB_CREDENTIALS_INVALID",
"Stored Hub login is invalid. Run `paseo hub login <origin>` to replace it.",
);
}
function credentialStorageError(): HubCommandError {
return new HubCommandError(
"HUB_CREDENTIALS_UNAVAILABLE",
"Could not access the private Hub credential store under PASEO_HOME.",
);
}
@@ -0,0 +1,38 @@
import { connectToDaemon } from "../../utils/client.js";
export interface HubStatus {
state: string;
daemonId: string | null;
hubOrigin: string | null;
scopes: string[];
connectedAt: string | null;
lastError: string | null;
}
export interface HubDaemonClient {
connectHub(url: string, token: string): Promise<{ status: HubStatus }>;
getHubStatus(): Promise<{ status: HubStatus }>;
disconnectHub(force: boolean): Promise<{ status: HubStatus; warning?: string }>;
close(): Promise<void>;
}
export interface HubDaemonConnection {
connect(host: string | undefined): Promise<HubDaemonClient>;
}
export const productionHubDaemonConnection: HubDaemonConnection = {
connect: (host) => connectToDaemon({ host }),
};
export async function withHubDaemon<T>(
connection: HubDaemonConnection,
host: string | undefined,
action: (client: HubDaemonClient) => Promise<T>,
): Promise<T> {
const client = await connection.connect(host);
try {
return await action(client);
} finally {
await client.close().catch(() => undefined);
}
}
+32 -32
View File
@@ -1,7 +1,7 @@
import { lstat, readFile } from "node:fs/promises";
import path from "node:path";
import YAML from "yaml";
import { HubDeployError } from "./error.js";
import { HubCommandError } from "./error.js";
const DEFAULT_CONFIGURATION_PATH = ".paseo/hub.yml";
const PROMPT_PARTIAL_ROOT = ".paseo/partials";
@@ -39,13 +39,13 @@ export async function resolveHubDeployInput(input: ResolveHubDeployInput): Promi
const projectSlug = input.project ?? projectFromConfiguration(configuration);
if (projectSlug === undefined) {
throw new HubDeployError(
throw new HubCommandError(
"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(
throw new HubCommandError(
"HUB_INVALID_PROJECT",
"Project must be a bare slug such as my-project.",
);
@@ -83,7 +83,7 @@ async function readConfiguration(
displayPath: string,
): Promise<string> {
const unsafePath = () =>
new HubDeployError(
new HubCommandError(
"HUB_CONFIGURATION_UNSAFE_PATH",
`Hub configuration at ${displayPath} must not use a symlink.`,
);
@@ -99,13 +99,13 @@ async function readConfiguration(
throw unsafePath();
}
if (!stats.isFile()) {
throw new HubDeployError(
throw new HubCommandError(
"HUB_CONFIGURATION_NOT_FILE",
`Hub configuration at ${displayPath} must be a regular file.`,
);
}
if (!hasReadPermission(stats.mode)) {
throw new HubDeployError(
throw new HubCommandError(
"HUB_CONFIGURATION_UNREADABLE",
`Could not read Hub configuration at ${displayPath}. Check file permissions.`,
);
@@ -119,7 +119,7 @@ async function readConfiguration(
}
const yaml = bytes.toString("utf8");
if (yaml.length > MAX_CONFIGURATION_LENGTH) {
throw new HubDeployError(
throw new HubCommandError(
"HUB_CONFIGURATION_TOO_LARGE",
`Hub configuration at ${displayPath} exceeds the ${MAX_CONFIGURATION_LENGTH}-character limit.`,
);
@@ -132,10 +132,10 @@ function parseConfiguration(yaml: string): Record<string, unknown> {
try {
configuration = YAML.parse(yaml);
} catch {
throw new HubDeployError("HUB_INVALID_CONFIGURATION", "Hub configuration is not valid YAML.");
throw new HubCommandError("HUB_INVALID_CONFIGURATION", "Hub configuration is not valid YAML.");
}
if (!isRecord(configuration)) {
throw new HubDeployError(
throw new HubCommandError(
"HUB_INVALID_CONFIGURATION",
"Hub configuration must be a YAML mapping.",
);
@@ -147,7 +147,7 @@ function projectFromConfiguration(configuration: Record<string, unknown>): strin
const project: unknown = configuration["project"];
if (project === undefined) return undefined;
if (typeof project !== "string") {
throw new HubDeployError(
throw new HubCommandError(
"HUB_INVALID_PROJECT",
"Top-level project must be a bare project slug.",
);
@@ -161,7 +161,7 @@ async function resolvePromptPartials(
): Promise<HubDeployPartial[]> {
const references = collectPromptPartialReferences(configuration);
if (references.length > MAX_PROMPT_PARTIAL_COUNT) {
throw new HubDeployError(
throw new HubCommandError(
"HUB_PARTIAL_LIMIT_EXCEEDED",
`Hub configuration references ${references.length} partials; the limit is ${MAX_PROMPT_PARTIAL_COUNT}.`,
);
@@ -178,14 +178,14 @@ async function resolvePromptPartials(
const content = await readPartial(projectRoot, partialPath, reference.path);
const contentBytes = Buffer.byteLength(content, "utf8");
if (contentBytes > MAX_PROMPT_PARTIAL_CONTENT_BYTES) {
throw new HubDeployError(
throw new HubCommandError(
"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(
throw new HubCommandError(
"HUB_PARTIAL_BUNDLE_TOO_LARGE",
`Referenced Hub partials exceed the ${MAX_PROMPT_PARTIAL_BUNDLE_BYTES}-byte combined limit.`,
);
@@ -215,14 +215,14 @@ function collectPromptPartialReferences(
if (!isRecord(block) || !Object.hasOwn(block, "include")) continue;
const include = block["include"];
if (typeof include !== "string") {
throw new HubDeployError(
throw new HubCommandError(
"HUB_PARTIAL_PATH_INVALID",
"Hub partial include path must be a string.",
);
}
const normalizedPath = normalizePromptPartialPath(include);
if (seen.has(normalizedPath)) {
throw new HubDeployError(
throw new HubCommandError(
"HUB_PARTIAL_DUPLICATE",
`Hub partial ${normalizedPath} is referenced more than once. Remove the duplicate include.`,
);
@@ -251,7 +251,7 @@ function normalizePromptPartialPath(value: string): string {
throw invalidPromptPartialPath(value);
}
if (canonical.length > MAX_PROMPT_PARTIAL_PATH_LENGTH) {
throw new HubDeployError(
throw new HubCommandError(
"HUB_PARTIAL_PATH_TOO_LONG",
`Hub partial path ${value} exceeds the ${MAX_PROMPT_PARTIAL_PATH_LENGTH}-character limit.`,
);
@@ -286,7 +286,7 @@ async function readPartial(
projectRoot,
partialPath,
() =>
new HubDeployError(
new HubCommandError(
"HUB_PARTIAL_UNSAFE_PATH",
`Referenced Hub partial ${displayPath} must not use a symlink.`,
),
@@ -297,30 +297,30 @@ async function readPartial(
stats = await lstat(partialPath);
} catch (error) {
if (errorCode(error) === "ENOENT") {
throw new HubDeployError(
throw new HubCommandError(
"HUB_PARTIAL_MISSING",
`Referenced Hub partial ${displayPath} does not exist.`,
);
}
throw new HubDeployError(
throw new HubCommandError(
"HUB_PARTIAL_UNREADABLE",
`Could not read referenced Hub partial ${displayPath}. Check the file and permissions.`,
);
}
if (stats.isSymbolicLink()) {
throw new HubDeployError(
throw new HubCommandError(
"HUB_PARTIAL_UNSAFE_PATH",
`Referenced Hub partial ${displayPath} must not be a symlink.`,
);
}
if (!stats.isFile()) {
throw new HubDeployError(
throw new HubCommandError(
"HUB_PARTIAL_NOT_FILE",
`Referenced Hub partial ${displayPath} must be a regular file.`,
);
}
if (!hasReadPermission(stats.mode)) {
throw new HubDeployError(
throw new HubCommandError(
"HUB_PARTIAL_UNREADABLE",
`Could not read referenced Hub partial ${displayPath}. Check the file and permissions.`,
);
@@ -329,7 +329,7 @@ async function readPartial(
try {
return (await readFile(partialPath)).toString("utf8");
} catch {
throw new HubDeployError(
throw new HubCommandError(
"HUB_PARTIAL_UNREADABLE",
`Could not read referenced Hub partial ${displayPath}. Check the file and permissions.`,
);
@@ -339,7 +339,7 @@ async function readPartial(
async function rejectSymlinkComponents(
root: string,
target: string,
error: () => HubDeployError,
error: () => HubCommandError,
): Promise<void> {
const relative = path.relative(root, target);
let current = root;
@@ -348,35 +348,35 @@ async function rejectSymlinkComponents(
try {
if ((await lstat(current)).isSymbolicLink()) throw error();
} catch (failure) {
if (failure instanceof HubDeployError) throw failure;
if (failure instanceof HubCommandError) throw failure;
if (errorCode(failure) === "ENOENT") return;
throw error();
}
}
}
function configurationReadError(displayPath: string, error: unknown): HubDeployError {
function configurationReadError(displayPath: string, error: unknown): HubCommandError {
if (errorCode(error) === "ENOENT") {
return new HubDeployError(
return new HubCommandError(
"HUB_CONFIGURATION_UNREADABLE",
`Could not read Hub configuration at ${displayPath}. Pass an existing YAML file.`,
);
}
return new HubDeployError(
return new HubCommandError(
"HUB_CONFIGURATION_UNREADABLE",
`Could not read Hub configuration at ${displayPath}. Check the file and permissions.`,
);
}
function invalidConfigurationPath(): HubDeployError {
return new HubDeployError(
function invalidConfigurationPath(): HubCommandError {
return new HubCommandError(
"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(
function invalidPromptPartialPath(value: string): HubCommandError {
return new HubCommandError(
"HUB_PARTIAL_PATH_INVALID",
`Hub partial path must be a safe relative path under .paseo/partials/: ${value}`,
);
+66 -8
View File
@@ -24,6 +24,7 @@ describe("hub deploy", () => {
await writeFile(path.join(configurationDirectory, "hub.yml"), yaml);
const hub = await startHub();
const progress: string[] = [];
try {
const result = await runHubDeploy(
@@ -31,6 +32,7 @@ describe("hub deploy", () => {
{
cwd,
env: { PASEO_HUB_URL: hub.origin, PASEO_HUB_API_KEY: "test-operator-secret" },
reporter: { progress: (message) => progress.push(message) },
},
);
@@ -45,7 +47,9 @@ describe("hub deploy", () => {
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();
}
@@ -77,6 +81,47 @@ describe("hub deploy", () => {
}
});
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");
@@ -488,19 +533,30 @@ describe("hub deploy", () => {
}
});
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.",
});
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({ hub: "https://hub.example.com" }, { cwd: "/unused", env: {} }),
runHubDeploy(
{},
{ cwd, env: {}, reporter: { progress: (message) => progress.push(message) } },
),
).rejects.toMatchObject({
message: "Hub API key is required. Pass --api-key <secret> or set PASEO_HUB_API_KEY.",
message:
"No stored Hub login matches https://hub.paseo.sh. Run `paseo hub login https://hub.paseo.sh`, pass --api-key <secret>, 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 <secret>, 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",
@@ -509,7 +565,8 @@ describe("hub deploy", () => {
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.",
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].",
});
});
@@ -550,7 +607,7 @@ describe("hub deploy", () => {
runHubDeploy({ hub: hub.origin, apiKey: "response-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_INVALID_RESPONSE",
message: "Hub returned a malformed deployment response.",
message: "Hub returned a malformed response.",
});
} finally {
await hub.close();
@@ -608,6 +665,7 @@ describe("hub deploy", () => {
expect(help).toContain("-p, --project <slug>");
expect(help).toContain("--hub <origin>");
expect(help).toContain("--api-key <secret>");
expect(help).toContain("--dry-run");
expect(help).not.toContain("organization");
});
});
+93 -63
View File
@@ -1,107 +1,137 @@
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 { resolveHubCredential, resolveHubOrigin } from "./authority.js";
import { HubHttpClient, type HubInstallResult, type HubValidationResult } from "./client.js";
import { PrivateHubCredentialStore, type HubCredentialStore } from "./credentials.js";
import { resolveHubDeployInput } from "./deploy-input.js";
import { HubDeployError } from "./error.js";
import { processHubReporter, reportHubProgress, type HubReporter } from "./reporter.js";
import { addHubResolutionHelp } from "./help.js";
export interface HubDeployOptions {
file?: string;
project?: string;
hub?: string;
apiKey?: string;
dryRun?: boolean;
json?: boolean;
}
interface HubDeployEnvironment {
cwd: string;
env: Readonly<Record<string, string | undefined>>;
credentials?: HubCredentialStore;
hub?: Pick<HubHttpClient, "installConfiguration" | "validateConfiguration">;
reporter?: HubReporter;
}
const resultSchema: OutputSchema<HubInstallResult> = {
export interface HubDeployCommandDependencies {
env: Readonly<Record<string, string | undefined>>;
credentials: HubCredentialStore;
hub: Pick<HubHttpClient, "installConfiguration" | "validateConfiguration">;
reporter: HubReporter;
cwd(): string;
}
interface HubDeployResult extends HubInstallResult {
origin: string;
}
interface HubDryRunResult extends HubValidationResult {
origin: string;
}
const resultSchema: OutputSchema<HubDeployResult> = {
idField: "versionId",
columns: [
{ header: "PROJECT", field: "projectSlug" },
{ header: "VERSION", field: "version" },
{ header: "VERSION ID", field: "versionId" },
{ header: "ACTIVE", field: "active" },
{ header: "HUB", field: "origin" },
],
};
const validationSchema: OutputSchema<HubDryRunResult> = {
idField: "projectSlug",
columns: [
{ header: "PROJECT", field: "projectSlug" },
{ header: "VALID", field: "valid" },
{ header: "HUB", field: "origin" },
],
};
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);
environment: HubDeployEnvironment = {
cwd: process.cwd(),
env: process.env,
credentials: new PrivateHubCredentialStore(),
hub: new HubHttpClient(),
},
): Promise<SingleResult<HubDeployResult> | SingleResult<HubDryRunResult>> {
const credentials = environment.credentials ?? new PrivateHubCredentialStore(environment.env);
const resolution = {
options: { origin: options.hub, apiKey: options.apiKey },
env: environment.env,
credentials,
};
const origin = resolveHubOrigin(resolution);
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,
const action = options.dryRun === true ? "Validating" : "Deploying";
reportHubProgress(
environment.reporter ?? processHubReporter,
options,
`${action} ${deployInput.projectSlug} ${options.dryRun === true ? "against" : "to"} ${origin}`,
);
const credential = resolveHubCredential({ ...resolution, origin });
const request = {
origin,
apiKey: credential,
...deployInput,
});
};
if (options.dryRun === true) {
const validated = await (environment.hub ?? new HubHttpClient()).validateConfiguration(request);
return { type: "single", data: { ...validated, origin }, schema: validationSchema };
}
const deployed = await (environment.hub ?? new HubHttpClient()).installConfiguration(request);
return { type: "single", data: deployed, schema: resultSchema };
return { type: "single", data: { ...deployed, origin }, schema: resultSchema };
}
export function addHubDeployCommand(hub: Command): void {
export function addHubDeployCommand(
hub: Command,
dependencies: HubDeployCommandDependencies,
): 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"),
addHubResolutionHelp(
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")
.option("--dry-run", "Validate without installing or activating"),
),
).action(
withOutput(async (...args) => {
withOutput<HubDeployResult | HubDryRunResult, unknown[]>(async (...args) => {
const file = args[0] as string;
const options = args.at(-2) as HubDeployOptions;
return runHubDeploy({ ...options, file });
return runHubDeploy(
{ ...options, file },
{
cwd: dependencies.cwd(),
env: dependencies.env,
credentials: dependencies.credentials,
hub: dependencies.hub,
reporter: dependencies.reporter,
},
);
}),
);
}
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.",
);
}
@@ -1,273 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "vitest";
import { DeviceAuthorizationWorkflow, SystemBrowser } from "./device-authorization.js";
import type {
CloudDeviceAuthorization,
DeviceAuthorizationPoll,
} from "./cloud-device-authorization.js";
import { createHubCommand } from "./index.js";
describe("Hub device authorization", () => {
it("opens activation URLs on Windows without a command shell", async () => {
const launches: Array<{ command: string; args: string[] }> = [];
const browser = new SystemBrowser({
hostPlatform: "win32",
launch: async (command, args) => void launches.push({ command, args }),
});
await browser.open("https://cloud.paseo.test/activate?code=ABCD-EFGH-JKLMN");
assert.deepEqual(launches, [
{
command: "rundll32.exe",
args: [
"url.dll,FileProtocolHandler",
"https://cloud.paseo.test/activate?code=ABCD-EFGH-JKLMN",
],
},
]);
});
it("opens the browser, follows Cloud cadence, and returns the approved enrollment token", async () => {
const cloud = new FakeCloud([
{ status: "pending", interval: 5 },
{ status: "slow_down", interval: 10 },
{ status: "approved", interval: 10, enrollmentToken: "approved-enrollment-token-1234567890" },
]);
const authorization = new AuthorizationJourney(cloud);
const token = await authorization.approve("https://cloud.paseo.test", "Studio Mac");
assert.equal(token, "approved-enrollment-token-1234567890");
assert.deepEqual(authorization.observed(), {
starts: [{ hubUrl: "https://cloud.paseo.test", displayName: "Studio Mac" }],
polls: [
{
hubUrl: "https://cloud.paseo.test",
deviceCode: "device-code-with-more-than-thirty-two-characters",
timeoutMilliseconds: 595_000,
},
{
hubUrl: "https://cloud.paseo.test",
deviceCode: "device-code-with-more-than-thirty-two-characters",
timeoutMilliseconds: 590_000,
},
{
hubUrl: "https://cloud.paseo.test",
deviceCode: "device-code-with-more-than-thirty-two-characters",
timeoutMilliseconds: 580_000,
},
],
waits: [5_000, 5_000, 10_000],
opened: ["https://cloud.paseo.test/activate?code=ABCD-EFGH-JKLMN"],
instructions: ["https://cloud.paseo.test/activate ABCD-EFGH-JKLMN"],
});
});
it("stops without an enrollment token when the browser denies the request", async () => {
const authorization = new AuthorizationJourney(
new FakeCloud([{ status: "denied", interval: 5 }]),
);
await assert.rejects(authorization.approve("https://cloud.paseo.test", "Studio Mac"), {
message: "Daemon registration was denied",
});
});
it("recovers the approved authority after its first poll response is lost", async () => {
const authorization = new AuthorizationJourney(
new FakeCloud([
{ status: "retry_later" },
{
status: "approved",
interval: 5,
enrollmentToken: "stable-enrollment-token-after-response-loss",
},
]),
);
const token = await authorization.approve("https://cloud.paseo.test", "Studio Mac");
assert.equal(token, "stable-enrollment-token-after-response-loss");
assert.deepEqual(authorization.observed().waits, [5_000, 5_000]);
});
it("retries timeout failures only while the fixed authorization expiry remains", async () => {
const authorization = new AuthorizationJourney(
new FakeCloud(
[{ status: "retry_later" }, { status: "retry_later" }],
"2026-07-18T12:00:11.000Z",
),
);
await assert.rejects(authorization.approve("https://cloud.paseo.test", "Studio Mac"), {
message: "Daemon registration expired",
});
assert.deepEqual(authorization.observed().waits, [5_000, 5_000, 1_000]);
assert.deepEqual(
authorization.observed().polls.map(({ timeoutMilliseconds }) => timeoutMilliseconds),
[6_000, 1_000],
);
});
it("stops without an enrollment token when the request expires", async () => {
const authorization = new AuthorizationJourney(
new FakeCloud([{ status: "expired", interval: 5 }]),
);
await assert.rejects(authorization.approve("https://cloud.paseo.test", "Studio Mac"), {
message: "Daemon registration expired",
});
});
it("connects the real hub command with a browser-approved token", async () => {
const daemon = new FakeDaemon();
await createHubCommand({
connect: async () => daemon,
authorize: async (url, displayName) => {
assert.equal(url, "https://cloud.paseo.test");
assert.equal(displayName, "Studio Mac");
return "approved-enrollment-token-1234567890";
},
displayName: () => "Studio Mac",
}).parseAsync(["node", "paseo hub", "connect", "https://cloud.paseo.test", "--json"], {
from: "node",
});
assert.deepEqual(daemon.connections, [
{
url: "https://cloud.paseo.test",
token: "approved-enrollment-token-1234567890",
},
]);
assert.equal(daemon.closed, true);
});
it("bounds the default daemon name before starting browser authorization", async () => {
const daemon = new FakeDaemon();
const names: string[] = [];
await createHubCommand({
connect: async () => daemon,
authorize: async (_url, displayName) => {
names.push(displayName);
return "approved-enrollment-token-1234567890";
},
displayName: () => ` ${"very-long-hostname".repeat(10)} `,
}).parseAsync(["node", "paseo hub", "connect", "https://cloud.paseo.test", "--json"], {
from: "node",
});
assert.deepEqual(names, ["very-long-hostname".repeat(10).slice(0, 100)]);
});
});
class AuthorizationJourney {
private now = Date.parse("2026-07-18T12:00:00.000Z");
private readonly waits: number[] = [];
private readonly opened: string[] = [];
private readonly instructions: string[] = [];
private readonly workflow: DeviceAuthorizationWorkflow;
constructor(private readonly cloud: FakeCloud) {
this.workflow = new DeviceAuthorizationWorkflow({
cloud,
waiter: {
wait: async (milliseconds) => {
this.waits.push(milliseconds);
this.now += milliseconds;
},
now: () => this.now,
},
browser: { open: async (url) => void this.opened.push(url) },
reporter: {
instructions: (url, code) => void this.instructions.push(`${url} ${code}`),
},
});
}
approve(hubUrl: string, displayName: string): Promise<string> {
return this.workflow.authorize(hubUrl, displayName);
}
observed() {
return {
starts: this.cloud.starts,
polls: this.cloud.polls,
waits: this.waits,
opened: this.opened,
instructions: this.instructions,
};
}
}
class FakeCloud implements CloudDeviceAuthorization {
readonly starts: Array<{ hubUrl: string; displayName: string }> = [];
readonly polls: Array<{
hubUrl: string;
deviceCode: string;
timeoutMilliseconds: number;
}> = [];
constructor(
private readonly outcomes: DeviceAuthorizationPoll[],
private readonly expiresAt = "2026-07-18T12:10:00.000Z",
) {}
async start(hubUrl: string, displayName: string) {
this.starts.push({ hubUrl, displayName });
return {
deviceCode: "device-code-with-more-than-thirty-two-characters",
userCode: "ABCD-EFGH-JKLMN",
verificationUri: "https://cloud.paseo.test/activate",
verificationUriComplete: "https://cloud.paseo.test/activate?code=ABCD-EFGH-JKLMN",
expiresAt: this.expiresAt,
interval: 5,
};
}
async poll(
hubUrl: string,
deviceCode: string,
timeoutMilliseconds: number,
): Promise<DeviceAuthorizationPoll> {
this.polls.push({ hubUrl, deviceCode, timeoutMilliseconds });
const outcome = this.outcomes[this.polls.length - 1];
if (outcome === undefined) throw new Error("No Cloud poll outcome remains");
return outcome;
}
}
class FakeDaemon {
readonly connections: Array<{ url: string; token: string }> = [];
closed = false;
async getHubStatus() {
return { status: hubStatus("not_connected") };
}
async connectHub(url: string, token: string) {
this.connections.push({ url, token });
return { status: hubStatus("connected") };
}
async disconnectHub() {
return { status: hubStatus("not_connected") };
}
async close() {
this.closed = true;
}
}
function hubStatus(state: string) {
return {
state,
daemonId: state === "connected" ? "daemon-1" : null,
hubOrigin: state === "connected" ? "https://cloud.paseo.test" : null,
scopes: state === "connected" ? ["hub.execution.*"] : [],
connectedAt: null,
lastError: null,
};
}
@@ -1,118 +0,0 @@
import { spawn } from "node:child_process";
import { platform } from "node:os";
import {
CloudDeviceAuthorizationClient,
type CloudDeviceAuthorization,
} from "./cloud-device-authorization.js";
export interface AuthorizationWaiter {
wait(milliseconds: number): Promise<void>;
now(): number;
}
export interface BrowserOpener {
open(url: string): Promise<void>;
}
type BrowserLaunch = (command: string, args: string[]) => Promise<void>;
interface SystemBrowserOptions {
hostPlatform?: NodeJS.Platform;
launch?: BrowserLaunch;
}
export class SystemBrowser implements BrowserOpener {
private readonly hostPlatform: NodeJS.Platform;
private readonly launch: BrowserLaunch;
constructor(options: SystemBrowserOptions = {}) {
this.hostPlatform = options.hostPlatform ?? platform();
this.launch = options.launch ?? launchDetached;
}
async open(url: string): Promise<void> {
if (this.hostPlatform === "win32") {
await this.launch("rundll32.exe", ["url.dll,FileProtocolHandler", url]);
return;
}
await this.launch(this.hostPlatform === "darwin" ? "open" : "xdg-open", [url]);
}
}
export interface AuthorizationReporter {
instructions(verificationUri: string, userCode: string): void;
}
interface DeviceAuthorizationWorkflowOptions {
cloud: CloudDeviceAuthorization;
waiter: AuthorizationWaiter;
browser: BrowserOpener;
reporter: AuthorizationReporter;
openBrowser?: boolean;
}
export class DeviceAuthorizationWorkflow {
constructor(private readonly options: DeviceAuthorizationWorkflowOptions) {}
async authorize(hubUrl: string, displayName: string): Promise<string> {
const authorization = await this.options.cloud.start(hubUrl, displayName);
this.options.reporter.instructions(authorization.verificationUri, authorization.userCode);
if (this.options.openBrowser !== false) {
await this.options.browser.open(authorization.verificationUriComplete).catch(() => undefined);
}
let interval = authorization.interval;
const expiresAt = Date.parse(authorization.expiresAt);
while (true) {
const remaining = expiresAt - this.options.waiter.now();
if (remaining <= 0) throw new Error("Daemon registration expired");
await this.options.waiter.wait(Math.min(interval * 1_000, remaining));
if (this.options.waiter.now() >= expiresAt) throw new Error("Daemon registration expired");
const pollLifetime = expiresAt - this.options.waiter.now();
if (pollLifetime <= 0) throw new Error("Daemon registration expired");
const outcome = await this.options.cloud.poll(hubUrl, authorization.deviceCode, pollLifetime);
if (this.options.waiter.now() >= expiresAt) throw new Error("Daemon registration expired");
if (outcome.status === "retry_later") continue;
interval = outcome.interval;
if (outcome.status === "approved") return outcome.enrollmentToken;
if (outcome.status === "denied") throw new Error("Daemon registration was denied");
if (outcome.status === "expired") throw new Error("Daemon registration expired");
if (outcome.status === "enrolled") {
throw new Error("Daemon registration was already used");
}
}
}
}
export function createDeviceAuthorizationWorkflow(): DeviceAuthorizationWorkflow {
return new DeviceAuthorizationWorkflow({
cloud: new CloudDeviceAuthorizationClient(),
waiter: {
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
now: Date.now,
},
browser: new SystemBrowser(),
reporter: {
instructions(verificationUri, userCode) {
process.stderr.write(`Open ${verificationUri} and enter code ${userCode}\n`);
},
},
openBrowser: process.stderr.isTTY === true,
});
}
async function launchDetached(command: string, args: string[]): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
detached: true,
shell: false,
stdio: "ignore",
});
child.once("spawn", () => {
child.unref();
resolve();
});
child.once("error", reject);
});
}
@@ -0,0 +1,52 @@
import type { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import type { HubDaemonConnection } from "./daemon-client.js";
import { withHubDaemon } from "./daemon-client.js";
import { reportHubProgress, type HubReporter } from "./reporter.js";
import { hubStatusResult } from "./status-output.js";
interface HubDisconnectOptions {
force?: boolean;
host?: string;
json?: boolean;
}
interface HubDisconnectDependencies {
daemon: HubDaemonConnection;
reporter: HubReporter;
}
export function runHubDisconnect(
options: HubDisconnectOptions,
dependencies: HubDisconnectDependencies,
) {
return withHubDaemon(dependencies.daemon, options.host, async (client) => {
const current = (await client.getHubStatus()).status;
if (current.hubOrigin !== null) {
reportHubProgress(
dependencies.reporter,
options,
`Disconnecting this daemon from ${current.hubOrigin}`,
);
}
const response = await client.disconnectHub(options.force ?? false);
return hubStatusResult(response.status, response.warning, current.hubOrigin);
});
}
export function addHubDisconnectCommand(
parent: Command,
dependencies: HubDisconnectDependencies,
): void {
addJsonAndDaemonHostOptions(
parent
.command("disconnect")
.option("--force", "Remove local authority even if the Hub is offline"),
).action(
withOutput(async (...args) => {
const options = args.at(-2) as HubDisconnectOptions;
return runHubDisconnect(options, dependencies);
}),
);
}
+2 -2
View File
@@ -1,10 +1,10 @@
export class HubDeployError extends Error {
export class HubCommandError extends Error {
constructor(
readonly code: string,
message: string,
readonly details?: string,
) {
super(message);
this.name = "HubDeployError";
this.name = "HubCommandError";
}
}
+8
View File
@@ -0,0 +1,8 @@
import type { Command } from "commander";
const resolutionHelp =
"\nHub origin precedence: command origin/--hub, PASEO_HUB_URL, active stored login, then https://hub.paseo.sh.\nCredential precedence: --api-key, PASEO_HUB_API_KEY, then a stored login for the exact resolved origin.\n";
export function addHubResolutionHelp(command: Command): Command {
return command.addHelpText("after", resolutionHelp);
}
+79 -134
View File
@@ -1,154 +1,99 @@
import { Command } from "commander";
import { hostname } from "node:os";
import { withOutput, type ListResult, type OutputSchema } from "../../output/index.js";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { connectToDaemon } from "../../utils/client.js";
import { createDeviceAuthorizationWorkflow } from "./device-authorization.js";
import { HubHttpClient } from "./client.js";
import { addHubConnectCommand } from "./connect.js";
import { PrivateHubCredentialStore, type HubCredentialStore } from "./credentials.js";
import {
type HubDaemonConnection,
productionHubDaemonConnection,
withHubDaemon,
} from "./daemon-client.js";
import { addHubDeployCommand } from "./deploy.js";
interface HubCommandClient {
connectHub(url: string, token: string): Promise<{ status: HubStatus }>;
getHubStatus(): Promise<{ status: HubStatus }>;
disconnectHub(force: boolean): Promise<{ status: HubStatus; warning?: string }>;
close(): Promise<void>;
}
interface HubStatus {
state: string;
daemonId: string | null;
hubOrigin: string | null;
scopes: string[];
connectedAt: string | null;
lastError: string | null;
}
import { addHubDisconnectCommand } from "./disconnect.js";
import { createCliLoginFlow, type CliLoginFlow } from "./login-flow.js";
import { addHubLoginCommand } from "./login.js";
import { addHubLogoutCommand, productionLogoutPrompt } from "./logout.js";
import { addHubProjectsCommand } from "./projects.js";
import { processHubReporter, type HubReporter } from "./reporter.js";
import { hubStatusResult } from "./status-output.js";
import { addHubResolutionHelp } from "./help.js";
interface HubCommandEnvironment {
connect(host: string | undefined): Promise<HubCommandClient>;
authorize(url: string, displayName: string): Promise<string>;
displayName(): string;
env: Readonly<Record<string, string | undefined>>;
credentials: HubCredentialStore;
hub: HubHttpClient;
login: Pick<CliLoginFlow, "authorize">;
daemon: HubDaemonConnection;
isInteractive(): boolean;
confirmDisconnect(origin: string): Promise<boolean>;
reporter: HubReporter;
cwd(): string;
}
const productionEnvironment: HubCommandEnvironment = {
connect: (host) => connectToDaemon({ host }),
authorize: (url, displayName) => createDeviceAuthorizationWorkflow().authorize(url, displayName),
displayName: hostname,
};
interface HubRow {
state: string;
daemonId: string | null;
hub: string | null;
scopes: string;
connectedAt: string | null;
error: string | null;
warning?: string;
}
const schema: OutputSchema<HubRow> = {
idField: "state",
columns: [
{ header: "STATE", field: "state" },
{ header: "HUB", field: "hub" },
{ header: "DAEMON", field: "daemonId" },
{ header: "SCOPES", field: "scopes" },
{ header: "CONNECTED", field: "connectedAt" },
{ header: "ERROR", field: "error" },
{ header: "WARNING", field: "warning" },
],
};
function result(
status: {
state: string;
daemonId: string | null;
hubOrigin: string | null;
scopes: string[];
connectedAt: string | null;
lastError: string | null;
},
warning?: string,
): ListResult<HubRow> {
function productionEnvironment(): HubCommandEnvironment {
const env = process.env;
const hub = new HubHttpClient();
return {
type: "list",
data: [
{
state: status.state,
daemonId: status.daemonId,
hub: status.hubOrigin,
scopes: status.scopes.join(", "),
connectedAt: status.connectedAt,
error: status.lastError,
warning,
},
],
schema,
env,
credentials: new PrivateHubCredentialStore(env),
hub,
login: createCliLoginFlow(hub),
daemon: productionHubDaemonConnection,
reporter: processHubReporter,
cwd: () => process.cwd(),
...productionLogoutPrompt,
};
}
async function withClient<T>(
environment: HubCommandEnvironment,
host: string | undefined,
action: (client: HubCommandClient) => Promise<T>,
): Promise<T> {
const client = await environment.connect(host);
try {
return await action(client);
} finally {
await client.close().catch(() => undefined);
}
}
export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {}): Command {
const environment = { ...productionEnvironment(), ...overrides };
const hub = addHubResolutionHelp(new Command("hub").description("Manage Paseo Hub"));
export function createHubCommand(
environment: HubCommandEnvironment = productionEnvironment,
): Command {
const hub = new Command("hub").description("Manage Paseo Hub");
addJsonAndDaemonHostOptions(
hub.command("connect").argument("<url>").option("--token <token>"),
).action(
withOutput(async (...args) => {
const url = args[0] as string;
const options = args.at(-2) as { token?: string; host?: string };
return withClient(environment, options.host, async (client) => {
if (options.token !== undefined) {
return result((await client.connectHub(url, options.token)).status);
}
const existing = (await client.getHubStatus()).status;
if (existing.state !== "not_connected" && existing.state !== "revoked") {
throw new Error("This daemon already has a Hub relationship");
}
const token = await environment.authorize(
url,
suggestedDisplayName(environment.displayName()),
);
return result((await client.connectHub(url, token)).status);
});
}),
);
addHubLoginCommand(hub, {
env: environment.env,
credentials: environment.credentials,
flow: environment.login,
reporter: environment.reporter,
});
addHubConnectCommand(hub, {
env: environment.env,
credentials: environment.credentials,
hub: environment.hub,
daemon: environment.daemon,
reporter: environment.reporter,
});
addJsonAndDaemonHostOptions(hub.command("status")).action(
withOutput(async (...args) => {
const options = args.at(-2) as { host?: string };
return withClient(environment, options.host, async (client) =>
result((await client.getHubStatus()).status),
return withHubDaemon(environment.daemon, options.host, async (client) =>
hubStatusResult((await client.getHubStatus()).status),
);
}),
);
addJsonAndDaemonHostOptions(
hub
.command("disconnect")
.option("--force", "Remove local authority even if the Hub is offline"),
).action(
withOutput(async (...args) => {
const options = args.at(-2) as { host?: string; force?: boolean };
return withClient(environment, options.host, async (client) => {
const response = await client.disconnectHub(options.force ?? false);
return result(response.status, response.warning);
});
}),
);
addHubDeployCommand(hub);
addHubDisconnectCommand(hub, {
daemon: environment.daemon,
reporter: environment.reporter,
});
addHubProjectsCommand(hub, {
env: environment.env,
credentials: environment.credentials,
hub: environment.hub,
reporter: environment.reporter,
});
addHubDeployCommand(hub, {
env: environment.env,
credentials: environment.credentials,
hub: environment.hub,
reporter: environment.reporter,
cwd: environment.cwd,
});
addHubLogoutCommand(hub, {
credentials: environment.credentials,
daemon: environment.daemon,
isInteractive: environment.isInteractive,
confirmDisconnect: environment.confirmDisconnect,
reporter: environment.reporter,
});
return hub;
}
function suggestedDisplayName(value: string): string {
return value.trim().slice(0, 100) || "Paseo daemon";
}
@@ -0,0 +1,106 @@
import { strict as assert } from "node:assert";
import { describe, it } from "vitest";
import type { CliAuthorizationPoll } from "./client.js";
import { CliLoginFlow, SystemBrowser } from "./login-flow.js";
describe("Hub CLI login browser flow", () => {
it("opens browser URLs on Windows without a command shell", async () => {
const launches: Array<{ command: string; args: string[] }> = [];
const browser = new SystemBrowser({
hostPlatform: "win32",
launch: async (command, args) => void launches.push({ command, args }),
});
await browser.open("https://hub.test/cli-login?code=ABCD-EFGH");
assert.deepEqual(launches, [
{
command: "rundll32.exe",
args: ["url.dll,FileProtocolHandler", "https://hub.test/cli-login?code=ABCD-EFGH"],
},
]);
});
it("follows Hub cadence and returns the durable authorized credential", async () => {
const journey = new LoginJourney([
{ status: "pending", interval: 5 },
{ status: "slow_down", interval: 10 },
{
status: "authorized",
interval: 10,
credential: "paseo_cli_prefix_durable-secret-value",
organizationId: "organization-1",
},
]);
const credential = await journey.authorize();
assert.equal(credential, "paseo_cli_prefix_durable-secret-value");
assert.deepEqual(journey.waits, [5_000, 5_000, 10_000]);
assert.deepEqual(journey.opened, ["https://hub.test/cli-login?code=ABCD-EFGH"]);
assert.deepEqual(journey.instructions, ["https://hub.test/cli-login ABCD-EFGH"]);
});
it("does not open a browser in noninteractive mode", async () => {
const journey = new LoginJourney(
[
{
status: "authorized",
interval: 5,
credential: "paseo_cli_prefix_durable-secret-value",
organizationId: "organization-1",
},
],
false,
);
await journey.authorize();
assert.deepEqual(journey.opened, []);
assert.equal(journey.instructions.length, 1);
});
});
class LoginJourney {
now = Date.parse("2026-08-08T10:00:00.000Z");
readonly waits: number[] = [];
readonly opened: string[] = [];
readonly instructions: string[] = [];
private pollIndex = 0;
private readonly flow: CliLoginFlow;
constructor(outcomes: CliAuthorizationPoll[], openBrowser = true) {
this.flow = new CliLoginFlow({
hub: {
startCliAuthorization: async () => ({
deviceCode: "device-code-with-more-than-thirty-two-characters",
userCode: "ABCD-EFGH",
verificationUri: "https://hub.test/cli-login",
verificationUriComplete: "https://hub.test/cli-login?code=ABCD-EFGH",
expiresAt: "2026-08-08T10:10:00.000Z",
interval: 5,
}),
pollCliAuthorization: async () => {
const outcome = outcomes[this.pollIndex];
this.pollIndex += 1;
if (outcome === undefined) throw new Error("No poll outcome remains");
return outcome;
},
},
waiter: {
wait: async (milliseconds) => {
this.waits.push(milliseconds);
this.now += milliseconds;
},
now: () => this.now,
},
browser: { open: async (url) => void this.opened.push(url) },
instructions: (url, code) => void this.instructions.push(`${url} ${code}`),
openBrowser,
});
}
authorize(): Promise<string> {
return this.flow.authorize("https://hub.test");
}
}
+107
View File
@@ -0,0 +1,107 @@
import { spawn } from "node:child_process";
import { platform } from "node:os";
import type { CliAuthorizationPoll, HubHttpClient } from "./client.js";
export interface LoginWaiter {
wait(milliseconds: number): Promise<void>;
now(): number;
}
export interface BrowserOpener {
open(url: string): Promise<void>;
}
type BrowserLaunch = (command: string, args: string[]) => Promise<void>;
interface SystemBrowserOptions {
hostPlatform?: NodeJS.Platform;
launch?: BrowserLaunch;
}
export class SystemBrowser implements BrowserOpener {
private readonly hostPlatform: NodeJS.Platform;
private readonly launch: BrowserLaunch;
constructor(options: SystemBrowserOptions = {}) {
this.hostPlatform = options.hostPlatform ?? platform();
this.launch = options.launch ?? launchDetached;
}
async open(url: string): Promise<void> {
if (this.hostPlatform === "win32") {
await this.launch("rundll32.exe", ["url.dll,FileProtocolHandler", url]);
return;
}
await this.launch(this.hostPlatform === "darwin" ? "open" : "xdg-open", [url]);
}
}
interface CliLoginFlowOptions {
hub: Pick<HubHttpClient, "startCliAuthorization" | "pollCliAuthorization">;
waiter: LoginWaiter;
browser: BrowserOpener;
instructions(verificationUri: string, userCode: string): void;
openBrowser: boolean;
}
export class CliLoginFlow {
constructor(private readonly options: CliLoginFlowOptions) {}
async authorize(origin: string): Promise<string> {
const authorization = await this.options.hub.startCliAuthorization(origin);
this.options.instructions(authorization.verificationUri, authorization.userCode);
if (this.options.openBrowser) {
await this.options.browser.open(authorization.verificationUriComplete).catch(() => undefined);
}
let interval = authorization.interval;
const expiresAt = Date.parse(authorization.expiresAt);
while (true) {
const remaining = expiresAt - this.options.waiter.now();
if (remaining <= 0) throw new Error("Hub CLI login expired");
await this.options.waiter.wait(Math.min(interval * 1_000, remaining));
const pollLifetime = expiresAt - this.options.waiter.now();
if (pollLifetime <= 0) throw new Error("Hub CLI login expired");
const outcome = await this.options.hub.pollCliAuthorization(
origin,
authorization.deviceCode,
pollLifetime,
);
const credential = approvedCredential(outcome);
if (credential !== null) return credential;
if (outcome.status === "denied") throw new Error("Hub CLI login was denied");
if (outcome.status === "expired") throw new Error("Hub CLI login expired");
if (outcome.status === "disclosed") throw new Error("Hub CLI login was already completed");
if (outcome.status !== "retry_later") interval = outcome.interval;
}
}
}
export function createCliLoginFlow(hub: HubHttpClient): CliLoginFlow {
return new CliLoginFlow({
hub,
waiter: {
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
now: Date.now,
},
browser: new SystemBrowser(),
instructions(verificationUri, userCode) {
process.stderr.write(`Open ${verificationUri} and enter code ${userCode}\n`);
},
openBrowser: process.stderr.isTTY === true,
});
}
function approvedCredential(outcome: CliAuthorizationPoll): string | null {
return outcome.status === "authorized" ? outcome.credential : null;
}
async function launchDetached(command: string, args: string[]): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args, { detached: true, shell: false, stdio: "ignore" });
child.once("spawn", () => {
child.unref();
resolve();
});
child.once("error", reject);
});
}
+61
View File
@@ -0,0 +1,61 @@
import type { Command } from "commander";
import { withOutput, type OutputSchema, type SingleResult } from "../../output/index.js";
import { addJsonOption } from "../../utils/command-options.js";
import type { HubCredentialStore } from "./credentials.js";
import type { CliLoginFlow } from "./login-flow.js";
import { resolveHubOrigin } from "./authority.js";
import { reportHubProgress, type HubReporter } from "./reporter.js";
import { addHubResolutionHelp } from "./help.js";
interface HubLoginResult {
origin: string;
status: "logged_in";
}
const schema: OutputSchema<HubLoginResult> = {
idField: "origin",
columns: [
{ header: "HUB", field: "origin" },
{ header: "STATUS", field: "status" },
],
};
interface HubLoginDependencies {
env: Readonly<Record<string, string | undefined>>;
credentials: HubCredentialStore;
flow: Pick<CliLoginFlow, "authorize">;
reporter: HubReporter;
}
export async function runHubLogin(
originInput: string | undefined,
options: { json?: boolean },
dependencies: HubLoginDependencies,
): Promise<SingleResult<HubLoginResult>> {
const origin = resolveHubOrigin({
options: { origin: originInput },
env: dependencies.env,
credentials: dependencies.credentials,
});
reportHubProgress(dependencies.reporter, options, `Logging in to ${origin}`);
const credential = await dependencies.flow.authorize(origin);
dependencies.credentials.save({ origin, credential });
return { type: "single", data: { origin, status: "logged_in" }, schema };
}
export function addHubLoginCommand(parent: Command, dependencies: HubLoginDependencies): void {
addJsonOption(
addHubResolutionHelp(
parent
.command("login")
.description("Log in to a Paseo Hub for CLI access")
.argument("[origin]", "Paseo Hub origin"),
),
).action(
withOutput(async (...args) => {
const origin = args[0] as string | undefined;
const options = args.at(-2) as { json?: boolean };
return runHubLogin(origin, options, dependencies);
}),
);
}
+108
View File
@@ -0,0 +1,108 @@
import { confirm, isCancel } from "@clack/prompts";
import type { Command } from "commander";
import { withOutput, type OutputSchema, type SingleResult } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import type { HubCredentialStore } from "./credentials.js";
import type { HubDaemonConnection, HubStatus } from "./daemon-client.js";
import { withHubDaemon } from "./daemon-client.js";
import { reportHubProgress, type HubReporter } from "./reporter.js";
interface HubLogoutResult {
origin: string | null;
status: "logged_out" | "not_logged_in";
daemonDisconnected: boolean;
}
const schema: OutputSchema<HubLogoutResult> = {
idField: "status",
columns: [
{ header: "HUB", field: "origin" },
{ header: "STATUS", field: "status" },
{ header: "DAEMON DISCONNECTED", field: "daemonDisconnected" },
],
};
interface HubLogoutOptions {
disconnectDaemon?: boolean;
force?: boolean;
host?: string;
json?: boolean;
}
interface HubLogoutDependencies {
credentials: HubCredentialStore;
daemon: HubDaemonConnection;
isInteractive(): boolean;
confirmDisconnect(origin: string): Promise<boolean>;
reporter: HubReporter;
}
export async function runHubLogout(
options: HubLogoutOptions,
dependencies: HubLogoutDependencies,
): Promise<SingleResult<HubLogoutResult>> {
const active = dependencies.credentials.active();
if (active === null) {
return logoutResult({ origin: null, status: "not_logged_in", daemonDisconnected: false });
}
reportHubProgress(dependencies.reporter, options, `Logging out of ${active.origin}`);
const mayPrompt = options.json !== true && dependencies.isInteractive();
let daemonStatus: HubStatus | null = null;
const shouldInspectDaemon = options.disconnectDaemon === true || mayPrompt;
if (shouldInspectDaemon) {
daemonStatus = await withHubDaemon(dependencies.daemon, options.host, async (daemon) =>
daemon.getHubStatus().then((response) => response.status),
);
}
const sameHub = daemonStatus?.hubOrigin === active.origin;
let shouldDisconnect = options.disconnectDaemon === true && sameHub;
if (options.disconnectDaemon !== true && mayPrompt && sameHub) {
shouldDisconnect = await dependencies.confirmDisconnect(active.origin);
}
if (!shouldDisconnect) {
dependencies.credentials.logoutActive();
return logoutResult({ origin: active.origin, status: "logged_out", daemonDisconnected: false });
}
reportHubProgress(
dependencies.reporter,
options,
`Disconnecting this daemon from ${active.origin}`,
);
await withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
await daemon.disconnectHub(options.force ?? false);
});
dependencies.credentials.logoutActive();
return logoutResult({ origin: active.origin, status: "logged_out", daemonDisconnected: true });
}
export function addHubLogoutCommand(parent: Command, dependencies: HubLogoutDependencies): void {
addJsonAndDaemonHostOptions(
parent
.command("logout")
.description("Remove the active stored Hub CLI login")
.option("--disconnect-daemon", "Also disconnect a daemon related to the same Hub")
.option("--force", "Remove daemon authority even if Hub is offline"),
).action(
withOutput(async (...args) => {
const options = args.at(-2) as HubLogoutOptions;
return runHubLogout(options, dependencies);
}),
);
}
export const productionLogoutPrompt = {
isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
async confirmDisconnect(origin: string): Promise<boolean> {
const answer = await confirm({
message: `Disconnect this daemon from ${origin}?`,
initialValue: false,
});
return !isCancel(answer) && answer;
},
};
function logoutResult(data: HubLogoutResult): SingleResult<HubLogoutResult> {
return { type: "single", data, schema };
}
@@ -0,0 +1,18 @@
import { strict as assert } from "node:assert";
import { describe, it } from "vitest";
import { normalizeHubOrigin } from "./origin.js";
describe("Hub origin", () => {
it.each(["http://localhost:3000", "http://127.0.0.1:3000", "http://[::1]:3000"])(
"allows loopback HTTP for local development: %s",
(origin) => {
assert.equal(normalizeHubOrigin(origin), origin);
},
);
it("rejects cleartext credential transport to a remote Hub", () => {
assert.throws(() => normalizeHubOrigin("http://hub.example.com"), {
code: "HUB_INVALID_ORIGIN",
});
});
});
+33
View File
@@ -0,0 +1,33 @@
import { HubCommandError } from "./error.js";
export function normalizeHubOrigin(value: string): string {
let url: URL;
try {
url = new URL(value);
} catch {
throw invalidHubOrigin();
}
if (
!["http:", "https:"].includes(url.protocol) ||
(url.protocol === "http:" && !isLoopbackHostname(url.hostname)) ||
url.username ||
url.password ||
url.pathname !== "/" ||
url.search ||
url.hash
) {
throw invalidHubOrigin();
}
return url.origin;
}
function isLoopbackHostname(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
}
function invalidHubOrigin(): HubCommandError {
return new HubCommandError(
"HUB_INVALID_ORIGIN",
"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].",
);
}
+91
View File
@@ -0,0 +1,91 @@
import type { Command } from "commander";
import { render, withOutput, type OutputSchema, type SingleResult } from "../../output/index.js";
import { addJsonOption } from "../../utils/command-options.js";
import { resolveHubCredential, resolveHubOrigin } from "./authority.js";
import type { HubHttpClient, HubProject } from "./client.js";
import type { HubCredentialStore } from "./credentials.js";
import { reportHubProgress, type HubReporter } from "./reporter.js";
import { addHubResolutionHelp } from "./help.js";
interface HubProjectRow extends HubProject {
origin: string;
}
interface HubProjectsResult {
origin: string;
projects: HubProject[];
}
const projectSchema: OutputSchema<HubProjectRow> = {
idField: "id",
columns: [
{ header: "SLUG", field: "slug" },
{ header: "NAME", field: "name" },
{ header: "ID", field: "id" },
{ header: "HUB", field: "origin" },
],
};
const schema: OutputSchema<HubProjectsResult> = {
idField: "origin",
columns: [
{ header: "HUB", field: "origin" },
{ header: "PROJECTS", field: (result) => result.projects.length },
],
renderHuman(result, options) {
const results = result.type === "single" ? [result.data] : result.data;
const data = results.flatMap((entry) =>
entry.projects.map((project) => ({ ...project, origin: entry.origin })),
);
return render<HubProjectRow>({ type: "list", data, schema: projectSchema }, options);
},
};
export interface HubProjectsOptions {
hub?: string;
apiKey?: string;
json?: boolean;
}
interface HubProjectsDependencies {
env: Readonly<Record<string, string | undefined>>;
credentials: HubCredentialStore;
hub: Pick<HubHttpClient, "listProjects">;
reporter: HubReporter;
}
export async function runHubProjects(
options: HubProjectsOptions,
dependencies: HubProjectsDependencies,
): Promise<SingleResult<HubProjectsResult>> {
const resolution = {
options: { origin: options.hub, apiKey: options.apiKey },
env: dependencies.env,
credentials: dependencies.credentials,
};
const origin = resolveHubOrigin(resolution);
reportHubProgress(dependencies.reporter, options, `Listing projects from ${origin}`);
const credential = resolveHubCredential({ ...resolution, origin });
const projects = await dependencies.hub.listProjects(origin, credential);
return { type: "single", data: { origin, projects }, schema };
}
export function addHubProjectsCommand(
parent: Command,
dependencies: HubProjectsDependencies,
): void {
addJsonOption(
addHubResolutionHelp(
parent
.command("projects")
.description("List projects for the authenticated Hub organization")
.option("--hub <origin>", "Paseo Hub origin")
.option("--api-key <secret>", "Organization API key"),
),
).action(
withOutput(async (...args) => {
const options = args.at(-2) as HubProjectsOptions;
return runHubProjects(options, dependencies);
}),
);
}
+22
View File
@@ -0,0 +1,22 @@
export interface HubReporter {
progress(message: string): void;
}
export const processHubReporter: HubReporter = {
progress(message) {
process.stderr.write(`${message}\n`);
},
};
interface HubReportOptions {
json?: boolean;
}
export function reportHubProgress(
reporter: HubReporter,
options: HubReportOptions,
message: string,
): void {
if (options.json === true) return;
reporter.progress(message);
}
@@ -0,0 +1,47 @@
import type { ListResult, OutputSchema } from "../../output/index.js";
import type { HubStatus } from "./daemon-client.js";
export interface HubRow {
state: string;
daemonId: string | null;
hub: string | null;
scopes: string;
connectedAt: string | null;
error: string | null;
warning?: string;
}
const schema: OutputSchema<HubRow> = {
idField: "state",
columns: [
{ header: "STATE", field: "state" },
{ header: "HUB", field: "hub" },
{ header: "DAEMON", field: "daemonId" },
{ header: "SCOPES", field: "scopes" },
{ header: "CONNECTED", field: "connectedAt" },
{ header: "ERROR", field: "error" },
{ header: "WARNING", field: "warning" },
],
};
export function hubStatusResult(
status: HubStatus,
warning?: string,
reportedHubOrigin: string | null = status.hubOrigin,
): ListResult<HubRow> {
return {
type: "list",
data: [
{
state: status.state,
daemonId: status.daemonId,
hub: reportedHubOrigin,
scopes: status.scopes.join(", "),
connectedAt: status.connectedAt,
error: status.lastError,
warning,
},
],
schema,
};
}
+1 -1
View File
@@ -65,7 +65,7 @@ export interface ListResult<T> {
}
/** Union type for all command results */
export type AnyCommandResult<T> = SingleResult<T> | ListResult<T>;
export type AnyCommandResult<T> = T extends unknown ? SingleResult<T> | ListResult<T> : never;
/** Base interface for command results (deprecated, use SingleResult or ListResult) */
export type CommandResult<T> = SingleResult<T> | ListResult<T>;