orca/src/shared/runtime-environment-store.ts

167 lines
5.2 KiB
TypeScript

import { randomUUID } from 'crypto'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { parsePairingCode, type PairingOffer } from './pairing'
import { hardenExistingSecureFile, writeSecureJsonFile } from './secure-file'
import {
createEnvironmentFromPairingOffer,
getPreferredPairingOffer,
KnownRuntimeEnvironmentSchema,
RuntimeEnvironmentStoreSchema,
type KnownRuntimeEnvironment,
type RuntimeEnvironmentStore
} from './runtime-environments'
const ENVIRONMENTS_FILE = 'orca-environments.json'
export type RuntimeEnvironmentStoreErrorCode = 'invalid_argument' | 'runtime_error'
export class RuntimeEnvironmentStoreError extends Error {
readonly code: RuntimeEnvironmentStoreErrorCode
constructor(code: RuntimeEnvironmentStoreErrorCode, message: string) {
super(message)
this.name = 'RuntimeEnvironmentStoreError'
this.code = code
}
}
export function getEnvironmentStorePath(userDataPath: string): string {
return join(userDataPath, ENVIRONMENTS_FILE)
}
export function listEnvironments(userDataPath: string): KnownRuntimeEnvironment[] {
return readEnvironmentStore(userDataPath).environments
}
export function addEnvironmentFromPairingCode(
userDataPath: string,
args: { name: string; pairingCode: string; now?: number }
): KnownRuntimeEnvironment {
const offer = parsePairingCode(args.pairingCode)
if (!offer) {
throw new RuntimeEnvironmentStoreError(
'invalid_argument',
'Invalid pairing code. Expected an orca://pair#... URL or bare pairing payload.'
)
}
const store = readEnvironmentStore(userDataPath)
const now = args.now ?? Date.now()
const existing = store.environments.find((entry) => entry.name === args.name)
if (existing) {
throw new RuntimeEnvironmentStoreError(
'invalid_argument',
`A server named "${args.name}" already exists.`
)
}
const environment = createEnvironmentFromPairingOffer({
id: randomUUID(),
name: args.name,
now,
offer,
runtimeId: null
})
const next = {
version: 1 as const,
environments: [
...store.environments.filter((entry) => entry.id !== environment.id),
environment
].sort((a, b) => a.name.localeCompare(b.name))
}
writeEnvironmentStore(userDataPath, next)
return environment
}
export function removeEnvironment(userDataPath: string, selector: string): KnownRuntimeEnvironment {
const store = readEnvironmentStore(userDataPath)
const environment = resolveEnvironmentFromStore(store, selector)
writeEnvironmentStore(userDataPath, {
version: 1,
environments: store.environments.filter((entry) => entry.id !== environment.id)
})
return environment
}
export function resolveEnvironment(
userDataPath: string,
selector: string
): KnownRuntimeEnvironment {
return resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), selector)
}
export function resolveEnvironmentPairingOffer(
userDataPath: string,
selector: string
): PairingOffer {
return getPreferredPairingOffer(resolveEnvironment(userDataPath, selector))
}
export function markEnvironmentUsed(
userDataPath: string,
selector: string,
args: { runtimeId?: string | null; now?: number } = {}
): void {
const store = readEnvironmentStore(userDataPath)
const environment = resolveEnvironmentFromStore(store, selector)
const now = args.now ?? Date.now()
const next = store.environments.map((entry) =>
entry.id === environment.id
? {
...entry,
runtimeId: args.runtimeId ?? entry.runtimeId,
lastUsedAt: now,
updatedAt: now
}
: entry
)
writeEnvironmentStore(userDataPath, { version: 1, environments: next })
}
function resolveEnvironmentFromStore(
store: RuntimeEnvironmentStore,
selector: string
): KnownRuntimeEnvironment {
const byId = store.environments.find((entry) => entry.id === selector)
if (byId) {
return byId
}
const matches = store.environments.filter((entry) => entry.name === selector)
if (matches.length === 1) {
return matches[0]!
}
if (matches.length > 1) {
throw new RuntimeEnvironmentStoreError(
'invalid_argument',
`Environment name "${selector}" is ambiguous; use the environment id.`
)
}
throw new RuntimeEnvironmentStoreError('invalid_argument', `Unknown environment: ${selector}`)
}
function readEnvironmentStore(userDataPath: string): RuntimeEnvironmentStore {
const path = getEnvironmentStorePath(userDataPath)
if (!existsSync(path)) {
return { version: 1, environments: [] }
}
try {
hardenExistingSecureFile(path)
const parsed = RuntimeEnvironmentStoreSchema.parse(JSON.parse(readFileSync(path, 'utf8')))
return {
version: 1,
environments: parsed.environments
.map((entry) => KnownRuntimeEnvironmentSchema.parse(entry))
.sort((a, b) => a.name.localeCompare(b.name))
}
} catch {
throw new RuntimeEnvironmentStoreError(
'runtime_error',
`Could not read Orca environments at ${path}; the file is invalid.`
)
}
}
function writeEnvironmentStore(userDataPath: string, store: RuntimeEnvironmentStore): void {
const path = getEnvironmentStorePath(userDataPath)
writeSecureJsonFile(path, RuntimeEnvironmentStoreSchema.parse(store))
}