1860 lines
68 KiB
TypeScript
Raw Permalink Normal View History

import { spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { createServer } from 'node:http';
import { createConnection } from 'node:net';
import * as os from 'node:os';
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
invokeHostControlFromStatus,
type IHostSupervisorHostStatus,
} from '@open-matrix/host-control';
import {
readRegistryCredential,
removeRegistryCredential,
saveRegistryCredential,
type IRegistryCredential,
} from '../utils/auth-store.js';
import {
readHiveCastHostLink,
readHiveCastLocalOwnerTransport,
readHiveCastLinkStatus,
ensureHiveCastInstallIdentity,
removeHiveCastCredentialFiles,
removeHiveCastHostLink,
saveHiveCastHostLink,
type IHiveCastLinkStatus,
type IHiveCastHostLinkRecord,
type IHiveCastNatsConfig,
type IHiveCastNatsCredentials,
} from '../utils/hivecast-link-store.js';
export interface IAuthContextOptions {
readonly cwd?: string;
readonly registry?: string;
readonly credentialsPath?: string;
readonly home?: string;
}
export interface ILoginCommandOptions extends IAuthContextOptions {
readonly username: string;
readonly token: string;
readonly email?: string;
readonly expiresAt?: string;
}
export interface ILoginCommandResult {
readonly registry: string;
readonly username: string;
readonly credentialsPath: string;
}
export interface ILogoutCommandResult {
readonly registry: string;
readonly removed: boolean;
readonly credentialsPath: string;
}
export interface IWhoamiCommandResult {
readonly registry: string;
readonly authenticated: boolean;
readonly username?: string;
readonly email?: string;
readonly expiresAt?: string;
}
export interface IHiveCastLoginCommandOptions extends IAuthContextOptions {
readonly cloudUrl?: string;
readonly setupCode?: string;
readonly routeKey?: string;
readonly device?: boolean;
readonly hostName?: string;
readonly callbackUrl?: string;
readonly pairNonce?: string;
readonly localPort?: number;
readonly waitForCallback?: boolean;
readonly noOpen?: boolean;
readonly forceRelink?: boolean;
readonly callbackTimeoutMs?: number;
readonly pollTimeoutMs?: number;
readonly openApprovalUrl?: (url: string) => void;
readonly onSetupUrl?: (setupUrl: string) => void;
readonly onDeviceStart?: (challenge: IHiveCastDeviceChallenge) => void;
readonly onProgress?: (event: IHiveCastLoginProgressEvent) => void;
}
export interface IHiveCastLoginProgressEvent {
readonly phase: 'link' | 'namespace' | 'ready';
readonly message: string;
}
export interface IHiveCastLoginCommandResult {
readonly mode: 'approval-required' | 'linked';
readonly linked: boolean;
readonly alreadyLinked?: boolean;
readonly cloudUrl: string;
readonly setupUrl?: string;
readonly deviceCode?: string;
readonly userCode?: string;
readonly verificationUri?: string;
readonly verificationUriComplete?: string;
readonly expiresIn?: number;
readonly matrixHome?: string;
readonly linkPath?: string;
readonly hostConfigPath?: string;
readonly natsCredentialsPath?: string;
readonly hostLinkId?: string;
readonly hostId?: string;
readonly authorityRoot?: string;
readonly routeKey?: string;
readonly publicNamespace?: string;
readonly spaceId?: string;
readonly hostName?: string;
readonly deviceSlug?: string;
readonly authorityCoordinator?: boolean;
readonly linkedAt?: string;
readonly hostConfigReload?: IHiveCastHostConfigReloadResult;
}
export interface IHiveCastLogoutCommandResult {
readonly removed: boolean;
readonly cloudRevoke?: IHiveCastCloudRevokeResult;
readonly hostConfigReset: boolean;
readonly matrixHome: string;
readonly linkPath: string;
readonly natsCredentialsPath: string;
readonly hostConfigReload?: IHiveCastHostConfigReloadResult;
}
export interface IHiveCastRefreshCredentialsCommandResult {
readonly refreshed: boolean;
readonly matrixHome: string;
readonly linkPath: string;
readonly natsCredentialsPath: string;
readonly currentCredentialsExpiresAt?: string;
readonly currentCredentialsSecondsRemaining?: number;
readonly refreshThresholdSeconds?: number;
readonly hostLinkId?: string;
readonly hostId?: string;
readonly authorityRoot?: string;
readonly routeKey?: string;
readonly publicNamespace?: string;
readonly spaceId?: string;
readonly hostName?: string;
readonly deviceSlug?: string;
readonly authorityCoordinator?: boolean;
readonly hostConfigReload?: IHiveCastHostConfigReloadResult;
}
export interface IHiveCastWhoamiCommandResult {
readonly linked: boolean;
readonly matrixHome: string;
readonly cloudUrl?: string;
readonly hostLinkId?: string;
readonly authorityRoot?: string;
readonly routeKey?: string;
readonly publicNamespace?: string;
readonly spaceId?: string;
readonly principalId?: string;
readonly hostName?: string;
readonly deviceSlug?: string;
readonly linkedAt?: string;
}
export interface IHiveCastHostConfigReloadResult {
readonly attempted: boolean;
readonly applied: boolean;
readonly statusPath: string;
readonly error?: string;
}
interface ITokenExchangeResponse {
readonly ok?: unknown;
readonly root?: unknown;
readonly authorityRoot?: unknown;
readonly deviceRegistryRoot?: unknown;
readonly routeKey?: unknown;
readonly publicNamespace?: unknown;
readonly spaceId?: unknown;
readonly principalId?: unknown;
readonly hostName?: unknown;
readonly deviceSlug?: unknown;
readonly authorityCoordinator?: unknown;
readonly hostLinkToken?: unknown;
readonly hostLinkId?: unknown;
readonly hostId?: unknown;
readonly hostLink?: unknown;
readonly device?: unknown;
readonly nats?: unknown;
readonly credentials?: unknown;
readonly error?: unknown;
}
export interface IHiveCastDeviceChallenge {
readonly deviceCode: string;
readonly userCode: string;
readonly verificationUri: string;
readonly verificationUriComplete?: string;
readonly expiresIn: number;
readonly interval: number;
}
interface IDeviceStartResponse {
readonly ok?: unknown;
readonly deviceCode?: unknown;
readonly userCode?: unknown;
readonly verificationUri?: unknown;
readonly verificationUriComplete?: unknown;
readonly expiresIn?: unknown;
readonly interval?: unknown;
readonly error?: unknown;
}
interface IDevicePollResponse {
readonly ok?: unknown;
readonly status?: unknown;
readonly interval?: unknown;
readonly error?: unknown;
}
interface IPairStartResponse {
readonly ok?: unknown;
readonly pairRequestId?: unknown;
readonly approvalUrl?: unknown;
readonly expiresIn?: unknown;
readonly error?: unknown;
}
interface INormalizedSetupExchange {
readonly authorityRoot: string;
readonly deviceRegistryRoot?: string;
readonly hostLinkId?: string;
readonly hostId?: string;
readonly routeKey?: string;
readonly publicNamespace?: string;
readonly spaceId?: string;
readonly principalId?: string;
readonly hostName?: string;
readonly deviceSlug?: string;
readonly authorityCoordinator?: boolean;
readonly hostLinkToken?: string;
readonly nats: IHiveCastNatsConfig;
readonly credentials: IHiveCastNatsCredentials;
}
interface IHiveCastCloudRevokeResult {
readonly attempted: boolean;
readonly revoked: boolean;
readonly skippedReason?: string;
readonly error?: string;
}
export interface IHiveCastLogoutCommandOptions extends IAuthContextOptions {
readonly localOnly?: boolean;
readonly revokeCloudLink?: boolean;
readonly all?: boolean;
}
export interface IHiveCastRefreshCredentialsCommandOptions extends IAuthContextOptions {
readonly ifExpiringWithinSeconds?: number;
readonly authorityCoordinator?: boolean;
}
interface IHostConfigReloadExpectation {
readonly root: string;
readonly authorityRoot?: string;
readonly routeKey?: string | null;
readonly publicNamespace?: string | null;
readonly spaceId?: string | null;
readonly forceReload?: boolean;
readonly label?: 'linked' | 'local-owner';
}
function hostConfigExpectationForLink(
matrixHome: string,
record: IHiveCastHostLinkRecord,
forceReload: boolean,
): IHostConfigReloadExpectation {
return {
root: record.authorityRoot,
authorityRoot: record.authorityRoot,
routeKey: record.routeKey ?? null,
publicNamespace: record.publicNamespace ?? null,
spaceId: record.spaceId ?? null,
forceReload,
label: 'linked',
};
}
function reportLoginProgress(
options: IHiveCastLoginCommandOptions,
phase: IHiveCastLoginProgressEvent['phase'],
message: string,
): void {
options.onProgress?.({ phase, message });
}
function assertRunningHostConfigReloadApplied(
result: IHiveCastHostConfigReloadResult,
expectation: IHostConfigReloadExpectation,
): void {
if (!result.attempted || result.applied) {
return;
}
const expected = [
`expectedRoot=${expectation.root}`,
...(expectation.authorityRoot ? [`expectedAuthorityRoot=${expectation.authorityRoot}`] : []),
`statusPath=${result.statusPath}`,
].join(' ');
throw new Error(
`HIVECAST_LINK_HOST_CONFIG_RELOAD_FAILED: running Host did not apply ${expectation.label ?? 'linked'} config`
+ `${result.error ? `: ${result.error}` : ''}; ${expected}`,
);
}
function localOwnerTransportForExpectation(
matrixHome: string,
): { readonly root: string; readonly authorityRoot?: string } | null {
const localOwnerTransport = readHiveCastLocalOwnerTransport(process.cwd(), matrixHome);
if (localOwnerTransport) {
return localOwnerTransport;
}
const hostConfig = readJsonRecordFile(path.join(matrixHome, 'host.json'));
const transport = asRecord(hostConfig?.transport);
const root = readOptionalString(transport?.root);
if (!root) {
return null;
}
const authorityRoot = readOptionalString(transport?.authorityRoot);
return {
root,
...(authorityRoot ? { authorityRoot } : {}),
};
}
const DEFAULT_HIVECAST_CLOUD_URL = 'https://hivecast.ai';
const DEFAULT_NATS_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024;
const HOST_CONFIG_STATUS_APPLY_TIMEOUT_MS = 60_000;
const HOST_CONFIG_RUNTIME_APPLY_MIN_TIMEOUT_MS = 90_000;
const HOST_CONFIG_RUNTIME_APPLY_PER_RUNTIME_MS = 20_000;
const LOCAL_OWNER_NATS_START_TIMEOUT_MS = 10_000;
function readOptionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function decodeJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split('.');
if (parts.length !== 3 || !parts[1]) {
return null;
}
try {
return asRecord(JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')));
} catch {
return null;
}
}
function readJwtExpirationSeconds(token: string): number | null {
const payload = decodeJwtPayload(token);
const exp = payload?.exp;
return typeof exp === 'number' && Number.isFinite(exp) ? exp : null;
}
function readCredentialsExpirationSeconds(filePath: string): number | null {
if (!fs.existsSync(filePath)) {
return null;
}
let parsed: Record<string, unknown> | null;
try {
parsed = asRecord(JSON.parse(fs.readFileSync(filePath, 'utf8')));
} catch {
return null;
}
const jwt = readOptionalString(parsed?.jwt);
return jwt ? readJwtExpirationSeconds(jwt) : null;
}
export function normalizeNonNegativeInteger(value: string | undefined, label: string): number | undefined {
if (value === undefined) {
return undefined;
}
if (!/^\d+$/.test(value.trim())) {
throw new Error(`${label} must be a non-negative integer number of seconds`);
}
return Number.parseInt(value, 10);
}
async function readJsonResponse<T>(
response: Response,
label: string,
): Promise<T> {
const text = await response.text();
if (!text.trim()) {
throw new Error(`${label}: empty response body (HTTP ${response.status})`);
}
try {
return JSON.parse(text) as T;
} catch (error: unknown) {
const preview = text.slice(0, 240).replace(/\s+/g, ' ').trim();
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`${label}: invalid JSON response (HTTP ${response.status}): ${reason}${preview ? `: ${preview}` : ''}`);
}
}
function normalizeCloudUrl(raw: string | undefined): string {
const value = raw?.trim() || process.env.HIVECAST_PLATFORM_URL || DEFAULT_HIVECAST_CLOUD_URL;
const parsed = new URL(value);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw new Error(`HIVECAST_LOGIN_INVALID_CLOUD_URL: unsupported protocol ${parsed.protocol}`);
}
return parsed.origin;
}
function openBrowser(url: string): void {
const command = process.platform === 'darwin'
? 'open'
: process.platform === 'win32'
? 'cmd'
: 'xdg-open';
const args = process.platform === 'win32'
? ['/c', 'start', '', url]
: [url];
const child = spawn(command, args, {
stdio: 'ignore',
detached: true,
windowsHide: true,
});
child.on('error', () => {
// Best effort: the setup URL is printed by the caller.
});
child.unref();
}
function readJsonRecordFile(filePath: string): Record<string, unknown> | null {
if (!fs.existsSync(filePath)) {
return null;
}
try {
return asRecord(JSON.parse(fs.readFileSync(filePath, 'utf8')));
} catch {
return null;
}
}
function hostStatusPath(matrixHome: string): string {
return path.join(matrixHome, 'host.status.json');
}
function hostRuntimeRecordsDir(matrixHome: string): string {
return path.join(matrixHome, 'runtimes');
}
function readHostStatusForSupervisor(statusPath: string): IHostSupervisorHostStatus | null {
const parsed = readJsonRecordFile(statusPath);
if (!parsed || readOptionalString(parsed.status) !== 'running') {
return null;
}
const transport = asRecord(parsed.transport);
const nats = asRecord(transport?.nats);
const root = readOptionalString(transport?.root);
const natsUrl = readOptionalString(nats?.url);
if (!root || !natsUrl) {
return null;
}
const credentialsRef = readOptionalString(nats?.credentialsRef);
const supervisorMount = readOptionalString(parsed.supervisorMount);
return {
...(supervisorMount ? { supervisorMount } : {}),
transport: {
root,
nats: {
url: natsUrl,
...(credentialsRef ? { credentialsRef } : {}),
},
},
};
}
function hostStatusMatchesExpectation(
statusPath: string,
expectation: IHostConfigReloadExpectation,
): boolean {
const parsed = readJsonRecordFile(statusPath);
const transport = asRecord(parsed?.transport);
if (!transport) {
return false;
}
const root = readOptionalString(transport.root);
const authorityRoot = readOptionalString(transport.authorityRoot) ?? root;
const routeKey = readOptionalString(transport.routeKey);
const publicNamespace = readOptionalString(transport.publicNamespace);
const spaceId = readOptionalString(transport.spaceId);
if (root !== expectation.root) {
return false;
}
if (expectation.authorityRoot !== undefined && authorityRoot !== expectation.authorityRoot) {
return false;
}
if (expectation.routeKey !== undefined && routeKey !== (expectation.routeKey ?? undefined)) {
return false;
}
if (
expectation.publicNamespace !== undefined
&& publicNamespace !== (expectation.publicNamespace ?? undefined)
) {
return false;
}
if (expectation.spaceId !== undefined && spaceId !== (expectation.spaceId ?? undefined)) {
return false;
}
return true;
}
async function waitForHostStatusExpectation(
statusPath: string,
expectation: IHostConfigReloadExpectation,
timeoutMs = HOST_CONFIG_STATUS_APPLY_TIMEOUT_MS,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
if (hostStatusMatchesExpectation(statusPath, expectation)) {
return true;
}
await delay(100);
}
return false;
}
function readHostRuntimeRecords(matrixHome: string): Record<string, unknown>[] {
const recordsDir = hostRuntimeRecordsDir(matrixHome);
if (!fs.existsSync(recordsDir)) {
return [];
}
const records: Record<string, unknown>[] = [];
for (const entry of fs.readdirSync(recordsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const record = readJsonRecordFile(path.join(recordsDir, entry.name, 'runtime.json'));
if (record) {
records.push(record);
}
}
return records;
}
function isProcessAliveForRuntime(pid: unknown): boolean {
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (error: unknown) {
return asRecord(error)?.code === 'EPERM';
}
}
function positivePort(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0 || value > 65535) {
return undefined;
}
return value;
}
function positiveInteger(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
return undefined;
}
return value;
}
function loopbackUrlPort(raw: string | undefined): number | undefined {
if (!raw) return undefined;
try {
const parsed = new URL(raw);
if (!['127.0.0.1', 'localhost', '[::1]', '::1'].includes(parsed.hostname)) {
return undefined;
}
const port = Number.parseInt(parsed.port, 10);
return Number.isFinite(port) && port > 0 && port <= 65535 ? port : undefined;
} catch {
return undefined;
}
}
async function isTcpPortOpen(host: string, port: number): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const socket = createConnection({ host, port });
const done = (open: boolean): void => {
socket.removeAllListeners();
socket.destroy();
resolve(open);
};
socket.setTimeout(500);
socket.once('connect', () => done(true));
socket.once('timeout', () => done(false));
socket.once('error', () => done(false));
});
}
async function waitForTcpPort(host: string, port: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
if (await isTcpPortOpen(host, port)) {
return true;
}
await delay(100);
}
return false;
}
function resolveHostRelativePath(matrixHome: string, raw: string | undefined, defaultRelative: string): string {
const value = raw ?? defaultRelative;
return path.isAbsolute(value) ? value : path.resolve(matrixHome, value);
}
function writeLocalOwnerNatsConfig(matrixHome: string, nats: Record<string, unknown>): {
readonly binaryPath: string;
readonly confFile: string;
readonly pidFile: string;
readonly dataDir: string;
readonly port: number;
readonly wsPort: number;
} {
const port = positivePort(nats.port) ?? loopbackUrlPort(readOptionalString(nats.url));
const wsPort = positivePort(nats.wsPort) ?? loopbackUrlPort(readOptionalString(nats.wsUrl));
const maxPayloadBytes = positiveInteger(nats.maxPayloadBytes) ?? DEFAULT_NATS_MAX_PAYLOAD_BYTES;
if (!port || !wsPort) {
throw new Error('Local owner NATS transport requires loopback client and websocket ports');
}
const dataDir = resolveHostRelativePath(matrixHome, readOptionalString(nats.dataDir), path.join('nats', 'host-default'));
const pidFile = resolveHostRelativePath(
matrixHome,
readOptionalString(nats.pidFile),
path.join('nats', 'host-default', 'nats-server.pid'),
);
const binaryPath = resolveHostRelativePath(matrixHome, readOptionalString(nats.binaryPath), path.join('bin', 'nats-server'));
const confFile = path.join(dataDir, 'nats-server.conf');
fs.mkdirSync(dataDir, { recursive: true });
const escapedStoreDir = dataDir.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
fs.writeFileSync(confFile, [
'host: "127.0.0.1"',
`port: ${port}`,
`max_payload: ${maxPayloadBytes}`,
'jetstream {',
` store_dir: "${escapedStoreDir}"`,
'}',
'websocket {',
' host: "127.0.0.1"',
` port: ${wsPort}`,
' no_tls: true',
'}',
'',
].join('\n'), 'utf8');
return { binaryPath, confFile, pidFile, dataDir, port, wsPort };
}
async function ensureLocalOwnerNatsDependency(matrixHome: string): Promise<void> {
const config = readJsonRecordFile(path.join(matrixHome, 'host.json'));
const transport = asRecord(config?.transport);
const nats = asRecord(transport?.nats);
if (!transport || !nats) return;
const root = readOptionalString(transport.root);
const mode = readOptionalString(nats.mode);
const credentialsRef = readOptionalString(nats.credentialsRef);
if (!root || mode !== 'external' || credentialsRef) {
return;
}
const port = positivePort(nats.port) ?? loopbackUrlPort(readOptionalString(nats.url));
const wsPort = positivePort(nats.wsPort) ?? loopbackUrlPort(readOptionalString(nats.wsUrl));
if (!port || !wsPort) return;
if (await isTcpPortOpen('127.0.0.1', port)) {
return;
}
const paths = writeLocalOwnerNatsConfig(matrixHome, nats);
if (!fs.existsSync(paths.binaryPath)) {
throw new Error(`Local owner NATS binary is missing: ${paths.binaryPath}`);
}
if (fs.existsSync(paths.pidFile)) {
const pid = Number.parseInt(fs.readFileSync(paths.pidFile, 'utf8').trim(), 10);
if (Number.isInteger(pid) && pid > 0 && isProcessAliveForRuntime(pid)) {
throw new Error(`Local owner NATS pid ${pid} is alive but port ${port} is not reachable`);
}
fs.rmSync(paths.pidFile, { force: true });
}
fs.mkdirSync(path.dirname(paths.pidFile), { recursive: true });
fs.mkdirSync(path.join(matrixHome, 'logs'), { recursive: true });
const stdout = fs.openSync(path.join(matrixHome, 'logs', 'nats.stdout.log'), 'a');
const stderr = fs.openSync(path.join(matrixHome, 'logs', 'nats.stderr.log'), 'a');
const child = spawn(paths.binaryPath, ['-c', paths.confFile], {
detached: true,
stdio: ['ignore', stdout, stderr],
windowsHide: true,
});
if (!child.pid) {
throw new Error('Local owner NATS did not provide a process id');
}
fs.writeFileSync(paths.pidFile, `${child.pid}\n`, 'utf8');
child.unref();
const started = await waitForTcpPort('127.0.0.1', port, LOCAL_OWNER_NATS_START_TIMEOUT_MS);
if (!started) {
throw new Error(`Local owner NATS pid ${child.pid} did not open port ${port} within ${LOCAL_OWNER_NATS_START_TIMEOUT_MS}ms`);
}
}
function runtimeRecordMatchesExpectation(
record: Record<string, unknown>,
expectation: IHostConfigReloadExpectation,
): boolean {
const metadata = asRecord(record.metadata);
const transport = asRecord(metadata?.transport);
const root = readOptionalString(transport?.root) ?? readOptionalString(record.runtimeWireRoot);
const authorityRoot = readOptionalString(transport?.authorityRoot) ?? root;
const routeKey = readOptionalString(transport?.routeKey);
const publicNamespace = readOptionalString(transport?.publicNamespace);
const spaceId = readOptionalString(transport?.spaceId);
if (root !== expectation.root) {
return false;
}
if (expectation.authorityRoot !== undefined && authorityRoot !== expectation.authorityRoot) {
return false;
}
if (expectation.routeKey !== undefined && routeKey !== (expectation.routeKey ?? undefined)) {
return false;
}
if (
expectation.publicNamespace !== undefined
&& publicNamespace !== (expectation.publicNamespace ?? undefined)
) {
return false;
}
if (expectation.spaceId !== undefined && spaceId !== (expectation.spaceId ?? undefined)) {
return false;
}
return true;
}
function activeDefaultRuntimeRecordsReady(
matrixHome: string,
expectation: IHostConfigReloadExpectation,
): boolean {
const activeDefaultRecords = readActiveDefaultRuntimeRecords(matrixHome);
if (activeDefaultRecords.length === 0) {
return true;
}
return activeDefaultRecords.every((record) => (
record.status === 'running'
&& isProcessAliveForRuntime(record.pid)
&& runtimeRecordMatchesExpectation(record, expectation)
));
}
function readActiveDefaultRuntimeRecords(matrixHome: string): Record<string, unknown>[] {
return readHostRuntimeRecords(matrixHome).filter((record) => {
const metadata = asRecord(record.metadata);
return metadata?.hivecastDefaultRuntimePolicy === 'active' && record.startup === 'auto';
});
}
function hostConfigRuntimeApplyTimeoutMs(matrixHome: string): number {
const activeRuntimeCount = readActiveDefaultRuntimeRecords(matrixHome).length;
if (activeRuntimeCount === 0) {
return HOST_CONFIG_RUNTIME_APPLY_MIN_TIMEOUT_MS;
}
// Host Service starts persisted runtimes in dependency order and waits for
// each control actor. The CLI reload waiter must scale with that contract.
return Math.max(
HOST_CONFIG_RUNTIME_APPLY_MIN_TIMEOUT_MS,
activeRuntimeCount * HOST_CONFIG_RUNTIME_APPLY_PER_RUNTIME_MS,
);
}
async function waitForActiveDefaultRuntimeRecords(
matrixHome: string,
expectation: IHostConfigReloadExpectation,
timeoutMs = hostConfigRuntimeApplyTimeoutMs(matrixHome),
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
if (activeDefaultRuntimeRecordsReady(matrixHome, expectation)) {
return true;
}
await delay(100);
}
return false;
}
async function activeDefaultWebappOriginsReady(matrixHome: string): Promise<boolean> {
const webappOrigins = readActiveDefaultRuntimeRecords(matrixHome)
.map((record) => readOptionalString(asRecord(asRecord(record.metadata)?.webapp)?.origin))
.filter((origin): origin is string => origin !== undefined);
if (webappOrigins.length === 0) {
return true;
}
const probes = await Promise.all(webappOrigins.map((origin) => webappOriginReady(origin)));
return probes.every(Boolean);
}
async function activeDefaultHostWebappRoutesReady(matrixHome: string, statusPath: string): Promise<boolean> {
const hostOrigin = readHostHttpOrigin(statusPath);
if (!hostOrigin) {
return false;
}
const routePrefixes = readActiveDefaultRuntimeRecords(matrixHome)
.map((record) => readOptionalString(asRecord(asRecord(record.metadata)?.webapp)?.routePrefix))
.filter((routePrefix): routePrefix is string => routePrefix !== undefined);
if (routePrefixes.length === 0) {
return true;
}
const probes = await Promise.all(routePrefixes.map((routePrefix) => {
const routeUrl = new URL(routePrefix, hostOrigin);
return webappOriginReady(routeUrl.href);
}));
return probes.every(Boolean);
}
async function waitForActiveDefaultWebappOrigins(
matrixHome: string,
statusPath: string,
timeoutMs = hostConfigRuntimeApplyTimeoutMs(matrixHome),
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
if (
await activeDefaultWebappOriginsReady(matrixHome)
&& await activeDefaultHostWebappRoutesReady(matrixHome, statusPath)
) {
return true;
}
await delay(100);
}
return false;
}
async function describeActiveDefaultWebappReadiness(matrixHome: string, statusPath: string): Promise<string> {
const hostOrigin = readHostHttpOrigin(statusPath);
const records = readActiveDefaultRuntimeRecords(matrixHome)
.map((record) => {
const webapp = asRecord(asRecord(record.metadata)?.webapp);
return {
runtimeId: readOptionalString(record.runtimeId) ?? 'unknown-runtime',
routePrefix: readOptionalString(webapp?.routePrefix),
origin: readOptionalString(webapp?.origin),
};
})
.filter((record) => record.routePrefix || record.origin);
const lines: string[] = [];
for (const record of records) {
const direct = record.origin
? await describeWebappUrl(record.origin)
: 'direct=missing';
const routed = hostOrigin && record.routePrefix
? await describeWebappUrl(new URL(record.routePrefix, hostOrigin).href)
: 'routed=missing';
lines.push(`${record.runtimeId} route=${record.routePrefix ?? 'none'} ${direct} ${routed}`);
}
return lines.slice(0, 12).join('; ');
}
async function describeWebappUrl(url: string): Promise<string> {
try {
const response = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(1_500) });
const body = await response.text();
const assetUrls = webappAssetUrls(new URL(url), body);
const firstAsset = assetUrls[0];
if (!firstAsset) {
return `${url} html=${response.status} asset=none`;
}
try {
const assetResponse = await fetch(firstAsset, { method: 'GET', signal: AbortSignal.timeout(1_500) });
await assetResponse.arrayBuffer();
return `${url} html=${response.status} asset=${assetResponse.status} assetUrl=${firstAsset.href}`;
} catch (error) {
return `${url} html=${response.status} asset=fetch-failed:${error instanceof Error ? error.message : String(error)}`;
}
} catch (error) {
return `${url} html=fetch-failed:${error instanceof Error ? error.message : String(error)}`;
}
}
function readHostHttpOrigin(statusPath: string): string | undefined {
const parsed = readJsonRecordFile(statusPath);
const http = asRecord(parsed?.http);
return readOptionalString(http?.origin);
}
async function webappOriginReady(origin: string): Promise<boolean> {
let url: URL;
try {
url = new URL(origin);
} catch {
return false;
}
if (!['http:', 'https:'].includes(url.protocol)) {
return false;
}
try {
const response = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(1_500) });
const body = await response.text();
if (response.status < 200 || response.status >= 500) {
return false;
}
const assetUrls = webappAssetUrls(url, body);
if (assetUrls.length === 0) {
return true;
}
const assetResponses = await Promise.all(assetUrls.map(async (assetUrl) => {
try {
const assetResponse = await fetch(assetUrl, { method: 'GET', signal: AbortSignal.timeout(1_500) });
await assetResponse.arrayBuffer();
return assetResponse.status >= 200 && assetResponse.status < 500;
} catch {
return false;
}
}));
return assetResponses.every(Boolean);
} catch {
return false;
}
}
function webappAssetUrls(origin: URL, body: string): URL[] {
const urls: URL[] = [];
const seen = new Set<string>();
const assetPattern = /\b(?:src|href)=["']([^"']*assets\/[^"']+)["']/g;
let match = assetPattern.exec(body);
while (match) {
const raw = match[1];
if (raw) {
try {
const url = new URL(raw, origin);
if (url.origin === origin.origin && !seen.has(url.href)) {
seen.add(url.href);
urls.push(url);
}
} catch {
// Ignore malformed asset references in readiness probing.
}
}
match = assetPattern.exec(body);
}
return urls.slice(0, 8);
}
async function reloadRunningHostConfig(
matrixHome: string,
expectation: IHostConfigReloadExpectation,
): Promise<IHiveCastHostConfigReloadResult> {
const statusPath = hostStatusPath(matrixHome);
if (
!expectation.forceReload
&& hostStatusMatchesExpectation(statusPath, expectation)
&& activeDefaultRuntimeRecordsReady(matrixHome, expectation)
) {
return { attempted: false, applied: true, statusPath };
}
const status = readHostStatusForSupervisor(statusPath);
if (!status) {
return { attempted: false, applied: false, statusPath };
}
try {
await invokeHostControlFromStatus(status, 'config.reload', {}, 5_000);
const statusApplied = await waitForHostStatusExpectation(statusPath, expectation);
const applied = statusApplied;
const expectationLabel = expectation.label ?? 'linked';
return {
attempted: true,
applied,
statusPath,
...(applied ? {} : { error: statusApplied
? `Running Host applied ${expectationLabel} config but did not report success`
: `Timed out waiting for running Host to apply ${expectationLabel} config` }),
};
} catch (error) {
return {
attempted: true,
applied: false,
statusPath,
error: error instanceof Error ? error.message : String(error),
};
}
}
async function waitForLoopbackSetupCode(
cloudUrl: string,
options: IHiveCastLoginCommandOptions,
): Promise<INormalizedSetupExchange> {
const timeoutMs = options.callbackTimeoutMs ?? 10 * 60 * 1000;
const pairNonce = randomBytes(18).toString('base64url');
return await new Promise<INormalizedSetupExchange>((resolve, reject) => {
let settled = false;
const server = createServer((req, res) => {
void (async () => {
const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
if (requestUrl.pathname !== '/auth/callback') {
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Not found');
return;
}
const pairRequestId = readOptionalString(requestUrl.searchParams.get('pairRequestId'));
const approvalCode = readOptionalString(requestUrl.searchParams.get('approvalCode'));
const nonce = readOptionalString(requestUrl.searchParams.get('nonce'));
if (!pairRequestId || !approvalCode) {
res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' });
res.end('<h1>HiveCast login failed</h1><p>Missing approval code.</p>');
return;
}
if (nonce !== pairNonce) {
res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' });
res.end('<h1>HiveCast login failed</h1><p>Invalid approval nonce.</p>');
return;
}
const exchange = await exchangePairApprovalWithRequiredCodes(cloudUrl, pairRequestId, approvalCode);
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end('<h1>HiveCast connected</h1><p>You can return to the terminal.</p>');
settled = true;
clearTimeout(timer);
resolve(exchange);
server.close();
})().catch((error: unknown) => {
if (!settled) {
settled = true;
clearTimeout(timer);
if (!res.headersSent) {
res.writeHead(500, { 'content-type': 'text/html; charset=utf-8' });
res.end('<h1>HiveCast login failed</h1><p>Pairing exchange failed.</p>');
}
server.close();
reject(error instanceof Error ? error : new Error(String(error)));
}
});
});
const timer = setTimeout(() => {
if (settled) return;
settled = true;
server.close();
reject(new Error('HIVECAST_LOGIN_TIMEOUT: timed out waiting for browser approval'));
}, timeoutMs);
server.once('error', (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(err);
});
server.listen(options.localPort ?? 0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address !== 'object') {
clearTimeout(timer);
settled = true;
server.close();
reject(new Error('HIVECAST_LOGIN_CALLBACK_FAILED: failed to bind loopback callback'));
return;
}
void startPairFlow(cloudUrl, {
...options,
callbackUrl: `http://127.0.0.1:${address.port}/auth/callback`,
pairNonce,
}).then((pair) => {
options.onSetupUrl?.(pair.approvalUrl);
if (!options.noOpen) {
(options.openApprovalUrl ?? openBrowser)(pair.approvalUrl);
}
}).catch((error: unknown) => {
if (settled) return;
settled = true;
clearTimeout(timer);
server.close();
reject(error instanceof Error ? error : new Error(String(error)));
});
});
});
}
function normalizeSetupExchange(value: ITokenExchangeResponse): INormalizedSetupExchange {
const authorityRoot = readOptionalString(value.authorityRoot) ?? readOptionalString(value.root);
if (!authorityRoot) {
throw new Error('HIVECAST_LOGIN_INVALID_EXCHANGE: response is missing authority root');
}
const nats = asRecord(value.nats);
const natsUrl = readOptionalString(nats?.url);
if (!natsUrl) {
throw new Error('HIVECAST_LOGIN_INVALID_EXCHANGE: response is missing nats.url');
}
const credentials = asRecord(value.credentials);
const jwt = readOptionalString(credentials?.jwt);
const seed = readOptionalString(credentials?.seed);
if (!jwt || !seed) {
throw new Error('HIVECAST_LOGIN_INVALID_EXCHANGE: credentials must contain device-scoped jwt+seed');
}
const wsUrl = readOptionalString(nats?.wsUrl);
const leafnodeUrl = readOptionalString(nats?.leafnodeUrl);
const routeKey = readOptionalString(value.routeKey);
const deviceRegistryRoot = readOptionalString(value.deviceRegistryRoot);
const publicNamespace = readOptionalString(value.publicNamespace);
const spaceId = readOptionalString(value.spaceId);
const principalId = readOptionalString(value.principalId);
const hostLink = asRecord(value.hostLink);
const device = asRecord(value.device);
const hostLinkId = readOptionalString(value.hostLinkId)
?? readOptionalString(hostLink?.id);
const hostId = readOptionalString(value.hostId)
?? readOptionalString(hostLink?.hostId)
?? readOptionalString(device?.deviceCode);
const hostName = readOptionalString(value.hostName)
?? readOptionalString(hostLink?.hostName);
const deviceSlug = readOptionalString(value.deviceSlug)
?? readOptionalString(hostLink?.deviceSlug);
const authorityCoordinator = typeof value.authorityCoordinator === 'boolean'
? value.authorityCoordinator
: typeof hostLink?.authorityCoordinator === 'boolean'
? hostLink.authorityCoordinator
: undefined;
const hostLinkToken = readOptionalString(value.hostLinkToken)
?? readOptionalString(hostLink?.heartbeatToken)
?? readOptionalString(hostLink?.hostLinkToken);
return {
authorityRoot,
...(deviceRegistryRoot ? { deviceRegistryRoot } : {}),
...(hostLinkId ? { hostLinkId } : {}),
...(hostId ? { hostId } : {}),
...(routeKey ? { routeKey } : {}),
...(publicNamespace ? { publicNamespace } : {}),
...(spaceId ? { spaceId } : {}),
...(principalId ? { principalId } : {}),
...(hostName ? { hostName } : {}),
...(deviceSlug ? { deviceSlug } : {}),
...(typeof authorityCoordinator === 'boolean' ? { authorityCoordinator } : {}),
...(hostLinkToken ? { hostLinkToken } : {}),
nats: {
url: natsUrl,
...(wsUrl ? { wsUrl } : {}),
...(leafnodeUrl ? { leafnodeUrl } : {}),
},
credentials: { jwt, seed },
};
}
async function exchangeSetupCode(cloudUrl: string, code: string): Promise<INormalizedSetupExchange> {
const response = await fetch(`${cloudUrl}/api/token-exchange`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code }),
});
const parsed = await readJsonResponse<ITokenExchangeResponse>(response, 'HiveCast setup exchange');
if (!response.ok || parsed.ok !== true) {
const message = readOptionalString(parsed.error) ?? `HiveCast setup exchange failed: HTTP ${response.status}`;
throw new Error(`HIVECAST_LOGIN_EXCHANGE_FAILED: ${message}`);
}
return normalizeSetupExchange(parsed);
}
async function exchangePairApproval(
cloudUrl: string,
pairRequestId: string,
approvalCode: string,
): Promise<INormalizedSetupExchange> {
const response = await fetch(`${cloudUrl}/_auth/pair/exchange`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ pairRequestId, approvalCode }),
});
const parsed = await readJsonResponse<ITokenExchangeResponse>(response, 'HiveCast pair exchange');
if (!response.ok || parsed.ok !== true) {
const message = readOptionalString(parsed.error) ?? `HiveCast pair exchange failed: HTTP ${response.status}`;
throw new Error(`HIVECAST_PAIR_EXCHANGE_FAILED: ${message}`);
}
return normalizeSetupExchange(parsed);
}
async function exchangePairApprovalWithRequiredCodes(
cloudUrl: string,
pairRequestId: string | undefined,
approvalCode: string | undefined,
): Promise<INormalizedSetupExchange> {
if (!pairRequestId || !approvalCode) {
throw new Error('HIVECAST_PAIR_APPROVAL_REQUIRED: missing pair approval code');
}
return await exchangePairApproval(cloudUrl, pairRequestId, approvalCode);
}
async function startPairFlow(
cloudUrl: string,
options: IHiveCastLoginCommandOptions,
): Promise<{ readonly pairRequestId: string; readonly approvalUrl: string; readonly expiresIn: number }> {
const localReturnUrl = readOptionalString(options.callbackUrl);
const nonce = readOptionalString(options.pairNonce);
const routeKey = readOptionalString(options.routeKey);
const requestedHostName = readOptionalString(options.hostName) ?? os.hostname();
const { identity } = ensureHiveCastInstallIdentity({
cwd: options.cwd ?? process.cwd(),
matrixHome: options.home,
hostName: requestedHostName,
});
const response = await fetch(`${cloudUrl}/_auth/pair/start`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
hostId: identity.installId,
hostName: identity.hostName ?? requestedHostName,
authorityCoordinator: false,
...(localReturnUrl ? { localReturnUrl } : {}),
...(nonce ? { nonce } : {}),
...(routeKey ? { routeKey } : {}),
}),
});
const parsed = await readJsonResponse<IPairStartResponse>(response, 'HiveCast pair start');
if (!response.ok || parsed.ok !== true) {
const message = readOptionalString(parsed.error) ?? `HiveCast pairing start failed: HTTP ${response.status}`;
throw new Error(`HIVECAST_PAIR_START_FAILED: ${message}`);
}
const pairRequestId = readOptionalString(parsed.pairRequestId);
const approvalUrl = readOptionalString(parsed.approvalUrl);
if (!pairRequestId || !approvalUrl) {
throw new Error('HIVECAST_PAIR_INVALID_START: response is missing pairing challenge fields');
}
const expiresIn = typeof parsed.expiresIn === 'number' && Number.isFinite(parsed.expiresIn)
? parsed.expiresIn
: 180 * 24 * 60 * 60;
return { pairRequestId, approvalUrl, expiresIn };
}
function normalizeDeviceChallenge(value: IDeviceStartResponse): IHiveCastDeviceChallenge {
const deviceCode = readOptionalString(value.deviceCode);
const userCode = readOptionalString(value.userCode);
const verificationUri = readOptionalString(value.verificationUri);
const verificationUriComplete = readOptionalString(value.verificationUriComplete);
const expiresIn = typeof value.expiresIn === 'number' && Number.isFinite(value.expiresIn)
? value.expiresIn
: 180 * 24 * 60 * 60;
const interval = typeof value.interval === 'number' && Number.isFinite(value.interval) && value.interval > 0
? value.interval
: 2;
if (!deviceCode || !userCode || !verificationUri) {
throw new Error('HIVECAST_DEVICE_INVALID_START: response is missing device challenge fields');
}
return {
deviceCode,
userCode,
verificationUri,
...(verificationUriComplete ? { verificationUriComplete } : {}),
expiresIn,
interval,
};
}
async function startDeviceFlow(
cloudUrl: string,
options: IHiveCastLoginCommandOptions,
): Promise<IHiveCastDeviceChallenge> {
const requestedHostName = readOptionalString(options.hostName) ?? os.hostname();
const { identity } = ensureHiveCastInstallIdentity({
cwd: options.cwd ?? process.cwd(),
matrixHome: options.home,
hostName: requestedHostName,
});
let lastError: unknown;
for (let attempt = 1; attempt <= 4; attempt += 1) {
try {
const response = await fetch(`${cloudUrl}/_auth/device/start`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
hostId: identity.installId,
hostName: identity.hostName ?? requestedHostName,
authorityCoordinator: false,
...(readOptionalString(options.routeKey) ? { routeKey: readOptionalString(options.routeKey) } : {}),
}),
});
const parsed = await readJsonResponse<IDeviceStartResponse>(response, 'HiveCast device start');
if (!response.ok || parsed.ok !== true) {
const message = readOptionalString(parsed.error) ?? `HiveCast device start failed: HTTP ${response.status}`;
throw new Error(`HIVECAST_DEVICE_START_FAILED: ${message}`);
}
return normalizeDeviceChallenge(parsed);
} catch (error: unknown) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
if (!isRetryableDeviceStartError(message) || attempt === 4) {
break;
}
await delay(attempt * 500);
}
}
const message = lastError instanceof Error ? lastError.message : String(lastError ?? 'unknown error');
throw message.startsWith('HIVECAST_DEVICE_START_FAILED:')
? new Error(message)
: new Error(`HIVECAST_DEVICE_START_FAILED: ${message}`);
}
function isRetryableDeviceStartError(message: string): boolean {
return /\bHTTP (502|503|504)\b/i.test(message)
|| /empty response body/i.test(message)
|| /fetch failed|network|timeout/i.test(message);
}
async function pollDeviceApproval(
cloudUrl: string,
challenge: IHiveCastDeviceChallenge,
timeoutMs: number,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let intervalMs = Math.max(1, challenge.interval) * 1000;
while (Date.now() < deadline) {
const response = await fetch(`${cloudUrl}/_auth/device/poll`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ deviceCode: challenge.deviceCode }),
});
let parsed: IDevicePollResponse;
try {
parsed = await readJsonResponse<IDevicePollResponse>(response, 'HiveCast device poll');
} catch (error: unknown) {
const remainingMs = deadline - Date.now();
if (response.ok && remainingMs > 1_000) {
await delay(Math.min(1_000, remainingMs));
continue;
}
throw error;
}
const status = readOptionalString(parsed.status);
if ((!response.ok || parsed.ok !== true) && status !== 'namespace_required') {
const message = readOptionalString(parsed.error) ?? `HiveCast device poll failed: HTTP ${response.status}`;
throw new Error(`HIVECAST_DEVICE_POLL_FAILED: ${message}`);
}
if (status === 'approved') {
return;
}
if (status !== 'authorization_pending' && status !== 'namespace_required') {
throw new Error(`HIVECAST_DEVICE_POLL_FAILED: unexpected status ${status ?? 'unknown'}`);
}
if (typeof parsed.interval === 'number' && Number.isFinite(parsed.interval) && parsed.interval > 0) {
intervalMs = parsed.interval * 1000;
}
await delay(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
}
throw new Error('HIVECAST_DEVICE_TIMEOUT: timed out waiting for device approval');
}
async function exchangeDeviceCode(
cloudUrl: string,
deviceCode: string,
): Promise<INormalizedSetupExchange> {
const response = await fetch(`${cloudUrl}/_auth/device/exchange`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ deviceCode }),
});
const parsed = await readJsonResponse<ITokenExchangeResponse>(response, 'HiveCast device exchange');
if (!response.ok || parsed.ok !== true) {
const message = readOptionalString(parsed.error) ?? `HiveCast device exchange failed: HTTP ${response.status}`;
throw new Error(`HIVECAST_DEVICE_EXCHANGE_FAILED: ${message}`);
}
return normalizeSetupExchange(parsed);
}
function deviceChallengeTimeoutMs(challenge: IHiveCastDeviceChallenge): number {
if (Number.isFinite(challenge.expiresIn) && challenge.expiresIn > 0) {
return (challenge.expiresIn * 1000) + 5_000;
}
return 180 * 24 * 60 * 60 * 1000;
}
async function delay(ms: number): Promise<void> {
if (ms <= 0) {
return;
}
await new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
export async function loginCommand(options: ILoginCommandOptions): Promise<ILoginCommandResult> {
if (!options.username.trim()) {
throw new Error('MX_AUTH_REQUIRED: username is required for login');
}
if (!options.token.trim()) {
throw new Error('MX_AUTH_REQUIRED: token is required for login');
}
const cwd = options.cwd ?? process.cwd();
const credential: IRegistryCredential = {
username: options.username.trim(),
token: options.token.trim(),
email: options.email?.trim() || undefined,
expiresAt: options.expiresAt?.trim() || undefined,
};
const { registry, credentialsPath } = saveRegistryCredential(cwd, credential, {
registry: options.registry,
credentialsPath: options.credentialsPath,
});
return {
registry,
username: credential.username,
credentialsPath,
};
}
export async function logoutCommand(options: IAuthContextOptions = {}): Promise<ILogoutCommandResult> {
const cwd = options.cwd ?? process.cwd();
return removeRegistryCredential(cwd, {
registry: options.registry,
credentialsPath: options.credentialsPath,
});
}
export async function whoamiCommand(options: IAuthContextOptions = {}): Promise<IWhoamiCommandResult> {
const cwd = options.cwd ?? process.cwd();
const { registry, credential } = readRegistryCredential(cwd, {
registry: options.registry,
credentialsPath: options.credentialsPath,
});
if (!credential) {
return { registry, authenticated: false };
}
return {
registry,
authenticated: true,
username: credential.username,
email: credential.email,
expiresAt: credential.expiresAt,
};
}
export async function hiveCastLoginCommand(
options: IHiveCastLoginCommandOptions = {},
): Promise<IHiveCastLoginCommandResult> {
const cwd = options.cwd ?? process.cwd();
const cloudUrl = normalizeCloudUrl(options.cloudUrl);
const existing = readHiveCastHostLink(cwd, options.home);
if (existing.record && options.forceRelink !== true) {
let activeRecord = existing.record;
let activePaths = existing.paths;
reportLoginProgress(options, 'link', `existing Device link found for ${activeRecord.authorityRoot}`);
const requestedRouteKey = readOptionalString(options.routeKey);
if (normalizeCloudUrl(activeRecord.cloudUrl) !== cloudUrl) {
throw new Error(
`HIVECAST_LINK_CLOUD_MISMATCH: Host is already linked to ${activeRecord.cloudUrl}; use --relink to replace it`,
);
}
if (requestedRouteKey && activeRecord.routeKey !== requestedRouteKey) {
throw new Error(
`HIVECAST_LINK_ROUTE_MISMATCH: Host is already linked to route key ${activeRecord.routeKey ?? '(none)'}; use --relink to replace it`,
);
}
if (options.device === true) {
reportLoginProgress(options, 'link', 'refreshing linked Device credentials');
await hiveCastRefreshCredentialsCommand({
cwd,
home: options.home,
authorityCoordinator: false,
});
const refreshed = readHiveCastHostLink(cwd, options.home);
if (refreshed.record) {
activeRecord = refreshed.record;
activePaths = refreshed.paths;
}
}
const expectation = hostConfigExpectationForLink(activePaths.matrixHome, activeRecord, false);
reportLoginProgress(options, 'namespace', `applying authority root ${expectation.root}`);
const hostConfigReload = await reloadRunningHostConfig(activePaths.matrixHome, expectation);
assertRunningHostConfigReloadApplied(hostConfigReload, expectation);
reportLoginProgress(
options,
'namespace',
hostConfigReload.applied ? `Host config converged at ${expectation.root}` : 'no running Host config to reload',
);
return {
mode: 'linked',
linked: true,
alreadyLinked: true,
cloudUrl: activeRecord.cloudUrl,
matrixHome: activePaths.matrixHome,
linkPath: activePaths.linkPath,
hostConfigPath: activePaths.hostConfigPath,
natsCredentialsPath: activePaths.natsCredentialsPath,
...(activeRecord.hostLinkId ? { hostLinkId: activeRecord.hostLinkId } : {}),
...(activeRecord.hostId ? { hostId: activeRecord.hostId } : {}),
authorityRoot: activeRecord.authorityRoot,
...(activeRecord.routeKey ? { routeKey: activeRecord.routeKey } : {}),
...(activeRecord.publicNamespace ? { publicNamespace: activeRecord.publicNamespace } : {}),
...(activeRecord.spaceId ? { spaceId: activeRecord.spaceId } : {}),
...(typeof activeRecord.authorityCoordinator === 'boolean'
? { authorityCoordinator: activeRecord.authorityCoordinator }
: {}),
linkedAt: activeRecord.linkedAt,
hostConfigReload,
};
}
let exchange: INormalizedSetupExchange | null = null;
if (options.device) {
reportLoginProgress(options, 'link', `starting device approval with ${cloudUrl}`);
const challenge = await startDeviceFlow(cloudUrl, options);
options.onDeviceStart?.(challenge);
if (options.waitForCallback === false) {
return {
mode: 'approval-required',
linked: false,
cloudUrl,
setupUrl: challenge.verificationUriComplete ?? challenge.verificationUri,
deviceCode: challenge.deviceCode,
userCode: challenge.userCode,
verificationUri: challenge.verificationUri,
...(challenge.verificationUriComplete ? { verificationUriComplete: challenge.verificationUriComplete } : {}),
expiresIn: challenge.expiresIn,
};
}
reportLoginProgress(options, 'link', `waiting for approval code ${challenge.userCode}`);
await pollDeviceApproval(
cloudUrl,
challenge,
options.pollTimeoutMs ?? options.callbackTimeoutMs ?? deviceChallengeTimeoutMs(challenge),
);
reportLoginProgress(options, 'link', 'approved');
reportLoginProgress(options, 'link', 'exchanging device credentials');
exchange = await exchangeDeviceCode(cloudUrl, challenge.deviceCode);
}
const setupCode = readOptionalString(options.setupCode);
if (!exchange && setupCode) {
exchange = await exchangeSetupCode(cloudUrl, setupCode);
}
if (!exchange && options.waitForCallback) {
exchange = await waitForLoopbackSetupCode(cloudUrl, options);
}
if (!exchange) {
const pair = await startPairFlow(cloudUrl, options);
return {
mode: 'approval-required',
linked: false,
cloudUrl,
setupUrl: pair.approvalUrl,
};
}
const routeKey = readOptionalString(options.routeKey) ?? exchange.routeKey;
const publicNamespace = exchange.publicNamespace ?? (routeKey ? `space.${routeKey}` : undefined);
const requestedHostName = readOptionalString(options.hostName);
const { identity: installIdentity } = ensureHiveCastInstallIdentity({
cwd,
matrixHome: options.home,
...(requestedHostName ? { hostName: requestedHostName } : {}),
});
const hostName = installIdentity.hostName ?? exchange.hostName ?? requestedHostName;
const { record, paths } = saveHiveCastHostLink({
cwd,
matrixHome: options.home,
cloudUrl,
...(exchange.deviceRegistryRoot ? { deviceRegistryRoot: exchange.deviceRegistryRoot } : {}),
authorityRoot: exchange.authorityRoot,
nats: exchange.nats,
natsCredentials: exchange.credentials,
...(exchange.hostLinkToken ? { hostLinkToken: exchange.hostLinkToken } : {}),
...(exchange.hostLinkId ? { hostLinkId: exchange.hostLinkId } : {}),
...(exchange.hostId ? { hostId: exchange.hostId } : {}),
...(exchange.principalId ? { principalId: exchange.principalId } : {}),
...(hostName ? { hostName } : {}),
...(exchange.deviceSlug ? { deviceSlug: exchange.deviceSlug } : {}),
...(typeof exchange.authorityCoordinator === 'boolean' ? { authorityCoordinator: exchange.authorityCoordinator } : {}),
...(exchange.spaceId ? { spaceId: exchange.spaceId } : {}),
...(routeKey ? { routeKey } : {}),
...(publicNamespace ? { publicNamespace } : {}),
});
reportLoginProgress(options, 'link', `writing credentials: ${paths.linkPath}`);
const expectation = hostConfigExpectationForLink(paths.matrixHome, record, true);
reportLoginProgress(options, 'namespace', `applying authority root ${expectation.root}`);
const hostConfigReload = await reloadRunningHostConfig(paths.matrixHome, expectation);
assertRunningHostConfigReloadApplied(hostConfigReload, expectation);
reportLoginProgress(
options,
'namespace',
hostConfigReload.applied ? `Host config converged at ${expectation.root}` : 'no running Host config to reload',
);
reportLoginProgress(options, 'ready', `Device link persisted for ${record.authorityRoot}`);
return {
mode: 'linked',
linked: true,
cloudUrl,
matrixHome: paths.matrixHome,
linkPath: paths.linkPath,
hostConfigPath: paths.hostConfigPath,
natsCredentialsPath: paths.natsCredentialsPath,
...(record.hostLinkId ? { hostLinkId: record.hostLinkId } : {}),
...(record.hostId ? { hostId: record.hostId } : {}),
authorityRoot: record.authorityRoot,
...(record.routeKey ? { routeKey: record.routeKey } : {}),
...(record.publicNamespace ? { publicNamespace: record.publicNamespace } : {}),
...(record.spaceId ? { spaceId: record.spaceId } : {}),
...(typeof record.authorityCoordinator === 'boolean' ? { authorityCoordinator: record.authorityCoordinator } : {}),
...(record.hostName ? { hostName: record.hostName } : {}),
...(record.deviceSlug ? { deviceSlug: record.deviceSlug } : {}),
linkedAt: record.linkedAt,
hostConfigReload,
};
}
async function revokeHiveCastCloudHostLink(
existing: ReturnType<typeof readHiveCastHostLink>,
): Promise<IHiveCastCloudRevokeResult> {
const { record, paths } = existing;
if (!record) {
return { attempted: false, revoked: false, skippedReason: 'not-linked' };
}
if (!record.hostLinkId || !record.hostId) {
return { attempted: false, revoked: false, skippedReason: 'missing-host-link-id-or-host-id' };
}
const hostLinkToken = fs.existsSync(paths.hostLinkTokenPath)
? fs.readFileSync(paths.hostLinkTokenPath, 'utf8').trim()
: '';
if (!hostLinkToken) {
return { attempted: false, revoked: false, skippedReason: 'missing-host-link-token' };
}
try {
const response = await fetch(`${record.cloudUrl}/_auth/host-link/revoke`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${hostLinkToken}`,
},
body: JSON.stringify({
hostLinkId: record.hostLinkId,
hostId: record.hostId,
}),
});
const parsed = await readJsonResponse<{ readonly ok?: unknown; readonly error?: unknown }>(
response,
'HiveCast Host-link revoke',
);
if (!response.ok || parsed.ok !== true) {
return {
attempted: true,
revoked: false,
error: readOptionalString(parsed.error) ?? `HiveCast Host-link revoke failed: HTTP ${response.status}`,
};
}
return { attempted: true, revoked: true };
} catch (error) {
return {
attempted: true,
revoked: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
export async function hiveCastRefreshCredentialsCommand(
options: IHiveCastRefreshCredentialsCommandOptions = {},
): Promise<IHiveCastRefreshCredentialsCommandResult> {
const cwd = options.cwd ?? process.cwd();
const { record, paths } = readHiveCastHostLink(cwd, options.home);
if (!record) {
throw new Error('HIVECAST_REFRESH_NOT_LINKED: no local HiveCast Host link found');
}
if (!record.hostLinkId || !record.hostId) {
throw new Error('HIVECAST_REFRESH_MISSING_LINK_ID: local HiveCast Host link is missing hostLinkId or hostId');
}
const refreshThresholdSeconds = options.ifExpiringWithinSeconds;
const currentExpirationSeconds = readCredentialsExpirationSeconds(paths.natsCredentialsPath)
?? readCredentialsExpirationSeconds(paths.hostCredentialsPath);
if (refreshThresholdSeconds !== undefined && currentExpirationSeconds !== null) {
const currentCredentialsSecondsRemaining = currentExpirationSeconds - Math.floor(Date.now() / 1000);
if (currentCredentialsSecondsRemaining > refreshThresholdSeconds) {
return {
refreshed: false,
matrixHome: paths.matrixHome,
linkPath: paths.linkPath,
natsCredentialsPath: paths.natsCredentialsPath,
currentCredentialsExpiresAt: new Date(currentExpirationSeconds * 1000).toISOString(),
currentCredentialsSecondsRemaining,
refreshThresholdSeconds,
...(record.hostLinkId ? { hostLinkId: record.hostLinkId } : {}),
...(record.hostId ? { hostId: record.hostId } : {}),
authorityRoot: record.authorityRoot,
...(record.routeKey ? { routeKey: record.routeKey } : {}),
...(record.publicNamespace ? { publicNamespace: record.publicNamespace } : {}),
...(record.spaceId ? { spaceId: record.spaceId } : {}),
...(record.hostName ? { hostName: record.hostName } : {}),
...(record.deviceSlug ? { deviceSlug: record.deviceSlug } : {}),
...(typeof record.authorityCoordinator === 'boolean' ? { authorityCoordinator: record.authorityCoordinator } : {}),
};
}
}
const hostLinkToken = fs.existsSync(paths.hostLinkTokenPath)
? fs.readFileSync(paths.hostLinkTokenPath, 'utf8').trim()
: '';
if (!hostLinkToken) {
throw new Error('HIVECAST_REFRESH_MISSING_TOKEN: local HiveCast Host link is missing its refresh token');
}
const response = await fetch(`${record.cloudUrl}/_auth/host-link/credentials/refresh`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${hostLinkToken}`,
},
body: JSON.stringify({
hostLinkId: record.hostLinkId,
hostId: record.hostId,
...(options.authorityCoordinator === false ? { authorityCoordinator: false } : {}),
}),
});
const parsed = await readJsonResponse<ITokenExchangeResponse>(response, 'HiveCast credential refresh');
if (!response.ok || parsed.ok !== true) {
const message = readOptionalString(parsed.error) ?? `HiveCast credential refresh failed: HTTP ${response.status}`;
throw new Error(`HIVECAST_REFRESH_FAILED: ${message}`);
}
const exchange = normalizeSetupExchange(parsed);
const routeKey = exchange.routeKey ?? record.routeKey;
const publicNamespace = exchange.publicNamespace ?? record.publicNamespace;
const hostName = exchange.hostName ?? record.hostName;
const { record: saved, paths: savedPaths } = saveHiveCastHostLink({
cwd,
matrixHome: options.home,
cloudUrl: record.cloudUrl,
...(exchange.deviceRegistryRoot ?? record.deviceRegistryRoot ? { deviceRegistryRoot: exchange.deviceRegistryRoot ?? record.deviceRegistryRoot } : {}),
authorityRoot: exchange.authorityRoot,
nats: exchange.nats,
natsCredentials: exchange.credentials,
hostLinkToken: exchange.hostLinkToken ?? hostLinkToken,
...(exchange.hostLinkId ?? record.hostLinkId ? { hostLinkId: exchange.hostLinkId ?? record.hostLinkId } : {}),
...(exchange.hostId ?? record.hostId ? { hostId: exchange.hostId ?? record.hostId } : {}),
...(exchange.principalId ?? record.principalId ? { principalId: exchange.principalId ?? record.principalId } : {}),
...(hostName ? { hostName } : {}),
...(exchange.deviceSlug ?? record.deviceSlug ? { deviceSlug: exchange.deviceSlug ?? record.deviceSlug } : {}),
...(typeof (exchange.authorityCoordinator ?? record.authorityCoordinator) === 'boolean'
? { authorityCoordinator: exchange.authorityCoordinator ?? record.authorityCoordinator }
: {}),
...(exchange.spaceId ?? record.spaceId ? { spaceId: exchange.spaceId ?? record.spaceId } : {}),
...(routeKey ? { routeKey } : {}),
...(publicNamespace ? { publicNamespace } : {}),
linkedAt: record.linkedAt,
});
const hostConfigReload = await reloadRunningHostConfig(
savedPaths.matrixHome,
hostConfigExpectationForLink(savedPaths.matrixHome, saved, true),
);
assertRunningHostConfigReloadApplied(
hostConfigReload,
hostConfigExpectationForLink(savedPaths.matrixHome, saved, true),
);
const refreshedExpirationSeconds = readCredentialsExpirationSeconds(savedPaths.natsCredentialsPath);
return {
refreshed: true,
matrixHome: savedPaths.matrixHome,
linkPath: savedPaths.linkPath,
natsCredentialsPath: savedPaths.natsCredentialsPath,
...(refreshedExpirationSeconds !== null
? {
currentCredentialsExpiresAt: new Date(refreshedExpirationSeconds * 1000).toISOString(),
currentCredentialsSecondsRemaining: refreshedExpirationSeconds - Math.floor(Date.now() / 1000),
}
: {}),
...(refreshThresholdSeconds !== undefined ? { refreshThresholdSeconds } : {}),
...(saved.hostLinkId ? { hostLinkId: saved.hostLinkId } : {}),
...(saved.hostId ? { hostId: saved.hostId } : {}),
authorityRoot: saved.authorityRoot,
...(saved.routeKey ? { routeKey: saved.routeKey } : {}),
...(saved.publicNamespace ? { publicNamespace: saved.publicNamespace } : {}),
...(saved.spaceId ? { spaceId: saved.spaceId } : {}),
...(saved.hostName ? { hostName: saved.hostName } : {}),
...(saved.deviceSlug ? { deviceSlug: saved.deviceSlug } : {}),
...(typeof saved.authorityCoordinator === 'boolean' ? { authorityCoordinator: saved.authorityCoordinator } : {}),
hostConfigReload,
};
}
export async function hiveCastLogoutCommand(
options: IHiveCastLogoutCommandOptions = {},
): Promise<IHiveCastLogoutCommandResult> {
const cwd = options.cwd ?? process.cwd();
const existing = readHiveCastHostLink(cwd, options.home);
const shouldRevokeCloudLink = options.all === true || options.revokeCloudLink === true;
const cloudRevoke = shouldRevokeCloudLink
? await revokeHiveCastCloudHostLink(existing)
: undefined;
const result = removeHiveCastHostLink(cwd, options.home, { preserveCredentials: true });
await ensureLocalOwnerNatsDependency(result.paths.matrixHome);
const localOwnerTransport = localOwnerTransportForExpectation(result.paths.matrixHome);
if (!localOwnerTransport) {
throw new Error(`HIVECAST_LINK_LOCAL_TRANSPORT_MISSING: installed local Host transport is missing for ${result.paths.matrixHome}`);
}
const hostConfigReload = await reloadRunningHostConfig(result.paths.matrixHome, {
root: localOwnerTransport.root,
authorityRoot: localOwnerTransport.authorityRoot ?? localOwnerTransport.root,
routeKey: null,
publicNamespace: null,
spaceId: null,
forceReload: true,
label: 'local-owner',
});
removeHiveCastCredentialFiles(result.paths);
return {
removed: result.removed,
...(cloudRevoke ? { cloudRevoke } : {}),
hostConfigReset: result.hostConfigReset,
matrixHome: result.paths.matrixHome,
linkPath: result.paths.linkPath,
natsCredentialsPath: result.paths.natsCredentialsPath,
hostConfigReload,
};
}
export async function hiveCastWhoamiCommand(
options: IAuthContextOptions = {},
): Promise<IHiveCastWhoamiCommandResult> {
const cwd = options.cwd ?? process.cwd();
const { record, paths } = readHiveCastHostLink(cwd, options.home);
if (!record) {
return {
linked: false,
matrixHome: paths.matrixHome,
};
}
return {
linked: true,
matrixHome: paths.matrixHome,
cloudUrl: record.cloudUrl,
...(record.hostLinkId ? { hostLinkId: record.hostLinkId } : {}),
authorityRoot: record.authorityRoot,
...(record.routeKey ? { routeKey: record.routeKey } : {}),
...(record.publicNamespace ? { publicNamespace: record.publicNamespace } : {}),
...(record.spaceId ? { spaceId: record.spaceId } : {}),
...(record.principalId ? { principalId: record.principalId } : {}),
...(record.hostName ? { hostName: record.hostName } : {}),
...(record.deviceSlug ? { deviceSlug: record.deviceSlug } : {}),
linkedAt: record.linkedAt,
};
}
export async function hiveCastLinkStatusCommand(
options: IAuthContextOptions = {},
): Promise<IHiveCastLinkStatus> {
const cwd = options.cwd ?? process.cwd();
return readHiveCastLinkStatus(cwd, options.home);
}
export function formatHiveCastWhoami(result: IHiveCastWhoamiCommandResult): string {
if (!result.linked) {
return `HiveCast Host is not linked (${result.matrixHome})`;
}
return [
`Cloud: ${result.cloudUrl ?? 'unknown'}`,
result.hostName ? `Device name: ${result.hostName}` : null,
result.deviceSlug ? `Device inventory projection: system.devices.${result.deviceSlug}` : null,
result.routeKey ? `Route key: ${result.routeKey}` : null,
result.publicNamespace ? `Public namespace: ${result.publicNamespace}` : null,
result.spaceId ? `Space: ${result.spaceId}` : null,
`Authority root: ${result.authorityRoot ?? 'unknown'}`,
`Matrix home: ${result.matrixHome}`,
].filter((line): line is string => Boolean(line)).join('\n');
}