1860 lines
68 KiB
TypeScript
1860 lines
68 KiB
TypeScript
|
|
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');
|
||
|
|
}
|