167 lines
5.2 KiB
TypeScript
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))
|
|
}
|