122 lines
4.6 KiB
TypeScript
122 lines
4.6 KiB
TypeScript
import { hostApiFetch } from '@/lib/host-api';
|
|
import type {
|
|
DevicePackageIndexV1,
|
|
DevicePackageKind,
|
|
DevicePackageRecordV1,
|
|
DevicePackageSourceKind,
|
|
} from '../../shared/device-packages';
|
|
|
|
const PACKAGE_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/u;
|
|
|
|
function object(value: unknown): Record<string, unknown> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new Error('device package projection is invalid');
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function exact(value: Record<string, unknown>, keys: readonly string[]): void {
|
|
const actual = Object.keys(value).sort();
|
|
const expected = [...keys].sort();
|
|
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
|
throw new Error('device package projection has unexpected fields');
|
|
}
|
|
}
|
|
|
|
function text(value: unknown, maximum: number): string {
|
|
if (typeof value !== 'string' || !value || value.length > maximum) {
|
|
throw new Error('device package text is invalid');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function oneOf<T extends string>(value: unknown, choices: readonly T[]): T {
|
|
if (typeof value !== 'string' || !choices.includes(value as T)) {
|
|
throw new Error('device package value is invalid');
|
|
}
|
|
return value as T;
|
|
}
|
|
|
|
function entry(value: unknown): DevicePackageRecordV1['skillEntries'][number] {
|
|
const item = object(value);
|
|
exact(item, ['id', 'entryPath']);
|
|
return { id: text(item.id, 128), entryPath: text(item.entryPath, 1_024) };
|
|
}
|
|
|
|
function record(value: unknown): DevicePackageRecordV1 {
|
|
const item = object(value);
|
|
exact(item, [
|
|
'schemaVersion', 'packageId', 'displayName', 'resolvedVersion', 'source', 'kind',
|
|
'skillEntries', 'extensionEntries', 'enabled', 'confirmedExecutableCode', 'installedAt',
|
|
]);
|
|
const source = object(item.source);
|
|
exact(source, ['kind', 'requested', 'resolved']);
|
|
if (item.schemaVersion !== 1 || typeof item.enabled !== 'boolean'
|
|
|| typeof item.confirmedExecutableCode !== 'boolean'
|
|
|| !Array.isArray(item.skillEntries) || item.skillEntries.length > 100
|
|
|| !Array.isArray(item.extensionEntries) || item.extensionEntries.length > 100) {
|
|
throw new Error('device package record is invalid');
|
|
}
|
|
const packageId = text(item.packageId, 128);
|
|
if (!PACKAGE_ID.test(packageId)) throw new Error('device package id is invalid');
|
|
return {
|
|
schemaVersion: 1,
|
|
packageId,
|
|
displayName: text(item.displayName, 256),
|
|
resolvedVersion: text(item.resolvedVersion, 128),
|
|
source: {
|
|
kind: oneOf<DevicePackageSourceKind>(source.kind, ['npm', 'git', 'file']),
|
|
requested: text(source.requested, 2_048),
|
|
resolved: text(source.resolved, 4_096),
|
|
},
|
|
kind: oneOf<DevicePackageKind>(item.kind, ['skill-only', 'pi-extension', 'mixed']),
|
|
skillEntries: item.skillEntries.map(entry),
|
|
extensionEntries: item.extensionEntries.map((value) => text(value, 1_024)),
|
|
enabled: item.enabled,
|
|
confirmedExecutableCode: item.confirmedExecutableCode,
|
|
installedAt: text(item.installedAt, 64),
|
|
};
|
|
}
|
|
|
|
export function parseDevicePackageIndex(value: unknown): DevicePackageIndexV1 {
|
|
const index = object(value);
|
|
exact(index, ['schemaVersion', 'generation', 'packages']);
|
|
if (index.schemaVersion !== 1 || !Number.isSafeInteger(index.generation)
|
|
|| (index.generation as number) < 0 || !Array.isArray(index.packages)
|
|
|| index.packages.length > 200) throw new Error('device package index is invalid');
|
|
return {
|
|
schemaVersion: 1,
|
|
generation: index.generation as number,
|
|
packages: index.packages.map(record),
|
|
};
|
|
}
|
|
|
|
function id(value: string): string {
|
|
const normalized = value.trim();
|
|
if (!PACKAGE_ID.test(normalized)) throw new Error('device package id is invalid');
|
|
return normalized;
|
|
}
|
|
|
|
export async function readDevicePackages(): Promise<DevicePackageIndexV1> {
|
|
return parseDevicePackageIndex(await hostApiFetch('/api/coding/device-packages'));
|
|
}
|
|
|
|
export async function setDevicePackageEnabled(
|
|
packageId: string,
|
|
enabled: boolean,
|
|
): Promise<DevicePackageIndexV1> {
|
|
return parseDevicePackageIndex(await hostApiFetch(
|
|
`/api/coding/device-packages/${encodeURIComponent(id(packageId))}`,
|
|
{ method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ enabled }) },
|
|
));
|
|
}
|
|
|
|
export async function uninstallDevicePackage(packageId: string): Promise<DevicePackageIndexV1> {
|
|
return parseDevicePackageIndex(await hostApiFetch(
|
|
`/api/coding/device-packages/${encodeURIComponent(id(packageId))}`,
|
|
{ method: 'DELETE', headers: { 'content-type': 'application/json' }, body: '{}' },
|
|
));
|
|
}
|
|
|
|
export type { DevicePackageIndexV1, DevicePackageRecordV1 } from '../../shared/device-packages';
|