993 lines
36 KiB
TypeScript
993 lines
36 KiB
TypeScript
|
|
import * as fs from 'node:fs';
|
||
|
|
import * as os from 'node:os';
|
||
|
|
import * as path from 'node:path';
|
||
|
|
import { randomBytes } from 'node:crypto';
|
||
|
|
|
||
|
|
type JsonRecord = Record<string, unknown>;
|
||
|
|
|
||
|
|
export interface IHiveCastNatsConfig {
|
||
|
|
readonly url: string;
|
||
|
|
readonly wsUrl?: string;
|
||
|
|
readonly leafnodeUrl?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IHiveCastNatsCredentials {
|
||
|
|
readonly accountSeed?: string;
|
||
|
|
readonly jwt?: string;
|
||
|
|
readonly seed?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IHiveCastHostLinkRecord {
|
||
|
|
readonly version: 1;
|
||
|
|
readonly cloudUrl: string;
|
||
|
|
readonly deviceRegistryRoot?: string;
|
||
|
|
readonly hostLinkId?: string;
|
||
|
|
readonly hostId?: string;
|
||
|
|
readonly principalId?: string;
|
||
|
|
readonly hostName?: string;
|
||
|
|
readonly deviceSlug?: string;
|
||
|
|
readonly authorityCoordinator?: boolean;
|
||
|
|
readonly spaceId?: string;
|
||
|
|
readonly routeKey?: string;
|
||
|
|
readonly publicNamespace?: string;
|
||
|
|
readonly authorityRoot: string;
|
||
|
|
readonly linkedAt: string;
|
||
|
|
readonly credentials: {
|
||
|
|
readonly hostLinkTokenRef?: string;
|
||
|
|
readonly registryTokenRef?: string;
|
||
|
|
readonly natsCredentialRef: string;
|
||
|
|
readonly natsLeafnodeCredentialRef?: string;
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ILocalOwnerTransportSnapshot {
|
||
|
|
readonly root: string;
|
||
|
|
readonly authorityRoot?: string;
|
||
|
|
readonly nats: JsonRecord;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IHiveCastLocalOwnerTransport {
|
||
|
|
readonly root: string;
|
||
|
|
readonly authorityRoot?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ILinkedAuthorityTransportSnapshot {
|
||
|
|
readonly authorityRoot: string;
|
||
|
|
readonly nats: JsonRecord;
|
||
|
|
readonly leafnode?: {
|
||
|
|
readonly url: string;
|
||
|
|
readonly credentialsRef: string;
|
||
|
|
};
|
||
|
|
readonly authorityCoordinator?: boolean;
|
||
|
|
readonly routeKey?: string;
|
||
|
|
readonly publicNamespace?: string;
|
||
|
|
readonly spaceId?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IHiveCastLinkPaths {
|
||
|
|
readonly matrixHome: string;
|
||
|
|
readonly credentialsDir: string;
|
||
|
|
readonly installIdentityPath: string;
|
||
|
|
readonly linkPath: string;
|
||
|
|
readonly natsCredentialsPath: string;
|
||
|
|
readonly natsLeafnodeCredentialsPath: string;
|
||
|
|
readonly hostLinkTokenPath: string;
|
||
|
|
readonly hostCredentialsPath: string;
|
||
|
|
readonly hostConfigPath: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IHiveCastInstallIdentity {
|
||
|
|
readonly version: 1;
|
||
|
|
readonly installId: string;
|
||
|
|
readonly hostName?: string;
|
||
|
|
readonly createdAt: string;
|
||
|
|
readonly updatedAt: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ISaveHiveCastHostLinkOptions {
|
||
|
|
readonly cwd: string;
|
||
|
|
readonly matrixHome?: string;
|
||
|
|
readonly cloudUrl: string;
|
||
|
|
readonly deviceRegistryRoot?: string;
|
||
|
|
readonly authorityRoot: string;
|
||
|
|
readonly nats: IHiveCastNatsConfig;
|
||
|
|
readonly natsCredentials: IHiveCastNatsCredentials;
|
||
|
|
readonly hostLinkToken?: string;
|
||
|
|
readonly hostLinkId?: string;
|
||
|
|
readonly hostId?: string;
|
||
|
|
readonly principalId?: string;
|
||
|
|
readonly hostName?: string;
|
||
|
|
readonly deviceSlug?: string;
|
||
|
|
readonly authorityCoordinator?: boolean;
|
||
|
|
readonly spaceId?: string;
|
||
|
|
readonly routeKey?: string;
|
||
|
|
readonly publicNamespace?: string;
|
||
|
|
readonly linkedAt?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IHiveCastLinkStatus {
|
||
|
|
readonly linked: boolean;
|
||
|
|
readonly matrixHome: string;
|
||
|
|
readonly linkPath: string;
|
||
|
|
readonly natsCredentialsPath: string;
|
||
|
|
readonly hostConfigPath: string;
|
||
|
|
readonly natsCredentialsPresent: boolean;
|
||
|
|
readonly hostConfigPresent: boolean;
|
||
|
|
readonly hostConfigLinked: boolean;
|
||
|
|
readonly hostLinkId?: string;
|
||
|
|
readonly authorityRoot?: string;
|
||
|
|
readonly routeKey?: string;
|
||
|
|
readonly publicNamespace?: string;
|
||
|
|
readonly spaceId?: string;
|
||
|
|
readonly authorityCoordinator?: boolean;
|
||
|
|
readonly cloudUrl?: string;
|
||
|
|
readonly linkedAt?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IRemoveHiveCastHostLinkResult {
|
||
|
|
readonly removed: boolean;
|
||
|
|
readonly hostConfigReset: boolean;
|
||
|
|
readonly paths: IHiveCastLinkPaths;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IRemoveHiveCastHostLinkOptions {
|
||
|
|
readonly preserveCredentials?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
const DEFAULT_MATRIX_HOME = path.join(os.homedir(), '.matrix');
|
||
|
|
const HIVECAST_NATS_JSON_CREDENTIAL_REF = 'file:credentials/hivecast-nats.json';
|
||
|
|
const HIVECAST_NATS_LEAFNODE_CREDENTIAL_REF = 'file:credentials/hivecast-nats.creds';
|
||
|
|
const HIVECAST_DEFAULT_RUNTIME_KEYS = [
|
||
|
|
'SYSTEM',
|
||
|
|
'GATEWAY',
|
||
|
|
'INFERENCE',
|
||
|
|
'AGENTS',
|
||
|
|
'WEB',
|
||
|
|
'EDGE',
|
||
|
|
'DIRECTOR',
|
||
|
|
'CHAT',
|
||
|
|
'INFERENCE-SETTINGS',
|
||
|
|
'FLOWPAD',
|
||
|
|
'SMITHERS',
|
||
|
|
'HOST-CONTROL',
|
||
|
|
'HOST-OPS',
|
||
|
|
] as const;
|
||
|
|
|
||
|
|
function asRecord(value: unknown): JsonRecord | null {
|
||
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return value as JsonRecord;
|
||
|
|
}
|
||
|
|
|
||
|
|
function optionalString(value: unknown): string | undefined {
|
||
|
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function expandHome(value: string): string {
|
||
|
|
if (value === '~') {
|
||
|
|
return os.homedir();
|
||
|
|
}
|
||
|
|
return value.startsWith('~/') ? path.join(os.homedir(), value.slice(2)) : value;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function resolveHiveCastMatrixHome(cwd: string, matrixHome?: string): string {
|
||
|
|
const raw = matrixHome
|
||
|
|
?? process.env.MATRIX_HOST_HOME
|
||
|
|
?? process.env.MATRIX_HOME
|
||
|
|
?? DEFAULT_MATRIX_HOME;
|
||
|
|
const expanded = expandHome(raw);
|
||
|
|
return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(cwd, expanded);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function hiveCastLinkPaths(cwd: string, matrixHome?: string): IHiveCastLinkPaths {
|
||
|
|
const resolvedHome = resolveHiveCastMatrixHome(cwd, matrixHome);
|
||
|
|
const credentialsDir = path.join(resolvedHome, 'credentials');
|
||
|
|
return {
|
||
|
|
matrixHome: resolvedHome,
|
||
|
|
credentialsDir,
|
||
|
|
installIdentityPath: path.join(credentialsDir, 'hivecast-install.json'),
|
||
|
|
linkPath: path.join(credentialsDir, 'hivecast-link.json'),
|
||
|
|
natsCredentialsPath: path.join(credentialsDir, 'hivecast-nats.json'),
|
||
|
|
natsLeafnodeCredentialsPath: path.join(credentialsDir, 'hivecast-nats.creds'),
|
||
|
|
hostLinkTokenPath: path.join(credentialsDir, 'hivecast-host-link-token'),
|
||
|
|
hostCredentialsPath: path.join(resolvedHome, 'credentials.json'),
|
||
|
|
hostConfigPath: path.join(resolvedHome, 'host.json'),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ensureHiveCastInstallIdentity(options: {
|
||
|
|
readonly cwd: string;
|
||
|
|
readonly matrixHome?: string;
|
||
|
|
readonly hostName?: string;
|
||
|
|
}): { readonly identity: IHiveCastInstallIdentity; readonly paths: IHiveCastLinkPaths } {
|
||
|
|
const paths = hiveCastLinkPaths(options.cwd, options.matrixHome);
|
||
|
|
const existing = readJsonFile(paths.installIdentityPath);
|
||
|
|
const existingInstallId = optionalString(existing?.installId);
|
||
|
|
const now = new Date().toISOString();
|
||
|
|
const hostName = optionalString(options.hostName);
|
||
|
|
if (existingInstallId) {
|
||
|
|
const existingHostName = optionalString(existing?.hostName);
|
||
|
|
const nextHostName = hostName ?? existingHostName;
|
||
|
|
const identity: IHiveCastInstallIdentity = {
|
||
|
|
version: 1,
|
||
|
|
installId: existingInstallId,
|
||
|
|
...(nextHostName ? { hostName: nextHostName } : {}),
|
||
|
|
createdAt: optionalString(existing?.createdAt) ?? now,
|
||
|
|
updatedAt: hostName && hostName !== existingHostName ? now : (optionalString(existing?.updatedAt) ?? now),
|
||
|
|
};
|
||
|
|
if (hostName && hostName !== existingHostName) {
|
||
|
|
writePrivateJson(paths.installIdentityPath, identity);
|
||
|
|
}
|
||
|
|
return { identity, paths };
|
||
|
|
}
|
||
|
|
const identity: IHiveCastInstallIdentity = {
|
||
|
|
version: 1,
|
||
|
|
installId: `install_${randomBytes(10).toString('hex')}`,
|
||
|
|
...(hostName ? { hostName } : {}),
|
||
|
|
createdAt: now,
|
||
|
|
updatedAt: now,
|
||
|
|
};
|
||
|
|
writePrivateJson(paths.installIdentityPath, identity);
|
||
|
|
return { identity, paths };
|
||
|
|
}
|
||
|
|
|
||
|
|
function localOwnerRootToken(installId: string): string {
|
||
|
|
const token = installId
|
||
|
|
.toLowerCase()
|
||
|
|
.replace(/[^a-z0-9]+/g, '-')
|
||
|
|
.replace(/^-+|-+$/g, '');
|
||
|
|
if (!token) {
|
||
|
|
throw new Error('HIVECAST_INSTALL_IDENTITY_INVALID: installId cannot produce a local owner root');
|
||
|
|
}
|
||
|
|
return token;
|
||
|
|
}
|
||
|
|
|
||
|
|
function localOwnerAuthorityRoot(matrixHome: string): string {
|
||
|
|
const { identity } = ensureHiveCastInstallIdentity({ cwd: process.cwd(), matrixHome });
|
||
|
|
return `local-${localOwnerRootToken(identity.installId)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function runtimeScopeToken(installId: string): string {
|
||
|
|
const token = installId
|
||
|
|
.toUpperCase()
|
||
|
|
.replace(/[^A-Z0-9]+/g, '-')
|
||
|
|
.replace(/^-+|-+$/g, '');
|
||
|
|
if (!token) {
|
||
|
|
throw new Error('HIVECAST_INSTALL_IDENTITY_INVALID: installId cannot produce a runtime scope');
|
||
|
|
}
|
||
|
|
return token;
|
||
|
|
}
|
||
|
|
|
||
|
|
function installRuntimeScope(matrixHome: string): string {
|
||
|
|
const { identity } = ensureHiveCastInstallIdentity({ cwd: process.cwd(), matrixHome });
|
||
|
|
return runtimeScopeToken(identity.installId);
|
||
|
|
}
|
||
|
|
|
||
|
|
function writePrivateJson(filePath: string, payload: object): void {
|
||
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||
|
|
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||
|
|
try {
|
||
|
|
fs.chmodSync(filePath, 0o600);
|
||
|
|
} catch {
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function writePrivateText(filePath: string, payload: string): void {
|
||
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||
|
|
fs.writeFileSync(filePath, payload, 'utf8');
|
||
|
|
try {
|
||
|
|
fs.chmodSync(filePath, 0o600);
|
||
|
|
} catch {
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function natsUserCredentialsFile(credentials: IHiveCastNatsCredentials): string | undefined {
|
||
|
|
const jwt = optionalString(credentials.jwt);
|
||
|
|
const seed = optionalString(credentials.seed);
|
||
|
|
if (!jwt || !seed) {
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
return [
|
||
|
|
'-----BEGIN NATS USER JWT-----',
|
||
|
|
jwt,
|
||
|
|
'------END NATS USER JWT------',
|
||
|
|
'',
|
||
|
|
'-----BEGIN USER NKEY SEED-----',
|
||
|
|
seed,
|
||
|
|
'------END USER NKEY SEED------',
|
||
|
|
'',
|
||
|
|
].join('\n');
|
||
|
|
}
|
||
|
|
|
||
|
|
function ownerForPath(filePath: string): { readonly uid: number; readonly gid: number } {
|
||
|
|
const stat = fs.statSync(filePath);
|
||
|
|
return { uid: stat.uid, gid: stat.gid };
|
||
|
|
}
|
||
|
|
|
||
|
|
function alignOwnership(filePath: string, owner: { readonly uid: number; readonly gid: number }): void {
|
||
|
|
if (typeof process.getuid !== 'function' || process.getuid() !== 0) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
fs.chownSync(filePath, owner.uid, owner.gid);
|
||
|
|
}
|
||
|
|
|
||
|
|
function readJsonFile(filePath: string): JsonRecord | null {
|
||
|
|
if (!fs.existsSync(filePath)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
|
||
|
|
return asRecord(parsed);
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildHostConfig(
|
||
|
|
existing: JsonRecord | null,
|
||
|
|
matrixHome: string,
|
||
|
|
localOwnerTransportOverride?: ILocalOwnerTransportSnapshot,
|
||
|
|
linkedAuthorityTransport?: ILinkedAuthorityTransportSnapshot,
|
||
|
|
): JsonRecord {
|
||
|
|
const currentHttp = asRecord(existing?.http);
|
||
|
|
const currentRuntimeStorage = asRecord(existing?.runtimeStorage);
|
||
|
|
const currentPackageStorage = asRecord(existing?.packageStorage);
|
||
|
|
const currentAuth = asRecord(existing?.auth);
|
||
|
|
const localOwnerTransport = readLocalOwnerTransportSnapshotForHome(matrixHome, existing)
|
||
|
|
?? localOwnerTransportOverride
|
||
|
|
?? captureLocalOwnerTransport(matrixHome, existing);
|
||
|
|
const generatedLocalOwnerRoot = localOwnerAuthorityRoot(matrixHome);
|
||
|
|
const generatedLocalOwnerTransport = {
|
||
|
|
root: generatedLocalOwnerRoot,
|
||
|
|
authorityRoot: generatedLocalOwnerRoot,
|
||
|
|
addressRoot: generatedLocalOwnerRoot,
|
||
|
|
nats: defaultLocalOwnerNatsTransport(),
|
||
|
|
};
|
||
|
|
const localOwnerHostTransport = localOwnerTransport
|
||
|
|
? localOwnerHostConfigTransport(localOwnerTransport)
|
||
|
|
: generatedLocalOwnerTransport;
|
||
|
|
const hostTransport = linkedAuthorityTransport
|
||
|
|
? linkedAuthorityTransport.authorityCoordinator === false
|
||
|
|
? linkedLeafHostConfigTransport(linkedAuthorityTransport, localOwnerHostTransport)
|
||
|
|
: linkedAuthorityHostConfigTransport(linkedAuthorityTransport)
|
||
|
|
: localOwnerHostTransport;
|
||
|
|
|
||
|
|
const nextConfig: JsonRecord = {
|
||
|
|
...(existing ?? {}),
|
||
|
|
kind: optionalString(existing?.kind) ?? 'MatrixHostConfig',
|
||
|
|
version: typeof existing?.version === 'number' ? existing.version : 1,
|
||
|
|
home: matrixHome,
|
||
|
|
...(localOwnerTransport ? { hivecastLocalOwnerTransport: localOwnerTransport } : {}),
|
||
|
|
http: currentHttp ?? {
|
||
|
|
host: '127.0.0.1',
|
||
|
|
port: 3100,
|
||
|
|
},
|
||
|
|
transport: hostTransport,
|
||
|
|
auth: currentAuth ?? {
|
||
|
|
mode: 'local-client',
|
||
|
|
},
|
||
|
|
runtimeStorage: currentRuntimeStorage ?? {
|
||
|
|
recordsDir: 'runtimes',
|
||
|
|
logsDir: 'logs/runtimes',
|
||
|
|
},
|
||
|
|
packageStorage: currentPackageStorage ?? {
|
||
|
|
globalDir: 'packages/global',
|
||
|
|
systemDir: 'packages/system',
|
||
|
|
},
|
||
|
|
};
|
||
|
|
if (linkedAuthorityTransport?.authorityCoordinator === false && linkedAuthorityTransport.leafnode) {
|
||
|
|
nextConfig.natsTopology = linkedLeafNatsTopology(hostTransport.nats as JsonRecord, linkedAuthorityTransport.leafnode);
|
||
|
|
} else {
|
||
|
|
delete nextConfig.natsTopology;
|
||
|
|
}
|
||
|
|
return nextConfig;
|
||
|
|
}
|
||
|
|
|
||
|
|
function linkedLeafNatsTopology(
|
||
|
|
localNats: JsonRecord,
|
||
|
|
leafnode: { readonly url: string; readonly credentialsRef: string },
|
||
|
|
): JsonRecord {
|
||
|
|
const clientUrl = optionalString(localNats.url);
|
||
|
|
const wsUrl = optionalString(localNats.wsUrl);
|
||
|
|
return {
|
||
|
|
mode: 'leafnode',
|
||
|
|
...(clientUrl ? { clientUrl } : {}),
|
||
|
|
...(wsUrl ? { wsUrl } : {}),
|
||
|
|
leafnodes: [{
|
||
|
|
url: leafnode.url,
|
||
|
|
credentialsRef: leafnode.credentialsRef,
|
||
|
|
}],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function assertLinkedHostConfigConverged(
|
||
|
|
record: IHiveCastHostLinkRecord,
|
||
|
|
hostConfig: JsonRecord,
|
||
|
|
): void {
|
||
|
|
const transport = asRecord(hostConfig.transport);
|
||
|
|
const root = optionalString(transport?.root);
|
||
|
|
const authorityRoot = optionalString(transport?.authorityRoot);
|
||
|
|
const addressRoot = optionalString(transport?.addressRoot);
|
||
|
|
for (const [field, actual] of [
|
||
|
|
['transport.root', root],
|
||
|
|
['transport.authorityRoot', authorityRoot],
|
||
|
|
['transport.addressRoot', addressRoot],
|
||
|
|
] as const) {
|
||
|
|
if (actual !== record.authorityRoot) {
|
||
|
|
throw new Error(
|
||
|
|
`HIVECAST_LINK_ROOT_CONVERGENCE_FAILED: expected host config ${field} ${record.authorityRoot}, got ${actual ?? '(missing)'}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function linkedAuthorityHostConfigTransport(snapshot: ILinkedAuthorityTransportSnapshot): JsonRecord {
|
||
|
|
return {
|
||
|
|
root: snapshot.authorityRoot,
|
||
|
|
authorityRoot: snapshot.authorityRoot,
|
||
|
|
addressRoot: snapshot.authorityRoot,
|
||
|
|
...(snapshot.spaceId ? { spaceId: snapshot.spaceId } : {}),
|
||
|
|
...(snapshot.routeKey ? { routeKey: snapshot.routeKey } : {}),
|
||
|
|
...(snapshot.publicNamespace ? { publicNamespace: snapshot.publicNamespace } : {}),
|
||
|
|
nats: { ...snapshot.nats },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function linkedLeafHostConfigTransport(
|
||
|
|
snapshot: ILinkedAuthorityTransportSnapshot,
|
||
|
|
localOwnerTransport: JsonRecord,
|
||
|
|
): JsonRecord {
|
||
|
|
return {
|
||
|
|
root: snapshot.authorityRoot,
|
||
|
|
authorityRoot: snapshot.authorityRoot,
|
||
|
|
addressRoot: snapshot.authorityRoot,
|
||
|
|
...(snapshot.spaceId ? { spaceId: snapshot.spaceId } : {}),
|
||
|
|
...(snapshot.routeKey ? { routeKey: snapshot.routeKey } : {}),
|
||
|
|
...(snapshot.publicNamespace ? { publicNamespace: snapshot.publicNamespace } : {}),
|
||
|
|
nats: localNatsTransportForLinkedLeaf(snapshot, localOwnerTransport),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function localNatsTransportForLinkedLeaf(
|
||
|
|
snapshot: ILinkedAuthorityTransportSnapshot,
|
||
|
|
localOwnerTransport: JsonRecord,
|
||
|
|
): JsonRecord {
|
||
|
|
const localNats = asRecord(localOwnerTransport.nats);
|
||
|
|
return stripNatsCredentialFields(localNats ?? snapshot.nats);
|
||
|
|
}
|
||
|
|
|
||
|
|
function defaultLocalOwnerNatsTransport(): JsonRecord {
|
||
|
|
return {
|
||
|
|
mode: 'external',
|
||
|
|
url: 'nats://127.0.0.1:4222',
|
||
|
|
wsUrl: 'ws://127.0.0.1:4223',
|
||
|
|
port: 4222,
|
||
|
|
wsPort: 4223,
|
||
|
|
dataDir: 'nats/host-default',
|
||
|
|
pidFile: 'nats/host-default/nats-server.pid',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function stripNatsCredentialFields(nats: JsonRecord): JsonRecord {
|
||
|
|
const {
|
||
|
|
credentialsRef: _credentialsRef,
|
||
|
|
credentials: _credentials,
|
||
|
|
natsCredentialRef: _natsCredentialRef,
|
||
|
|
...rest
|
||
|
|
} = nats;
|
||
|
|
return rest;
|
||
|
|
}
|
||
|
|
|
||
|
|
function linkedAuthorityNatsTransport(
|
||
|
|
nats: IHiveCastNatsConfig,
|
||
|
|
natsCredentialsPath: string,
|
||
|
|
): JsonRecord {
|
||
|
|
const url = optionalString(nats.url);
|
||
|
|
if (!url) {
|
||
|
|
throw new Error('HIVECAST_LINK_AUTHORITY_NATS_MISSING: linked Device setup requires nats.url from the setup exchange');
|
||
|
|
}
|
||
|
|
if (/^wss?:\/\//i.test(url)) {
|
||
|
|
throw new Error(
|
||
|
|
`HIVECAST_LINK_AUTHORITY_NATS_WS_ONLY: linked Device setup requires a server NATS URL for runtimes, got ${url}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
const wsUrl = optionalString(nats.wsUrl);
|
||
|
|
return {
|
||
|
|
mode: 'external',
|
||
|
|
url,
|
||
|
|
...(wsUrl ? { wsUrl } : {}),
|
||
|
|
credentialsRef: `file:${natsCredentialsPath}`,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function localOwnerHostConfigTransport(snapshot: ILocalOwnerTransportSnapshot): JsonRecord {
|
||
|
|
return {
|
||
|
|
root: snapshot.root,
|
||
|
|
authorityRoot: snapshot.authorityRoot ?? snapshot.root,
|
||
|
|
addressRoot: snapshot.root,
|
||
|
|
nats: { ...snapshot.nats },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function captureLocalOwnerTransport(matrixHome: string, existing: JsonRecord | null): ILocalOwnerTransportSnapshot | undefined {
|
||
|
|
return readLocalOwnerTransportSnapshotFromTransportForHome(matrixHome, asRecord(existing?.transport));
|
||
|
|
}
|
||
|
|
|
||
|
|
function captureLocalOwnerTransportFromCurrentNats(matrixHome: string, existing: JsonRecord | null): ILocalOwnerTransportSnapshot | undefined {
|
||
|
|
const transport = asRecord(existing?.transport);
|
||
|
|
const nats = asRecord(transport?.nats);
|
||
|
|
if (!nats) return undefined;
|
||
|
|
return readLocalOwnerTransportSnapshotFromTransportForHome(matrixHome, {
|
||
|
|
root: localOwnerAuthorityRoot(matrixHome),
|
||
|
|
nats: { ...nats },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function readLocalOwnerTransportSnapshot(config: JsonRecord | null): ILocalOwnerTransportSnapshot | undefined {
|
||
|
|
const snapshot = asRecord(config?.hivecastLocalOwnerTransport);
|
||
|
|
if (!snapshot) return undefined;
|
||
|
|
const root = optionalString(snapshot.root);
|
||
|
|
const nats = asRecord(snapshot.nats);
|
||
|
|
if (!root || !nats) return undefined;
|
||
|
|
return readLocalOwnerTransportSnapshotFromTransport({
|
||
|
|
root,
|
||
|
|
...(optionalString(snapshot.authorityRoot) ? { authorityRoot: optionalString(snapshot.authorityRoot) } : {}),
|
||
|
|
nats: { ...nats },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function readLocalOwnerTransportSnapshotForHome(
|
||
|
|
matrixHome: string,
|
||
|
|
config: JsonRecord | null,
|
||
|
|
): ILocalOwnerTransportSnapshot | undefined {
|
||
|
|
const snapshot = readLocalOwnerTransportSnapshot(config);
|
||
|
|
if (snapshot) return snapshot;
|
||
|
|
const saved = asRecord(config?.hivecastLocalOwnerTransport);
|
||
|
|
if (!saved) return undefined;
|
||
|
|
return readLocalOwnerTransportSnapshotFromTransportForHome(matrixHome, saved);
|
||
|
|
}
|
||
|
|
|
||
|
|
function readLocalOwnerTransportSnapshotFromStatus(matrixHome: string): ILocalOwnerTransportSnapshot | undefined {
|
||
|
|
const status = readJsonFile(path.join(matrixHome, 'host.status.json'));
|
||
|
|
if (optionalString(status?.status) !== 'running') return undefined;
|
||
|
|
return readLocalOwnerTransportSnapshotFromTransportForHome(matrixHome, asRecord(status?.transport));
|
||
|
|
}
|
||
|
|
|
||
|
|
function readLocalOwnerTransportSnapshotFromRuntimeRecords(matrixHome: string): ILocalOwnerTransportSnapshot | undefined {
|
||
|
|
const runtimesDir = path.join(matrixHome, 'runtimes');
|
||
|
|
if (!fs.existsSync(runtimesDir)) return undefined;
|
||
|
|
const localScope = localDefaultRuntimeScope(matrixHome);
|
||
|
|
const preferredRuntimeIds = HIVECAST_DEFAULT_RUNTIME_KEYS.map((key) => `RUNTIME-HOST-${localScope}-${key}`);
|
||
|
|
for (const runtimeId of preferredRuntimeIds) {
|
||
|
|
const snapshot = readLocalOwnerTransportSnapshotFromRuntimeRecord(matrixHome, path.join(runtimesDir, runtimeId, 'runtime.json'));
|
||
|
|
if (snapshot) return snapshot;
|
||
|
|
}
|
||
|
|
for (const entry of fs.readdirSync(runtimesDir, { withFileTypes: true })) {
|
||
|
|
if (!entry.isDirectory() || !entry.name.startsWith(`RUNTIME-HOST-${localScope}-`)) continue;
|
||
|
|
const snapshot = readLocalOwnerTransportSnapshotFromRuntimeRecord(matrixHome, path.join(runtimesDir, entry.name, 'runtime.json'));
|
||
|
|
if (snapshot) return snapshot;
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function readLocalOwnerTransportSnapshotFromRuntimeRecord(
|
||
|
|
matrixHome: string,
|
||
|
|
recordPath: string,
|
||
|
|
): ILocalOwnerTransportSnapshot | undefined {
|
||
|
|
const record = readJsonFile(recordPath);
|
||
|
|
const metadata = asRecord(record?.metadata);
|
||
|
|
return readLocalOwnerTransportSnapshotFromTransportForHome(matrixHome, asRecord(metadata?.transport));
|
||
|
|
}
|
||
|
|
|
||
|
|
function readLocalOwnerTransportSnapshotFromTransportForHome(
|
||
|
|
_matrixHome: string,
|
||
|
|
transport: JsonRecord | null,
|
||
|
|
): ILocalOwnerTransportSnapshot | undefined {
|
||
|
|
return readLocalOwnerTransportSnapshotFromTransport(transport);
|
||
|
|
}
|
||
|
|
|
||
|
|
function readLocalOwnerTransportSnapshotFromTransport(transport: JsonRecord | null): ILocalOwnerTransportSnapshot | undefined {
|
||
|
|
if (!transport) return undefined;
|
||
|
|
if (
|
||
|
|
optionalString(transport.routeKey)
|
||
|
|
|| optionalString(transport.publicNamespace)
|
||
|
|
|| optionalString(transport.spaceId)
|
||
|
|
) {
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
const root = optionalString(transport.root);
|
||
|
|
if (!root) return undefined;
|
||
|
|
const nats = asRecord(transport.nats);
|
||
|
|
if (!nats || optionalString(nats.credentialsRef)) return undefined;
|
||
|
|
const mode = optionalString(nats.mode);
|
||
|
|
if (mode !== 'embedded' && mode !== 'external') return undefined;
|
||
|
|
const natsPort = positivePort(nats.port) ?? loopbackUrlPort(optionalString(nats.url));
|
||
|
|
const natsWsPort = positivePort(nats.wsPort) ?? loopbackUrlPort(optionalString(nats.wsUrl));
|
||
|
|
const maxPayloadBytes = positiveInteger(nats.maxPayloadBytes);
|
||
|
|
if (!natsPort || !natsWsPort) return undefined;
|
||
|
|
const natsUrl = mode === 'external'
|
||
|
|
? optionalString(nats.url) ?? `nats://127.0.0.1:${natsPort}`
|
||
|
|
: undefined;
|
||
|
|
const natsWsUrl = mode === 'external'
|
||
|
|
? optionalString(nats.wsUrl) ?? `ws://127.0.0.1:${natsWsPort}`
|
||
|
|
: undefined;
|
||
|
|
const authorityRoot = optionalString(transport.authorityRoot);
|
||
|
|
return {
|
||
|
|
root,
|
||
|
|
...(authorityRoot && authorityRoot !== root ? { authorityRoot } : {}),
|
||
|
|
nats: {
|
||
|
|
mode,
|
||
|
|
...(natsUrl ? { url: natsUrl } : {}),
|
||
|
|
...(natsWsUrl ? { wsUrl: natsWsUrl } : {}),
|
||
|
|
port: natsPort,
|
||
|
|
wsPort: natsWsPort,
|
||
|
|
...(maxPayloadBytes ? { maxPayloadBytes } : {}),
|
||
|
|
dataDir: optionalString(nats.dataDir) ?? 'nats/host-default',
|
||
|
|
pidFile: optionalString(nats.pidFile) ?? 'nats/host-default/nats-server.pid',
|
||
|
|
...(optionalString(nats.binaryPath) ? { binaryPath: optionalString(nats.binaryPath) } : {}),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function readHiveCastLocalOwnerTransport(
|
||
|
|
cwd: string,
|
||
|
|
matrixHome?: string,
|
||
|
|
): IHiveCastLocalOwnerTransport | null {
|
||
|
|
const paths = hiveCastLinkPaths(cwd, matrixHome);
|
||
|
|
const config = readJsonFile(paths.hostConfigPath);
|
||
|
|
const snapshot = readLocalOwnerTransportSnapshot(config)
|
||
|
|
?? readLocalOwnerTransportSnapshotForHome(paths.matrixHome, config)
|
||
|
|
?? captureLocalOwnerTransport(paths.matrixHome, config)
|
||
|
|
?? readLocalOwnerTransportSnapshotFromStatus(paths.matrixHome)
|
||
|
|
?? readLocalOwnerTransportSnapshotFromRuntimeRecords(paths.matrixHome);
|
||
|
|
if (!snapshot) return null;
|
||
|
|
return {
|
||
|
|
root: snapshot.root,
|
||
|
|
...(snapshot.authorityRoot ? { authorityRoot: snapshot.authorityRoot } : {}),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function positivePort(value: unknown): number | undefined {
|
||
|
|
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
||
|
|
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 : undefined;
|
||
|
|
} catch {
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function localDefaultRuntimeScope(matrixHome: string): string {
|
||
|
|
return installRuntimeScope(matrixHome);
|
||
|
|
}
|
||
|
|
|
||
|
|
function activeDefaultRuntimeScope(matrixHome: string): string {
|
||
|
|
return localDefaultRuntimeScope(matrixHome);
|
||
|
|
}
|
||
|
|
|
||
|
|
function defaultRuntimeIdsForScope(scope: string): Set<string> {
|
||
|
|
return new Set(HIVECAST_DEFAULT_RUNTIME_KEYS.map((key) => `RUNTIME-HOST-${scope}-${key}`));
|
||
|
|
}
|
||
|
|
|
||
|
|
function isHiveCastDefaultRuntimeId(runtimeId: string): boolean {
|
||
|
|
return runtimeId.startsWith('RUNTIME-HOST-')
|
||
|
|
&& HIVECAST_DEFAULT_RUNTIME_KEYS.some((key) => runtimeId.endsWith(`-${key}`));
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateHiveCastDefaultRuntimePolicies(matrixHome: string, activeScope: string): void {
|
||
|
|
const runtimesDir = path.join(matrixHome, 'runtimes');
|
||
|
|
if (!fs.existsSync(runtimesDir)) return;
|
||
|
|
const activeIds = defaultRuntimeIdsForScope(activeScope);
|
||
|
|
for (const entry of fs.readdirSync(runtimesDir, { withFileTypes: true })) {
|
||
|
|
if (!entry.isDirectory()) continue;
|
||
|
|
const runtimeId = entry.name;
|
||
|
|
if (!isHiveCastDefaultRuntimeId(runtimeId)) continue;
|
||
|
|
const recordPath = path.join(runtimesDir, runtimeId, 'runtime.json');
|
||
|
|
const record = readJsonFile(recordPath);
|
||
|
|
if (!record) continue;
|
||
|
|
const recordOwner = ownerForPath(recordPath);
|
||
|
|
const active = activeIds.has(runtimeId);
|
||
|
|
const nextRecord = {
|
||
|
|
...record,
|
||
|
|
startup: active ? 'auto' : 'manual',
|
||
|
|
restart: active ? 'always' : 'never',
|
||
|
|
updatedAt: new Date().toISOString(),
|
||
|
|
metadata: {
|
||
|
|
...(asRecord(record.metadata) ?? {}),
|
||
|
|
hivecastDefaultRuntimePolicy: active ? 'active' : 'inactive',
|
||
|
|
hivecastDefaultRuntimePolicyUpdatedAt: new Date().toISOString(),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
writePrivateJson(recordPath, nextRecord);
|
||
|
|
alignOwnership(recordPath, recordOwner);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function saveHiveCastHostLink(options: ISaveHiveCastHostLinkOptions): {
|
||
|
|
readonly record: IHiveCastHostLinkRecord;
|
||
|
|
readonly paths: IHiveCastLinkPaths;
|
||
|
|
} {
|
||
|
|
const paths = hiveCastLinkPaths(options.cwd, options.matrixHome);
|
||
|
|
fs.mkdirSync(paths.matrixHome, { recursive: true });
|
||
|
|
const homeOwner = ownerForPath(paths.matrixHome);
|
||
|
|
fs.mkdirSync(paths.credentialsDir, { recursive: true });
|
||
|
|
alignOwnership(paths.credentialsDir, homeOwner);
|
||
|
|
|
||
|
|
writePrivateJson(paths.natsCredentialsPath, options.natsCredentials);
|
||
|
|
alignOwnership(paths.natsCredentialsPath, homeOwner);
|
||
|
|
writePrivateJson(paths.hostCredentialsPath, options.natsCredentials);
|
||
|
|
alignOwnership(paths.hostCredentialsPath, homeOwner);
|
||
|
|
const leafnodeUrl = optionalString(options.nats.leafnodeUrl);
|
||
|
|
const leafnodeCredentialBody = natsUserCredentialsFile(options.natsCredentials);
|
||
|
|
if (options.authorityCoordinator !== true && leafnodeUrl) {
|
||
|
|
if (!leafnodeCredentialBody) {
|
||
|
|
throw new Error('HIVECAST_LINK_LEAFNODE_CREDENTIALS_MISSING: linked Device leaf-node transport requires jwt+seed credentials');
|
||
|
|
}
|
||
|
|
writePrivateText(paths.natsLeafnodeCredentialsPath, leafnodeCredentialBody);
|
||
|
|
alignOwnership(paths.natsLeafnodeCredentialsPath, homeOwner);
|
||
|
|
} else {
|
||
|
|
fs.rmSync(paths.natsLeafnodeCredentialsPath, { force: true });
|
||
|
|
}
|
||
|
|
if (options.hostLinkToken) {
|
||
|
|
writePrivateText(paths.hostLinkTokenPath, `${options.hostLinkToken}\n`);
|
||
|
|
alignOwnership(paths.hostLinkTokenPath, homeOwner);
|
||
|
|
} else {
|
||
|
|
fs.rmSync(paths.hostLinkTokenPath, { force: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
const record: IHiveCastHostLinkRecord = {
|
||
|
|
version: 1,
|
||
|
|
cloudUrl: options.cloudUrl,
|
||
|
|
...(options.deviceRegistryRoot ? { deviceRegistryRoot: options.deviceRegistryRoot } : {}),
|
||
|
|
...(options.hostLinkId ? { hostLinkId: options.hostLinkId } : {}),
|
||
|
|
...(options.hostId ? { hostId: options.hostId } : {}),
|
||
|
|
...(options.principalId ? { principalId: options.principalId } : {}),
|
||
|
|
...(options.hostName ? { hostName: options.hostName } : {}),
|
||
|
|
...(options.deviceSlug ? { deviceSlug: options.deviceSlug } : {}),
|
||
|
|
authorityCoordinator: options.authorityCoordinator === true,
|
||
|
|
...(options.spaceId ? { spaceId: options.spaceId } : {}),
|
||
|
|
...(options.routeKey ? { routeKey: options.routeKey } : {}),
|
||
|
|
...(options.publicNamespace ? { publicNamespace: options.publicNamespace } : {}),
|
||
|
|
authorityRoot: options.authorityRoot,
|
||
|
|
linkedAt: options.linkedAt ?? new Date().toISOString(),
|
||
|
|
credentials: {
|
||
|
|
...(options.hostLinkToken ? { hostLinkTokenRef: 'file:credentials/hivecast-host-link-token' } : {}),
|
||
|
|
natsCredentialRef: HIVECAST_NATS_JSON_CREDENTIAL_REF,
|
||
|
|
...(options.authorityCoordinator !== true && leafnodeUrl
|
||
|
|
? { natsLeafnodeCredentialRef: HIVECAST_NATS_LEAFNODE_CREDENTIAL_REF }
|
||
|
|
: {}),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
writePrivateJson(paths.linkPath, record);
|
||
|
|
alignOwnership(paths.linkPath, homeOwner);
|
||
|
|
|
||
|
|
const existingHostConfig = readJsonFile(paths.hostConfigPath);
|
||
|
|
const localOwnerTransport = readLocalOwnerTransportSnapshotForHome(paths.matrixHome, existingHostConfig)
|
||
|
|
?? captureLocalOwnerTransportFromCurrentNats(paths.matrixHome, existingHostConfig)
|
||
|
|
?? readLocalOwnerTransportSnapshotFromStatus(paths.matrixHome)
|
||
|
|
?? readLocalOwnerTransportSnapshotFromRuntimeRecords(paths.matrixHome)
|
||
|
|
?? captureLocalOwnerTransport(paths.matrixHome, existingHostConfig);
|
||
|
|
const linkedAuthorityTransport: ILinkedAuthorityTransportSnapshot = {
|
||
|
|
authorityRoot: options.authorityRoot,
|
||
|
|
...(options.routeKey ? { routeKey: options.routeKey } : {}),
|
||
|
|
...(options.publicNamespace ? { publicNamespace: options.publicNamespace } : {}),
|
||
|
|
...(options.spaceId ? { spaceId: options.spaceId } : {}),
|
||
|
|
authorityCoordinator: options.authorityCoordinator === true,
|
||
|
|
...(options.authorityCoordinator !== true && leafnodeUrl
|
||
|
|
? { leafnode: { url: leafnodeUrl, credentialsRef: HIVECAST_NATS_LEAFNODE_CREDENTIAL_REF } }
|
||
|
|
: {}),
|
||
|
|
nats: linkedAuthorityNatsTransport(options.nats, paths.natsCredentialsPath),
|
||
|
|
};
|
||
|
|
const hostConfig = buildHostConfig(
|
||
|
|
existingHostConfig,
|
||
|
|
paths.matrixHome,
|
||
|
|
localOwnerTransport,
|
||
|
|
linkedAuthorityTransport,
|
||
|
|
);
|
||
|
|
assertLinkedHostConfigConverged(record, hostConfig);
|
||
|
|
writePrivateJson(paths.hostConfigPath, hostConfig);
|
||
|
|
alignOwnership(paths.hostConfigPath, homeOwner);
|
||
|
|
updateHiveCastDefaultRuntimePolicies(paths.matrixHome, activeDefaultRuntimeScope(paths.matrixHome));
|
||
|
|
|
||
|
|
return { record, paths };
|
||
|
|
}
|
||
|
|
|
||
|
|
export function readHiveCastHostLink(cwd: string, matrixHome?: string): {
|
||
|
|
readonly record: IHiveCastHostLinkRecord | null;
|
||
|
|
readonly paths: IHiveCastLinkPaths;
|
||
|
|
} {
|
||
|
|
const paths = hiveCastLinkPaths(cwd, matrixHome);
|
||
|
|
const parsed = readJsonFile(paths.linkPath);
|
||
|
|
if (!parsed || parsed.version !== 1) {
|
||
|
|
return { record: null, paths };
|
||
|
|
}
|
||
|
|
const authorityRoot = optionalString(parsed.authorityRoot);
|
||
|
|
const cloudUrl = optionalString(parsed.cloudUrl);
|
||
|
|
const credentials = asRecord(parsed.credentials);
|
||
|
|
const natsCredentialRef = optionalString(credentials?.natsCredentialRef);
|
||
|
|
const linkedAt = optionalString(parsed.linkedAt);
|
||
|
|
if (!authorityRoot || !cloudUrl || !natsCredentialRef || !linkedAt) {
|
||
|
|
return { record: null, paths };
|
||
|
|
}
|
||
|
|
const hostId = optionalString(parsed.hostId);
|
||
|
|
const deviceRegistryRoot = optionalString(parsed.deviceRegistryRoot);
|
||
|
|
const hostLinkId = optionalString(parsed.hostLinkId);
|
||
|
|
const principalId = optionalString(parsed.principalId);
|
||
|
|
const hostName = optionalString(parsed.hostName);
|
||
|
|
const deviceSlug = optionalString(parsed.deviceSlug);
|
||
|
|
const authorityCoordinator = typeof parsed.authorityCoordinator === 'boolean'
|
||
|
|
? parsed.authorityCoordinator
|
||
|
|
: undefined;
|
||
|
|
const spaceId = optionalString(parsed.spaceId);
|
||
|
|
const routeKey = optionalString(parsed.routeKey);
|
||
|
|
const publicNamespace = optionalString(parsed.publicNamespace);
|
||
|
|
const hostLinkTokenRef = optionalString(credentials?.hostLinkTokenRef);
|
||
|
|
const registryTokenRef = optionalString(credentials?.registryTokenRef);
|
||
|
|
const natsLeafnodeCredentialRef = optionalString(credentials?.natsLeafnodeCredentialRef);
|
||
|
|
return {
|
||
|
|
paths,
|
||
|
|
record: {
|
||
|
|
version: 1,
|
||
|
|
cloudUrl,
|
||
|
|
...(deviceRegistryRoot ? { deviceRegistryRoot } : {}),
|
||
|
|
...(hostLinkId ? { hostLinkId } : {}),
|
||
|
|
...(hostId ? { hostId } : {}),
|
||
|
|
...(principalId ? { principalId } : {}),
|
||
|
|
...(hostName ? { hostName } : {}),
|
||
|
|
...(deviceSlug ? { deviceSlug } : {}),
|
||
|
|
...(typeof authorityCoordinator === 'boolean' ? { authorityCoordinator } : {}),
|
||
|
|
...(spaceId ? { spaceId } : {}),
|
||
|
|
...(routeKey ? { routeKey } : {}),
|
||
|
|
...(publicNamespace ? { publicNamespace } : {}),
|
||
|
|
authorityRoot,
|
||
|
|
linkedAt,
|
||
|
|
credentials: {
|
||
|
|
natsCredentialRef,
|
||
|
|
...(hostLinkTokenRef ? { hostLinkTokenRef } : {}),
|
||
|
|
...(registryTokenRef ? { registryTokenRef } : {}),
|
||
|
|
...(natsLeafnodeCredentialRef ? { natsLeafnodeCredentialRef } : {}),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function removeHiveCastCredentialFiles(
|
||
|
|
paths: Pick<IHiveCastLinkPaths, 'natsCredentialsPath' | 'natsLeafnodeCredentialsPath' | 'hostCredentialsPath' | 'hostLinkTokenPath'>,
|
||
|
|
): boolean {
|
||
|
|
const existed = fs.existsSync(paths.natsCredentialsPath)
|
||
|
|
|| fs.existsSync(paths.natsLeafnodeCredentialsPath)
|
||
|
|
|| fs.existsSync(paths.hostCredentialsPath)
|
||
|
|
|| fs.existsSync(paths.hostLinkTokenPath);
|
||
|
|
fs.rmSync(paths.natsCredentialsPath, { force: true });
|
||
|
|
fs.rmSync(paths.natsLeafnodeCredentialsPath, { force: true });
|
||
|
|
fs.rmSync(paths.hostCredentialsPath, { force: true });
|
||
|
|
fs.rmSync(paths.hostLinkTokenPath, { force: true });
|
||
|
|
return existed;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function removeHiveCastHostLink(
|
||
|
|
cwd: string,
|
||
|
|
matrixHome?: string,
|
||
|
|
options: IRemoveHiveCastHostLinkOptions = {},
|
||
|
|
): IRemoveHiveCastHostLinkResult {
|
||
|
|
const paths = hiveCastLinkPaths(cwd, matrixHome);
|
||
|
|
const existed = fs.existsSync(paths.linkPath)
|
||
|
|
|| fs.existsSync(paths.natsCredentialsPath)
|
||
|
|
|| fs.existsSync(paths.natsLeafnodeCredentialsPath)
|
||
|
|
|| fs.existsSync(paths.hostLinkTokenPath)
|
||
|
|
|| fs.existsSync(paths.hostCredentialsPath)
|
||
|
|
|| isHostConfigLinked(paths.hostConfigPath);
|
||
|
|
fs.rmSync(paths.linkPath, { force: true });
|
||
|
|
updateHiveCastDefaultRuntimePolicies(paths.matrixHome, localDefaultRuntimeScope(paths.matrixHome));
|
||
|
|
const hostConfigReset = resetHostConfigToLocalOwner(paths.hostConfigPath);
|
||
|
|
if (options.preserveCredentials !== true) {
|
||
|
|
removeHiveCastCredentialFiles(paths);
|
||
|
|
}
|
||
|
|
return { removed: existed, hostConfigReset, paths };
|
||
|
|
}
|
||
|
|
|
||
|
|
export function readHiveCastLinkStatus(cwd: string, matrixHome?: string): IHiveCastLinkStatus {
|
||
|
|
const { record, paths } = readHiveCastHostLink(cwd, matrixHome);
|
||
|
|
const hostConfigLinked = Boolean(record) && fs.existsSync(paths.hostConfigPath);
|
||
|
|
return {
|
||
|
|
linked: Boolean(record),
|
||
|
|
matrixHome: paths.matrixHome,
|
||
|
|
linkPath: paths.linkPath,
|
||
|
|
natsCredentialsPath: paths.natsCredentialsPath,
|
||
|
|
hostConfigPath: paths.hostConfigPath,
|
||
|
|
natsCredentialsPresent: fs.existsSync(paths.natsCredentialsPath),
|
||
|
|
hostConfigPresent: fs.existsSync(paths.hostConfigPath),
|
||
|
|
hostConfigLinked,
|
||
|
|
...(record?.hostLinkId ? { hostLinkId: record.hostLinkId } : {}),
|
||
|
|
...(record?.authorityRoot ? { 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?.cloudUrl ? { cloudUrl: record.cloudUrl } : {}),
|
||
|
|
...(record?.linkedAt ? { linkedAt: record.linkedAt } : {}),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function isHostConfigLinked(hostConfigPath: string): boolean {
|
||
|
|
const config = readJsonFile(hostConfigPath);
|
||
|
|
const transport = asRecord(config?.transport);
|
||
|
|
if (!transport) return false;
|
||
|
|
const nats = asRecord(transport.nats);
|
||
|
|
const root = optionalString(transport.root);
|
||
|
|
const authorityRoot = optionalString(transport.authorityRoot);
|
||
|
|
const matrixHome = optionalString(config?.home) ?? path.dirname(hostConfigPath);
|
||
|
|
const localOwnerTransport = readLocalOwnerTransportSnapshotForHome(matrixHome, config);
|
||
|
|
const localOwnerRoot = localOwnerTransport?.root;
|
||
|
|
const hasCloudAuthority = Boolean(
|
||
|
|
authorityRoot
|
||
|
|
&& authorityRoot !== root,
|
||
|
|
) && (localOwnerRoot ? authorityRoot !== localOwnerRoot : true);
|
||
|
|
const externalNatsIsHiveCast = nats?.mode === 'external'
|
||
|
|
&& (
|
||
|
|
Boolean(optionalString(nats.credentialsRef))
|
||
|
|
|| (!loopbackUrlPort(optionalString(nats.url)) && !loopbackUrlPort(optionalString(nats.wsUrl)))
|
||
|
|
);
|
||
|
|
return Boolean(
|
||
|
|
optionalString(transport.routeKey)
|
||
|
|
|| optionalString(transport.publicNamespace)
|
||
|
|
|| optionalString(transport.spaceId)
|
||
|
|
|| hasCloudAuthority
|
||
|
|
|| externalNatsIsHiveCast,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function resetHostConfigToLocalOwner(hostConfigPath: string): boolean {
|
||
|
|
const config = readJsonFile(hostConfigPath);
|
||
|
|
if (!config) return false;
|
||
|
|
const transport = asRecord(config.transport);
|
||
|
|
if (!transport) return false;
|
||
|
|
|
||
|
|
const currentNats = asRecord(transport.nats);
|
||
|
|
const matrixHome = optionalString(config.home) ?? path.dirname(hostConfigPath);
|
||
|
|
const localOwnerTransport = readLocalOwnerTransportSnapshotForHome(matrixHome, config)
|
||
|
|
?? readLocalOwnerTransportSnapshotFromRuntimeRecords(matrixHome)
|
||
|
|
?? readLocalOwnerTransportSnapshotFromStatus(matrixHome);
|
||
|
|
const nextTransport: JsonRecord = { ...transport };
|
||
|
|
const localRoot = localOwnerAuthorityRoot(matrixHome);
|
||
|
|
nextTransport.root = localOwnerTransport?.root ?? localRoot;
|
||
|
|
if (localOwnerTransport?.authorityRoot) {
|
||
|
|
nextTransport.authorityRoot = localOwnerTransport.authorityRoot;
|
||
|
|
} else {
|
||
|
|
delete nextTransport.authorityRoot;
|
||
|
|
}
|
||
|
|
nextTransport.addressRoot = localOwnerTransport?.root ?? localRoot;
|
||
|
|
delete nextTransport.routeKey;
|
||
|
|
delete nextTransport.publicNamespace;
|
||
|
|
delete nextTransport.spaceId;
|
||
|
|
nextTransport.nats = localOwnerTransport?.nats
|
||
|
|
?? (currentNats && currentNats.mode !== 'external' ? currentNats : defaultLocalOwnerNatsTransport());
|
||
|
|
|
||
|
|
const nextConfig: JsonRecord = {
|
||
|
|
...config,
|
||
|
|
transport: nextTransport,
|
||
|
|
};
|
||
|
|
delete nextConfig.natsTopology;
|
||
|
|
writePrivateJson(hostConfigPath, nextConfig);
|
||
|
|
return true;
|
||
|
|
}
|