blob: ccc100b2b033941bfcc45cdedac198c29625434c [file]
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { truncateUtf8 } from '@maka/core/diagnostic-log';
import { connectRemoteRuntimeHost } from '@maka/runtime-host/client';
import {
encodeRuntimeHostSetupFrame,
RUNTIME_HOST_SETUP_ERROR_CODE_MAX_BYTES,
RUNTIME_HOST_SETUP_ERROR_MESSAGE_MAX_BYTES,
type RuntimeHostSetupFrame,
type RuntimeHostSetupPhase,
} from '@maka/runtime-host/operator';
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_PROTOCOL_VERSION,
} from '@maka/runtime-host/protocol';
import { withFileUpdateLock } from '@maka/storage/file-update-lock';
import {
prepareRuntimeHostAccessCredential,
replaceRuntimeHostAccessCredential,
revokeRuntimeHostAccessCredential,
type RuntimeHostAccessPreset,
} from './runtime-host-access-command.js';
import {
isRuntimeHostDevelopmentPackageVersion,
prepareRuntimeHostManagedPackageDeployment,
RuntimeHostManagedDeploymentError,
} from './runtime-host-managed-deployment.js';
import { createPlatformRuntimeHostServiceBackend } from './runtime-host-service-management-command.js';
import {
manageRuntimeHostService,
resolveRuntimeHostManagedServiceId,
RuntimeHostServiceManagerError,
type RuntimeHostManagedServiceResult,
type RuntimeHostManagedServiceTarget,
type RuntimeHostServiceBackend,
} from './runtime-host-service-manager.js';
const SETUP_LOCK_TIMEOUT_MS = 5 * 60_000;
export interface RuntimeHostSetupCliOptions {
readonly json: boolean;
readonly clientDataRoot: string;
readonly defaultRootPath: string;
readonly sourcePackageRoot: string;
readonly version: string;
readonly principalId: string;
readonly preset: RuntimeHostAccessPreset;
readonly deferPairingCommit?: boolean;
readonly rootPath?: string;
readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[];
readonly websocketPort?: number;
readonly websocketPath?: string;
readonly expectedTarget?: RuntimeHostManagedServiceTarget;
}
interface RuntimeHostSetupDeps {
readonly manageService: typeof manageRuntimeHostService;
readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend;
readonly prepareDeployment: typeof prepareRuntimeHostManagedPackageDeployment;
readonly prepareCredential: typeof prepareRuntimeHostAccessCredential;
readonly replaceCredential: typeof replaceRuntimeHostAccessCredential;
readonly revokeCredential: typeof revokeRuntimeHostAccessCredential;
readonly verifyCredential: typeof verifyRuntimeHostSetupCredential;
readonly writeOutput: (value: string) => unknown;
readonly writeError: (value: string) => unknown;
}
class RuntimeHostSetupError extends Error {
constructor(
readonly code: string,
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = 'RuntimeHostSetupError';
}
}
export async function runRuntimeHostSetupCli(
options: RuntimeHostSetupCliOptions,
overrides: Partial<RuntimeHostSetupDeps> = {},
): Promise<number> {
const deps: RuntimeHostSetupDeps = {
manageService: manageRuntimeHostService,
createBackend: createPlatformRuntimeHostServiceBackend,
prepareDeployment: prepareRuntimeHostManagedPackageDeployment,
prepareCredential: prepareRuntimeHostAccessCredential,
replaceCredential: replaceRuntimeHostAccessCredential,
revokeCredential: revokeRuntimeHostAccessCredential,
verifyCredential: verifyRuntimeHostSetupCredential,
writeOutput: (value) => process.stdout.write(value),
writeError: (value) => process.stderr.write(value),
...overrides,
};
const emit = createEmitter(options.json, deps);
try {
await mkdir(options.clientDataRoot, { recursive: true, mode: 0o700 });
await withFileUpdateLock(
join(options.clientDataRoot, 'runtime-host-setup'),
() => runRuntimeHostSetupLocked(options, deps, emit),
SETUP_LOCK_TIMEOUT_MS,
);
return 0;
} catch (error) {
const failure = setupFailure(error);
emit({ kind: 'error', error: failure });
return 1;
}
}
async function runRuntimeHostSetupLocked(
options: RuntimeHostSetupCliOptions,
deps: RuntimeHostSetupDeps,
emit: SetupEmitter,
): Promise<void> {
emit({ kind: 'progress', phase: 'checking_environment' });
const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot);
const backend = deps.createBackend(serviceId);
const common = {
clientDataRoot: options.clientDataRoot,
defaultRootPath: options.defaultRootPath,
nodePath: process.execPath,
cliPath: join(options.sourcePackageRoot, 'dist', 'cli.js'),
...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}),
} as const;
const status = await deps.manageService({ ...common, action: 'status' }, backend);
await assertCompatibleExistingVersion(status, options.version);
emit({ kind: 'progress', phase: 'installing_package' });
const deployment = await deps.prepareDeployment({
serviceId,
clientDataRoot: options.clientDataRoot,
sourcePackageRoot: options.sourcePackageRoot,
version: options.version,
});
emit({ kind: 'progress', phase: 'installing_service' });
let installed: RuntimeHostManagedServiceResult;
try {
installed = await deps.manageService(
{
...common,
action: 'install',
cliPath: deployment.cliPath,
...(options.rootPath ? { rootPath: options.rootPath } : {}),
...(options.projectDirectoryRoots
? { projectDirectoryRoots: options.projectDirectoryRoots }
: {}),
...(options.websocketPort === undefined ? {} : { websocketPort: options.websocketPort }),
...(options.websocketPath ? { websocketPath: options.websocketPath } : {}),
},
backend,
);
} catch (error) {
await deployment.rollback().catch(() => undefined);
throw error;
}
const config = installed.service.config;
if (!config || !installed.service.active) {
throw new RuntimeHostSetupError(
'service_not_ready',
'Managed Runtime Host service did not become ready',
);
}
await deployment.commit();
emit({ kind: 'progress', phase: 'pairing_client' });
let paired: Awaited<ReturnType<typeof prepareRuntimeHostAccessCredential>>;
try {
const pairCredential = options.deferPairingCommit
? deps.prepareCredential
: deps.replaceCredential;
paired = await pairCredential({
rootPath: config.rootPath,
principalKind: 'remote_owner',
principalId: options.principalId,
operationGrants: [],
canPublishClientCapabilities: false,
canUseHostPaths: false,
preset: options.preset,
});
} catch (error) {
throw new RuntimeHostSetupError(
'pairing_failed',
'Runtime Host could not pair the requested Client identity',
{ cause: error },
);
}
const endpoint = websocketUrl(config.websocket);
emit({ kind: 'progress', phase: 'verifying_connection' });
try {
await deps.verifyCredential({
endpoint,
rootId: paired.rootId,
credential: paired.credential,
});
emit({
kind: 'complete',
version: deployment.version,
serviceId,
operatorPath: deployment.operatorPath,
rootPath: config.rootPath,
rootId: paired.rootId,
endpoint,
credentialId: paired.credentialId,
credential: paired.credential,
});
} catch (error) {
if (options.deferPairingCommit) {
try {
await deps.revokeCredential({
rootPath: config.rootPath,
credentialId: paired.credentialId,
});
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
'Runtime Host pairing failed and its candidate credential could not be revoked',
);
}
}
throw error;
}
}
async function assertCompatibleExistingVersion(
status: RuntimeHostManagedServiceResult,
version: string,
): Promise<void> {
if (!status.service.config) {
if (!status.service.installed) return;
throw new RuntimeHostSetupError(
'existing_installation_unknown',
'The installed Runtime Host configuration is unavailable; repair it before setup',
);
}
const existingVersion = status.service.installedVersion;
if (!existingVersion) {
throw new RuntimeHostSetupError(
'existing_installation_unknown',
'The installed Runtime Host version could not be identified; repair it before setup',
);
}
if (
existingVersion !== version &&
!(
isRuntimeHostDevelopmentPackageVersion(existingVersion) &&
isRuntimeHostDevelopmentPackageVersion(version)
)
) {
throw new RuntimeHostSetupError(
'version_change_requires_update',
`Runtime Host ${String(existingVersion)} is already installed; changing to ${version} requires the update workflow`,
);
}
}
async function verifyRuntimeHostSetupCredential(input: {
readonly endpoint: string;
readonly rootId: string;
readonly credential: string;
}): Promise<void> {
const result = await connectRemoteRuntimeHost({
url: input.endpoint,
credential: input.credential,
expectedRootId: input.rootId,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION },
});
if (result.kind !== 'connected') {
throw new RuntimeHostSetupError(
'verification_failed',
`The paired Runtime Host connection could not be verified (${result.kind})`,
);
}
try {
const status = await result.connection.status();
if (status.state !== 'ready') {
throw new RuntimeHostSetupError(
'verification_failed',
`The paired Runtime Host is ${status.state}`,
);
}
} finally {
await result.connection.close();
}
}
type SetupEmitter = (
frame:
| Omit<Extract<RuntimeHostSetupFrame, { kind: 'progress' }>, 'schemaVersion' | 'sequence'>
| Omit<Extract<RuntimeHostSetupFrame, { kind: 'complete' }>, 'schemaVersion' | 'sequence'>
| Omit<Extract<RuntimeHostSetupFrame, { kind: 'error' }>, 'schemaVersion' | 'sequence'>,
) => void;
function createEmitter(json: boolean, deps: RuntimeHostSetupDeps): SetupEmitter {
let sequence = 0;
return (input) => {
const frame = { schemaVersion: 1, sequence: sequence++, ...input } as RuntimeHostSetupFrame;
if (json) {
deps.writeOutput(encodeRuntimeHostSetupFrame(frame));
return;
}
if (frame.kind === 'progress') {
deps.writeOutput(`${humanPhase(frame.phase)}\n`);
} else if (frame.kind === 'complete') {
deps.writeOutput(`${JSON.stringify(frame, null, 2)}\n`);
} else {
deps.writeError(`${frame.error.message}\n`);
}
};
}
function setupFailure(error: unknown): { code: string; message: string } {
let code = 'internal_setup_failure';
let message = 'Runtime Host setup failed';
if (
error instanceof RuntimeHostSetupError ||
error instanceof RuntimeHostServiceManagerError ||
error instanceof RuntimeHostManagedDeploymentError
) {
code = error.code;
message = error.message;
}
return {
code: truncateUtf8(code, RUNTIME_HOST_SETUP_ERROR_CODE_MAX_BYTES) || 'internal_setup_failure',
message:
truncateUtf8(message, RUNTIME_HOST_SETUP_ERROR_MESSAGE_MAX_BYTES) ||
'Runtime Host setup failed',
};
}
function websocketUrl(input: {
readonly host: string;
readonly port: number;
readonly path: string;
}) {
return `ws://${input.host}:${input.port}${input.path}`;
}
function humanPhase(phase: RuntimeHostSetupPhase): string {
switch (phase) {
case 'checking_environment':
return 'Checking the remote environment...';
case 'installing_package':
return 'Installing the managed Maka package...';
case 'installing_service':
return 'Installing the Runtime Host service...';
case 'pairing_client':
return 'Pairing the Client...';
case 'verifying_connection':
return 'Verifying the Runtime Host connection...';
}
}