Files
makelore/electron/coding-plugins/package-store.ts

1402 lines
57 KiB
TypeScript

import { Buffer } from 'node:buffer';
import { createHash, randomUUID } from 'node:crypto';
import {
lstat,
mkdir,
mkdtemp,
readFile,
rename,
rm,
stat,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import AdmZip from 'adm-zip';
import { getDataDir } from '../utils/paths';
import { subscribeWorksSquareSession } from '../services/works-square-session';
import {
loadCodingPluginDefinition,
} from './manifest';
import type { CodingPluginDefinition } from '../../shared/coding-plugins';
import {
buildPluginReleaseDescriptor,
compareSemVer,
isMakeLoreVersionCompatible,
isValidSemVer,
type PluginReleaseDescriptor,
} from './release-descriptor';
import {
createPluginSignatureVerifier,
type PluginSignatureFailureCode,
type PluginSignatureVerifier,
} from './signature-verifier';
import {
AccountPluginCache,
type AccountBinding,
type MarketplaceLibrarySnapshot,
type MarketplaceResolveItem,
type MarketplaceResolveSnapshot,
} from './account-plugin-cache';
import type {
DownloadGrant,
DownloadRequest,
InstalledReleaseInput,
MarketplaceClient,
ResolveRequest,
} from './marketplace-client';
import type { PluginSigningKeyStore } from './trusted-keys';
const INDEX_SCHEMA_VERSION = 1;
const CURRENT_SELECTION_SCHEMA_VERSION = 1;
const DEFAULT_MAX_ARCHIVE_BYTES = 16 * 1024 * 1024;
const DEFAULT_MAX_EXTRACTED_BYTES = 32 * 1024 * 1024;
const DEFAULT_MAX_FILES = 256;
const DEFAULT_MAX_FILE_BYTES = 4 * 1024 * 1024;
const PLUGIN_MANIFEST_MAX_BYTES = 256 * 1024;
const PLUGIN_SKILL_MAX_BYTES = 256 * 1024;
const ORPHAN_ARCHIVE_FILE = '.makelore-release.zip';
const MAX_PLUGIN_ID = 128;
const MAX_RELEASE_ID = 128;
const MAX_VERSION = 128;
const MAX_CONTRACT_VERSION = 2 ** 31 - 1;
const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,127}$/u;
const RELEASE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
const INDEX_ROOT_KEYS = new Set(['schema_version', 'releases']);
const INDEX_RELEASE_KEYS = new Set([
'plugin_id',
'release_id',
'version',
'package_schema_version',
'contract_version',
'runtime_kind',
'sha256',
'size_bytes',
'installed_at',
'channel',
'min_makelore_version',
'max_makelore_version',
]);
const CURRENT_SELECTION_ROOT_KEYS = new Set(['schema_version', 'current']);
const PACKAGE_MANIFEST_PATHS = new Set(['plugin.json', 'com.makelore/capability.json']);
const PACKAGE_TEXT_EXTENSIONS = new Set([
'.css', '.html', '.json', '.md', '.markdown', '.mustache', '.hbs', '.ini',
'.jinja', '.jinja2', '.template', '.txt', '.toml', '.tmpl', '.xml', '.yaml', '.yml',
]);
const PACKAGE_IMAGE_EXTENSIONS = new Set(['.jpeg', '.jpg', '.png', '.svg', '.webp']);
type UnknownRecord = Record<string, unknown>;
export type { DownloadGrant, MarketplaceResolveSnapshot } from './marketplace-client';
export type ResolveSnapshot = MarketplaceResolveSnapshot;
export interface InstalledReleaseRecord {
readonly pluginId: string;
readonly releaseId: string;
readonly version: string;
readonly packageSchemaVersion: number;
readonly contractVersion: number;
readonly runtimeKind: 'skill_only' | 'platform_hosted';
readonly sha256: string;
readonly sizeBytes: number;
readonly installedAt: string;
/** Channel is the latest explicit selection intent; verified client range is immutable. */
readonly channel?: 'stable' | 'beta';
readonly minMakeloreVersion?: string;
readonly maxMakeloreVersion?: string | null;
}
export interface InstalledRelease extends InstalledReleaseRecord {
readonly packageRoot: string;
readonly definition: CodingPluginDefinition;
readonly unavailableReason?: 'plugin_incompatible_client';
}
export type InstallationStatus = 'installed' | 'kept' | 'removed' | 'unavailable';
export interface InstallationSnapshot {
readonly status: InstallationStatus;
readonly pluginId: string;
readonly releaseId?: string;
readonly version?: string;
/** The immutable channel selected for this device package. */
readonly channel?: 'stable' | 'beta';
readonly packageRoot?: string;
readonly definition?: CodingPluginDefinition;
readonly reason?: string;
}
export interface ResolveInstallInput {
readonly pluginId: string;
readonly makeloreVersion: string;
readonly channel?: 'stable' | 'beta';
/** Beta packages are never selected by background/stable sync. */
readonly explicitBeta?: boolean;
readonly resolveRequestId?: string;
readonly resolveRequestDigest?: string;
readonly installed?: InstalledReleaseInput | null;
}
export interface MarketplacePackageClientPort {
resolve(input: ResolveRequest): Promise<MarketplaceResolveSnapshot>;
issueDownload(input: DownloadRequest): Promise<DownloadGrant>;
downloadContent(grant: DownloadGrant): Promise<Uint8Array>;
readLibrary?(): Promise<MarketplaceLibrarySnapshot>;
getCurrentAccountBinding?(): AccountBinding | null;
}
export interface PluginPackageStoreOptions {
/** A test-owned root may be supplied; production derives it from appData. */
readonly rootDir?: string;
readonly marketplace: MarketplacePackageClientPort | MarketplaceClient;
readonly accountCache?: AccountPluginCache;
readonly getAccountBinding?: () => AccountBinding | null;
readonly subscribeSession?: (listener: () => void) => () => void;
readonly clientVersion?: string;
readonly keyStore?: PluginSigningKeyStore | ReadonlyMap<string, Uint8Array | string>;
readonly signatureVerifier?: PluginSignatureVerifier;
readonly now?: () => number;
readonly maxArchiveBytes?: number;
readonly maxExtractedBytes?: number;
readonly maxFiles?: number;
readonly maxFileBytes?: number;
/** Replaced only by focused interruption tests; production is atomic. */
readonly writeIndex?: (filePath: string, bytes: Uint8Array) => Promise<void>;
readonly activeWorkerReleaseIds?: () => readonly string[];
}
export type PluginPackageStoreErrorCode =
| 'plugin_store_index_invalid'
| 'plugin_beta_selection_required'
| 'plugin_release_unavailable'
| 'plugin_account_changed'
| 'plugin_artifact_invalid'
| 'plugin_signature_invalid'
| 'plugin_incompatible_client'
| 'plugin_manifest_invalid'
| 'plugin_runtime_not_supported'
| 'plugin_release_conflict'
| 'plugin_release_not_ready'
| 'plugin_release_yanked'
| 'plugin_runtime_suspended'
| 'plugin_library_required'
| 'plugin_backend_unavailable'
| 'plugin_install_failed';
export class PluginPackageStoreError extends Error {
constructor(
readonly code: PluginPackageStoreErrorCode,
message: string = code,
) {
super(message);
this.name = 'PluginPackageStoreError';
}
}
interface IndexDocument {
readonly schema_version: typeof INDEX_SCHEMA_VERSION;
readonly releases: readonly InstalledReleaseRecord[];
}
interface CurrentSelectionDocument {
readonly schema_version: typeof CURRENT_SELECTION_SCHEMA_VERSION;
readonly current: Readonly<Record<string, string>>;
}
function isRecord(value: unknown): value is UnknownRecord {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function fail(code: PluginPackageStoreErrorCode, message: string = code): never {
throw new PluginPackageStoreError(code, message);
}
function assertExactKeys(value: UnknownRecord, allowed: ReadonlySet<string>, context: string): void {
for (const key of Object.keys(value)) {
if (!allowed.has(key)) fail('plugin_store_index_invalid', `unknown ${context} field: ${key.slice(0, 128)}`);
}
}
function boundedText(
value: unknown,
field: string,
maximum: number,
code: PluginPackageStoreErrorCode = 'plugin_store_index_invalid',
): string {
if (typeof value !== 'string' || value.length === 0 || value.length > maximum) {
fail(code, `invalid ${field}`);
}
return value;
}
function positiveInteger(value: unknown, field: string, maximum = Number.MAX_SAFE_INTEGER): number {
if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > maximum) {
fail('plugin_store_index_invalid', `invalid ${field}`);
}
return value as number;
}
function validPluginId(
value: unknown,
field = 'pluginId',
code: PluginPackageStoreErrorCode = 'plugin_release_unavailable',
): string {
const result = boundedText(value, field, MAX_PLUGIN_ID, code);
if (!PLUGIN_ID_PATTERN.test(result)) fail(code, `invalid ${field}`);
return result;
}
function validReleaseId(
value: unknown,
field = 'releaseId',
code: PluginPackageStoreErrorCode = 'plugin_release_unavailable',
): string {
const result = boundedText(value, field, MAX_RELEASE_ID, code);
if (!RELEASE_ID_PATTERN.test(result)) fail(code, `invalid ${field}`);
return result;
}
function validSha(
value: unknown,
field: string,
code: PluginPackageStoreErrorCode = 'plugin_artifact_invalid',
): string {
const result = boundedText(value, field, 64, code);
if (!SHA256_PATTERN.test(result)) fail(code, `invalid ${field}`);
return result;
}
function validVersion(value: unknown, field: string): string {
const result = boundedText(value, field, MAX_VERSION);
if (!isValidSemVer(result)) fail('plugin_store_index_invalid', `invalid ${field}`);
return result;
}
function assertPositiveOption(value: number | undefined, fallback: number, field: string): number {
const result = value ?? fallback;
if (!Number.isSafeInteger(result) || result < 1) throw new RangeError(`${field} must be a positive safe integer`);
return result;
}
function clone<T>(value: T): T {
return structuredClone(value);
}
function digest(bytes: Uint8Array): string {
return createHash('sha256').update(bytes).digest('hex');
}
function sameBinding(left: AccountBinding | null, right: AccountBinding): boolean {
return left !== null && left.accountKey === right.accountKey && left.epoch === right.epoch;
}
function toBuffer(bytes: Uint8Array): Buffer {
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
}
function isNotFound(error: unknown): boolean {
return isRecord(error) && error.code === 'ENOENT';
}
function isDirectoryPath(value: string): Promise<boolean> {
return stat(value).then((entry) => entry.isDirectory()).catch(() => false);
}
function parseIndexDocument(value: unknown): IndexDocument {
const root = isRecord(value) ? value : fail('plugin_store_index_invalid', 'index root must be an object');
assertExactKeys(root, INDEX_ROOT_KEYS, 'index');
if (root.schema_version !== INDEX_SCHEMA_VERSION) fail('plugin_store_index_invalid', 'unsupported index schema');
if (!Array.isArray(root.releases) || root.releases.length > DEFAULT_MAX_FILES) {
fail('plugin_store_index_invalid', 'invalid index releases');
}
const seen = new Set<string>();
const releases = root.releases.map((value, index) => {
const record = isRecord(value) ? value : fail('plugin_store_index_invalid', `invalid release ${index}`);
assertExactKeys(record, INDEX_RELEASE_KEYS, `release ${index}`);
if (record.package_schema_version !== 2) fail('plugin_store_index_invalid', `invalid release ${index} schema`);
const pluginId = validPluginId(record.plugin_id, `release ${index}.plugin_id`, 'plugin_store_index_invalid');
const releaseId = validReleaseId(record.release_id, `release ${index}.release_id`, 'plugin_store_index_invalid');
const key = `${pluginId}\u0000${releaseId}`;
if (seen.has(key)) fail('plugin_store_index_invalid', `duplicate release ${releaseId}`);
seen.add(key);
const runtimeKind = record.runtime_kind;
if (runtimeKind !== 'skill_only' && runtimeKind !== 'platform_hosted') {
fail('plugin_store_index_invalid', `invalid release ${index}.runtime_kind`);
}
const installedAt = boundedText(record.installed_at, `release ${index}.installed_at`, 80);
if (!Number.isFinite(Date.parse(installedAt))) fail('plugin_store_index_invalid', `invalid release ${index}.installed_at`);
const minMakeloreVersion = record.min_makelore_version === undefined
? undefined
: validVersion(record.min_makelore_version, `release ${index}.min_makelore_version`);
const maxMakeloreVersion = record.max_makelore_version === undefined || record.max_makelore_version === null
? record.max_makelore_version as string | null | undefined
: validVersion(record.max_makelore_version, `release ${index}.max_makelore_version`);
if (minMakeloreVersion && maxMakeloreVersion
&& compareSemVer(maxMakeloreVersion, minMakeloreVersion) < 0) {
fail('plugin_store_index_invalid', `invalid release ${index}.max_makelore_version`);
}
const channel = record.channel === undefined ? undefined : record.channel;
if (channel !== undefined && channel !== 'stable' && channel !== 'beta') {
fail('plugin_store_index_invalid', `invalid release ${index}.channel`);
}
return Object.freeze({
pluginId,
releaseId,
version: validVersion(record.version, `release ${index}.version`),
packageSchemaVersion: 2 as const,
contractVersion: positiveInteger(record.contract_version, `release ${index}.contract_version`, MAX_CONTRACT_VERSION),
runtimeKind,
sha256: validSha(record.sha256, `release ${index}.sha256`, 'plugin_store_index_invalid'),
sizeBytes: positiveInteger(record.size_bytes, `release ${index}.size_bytes`, DEFAULT_MAX_ARCHIVE_BYTES),
installedAt,
...(channel === undefined ? {} : { channel }),
...(minMakeloreVersion === undefined ? {} : { minMakeloreVersion }),
...(maxMakeloreVersion === undefined ? {} : { maxMakeloreVersion }),
});
});
return Object.freeze({ schema_version: INDEX_SCHEMA_VERSION, releases: Object.freeze(releases) });
}
function serializeIndex(document: IndexDocument): Uint8Array {
return Buffer.from(`${JSON.stringify({
schema_version: document.schema_version,
releases: document.releases.map((record) => ({
plugin_id: record.pluginId,
release_id: record.releaseId,
version: record.version,
package_schema_version: record.packageSchemaVersion,
contract_version: record.contractVersion,
runtime_kind: record.runtimeKind,
sha256: record.sha256,
size_bytes: record.sizeBytes,
installed_at: record.installedAt,
...(record.channel === undefined ? {} : { channel: record.channel }),
...(record.minMakeloreVersion === undefined ? {} : { min_makelore_version: record.minMakeloreVersion }),
...(record.maxMakeloreVersion === undefined ? {} : { max_makelore_version: record.maxMakeloreVersion }),
})),
})}\n`, 'utf8');
}
function parseCurrentSelectionDocument(value: unknown): CurrentSelectionDocument {
const root = isRecord(value) ? value : fail('plugin_store_index_invalid', 'current selection root must be an object');
assertExactKeys(root, CURRENT_SELECTION_ROOT_KEYS, 'current selection');
if (root.schema_version !== CURRENT_SELECTION_SCHEMA_VERSION) {
fail('plugin_store_index_invalid', 'unsupported current selection schema');
}
if (!isRecord(root.current) || Object.keys(root.current).length > DEFAULT_MAX_FILES) {
fail('plugin_store_index_invalid', 'invalid current selection');
}
const current: Record<string, string> = {};
for (const [pluginId, releaseId] of Object.entries(root.current)) {
const validId = validPluginId(pluginId, 'current plugin_id', 'plugin_store_index_invalid');
current[validId] = validReleaseId(releaseId, `current ${validId}.release_id`, 'plugin_store_index_invalid');
}
return Object.freeze({
schema_version: CURRENT_SELECTION_SCHEMA_VERSION,
current: Object.freeze(current),
});
}
function serializeCurrentSelection(document: CurrentSelectionDocument): Uint8Array {
return Buffer.from(`${JSON.stringify({
schema_version: document.schema_version,
current: document.current,
})}\n`, 'utf8');
}
async function parseJsonFile(filePath: string): Promise<unknown> {
const bytes = await readFile(filePath);
let source: string;
try {
source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
} catch {
fail('plugin_store_index_invalid', 'index is not valid UTF-8');
}
try {
return JSON.parse(source) as unknown;
} catch {
fail('plugin_store_index_invalid', 'index is not valid JSON');
}
}
async function atomicWriteIndex(filePath: string, bytes: Uint8Array): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true });
const temporaryPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${randomUUID()}.tmp`);
try {
await writeFile(temporaryPath, toBuffer(bytes), { flag: 'wx' });
await rename(temporaryPath, filePath);
} finally {
await rm(temporaryPath, { force: true }).catch(() => undefined);
}
}
function safeArchiveEntryName(entryName: string, isDirectory: boolean): string[] {
if (entryName.length === 0 || entryName.includes('\\') || entryName.startsWith('/')
|| /^[A-Za-z]:/u.test(entryName) || /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(entryName)) {
fail('plugin_artifact_invalid', 'archive contains a non-canonical path');
}
const segments = entryName.split('/');
if (segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) {
fail('plugin_artifact_invalid', 'archive contains a path traversal');
}
if (isDirectory && !entryName.endsWith('/')) {
fail('plugin_artifact_invalid', 'archive directory path is not canonical');
}
if (!isDirectory && entryName.endsWith('/')) {
fail('plugin_artifact_invalid', 'archive file path is not canonical');
}
return isDirectory ? segments.slice(0, -1) : segments;
}
function entryIsSymlink(entry: AdmZip.IZipEntry): boolean {
const mode = (entry.attr >>> 16) & 0xffff;
return (mode & 0xf000) === 0xa000;
}
function assertUtf8(content: Uint8Array, filePath: string): void {
try {
new TextDecoder('utf-8', { fatal: true }).decode(content);
} catch {
fail('plugin_artifact_invalid', `${filePath} must be UTF-8`);
}
}
function validateArchiveFile(filePath: string, content: Uint8Array): void {
if (PACKAGE_MANIFEST_PATHS.has(filePath)) {
if (content.byteLength > PLUGIN_MANIFEST_MAX_BYTES) {
fail('plugin_artifact_invalid', `${filePath} exceeds its byte limit`);
}
assertUtf8(content, filePath);
return;
}
if (!filePath.startsWith('skills/')) {
fail('plugin_artifact_invalid', 'package files must be under a declared Skill');
}
const extension = path.posix.extname(filePath).toLowerCase();
if (!PACKAGE_TEXT_EXTENSIONS.has(extension) && !PACKAGE_IMAGE_EXTENSIONS.has(extension)) {
fail('plugin_artifact_invalid', 'archive asset extension is not supported');
}
if (filePath.endsWith('/SKILL.md') && content.byteLength > PLUGIN_SKILL_MAX_BYTES) {
fail('plugin_artifact_invalid', 'Skill exceeds its byte limit');
}
if (PACKAGE_TEXT_EXTENSIONS.has(extension)) assertUtf8(content, filePath);
}
function validateArchiveDefinition(
files: readonly string[],
definition: CodingPluginDefinition,
): void {
for (const manifestPath of PACKAGE_MANIFEST_PATHS) {
if (!files.includes(manifestPath)) fail('plugin_artifact_invalid', `archive is missing ${manifestPath}`);
}
const skillIds = new Set(definition.skills.map(({ id }) => id));
for (const filePath of files) {
if (PACKAGE_MANIFEST_PATHS.has(filePath)) continue;
const [, skillId] = filePath.split('/');
if (!skillId || !skillIds.has(skillId)) {
fail('plugin_artifact_invalid', 'archive file is outside a declared Skill');
}
}
for (const { entryPath } of definition.skills) {
if (!files.includes(entryPath)) fail('plugin_artifact_invalid', 'archive is missing a declared Skill entry');
}
}
async function extractArchive(
bytes: Uint8Array,
destination: string,
options: {
readonly maxExtractedBytes: number;
readonly maxFiles: number;
readonly maxFileBytes: number;
},
): Promise<readonly string[]> {
let archive: AdmZip;
try {
archive = new AdmZip(toBuffer(bytes));
} catch {
fail('plugin_artifact_invalid', 'artifact is not a readable ZIP archive');
}
const entries = archive.getEntries();
if (entries.length === 0 || entries.length > options.maxFiles) {
fail('plugin_artifact_invalid', 'artifact file count is outside its bound');
}
const seen = new Set<string>();
const files: string[] = [];
let extractedBytes = 0;
for (const entry of entries) {
if (entryIsSymlink(entry)) fail('plugin_artifact_invalid', 'archive symlinks are not supported');
const segments = safeArchiveEntryName(entry.entryName, entry.isDirectory);
const relative = segments.join('/');
if (seen.has(relative)) fail('plugin_artifact_invalid', 'archive contains duplicate paths');
seen.add(relative);
const target = path.resolve(destination, ...segments);
const relativeTarget = path.relative(destination, target);
if (!relativeTarget || relativeTarget === '..' || relativeTarget.startsWith(`..${path.sep}`) || path.isAbsolute(relativeTarget)) {
fail('plugin_artifact_invalid', 'archive path escapes its staging directory');
}
if (entry.isDirectory) {
await mkdir(target, { recursive: true });
continue;
}
const declaredSize = entry.header.size;
if (!Number.isSafeInteger(declaredSize) || declaredSize < 0 || declaredSize > options.maxFileBytes) {
fail('plugin_artifact_invalid', 'archive file exceeds its bound');
}
extractedBytes += declaredSize;
if (extractedBytes > options.maxExtractedBytes) fail('plugin_artifact_invalid', 'archive extraction exceeds its bound');
let content: Buffer;
try {
content = entry.getData();
} catch {
fail('plugin_artifact_invalid', 'archive entry could not be decompressed');
}
if (content.byteLength !== declaredSize || content.byteLength > options.maxFileBytes) {
fail('plugin_artifact_invalid', 'archive entry size is invalid');
}
validateArchiveFile(relative, content);
files.push(relative);
await mkdir(path.dirname(target), { recursive: true });
try {
await writeFile(target, content, { flag: 'wx' });
} catch {
fail('plugin_artifact_invalid', 'archive extraction could not create a file');
}
}
return files;
}
function mapVerificationFailure(code: PluginSignatureFailureCode): PluginPackageStoreErrorCode {
return code;
}
function validateGrantAgainstResolve(
item: MarketplaceResolveItem,
grant: DownloadGrant,
input: ResolveInstallInput,
now: number,
maxArchiveBytes: number,
): void {
if (!item.releaseId || !item.version || !item.sha256 || !item.sizeBytes || !item.releaseAdmissionId) {
fail('plugin_release_unavailable', 'resolve result is missing download admission metadata');
}
validReleaseId(item.releaseId);
validPluginId(grant.pluginId);
validReleaseId(grant.releaseId);
validReleaseId(grant.releaseAdmissionId);
validSha(item.sha256, 'resolve sha256');
if (!Number.isSafeInteger(item.sizeBytes) || item.sizeBytes < 1 || item.sizeBytes > maxArchiveBytes) {
fail('plugin_artifact_invalid', 'resolve artifact size is outside its bound');
}
if (!Number.isSafeInteger(grant.sizeBytes) || grant.sizeBytes < 1 || grant.sizeBytes > maxArchiveBytes) {
fail('plugin_artifact_invalid', 'download grant size is outside its bound');
}
if (item.pluginId !== input.pluginId || grant.pluginId !== input.pluginId
|| grant.releaseId !== item.releaseId || grant.version !== item.version
|| grant.sha256 !== item.sha256 || grant.sizeBytes !== item.sizeBytes
|| grant.releaseAdmissionId !== item.releaseAdmissionId) {
fail('plugin_release_unavailable', 'download grant does not match the resolve result');
}
if (grant.packageSchemaVersion !== 2) fail('plugin_artifact_invalid', 'downloaded Release must use package schema 2');
const expiresAt = typeof grant.expiresAt === 'string' ? Date.parse(grant.expiresAt) : Number.NaN;
if (!Number.isFinite(expiresAt) || expiresAt <= now) fail('plugin_release_unavailable', 'download admission has expired');
}
function toInstalledInput(record: InstalledReleaseRecord): InstalledReleaseInput {
return { pluginId: record.pluginId, releaseId: record.releaseId, sha256: record.sha256 };
}
/**
* Main-owned device package store. The on-disk index is intentionally
* account-free; account Library/admission state stays in AccountPluginCache.
*/
export class PluginPackageStore {
private readonly rootDir: string;
private readonly packagesDir: string;
private readonly indexPath: string;
private readonly currentSelectionPath: string;
private readonly marketplace: MarketplacePackageClientPort;
private readonly accountCache: AccountPluginCache;
private readonly getAccountBindingImpl: () => AccountBinding | null;
private readonly clientVersion: string;
private readonly signatureVerifier: PluginSignatureVerifier;
private readonly now: () => number;
private readonly maxArchiveBytes: number;
private readonly maxExtractedBytes: number;
private readonly maxFiles: number;
private readonly maxFileBytes: number;
private readonly writeIndex: (filePath: string, bytes: Uint8Array) => Promise<void>;
private readonly activeWorkerReleaseIdsImpl: (() => readonly string[]) | null;
private readonly activeWorkers = new Set<string>();
private readonly pendingExplicitCleanupPluginIds = new Set<string>();
private readonly unsubscribeSession: (() => void) | null;
private operation: Promise<void> = Promise.resolve();
constructor(options: PluginPackageStoreOptions) {
this.rootDir = path.resolve(options.rootDir ?? path.join(getDataDir(), 'coding-plugins'));
this.packagesDir = path.join(this.rootDir, 'packages');
this.indexPath = path.join(this.rootDir, 'index.json');
this.currentSelectionPath = path.join(this.rootDir, 'current.json');
this.marketplace = options.marketplace;
this.accountCache = options.accountCache ?? new AccountPluginCache();
this.getAccountBindingImpl = options.getAccountBinding
?? (() => this.marketplace.getCurrentAccountBinding?.() ?? null);
this.clientVersion = options.clientVersion ?? '1.0.0';
this.signatureVerifier = options.signatureVerifier ?? createPluginSignatureVerifier({
clientVersion: this.clientVersion,
keyStore: options.keyStore,
});
this.now = options.now ?? (() => Date.now());
this.maxArchiveBytes = assertPositiveOption(options.maxArchiveBytes, DEFAULT_MAX_ARCHIVE_BYTES, 'maxArchiveBytes');
this.maxExtractedBytes = assertPositiveOption(options.maxExtractedBytes, DEFAULT_MAX_EXTRACTED_BYTES, 'maxExtractedBytes');
this.maxFiles = assertPositiveOption(options.maxFiles, DEFAULT_MAX_FILES, 'maxFiles');
this.maxFileBytes = assertPositiveOption(options.maxFileBytes, DEFAULT_MAX_FILE_BYTES, 'maxFileBytes');
this.writeIndex = options.writeIndex ?? atomicWriteIndex;
this.activeWorkerReleaseIdsImpl = options.activeWorkerReleaseIds ?? null;
const subscribeSession = options.subscribeSession
?? ((listener: () => void) => subscribeWorksSquareSession(() => listener()));
this.unsubscribeSession = subscribeSession(() => this.accountCache.invalidateAll());
}
get indexFilePath(): string {
return this.indexPath;
}
get packageRoot(): string {
return this.rootDir;
}
dispose(): void {
this.unsubscribeSession?.();
this.accountCache.invalidateAll();
}
async syncLibrary(): Promise<MarketplaceLibrarySnapshot> {
const binding = this.requireBinding();
return this.withOperation(async () => {
this.assertBinding(binding);
if (!this.marketplace.readLibrary) fail('plugin_install_failed', 'Marketplace Library is unavailable');
const snapshot = await this.marketplace.readLibrary();
this.assertBinding(binding);
this.accountCache.setLibrary(binding, snapshot);
return clone(snapshot);
});
}
async resolveAndInstall(input: ResolveInstallInput): Promise<InstallationSnapshot> {
validPluginId(input.pluginId);
if (input.channel !== undefined && input.channel !== 'stable' && input.channel !== 'beta') {
fail('plugin_release_unavailable', 'invalid Marketplace channel');
}
if ((input.channel ?? 'stable') === 'beta' && input.explicitBeta !== true) {
fail('plugin_beta_selection_required');
}
const binding = this.requireBinding();
return this.withOperation(async () => {
const result = await this.resolveAndInstallLocked(input, binding);
this.pendingExplicitCleanupPluginIds.delete(input.pluginId);
return result;
});
}
async getInstalled(pluginId: string): Promise<InstalledRelease | null> {
const validated = validPluginId(pluginId);
const index = await this.readIndex();
const current = await this.readCurrentSelection();
return this.getInstalledFromIndex(index, validated, undefined, current);
}
async getInstalledRelease(pluginId: string, releaseId: string): Promise<InstalledRelease | null> {
const validatedPluginId = validPluginId(pluginId);
const validatedReleaseId = validReleaseId(releaseId);
return this.getInstalledFromIndex(
await this.readIndex(),
validatedPluginId,
validatedReleaseId,
);
}
async removeUnused(pluginId: string): Promise<InstallationSnapshot> {
const validated = validPluginId(pluginId);
return this.withOperation(() => this.removeUnusedLocked(validated, undefined, 'background'));
}
/** Remove a device package while leaving the account Library projection intact. */
async uninstall(pluginId: string): Promise<InstallationSnapshot> {
const validated = validPluginId(pluginId);
const binding = this.requireBinding();
return this.withOperation(async () => {
this.assertBinding(binding);
this.accountCache.invalidatePlugin(binding, validated);
this.assertBinding(binding);
const result = await this.removeUnusedLocked(validated, binding, 'explicit');
if (result.status === 'kept' && result.reason === 'active_worker_reference') {
this.pendingExplicitCleanupPluginIds.add(validated);
} else {
this.pendingExplicitCleanupPluginIds.delete(validated);
}
return result;
});
}
registerActiveWorker(releaseId: string): void {
this.activeWorkers.add(validReleaseId(releaseId));
}
async releaseActiveWorker(releaseId: string): Promise<void> {
this.activeWorkers.delete(validReleaseId(releaseId));
if (this.pendingExplicitCleanupPluginIds.size === 0) return;
await this.withOperation(async () => {
for (const pluginId of [...this.pendingExplicitCleanupPluginIds]) {
const result = await this.removeUnusedLocked(pluginId, undefined, 'explicit');
if (result.status === 'removed') this.pendingExplicitCleanupPluginIds.delete(pluginId);
}
});
}
async readInstalledIndex(): Promise<readonly InstalledReleaseRecord[]> {
const index = await this.readIndex();
return clone(index.releases);
}
private async resolveAndInstallLocked(
input: ResolveInstallInput,
binding: AccountBinding,
): Promise<InstallationSnapshot> {
const pluginId = validPluginId(input.pluginId);
if (input.channel !== undefined && input.channel !== 'stable' && input.channel !== 'beta') {
fail('plugin_release_unavailable', 'invalid Marketplace channel');
}
const channel = input.channel ?? 'stable';
if (channel === 'beta' && input.explicitBeta !== true) fail('plugin_beta_selection_required');
this.assertBinding(binding);
const index = await this.readIndex();
const current = await this.getInstalledFromIndex(
index,
pluginId,
undefined,
await this.readCurrentSelection(),
);
const installed = input.installed === undefined
? (current ? [toInstalledInput(current)] : [])
: input.installed === null ? [] : [input.installed];
const request: ResolveRequest = {
makeloreVersion: input.makeloreVersion,
channel,
installed,
resolveRequestId: input.resolveRequestId ?? `makelore-resolve-${randomUUID()}`,
resolveRequestDigest: input.resolveRequestDigest,
};
let resolved: MarketplaceResolveSnapshot;
try {
resolved = await this.marketplace.resolve(request);
} catch (error) {
throw this.mapMarketplaceError(error);
}
this.assertBinding(binding);
const item = resolved.items.find((candidate) => candidate.pluginId === pluginId);
if (!item) fail('plugin_release_unavailable', `Plugin ${pluginId} was not included in the resolve result`);
if (item.channel !== undefined && item.channel !== null && item.channel !== channel) {
fail('plugin_release_unavailable', 'resolve channel does not match the requested channel');
}
if (item.action === 'unavailable') {
const reason = item.reason;
if (reason === 'plugin_release_not_ready'
|| reason === 'plugin_release_yanked'
|| reason === 'plugin_runtime_suspended'
|| reason === 'plugin_library_required'
|| reason === 'plugin_incompatible_client'
|| reason === 'plugin_signature_invalid'
|| reason === 'plugin_artifact_invalid'
|| reason === 'plugin_backend_unavailable') {
fail(reason, reason);
}
fail('plugin_release_unavailable', reason ?? 'Plugin Release is unavailable');
}
if (item.action === 'keep') {
if (!current) fail('plugin_release_unavailable', 'resolve requested keep without an installed Release');
this.assertBinding(binding);
await this.persistSelectedChannel(index, pluginId, current.releaseId, channel, binding);
return {
status: 'kept',
pluginId,
releaseId: current.releaseId,
version: current.version,
channel,
packageRoot: current.packageRoot,
definition: current.definition,
};
}
if (resolved.stale) fail('plugin_release_unavailable', 'stale resolve data cannot install a Release');
if (item.action !== 'install' && item.action !== 'update') fail('plugin_release_unavailable', 'unsupported resolve action');
if (!item.releaseId || !item.releaseAdmissionId || !item.version || !item.sha256 || !item.sizeBytes) {
fail('plugin_release_unavailable', 'resolve result is missing Release metadata');
}
const releaseId = validReleaseId(item.releaseId);
const releaseAdmissionId = validReleaseId(item.releaseAdmissionId);
validSha(item.sha256, 'resolve sha256');
if (!Number.isSafeInteger(item.sizeBytes) || item.sizeBytes < 1 || item.sizeBytes > this.maxArchiveBytes) {
fail('plugin_artifact_invalid', 'resolve artifact size is outside its bound');
}
const existingRecord = index.releases.find((record) => record.pluginId === pluginId && record.releaseId === item.releaseId);
if (existingRecord && existingRecord.sha256 === item.sha256 && await isDirectoryPath(this.releaseDirectory(existingRecord))) {
const existing = await this.getInstalledFromIndex(index, pluginId, item.releaseId);
if (existing && !existing.unavailableReason) {
this.assertBinding(binding);
await this.persistSelectedChannel(index, pluginId, existing.releaseId, channel, binding);
await this.setCurrentSelection(
await this.readCurrentSelection(),
pluginId,
existing.releaseId,
);
return {
status: 'kept',
pluginId,
releaseId: existing.releaseId,
version: existing.version,
channel,
packageRoot: existing.packageRoot,
definition: existing.definition,
};
}
}
let grant: DownloadGrant;
try {
grant = await this.marketplace.issueDownload({ releaseId, releaseAdmissionId });
this.assertBinding(binding);
validateGrantAgainstResolve(item, grant, { ...input, pluginId }, this.now(), this.maxArchiveBytes);
} catch (error) {
if (error instanceof PluginPackageStoreError) throw error;
throw this.mapMarketplaceError(error);
}
const packageRoot = this.releaseDirectory({ pluginId, releaseId: grant.releaseId } as InstalledReleaseRecord);
if (await pathExists(packageRoot)) {
return this.recoverOrphanedRelease({
index,
currentSelection: await this.readCurrentSelection(),
packageRoot,
pluginId,
grant,
channel,
binding,
});
}
let artifact: Uint8Array;
try {
artifact = await this.marketplace.downloadContent(grant);
this.assertBinding(binding);
} catch (error) {
if (error instanceof PluginPackageStoreError) throw error;
throw this.mapMarketplaceError(error);
}
const descriptor = this.verifyArtifact(artifact, grant);
try {
await mkdir(this.rootDir, { recursive: true });
} catch {
fail('plugin_install_failed', 'package store is unavailable');
}
let staging: string;
try {
staging = await mkdtemp(path.join(this.rootDir, `.download-${randomUUID()}-`));
} catch {
fail('plugin_install_failed', 'package store is unavailable');
}
let moved = false;
try {
const archivePath = path.join(staging, 'package.zip');
const extractedPath = path.join(staging, 'package');
await writeFile(archivePath, toBuffer(artifact), { flag: 'wx' });
await mkdir(extractedPath, { recursive: true });
const archiveFiles = await extractArchive(artifact, extractedPath, {
maxExtractedBytes: this.maxExtractedBytes,
maxFiles: this.maxFiles,
maxFileBytes: this.maxFileBytes,
});
const definition = await this.loadDefinition(extractedPath, grant, descriptor);
validateArchiveDefinition(archiveFiles, definition);
this.assertBinding(binding);
await mkdir(path.dirname(packageRoot), { recursive: true });
if (await isDirectoryPath(packageRoot) || await pathExists(packageRoot)) {
fail('plugin_release_conflict', 'immutable Release directory already exists');
}
await writeFile(path.join(extractedPath, ORPHAN_ARCHIVE_FILE), toBuffer(artifact), { flag: 'wx' });
await rename(extractedPath, packageRoot);
moved = true;
const record = this.installedRecord(grant, channel, definition.runtimeKind);
const records = index.releases.filter((candidate) => !(candidate.pluginId === pluginId && candidate.releaseId === grant.releaseId));
this.assertBinding(binding);
try {
await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: [...records, record] }));
} catch {
throw new PluginPackageStoreError('plugin_install_failed', 'package index replacement failed');
}
this.assertBinding(binding);
await this.setCurrentSelection(await this.readCurrentSelection(), pluginId, record.releaseId);
this.assertBinding(binding);
return {
status: 'installed',
pluginId,
releaseId: record.releaseId,
version: record.version,
channel: record.channel,
packageRoot,
definition,
};
} catch (error) {
if (error instanceof PluginPackageStoreError) throw error;
if (error instanceof Error && error.name === 'CodingPluginManifestError') {
throw new PluginPackageStoreError('plugin_manifest_invalid', 'package manifest is invalid');
}
throw new PluginPackageStoreError('plugin_install_failed', 'package installation failed');
} finally {
if (moved) {
await rm(path.join(staging, 'package.zip'), { force: true }).catch(() => undefined);
}
await rm(staging, { recursive: true, force: true }).catch(() => undefined);
}
}
private async recoverOrphanedRelease(input: {
readonly index: IndexDocument;
readonly currentSelection: CurrentSelectionDocument;
readonly packageRoot: string;
readonly pluginId: string;
readonly grant: DownloadGrant;
readonly channel: 'stable' | 'beta';
readonly binding: AccountBinding;
}): Promise<InstallationSnapshot> {
const existingRecord = input.index.releases.find((record) => (
record.pluginId === input.pluginId && record.releaseId === input.grant.releaseId
));
if (existingRecord) fail('plugin_release_conflict', 'immutable Release directory already exists');
let artifact: Uint8Array;
try {
artifact = await readFile(path.join(input.packageRoot, ORPHAN_ARCHIVE_FILE));
} catch {
fail('plugin_release_conflict', 'immutable Release directory cannot be recovered');
}
const descriptor = this.verifyArtifact(artifact, input.grant);
const definition = await this.loadDefinition(input.packageRoot, input.grant, descriptor);
this.assertBinding(input.binding);
const record = this.installedRecord(input.grant, input.channel, definition.runtimeKind);
this.assertBinding(input.binding);
try {
await this.writeIndex(this.indexPath, serializeIndex({
schema_version: INDEX_SCHEMA_VERSION,
releases: [...input.index.releases, record],
}));
} catch {
throw new PluginPackageStoreError('plugin_install_failed', 'package index replacement failed');
}
this.assertBinding(input.binding);
await this.setCurrentSelection(input.currentSelection, input.pluginId, record.releaseId);
this.assertBinding(input.binding);
return {
status: 'installed',
pluginId: input.pluginId,
releaseId: record.releaseId,
version: record.version,
channel: record.channel,
packageRoot: input.packageRoot,
definition,
};
}
private verifyArtifact(artifact: Uint8Array, grant: DownloadGrant): PluginReleaseDescriptor {
const expiresAt = typeof grant.expiresAt === 'string' ? Date.parse(grant.expiresAt) : Number.NaN;
if (!Number.isFinite(expiresAt) || expiresAt <= this.now()) {
fail('plugin_release_unavailable', 'download admission has expired');
}
if (!(artifact instanceof Uint8Array)) fail('plugin_artifact_invalid', 'downloaded artifact is not binary data');
if (artifact.byteLength !== grant.sizeBytes || artifact.byteLength > this.maxArchiveBytes || digest(artifact) !== grant.sha256) {
fail('plugin_artifact_invalid', 'downloaded artifact does not match its grant');
}
const descriptor = this.buildDescriptor(grant);
if (!isMakeLoreVersionCompatible(
this.clientVersion,
descriptor.minMakeloreVersion,
descriptor.maxMakeloreVersion,
)) {
fail('plugin_incompatible_client', 'Release is incompatible with this MakeLore client');
}
const verification = this.signatureVerifier.verify({
keyId: grant.signingKeyId,
signature: grant.descriptorSignature,
descriptor,
artifact,
});
if (!verification.ok) fail(mapVerificationFailure(verification.code), verification.message);
return descriptor;
}
private installedRecord(
grant: DownloadGrant,
channel: 'stable' | 'beta',
runtimeKind: 'skill_only' | 'platform_hosted',
): InstalledReleaseRecord {
return Object.freeze({
pluginId: grant.pluginId,
releaseId: grant.releaseId,
version: grant.version,
packageSchemaVersion: grant.packageSchemaVersion,
contractVersion: grant.contractVersion,
runtimeKind,
sha256: grant.sha256,
sizeBytes: grant.sizeBytes,
installedAt: new Date(this.now()).toISOString(),
channel,
minMakeloreVersion: grant.minMakeloreVersion,
maxMakeloreVersion: grant.maxMakeloreVersion,
});
}
private async persistSelectedChannel(
index: IndexDocument,
pluginId: string,
releaseId: string,
channel: 'stable' | 'beta',
binding: AccountBinding,
): Promise<void> {
const record = index.releases.find((candidate) => (
candidate.pluginId === pluginId && candidate.releaseId === releaseId
));
if (!record) fail('plugin_store_index_invalid', 'selected Release is missing from the package index');
if (record.channel === channel) return;
this.assertBinding(binding);
const releases = index.releases.map((candidate) => (
candidate === record ? Object.freeze({ ...candidate, channel }) : candidate
));
try {
await this.writeIndex(this.indexPath, serializeIndex({
schema_version: INDEX_SCHEMA_VERSION,
releases,
}));
} catch {
throw new PluginPackageStoreError('plugin_install_failed', 'package channel update failed');
}
this.assertBinding(binding);
}
private async removeUnusedLocked(
validated: string,
binding?: AccountBinding,
mode: 'background' | 'explicit' = 'background',
): Promise<InstallationSnapshot> {
if (binding) this.assertBinding(binding);
const index = await this.readIndex();
const currentSelection = await this.readCurrentSelection();
const records = index.releases.filter((record) => record.pluginId === validated);
if (records.length === 0) {
if (currentSelection.current[validated] !== undefined) {
if (binding) this.assertBinding(binding);
const current = { ...currentSelection.current };
delete current[validated];
await this.writeCurrentSelection({
schema_version: CURRENT_SELECTION_SCHEMA_VERSION,
current: Object.freeze(current),
});
}
return { status: 'removed', pluginId: validated, reason: 'none' };
}
const selected = currentSelection.current[validated]
? records.find((record) => record.releaseId === currentSelection.current[validated])
: undefined;
const protectedIds = new Set([
...this.accountCache.referencedReleaseIds(),
...(this.activeWorkerReleaseIds() ?? []),
...this.activeWorkers,
]);
let removable: InstalledReleaseRecord[];
if (mode === 'explicit') {
removable = records.filter((record) => !protectedIds.has(record.releaseId));
} else {
if (!selected) {
return {
status: 'kept',
pluginId: validated,
reason: 'current_selection_missing',
};
}
removable = records.length === 1 && !protectedIds.has(selected.releaseId)
? records
: records.filter((record) => (
record.releaseId !== selected.releaseId && !protectedIds.has(record.releaseId)
));
if (removable.length === 0) {
return {
status: 'kept',
pluginId: validated,
releaseId: selected.releaseId,
version: selected.version,
...(selected.channel === undefined ? {} : { channel: selected.channel }),
};
}
}
const remaining = index.releases.filter((record) => !removable.includes(record));
if (binding) this.assertBinding(binding);
if (removable.length > 0) {
try {
await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: remaining }));
} catch {
throw new PluginPackageStoreError('plugin_install_failed', 'package index cleanup failed');
}
}
const nextCurrent = { ...currentSelection.current };
if (mode === 'explicit') {
delete nextCurrent[validated];
} else if (nextCurrent[validated] && !remaining.some((record) => (
record.pluginId === validated && record.releaseId === nextCurrent[validated]
))) delete nextCurrent[validated];
await this.writeCurrentSelection({
schema_version: CURRENT_SELECTION_SCHEMA_VERSION,
current: Object.freeze(nextCurrent),
});
if (binding) this.assertBinding(binding);
await Promise.all(removable.map(async (record) => {
await rm(this.releaseDirectory(record), { recursive: true, force: true });
}));
if (binding) this.assertBinding(binding);
const currentAfter = nextCurrent[validated]
? remaining.find((record) => record.pluginId === validated && record.releaseId === nextCurrent[validated])
: undefined;
const selectedAfter = selected && remaining.some((record) => (
record.pluginId === validated && record.releaseId === selected.releaseId
)) ? selected : undefined;
if (mode === 'explicit' && !currentAfter
&& remaining.some((record) => record.pluginId === validated && protectedIds.has(record.releaseId))) {
return { status: 'kept', pluginId: validated, reason: 'active_worker_reference' };
}
const reportedAfter = currentAfter ?? selectedAfter ?? remaining.find((record) => record.pluginId === validated);
if (reportedAfter) {
return {
status: 'kept',
pluginId: validated,
releaseId: reportedAfter.releaseId,
version: reportedAfter.version,
...(reportedAfter.channel === undefined ? {} : { channel: reportedAfter.channel }),
};
}
return {
status: 'removed',
pluginId: validated,
...(selected ? { releaseId: selected.releaseId, version: selected.version } : { reason: 'none' }),
};
}
private buildDescriptor(grant: DownloadGrant): PluginReleaseDescriptor {
try {
return buildPluginReleaseDescriptor({
pluginId: grant.pluginId,
version: grant.version,
packageSchemaVersion: grant.packageSchemaVersion,
contractVersion: grant.contractVersion,
minMakeloreVersion: grant.minMakeloreVersion,
maxMakeloreVersion: grant.maxMakeloreVersion,
artifact: { sha256: grant.sha256, sizeBytes: grant.sizeBytes },
});
} catch (error) {
fail('plugin_artifact_invalid', error instanceof Error ? error.message : 'invalid Release descriptor');
}
}
private async loadDefinition(
packageRoot: string,
grant: DownloadGrant,
descriptor: PluginReleaseDescriptor,
): Promise<CodingPluginDefinition> {
let definition: CodingPluginDefinition;
try {
definition = await loadCodingPluginDefinition(packageRoot, {
acquisitionMode: 'user_acquired',
releaseId: grant.releaseId,
provenance: { source: 'marketplace', packageRoot },
});
} catch (error) {
if (error instanceof PluginPackageStoreError) throw error;
throw new PluginPackageStoreError('plugin_manifest_invalid', 'package manifest is invalid');
}
if (definition.id !== descriptor.pluginId || definition.version !== descriptor.version
|| definition.contractVersion !== descriptor.contractVersion || definition.releaseId !== grant.releaseId
|| (definition.runtimeKind !== 'skill_only' && definition.runtimeKind !== 'platform_hosted')
|| definition.acquisitionMode !== 'user_acquired') {
fail('plugin_manifest_invalid', 'package definition does not match the signed Release');
}
return definition;
}
private requireBinding(): AccountBinding {
const binding = this.getAccountBindingImpl();
if (!binding || typeof binding.accountKey !== 'string' || binding.accountKey.length === 0
|| binding.accountKey.length > 512 || !Number.isSafeInteger(binding.epoch) || binding.epoch < 0) {
fail('plugin_account_changed', 'Marketplace account session is unavailable');
}
return { accountKey: binding.accountKey, epoch: binding.epoch };
}
private assertBinding(binding: AccountBinding): void {
if (!sameBinding(this.getAccountBindingImpl(), binding)) fail('plugin_account_changed', 'Marketplace account changed during package operation');
}
private activeWorkerReleaseIds(): readonly string[] {
return this.activeWorkerReleaseIdsImpl?.() ?? [];
}
private async readIndex(): Promise<IndexDocument> {
try {
return parseIndexDocument(await parseJsonFile(this.indexPath));
} catch (error) {
if (isNotFound(error)) return { schema_version: INDEX_SCHEMA_VERSION, releases: [] };
if (error instanceof PluginPackageStoreError) throw error;
throw new PluginPackageStoreError('plugin_store_index_invalid', 'package index is unavailable');
}
}
private async readCurrentSelection(): Promise<CurrentSelectionDocument> {
try {
return parseCurrentSelectionDocument(await parseJsonFile(this.currentSelectionPath));
} catch (error) {
if (isNotFound(error)) {
return { schema_version: CURRENT_SELECTION_SCHEMA_VERSION, current: Object.freeze({}) };
}
if (error instanceof PluginPackageStoreError) throw error;
throw new PluginPackageStoreError('plugin_store_index_invalid', 'current package selection is unavailable');
}
}
private async writeCurrentSelection(document: CurrentSelectionDocument): Promise<void> {
try {
await atomicWriteIndex(this.currentSelectionPath, serializeCurrentSelection(document));
} catch {
throw new PluginPackageStoreError('plugin_install_failed', 'package current selection replacement failed');
}
}
private async setCurrentSelection(
currentSelection: CurrentSelectionDocument,
pluginId: string,
releaseId: string,
): Promise<void> {
if (currentSelection.current[pluginId] === releaseId) return;
const current = { ...currentSelection.current, [pluginId]: releaseId };
await this.writeCurrentSelection({
schema_version: CURRENT_SELECTION_SCHEMA_VERSION,
current: Object.freeze(current),
});
}
private releaseDirectory(record: Pick<InstalledReleaseRecord, 'pluginId' | 'releaseId'>): string {
return path.join(this.packagesDir, record.pluginId, record.releaseId);
}
private async getInstalledFromIndex(
index: IndexDocument,
pluginId: string,
releaseId?: string,
currentSelection?: CurrentSelectionDocument,
): Promise<InstalledRelease | null> {
const pointedReleaseId = releaseId === undefined ? currentSelection?.current[pluginId] : undefined;
const records = index.releases
.map((record, index) => ({ record, index }))
.filter(({ record }) => record.pluginId === pluginId && (releaseId === undefined || record.releaseId === releaseId))
.sort((left, right) => right.record.installedAt.localeCompare(left.record.installedAt) || right.index - left.index)
.map(({ record }) => record);
const selected = releaseId !== undefined
? records
: pointedReleaseId === undefined
? []
: records.filter((record) => record.releaseId === pointedReleaseId);
for (const record of selected) {
const packageRoot = this.releaseDirectory(record);
if (!await isDirectoryPath(packageRoot)) continue;
try {
const definition = await loadCodingPluginDefinition(packageRoot, {
runtimeKind: record.runtimeKind,
acquisitionMode: 'user_acquired',
releaseId: record.releaseId,
provenance: { source: 'marketplace', packageRoot },
});
if (definition.id !== record.pluginId || definition.version !== record.version
|| definition.contractVersion !== record.contractVersion || definition.runtimeKind !== record.runtimeKind) {
throw new PluginPackageStoreError('plugin_manifest_invalid', 'installed package does not match its immutable index record');
}
const compatible = record.minMakeloreVersion !== undefined
&& isMakeLoreVersionCompatible(
this.clientVersion,
record.minMakeloreVersion,
record.maxMakeloreVersion ?? null,
);
return {
...record,
packageRoot,
definition,
...(compatible ? {} : { unavailableReason: 'plugin_incompatible_client' as const }),
};
} catch (error) {
if (error instanceof PluginPackageStoreError) throw error;
throw new PluginPackageStoreError('plugin_manifest_invalid', 'installed package manifest is invalid');
}
}
return null;
}
private mapMarketplaceError(error: unknown): PluginPackageStoreError {
if (error instanceof PluginPackageStoreError) return error;
if (isRecord(error) && error.code === 'marketplace_account_changed') {
return new PluginPackageStoreError('plugin_account_changed', 'Marketplace account changed during package operation');
}
if (isRecord(error) && error.code === 'marketplace_beta_selection_required') {
return new PluginPackageStoreError('plugin_beta_selection_required');
}
if (isRecord(error) && (
error.code === 'plugin_release_yanked'
|| error.code === 'plugin_release_not_ready'
|| error.code === 'plugin_runtime_suspended'
|| error.code === 'plugin_library_required'
|| error.code === 'plugin_backend_unavailable'
|| error.code === 'plugin_incompatible_client'
|| error.code === 'plugin_signature_invalid'
|| error.code === 'plugin_artifact_invalid'
)) {
return new PluginPackageStoreError(
error.code,
typeof error.message === 'string' ? error.message : error.code,
);
}
if (isRecord(error) && error.code === 'plugin_auth_required') {
return new PluginPackageStoreError('plugin_account_changed', 'Marketplace authentication is required');
}
return new PluginPackageStoreError('plugin_install_failed', 'Marketplace package operation failed');
}
private async withOperation<T>(operation: () => Promise<T>): Promise<T> {
let release!: () => void;
const previous = this.operation;
this.operation = new Promise<void>((resolve) => { release = resolve; });
await previous;
try {
return await operation();
} finally {
release();
}
}
}
async function pathExists(value: string): Promise<boolean> {
try {
await lstat(value);
return true;
} catch {
return false;
}
}
export async function readPluginPackageIndex(filePath: string): Promise<readonly InstalledReleaseRecord[]> {
try {
const value = parseIndexDocument(await parseJsonFile(filePath));
return clone(value.releases);
} catch (error) {
if (error instanceof PluginPackageStoreError) throw error;
throw new PluginPackageStoreError('plugin_store_index_invalid', 'package index is unavailable');
}
}