Files
paseo/packages/cli/src/commands/hub/commands.test.ts
T
Mohamed BoudraandGitHub 7beff9cb0b fix(hub): make guided setup choices actionable (#3677)
Show a concise login success state, scope starter choices to available Hub connections, wait for daemon capabilities, and avoid partial starter bundles when setup cannot proceed.
2026-08-22 10:37:19 +02:00

573 lines
18 KiB
TypeScript

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",
"init",
"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",
"progress:Logged in",
]);
assert.equal(result.data.origin, "https://hub.paseo.sh");
});
it("interactive login continues through the injected guided setup coordinator without invoking a CLI command", async () => {
const credentials = new MemoryCredentials();
const events: string[] = [];
await runHubLogin(
"https://hub.test",
{},
{
env: {},
credentials,
flow: {
authorize: async () => {
events.push("login");
return "paseo_cli_prefix_durable-secret";
},
},
isInteractive: () => true,
continueGuidedSetup: async (origin) => {
events.push(`connect:${origin}`);
events.push("init");
events.push("deploy");
},
reporter: { progress: (message) => events.push(`progress:${message}`) },
},
);
assert.deepEqual(events, [
"progress:Logging in to https://hub.test",
"login",
"progress:Logged in",
"connect:https://hub.test",
"init",
"deploy",
]);
});
it("JSON and noninteractive login remain login-only", async () => {
for (const [options, interactive] of [
[{ json: true }, true],
[{}, false],
] as const) {
const credentials = new MemoryCredentials();
let continuationCount = 0;
await runHubLogin(undefined, options, {
env: {},
credentials,
flow: { authorize: async () => "paseo_cli_prefix_durable-secret" },
isInteractive: () => interactive,
continueGuidedSetup: async () => {
continuationCount += 1;
},
reporter: quietReporter,
});
assert.equal(continuationCount, 0);
}
});
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);
assert.equal(result.data.warning, undefined);
});
it("logout reports a daemon disconnect warning", async () => {
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "human-secret" });
const daemon = new FakeDaemon("https://hub.test");
daemon.disconnectWarning = "Hub could not be reached; cleanup may remain pending.";
const result = await runHubLogout(
{ json: true, disconnectDaemon: true },
{
credentials,
daemon: new FakeDaemonConnection(daemon),
isInteractive: () => false,
confirmDisconnect: async () => false,
reporter: quietReporter,
},
);
assert.equal(result.data.daemonDisconnected, true);
assert.equal(result.data.warning, "Hub could not be reached; cleanup may remain pending.");
});
it("disconnect reports clean local status without a ghost Hub origin", 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, null);
});
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;
disconnectWarning: string | 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 getProvidersSnapshot() {
return { entries: [] };
}
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),
...(this.disconnectWarning ? { warning: this.disconnectWarning } : {}),
};
}
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,
};
}