610 lines
25 KiB
TypeScript
610 lines
25 KiB
TypeScript
import { execFile } from 'node:child_process';
|
||
import { randomUUID } from 'node:crypto';
|
||
import { cp, mkdir, readFile, rename, rm, stat } from 'node:fs/promises';
|
||
import path from 'node:path';
|
||
import { DefaultPackageManager, SettingsManager } from '@earendil-works/pi-coding-agent';
|
||
import { atomicWriteJson } from '../coding-projects/atomic-json';
|
||
import {
|
||
devicePackageKind,
|
||
inspectDevicePackage,
|
||
parseDevicePackageSource,
|
||
relativeContainedPath,
|
||
safeStorageSegment,
|
||
type DevicePackageIndexV1,
|
||
type DevicePackageRecordV1,
|
||
type InstallPreviewV1,
|
||
} from './device-package-format';
|
||
|
||
export * from './device-package-format';
|
||
|
||
const PLAN_TTL_MS = 10 * 60 * 1000;
|
||
const INSTALL_OUTPUT_BYTES = 64 * 1024;
|
||
const INSTALL_TIMEOUT_MS = 2 * 60 * 1000;
|
||
const INDEX_SCHEMA_VERSION = 1;
|
||
const EXECUTABLE_WARNING = '此包包含可执行 Pi extension,将获得完整桌面权限,包括当前进程可用的文件、网络和进程权限。';
|
||
|
||
export class DevicePackageError extends Error {
|
||
constructor(
|
||
readonly code:
|
||
| 'local_package_source_unsupported'
|
||
| 'local_package_not_found'
|
||
| 'local_package_manifest_invalid'
|
||
| 'local_package_dependency_failed'
|
||
| 'local_package_confirmation_required'
|
||
| 'local_package_plan_expired'
|
||
| 'local_package_install_failed'
|
||
| 'local_package_not_installed'
|
||
| 'local_package_in_use',
|
||
message: string,
|
||
) {
|
||
super(message);
|
||
this.name = 'DevicePackageError';
|
||
}
|
||
}
|
||
|
||
export interface DevicePackageInstallInput {
|
||
source: string;
|
||
executablePath: string;
|
||
cliPath: string;
|
||
npmCliPath?: string;
|
||
agentDir: string;
|
||
cwd: string;
|
||
env: NodeJS.ProcessEnv;
|
||
}
|
||
|
||
export type DevicePackageInstallRunner = (input: DevicePackageInstallInput) => Promise<void>;
|
||
|
||
export interface DevicePackageManagerOptions {
|
||
rootDir: string;
|
||
executablePath?: string;
|
||
cliPath?: string;
|
||
npmCliPath?: string;
|
||
runInstall?: DevicePackageInstallRunner;
|
||
now?: () => number;
|
||
createId?: () => string;
|
||
writeIndex?: (filePath: string, value: DevicePackageIndexV1) => Promise<void>;
|
||
onGenerationChanged?(index: DevicePackageIndexV1): Promise<void> | void;
|
||
}
|
||
|
||
interface StagedPlanV1 {
|
||
schemaVersion: 1;
|
||
requestedByTurnId: string;
|
||
preview: InstallPreviewV1;
|
||
record: DevicePackageRecordV1;
|
||
}
|
||
|
||
export interface EnabledDevicePackageResources {
|
||
generation: number;
|
||
packageIds: string[];
|
||
packageRefs: Array<{ packageId: string; resolvedVersion: string }>;
|
||
skillEntries: Array<{ id: string; entryPath: string; packageRoot: string }>;
|
||
extensionPaths: string[];
|
||
}
|
||
|
||
function installEnvironment(agentDir: string): NodeJS.ProcessEnv {
|
||
return {
|
||
...process.env,
|
||
ELECTRON_RUN_AS_NODE: '1',
|
||
PI_CODING_AGENT_DIR: agentDir,
|
||
PI_TELEMETRY: '0',
|
||
GIT_TERMINAL_PROMPT: '0',
|
||
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes',
|
||
CI: '1',
|
||
npm_config_ignore_scripts: 'true',
|
||
npm_config_update_notifier: 'false',
|
||
npm_config_audit: 'false',
|
||
npm_config_fund: 'false',
|
||
};
|
||
}
|
||
|
||
export async function runPiPackageInstall(input: DevicePackageInstallInput): Promise<void> {
|
||
await mkdir(input.agentDir, { recursive: true });
|
||
await mkdir(input.cwd, { recursive: true });
|
||
if (input.npmCliPath) {
|
||
await atomicWriteJson(path.join(input.agentDir, 'settings.json'), {
|
||
npmCommand: [input.executablePath, input.npmCliPath],
|
||
});
|
||
}
|
||
await new Promise<void>((resolve, reject) => {
|
||
execFile(
|
||
input.executablePath,
|
||
[input.cliPath, 'install', input.source, '--no-approve'],
|
||
{
|
||
cwd: input.cwd,
|
||
env: input.env,
|
||
windowsHide: true,
|
||
timeout: INSTALL_TIMEOUT_MS,
|
||
maxBuffer: INSTALL_OUTPUT_BYTES,
|
||
},
|
||
(error) => error ? reject(error) : resolve(),
|
||
);
|
||
});
|
||
}
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||
}
|
||
|
||
function string(value: unknown, field: string, maximum = 2048): string {
|
||
if (typeof value !== 'string' || !value.trim() || value.length > maximum) {
|
||
throw new DevicePackageError('local_package_install_failed', `${field} is invalid`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function parseRecord(value: unknown): DevicePackageRecordV1 {
|
||
if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.source)
|
||
|| !Array.isArray(value.skillEntries) || !Array.isArray(value.extensionEntries)
|
||
|| typeof value.enabled !== 'boolean' || typeof value.confirmedExecutableCode !== 'boolean') {
|
||
throw new DevicePackageError('local_package_install_failed', 'Device package index is invalid');
|
||
}
|
||
const sourceKind = value.source.kind;
|
||
if (sourceKind !== 'npm' && sourceKind !== 'git' && sourceKind !== 'file') {
|
||
throw new DevicePackageError('local_package_install_failed', 'Device package source is invalid');
|
||
}
|
||
const kind = value.kind;
|
||
if (kind !== 'skill-only' && kind !== 'pi-extension' && kind !== 'mixed') {
|
||
throw new DevicePackageError('local_package_install_failed', 'Device package kind is invalid');
|
||
}
|
||
const skillEntries = value.skillEntries.map((entry) => {
|
||
if (!isRecord(entry)) throw new DevicePackageError('local_package_install_failed', 'Skill entry is invalid');
|
||
return { id: string(entry.id, 'Skill id', 128), entryPath: string(entry.entryPath, 'Skill path') };
|
||
});
|
||
return {
|
||
schemaVersion: 1,
|
||
packageId: string(value.packageId, 'Package id', 128),
|
||
displayName: string(value.displayName, 'Display name', 256),
|
||
resolvedVersion: string(value.resolvedVersion, 'Resolved version', 128),
|
||
source: {
|
||
kind: sourceKind,
|
||
requested: string(value.source.requested, 'Requested source'),
|
||
resolved: string(value.source.resolved, 'Resolved source'),
|
||
},
|
||
kind,
|
||
skillEntries,
|
||
extensionEntries: value.extensionEntries.map((entry) => string(entry, 'Extension path')),
|
||
enabled: value.enabled,
|
||
confirmedExecutableCode: value.confirmedExecutableCode,
|
||
installedAt: string(value.installedAt, 'Installed at', 64),
|
||
};
|
||
}
|
||
|
||
function parseIndex(value: unknown): DevicePackageIndexV1 {
|
||
if (!isRecord(value) || value.schemaVersion !== INDEX_SCHEMA_VERSION
|
||
|| !Number.isSafeInteger(value.generation) || (value.generation as number) < 0
|
||
|| !Array.isArray(value.packages)) {
|
||
throw new DevicePackageError('local_package_install_failed', 'Device package index is invalid');
|
||
}
|
||
const packages = value.packages.map(parseRecord);
|
||
const ids = new Set<string>();
|
||
for (const record of packages) {
|
||
if (ids.has(record.packageId)) {
|
||
throw new DevicePackageError('local_package_install_failed', 'Device package index contains duplicate ids');
|
||
}
|
||
ids.add(record.packageId);
|
||
}
|
||
return { schemaVersion: 1, generation: value.generation as number, packages };
|
||
}
|
||
|
||
function cloneIndex(index: DevicePackageIndexV1): DevicePackageIndexV1 {
|
||
return structuredClone(index);
|
||
}
|
||
|
||
async function pathMetadata(target: string): Promise<'file' | 'directory' | null> {
|
||
return await stat(target).then((value) => value.isDirectory() ? 'directory' : value.isFile() ? 'file' : null)
|
||
.catch((error: NodeJS.ErrnoException) => {
|
||
if (error.code === 'ENOENT') return null;
|
||
throw error;
|
||
});
|
||
}
|
||
|
||
async function gitHead(directory: string): Promise<string> {
|
||
return await new Promise<string>((resolve, reject) => {
|
||
execFile('git', ['rev-parse', 'HEAD'], {
|
||
cwd: directory,
|
||
windowsHide: true,
|
||
timeout: 10_000,
|
||
maxBuffer: 4096,
|
||
}, (error, stdout) => error ? reject(error) : resolve(stdout.trim()));
|
||
});
|
||
}
|
||
|
||
export class DevicePackageManager {
|
||
private readonly rootDir: string;
|
||
private readonly packagesDir: string;
|
||
private readonly stagingDir: string;
|
||
private readonly trashDir: string;
|
||
private readonly indexPath: string;
|
||
private readonly now: () => number;
|
||
private readonly createId: () => string;
|
||
private readonly runInstall: DevicePackageInstallRunner;
|
||
private readonly writeIndex: (filePath: string, value: DevicePackageIndexV1) => Promise<void>;
|
||
private readonly activePackages = new Map<string, number>();
|
||
private readonly pendingCleanup = new Set<string>();
|
||
private operation: Promise<void> = Promise.resolve();
|
||
|
||
constructor(private readonly options: DevicePackageManagerOptions) {
|
||
this.rootDir = path.resolve(options.rootDir);
|
||
this.packagesDir = path.join(this.rootDir, 'packages');
|
||
this.stagingDir = path.join(this.rootDir, 'staging');
|
||
this.trashDir = path.join(this.rootDir, 'trash');
|
||
this.indexPath = path.join(this.rootDir, 'index.json');
|
||
this.now = options.now ?? Date.now;
|
||
this.createId = options.createId ?? randomUUID;
|
||
this.runInstall = options.runInstall ?? runPiPackageInstall;
|
||
this.writeIndex = options.writeIndex ?? atomicWriteJson;
|
||
}
|
||
|
||
async prepare(sourceValue: string, requestedByTurnId: string): Promise<InstallPreviewV1> {
|
||
if (!requestedByTurnId.trim()) {
|
||
throw new DevicePackageError('local_package_confirmation_required', 'Request turn id is required');
|
||
}
|
||
let source;
|
||
try {
|
||
source = parseDevicePackageSource(sourceValue);
|
||
} catch (error) {
|
||
throw new DevicePackageError('local_package_source_unsupported', (error as Error).message);
|
||
}
|
||
const planId = this.createId();
|
||
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u.test(planId)) {
|
||
throw new DevicePackageError('local_package_install_failed', 'Install plan id is invalid');
|
||
}
|
||
const planRoot = path.join(this.stagingDir, planId);
|
||
const packageRoot = path.join(planRoot, 'package');
|
||
await mkdir(planRoot, { recursive: true });
|
||
try {
|
||
let resolvedSource = sourceValue.trim();
|
||
if (source.kind === 'file') {
|
||
const metadata = await pathMetadata(source.absolutePath);
|
||
if (!metadata) throw new DevicePackageError('local_package_not_found', 'Local package source does not exist');
|
||
if (metadata === 'directory') await cp(source.absolutePath, packageRoot, { recursive: true, force: false });
|
||
else {
|
||
await mkdir(packageRoot, { recursive: true });
|
||
await cp(source.absolutePath, path.join(packageRoot, path.basename(source.absolutePath)), { force: false });
|
||
}
|
||
resolvedSource = source.absolutePath;
|
||
} else {
|
||
const executablePath = this.options.executablePath;
|
||
const cliPath = this.options.cliPath;
|
||
if (!executablePath || !cliPath) {
|
||
throw new DevicePackageError('local_package_dependency_failed', 'Bundled Pi installer is unavailable');
|
||
}
|
||
const agentDir = path.join(planRoot, 'pi-agent');
|
||
const cwd = path.join(planRoot, 'project');
|
||
await this.runInstall({
|
||
source: source.spec,
|
||
executablePath,
|
||
cliPath,
|
||
...(this.options.npmCliPath ? { npmCliPath: this.options.npmCliPath } : {}),
|
||
agentDir,
|
||
cwd,
|
||
env: installEnvironment(agentDir),
|
||
});
|
||
const manager = new DefaultPackageManager({
|
||
cwd,
|
||
agentDir,
|
||
settingsManager: SettingsManager.inMemory({}, { projectTrusted: false }),
|
||
});
|
||
const installedPath = manager.getInstalledPath(source.spec, 'user');
|
||
if (!installedPath) throw new DevicePackageError('local_package_dependency_failed', 'Pi did not install the package');
|
||
if (source.kind === 'npm') {
|
||
const installRoot = path.join(agentDir, 'npm');
|
||
await cp(installRoot, packageRoot, { recursive: true, force: false });
|
||
const relativePackage = relativeContainedPath(installRoot, installedPath);
|
||
const nested = path.join(packageRoot, ...relativePackage.split('/'));
|
||
const inspected = await inspectDevicePackage(nested);
|
||
const prefix = relativeContainedPath(packageRoot, nested);
|
||
const remapped = {
|
||
...inspected,
|
||
skillEntries: inspected.skillEntries.map((entry) => ({
|
||
...entry, entryPath: `${prefix}/${entry.entryPath}`,
|
||
})),
|
||
extensionEntries: inspected.extensionEntries.map((entry) => `${prefix}/${entry}`),
|
||
};
|
||
return await this.finishPrepare({
|
||
planId, planRoot, packageRoot, sourceValue, source, requestedByTurnId,
|
||
resolvedSource: `npm:${inspected.displayName}@${inspected.resolvedVersion ?? 'unknown'}`,
|
||
inspected: remapped,
|
||
});
|
||
}
|
||
await cp(installedPath, packageRoot, { recursive: true, force: false });
|
||
const commit = await gitHead(installedPath).catch(() => {
|
||
throw new DevicePackageError('local_package_dependency_failed', 'Git package commit cannot be resolved');
|
||
});
|
||
resolvedSource = `${source.spec.replace(/@[^/@]+$/u, '')}@${commit}`;
|
||
}
|
||
return await this.finishPrepare({
|
||
planId, planRoot, packageRoot, sourceValue, source, requestedByTurnId, resolvedSource,
|
||
inspected: await inspectDevicePackage(
|
||
packageRoot,
|
||
source.kind === 'file'
|
||
? path.basename(source.absolutePath, path.extname(source.absolutePath))
|
||
: path.basename(packageRoot),
|
||
),
|
||
});
|
||
} catch (error) {
|
||
await rm(planRoot, { recursive: true, force: true }).catch(() => undefined);
|
||
if (error instanceof DevicePackageError) throw error;
|
||
throw new DevicePackageError('local_package_manifest_invalid', (error as Error).message);
|
||
}
|
||
}
|
||
|
||
private async finishPrepare(input: {
|
||
planId: string;
|
||
planRoot: string;
|
||
packageRoot: string;
|
||
sourceValue: string;
|
||
source: ReturnType<typeof parseDevicePackageSource>;
|
||
requestedByTurnId: string;
|
||
resolvedSource: string;
|
||
inspected: Awaited<ReturnType<typeof inspectDevicePackage>>;
|
||
}): Promise<InstallPreviewV1> {
|
||
const includesExecutableCode = input.inspected.extensionEntries.length > 0;
|
||
const resolvedVersion = input.inspected.resolvedVersion
|
||
?? `local-${new Date(this.now()).toISOString().replace(/[-:.]/gu, '')}`;
|
||
safeStorageSegment(resolvedVersion);
|
||
const preview: InstallPreviewV1 = {
|
||
schemaVersion: 1,
|
||
planId: input.planId,
|
||
expiresAt: new Date(this.now() + PLAN_TTL_MS).toISOString(),
|
||
requestedSource: input.sourceValue.trim(),
|
||
resolvedSource: input.resolvedSource,
|
||
packageId: input.inspected.packageId,
|
||
displayName: input.inspected.displayName,
|
||
resolvedVersion,
|
||
kind: devicePackageKind(input.inspected),
|
||
skillEntries: input.inspected.skillEntries,
|
||
extensionEntries: input.inspected.extensionEntries,
|
||
includesExecutableCode,
|
||
ignoredLifecycleScripts: input.inspected.ignoredLifecycleScripts,
|
||
warnings: includesExecutableCode ? [EXECUTABLE_WARNING] : [],
|
||
scope: 'device-parent-workers',
|
||
};
|
||
const record: DevicePackageRecordV1 = {
|
||
schemaVersion: 1,
|
||
packageId: preview.packageId,
|
||
displayName: preview.displayName,
|
||
resolvedVersion,
|
||
source: {
|
||
kind: input.source.kind,
|
||
requested: preview.requestedSource,
|
||
resolved: preview.resolvedSource,
|
||
},
|
||
kind: preview.kind,
|
||
skillEntries: preview.skillEntries,
|
||
extensionEntries: preview.extensionEntries,
|
||
enabled: true,
|
||
confirmedExecutableCode: includesExecutableCode,
|
||
installedAt: new Date(this.now()).toISOString(),
|
||
};
|
||
const plan: StagedPlanV1 = {
|
||
schemaVersion: 1,
|
||
requestedByTurnId: input.requestedByTurnId,
|
||
preview,
|
||
record,
|
||
};
|
||
await atomicWriteJson(path.join(input.planRoot, 'plan.json'), plan);
|
||
return structuredClone(preview);
|
||
}
|
||
|
||
async commit(planId: string, confirmed: true, confirmationTurnId: string): Promise<DevicePackageIndexV1> {
|
||
if (confirmed !== true || !confirmationTurnId.trim()) {
|
||
throw new DevicePackageError('local_package_confirmation_required', 'Literal confirmation is required');
|
||
}
|
||
return await this.withOperation(async () => {
|
||
const plan = await this.readPlan(planId);
|
||
if (plan.requestedByTurnId === confirmationTurnId) {
|
||
throw new DevicePackageError('local_package_confirmation_required', 'Confirm installation in a new user turn');
|
||
}
|
||
if (Date.parse(plan.preview.expiresAt) <= this.now()) {
|
||
throw new DevicePackageError('local_package_plan_expired', 'Install preview has expired');
|
||
}
|
||
if (plan.preview.includesExecutableCode && !plan.preview.warnings.includes(EXECUTABLE_WARNING)) {
|
||
throw new DevicePackageError('local_package_confirmation_required', 'Executable permission warning is missing');
|
||
}
|
||
const stagedPackage = path.join(this.stagingDir, planId, 'package');
|
||
await this.assertRecordEntries(stagedPackage, plan.record);
|
||
const index = await this.readIndex();
|
||
const existing = index.packages.find(({ packageId }) => packageId === plan.record.packageId);
|
||
if (existing && existing.source.requested !== plan.record.source.requested) {
|
||
throw new DevicePackageError('local_package_install_failed', 'Package id belongs to another source');
|
||
}
|
||
const destination = this.packageDirectory(plan.record);
|
||
const activeKey = this.packageKey(plan.record);
|
||
if (await pathMetadata(destination) && (this.activePackages.get(activeKey) ?? 0) > 0) {
|
||
throw new DevicePackageError('local_package_in_use', 'This package version is active in a worker');
|
||
}
|
||
await mkdir(path.dirname(destination), { recursive: true });
|
||
await mkdir(this.trashDir, { recursive: true });
|
||
const displaced = await pathMetadata(destination)
|
||
? path.join(this.trashDir, `${plan.record.packageId}-${this.createId()}`)
|
||
: null;
|
||
if (displaced) await rename(destination, displaced);
|
||
let moved = false;
|
||
try {
|
||
await rename(stagedPackage, destination);
|
||
moved = true;
|
||
const next: DevicePackageIndexV1 = {
|
||
schemaVersion: 1,
|
||
generation: index.generation + 1,
|
||
packages: [...index.packages.filter(({ packageId }) => packageId !== plan.record.packageId), plan.record]
|
||
.sort((left, right) => left.packageId.localeCompare(right.packageId)),
|
||
};
|
||
await this.writeIndex(this.indexPath, next);
|
||
await rm(path.join(this.stagingDir, planId), { recursive: true, force: true });
|
||
if (existing && existing.resolvedVersion !== plan.record.resolvedVersion) {
|
||
await this.cleanupPackage(existing);
|
||
}
|
||
if (displaced) await rm(displaced, { recursive: true, force: true }).catch(() => undefined);
|
||
await this.options.onGenerationChanged?.(cloneIndex(next));
|
||
return cloneIndex(next);
|
||
} catch (error) {
|
||
if (moved) await rm(destination, { recursive: true, force: true }).catch(() => undefined);
|
||
if (displaced) await rename(displaced, destination).catch(() => undefined);
|
||
if (error instanceof DevicePackageError) throw error;
|
||
throw new DevicePackageError('local_package_install_failed', 'Device package commit failed');
|
||
}
|
||
});
|
||
}
|
||
|
||
async list(): Promise<DevicePackageIndexV1> {
|
||
return cloneIndex(await this.readIndex());
|
||
}
|
||
|
||
async setEnabled(packageId: string, enabled: boolean): Promise<DevicePackageIndexV1> {
|
||
return await this.withOperation(async () => {
|
||
const index = await this.readIndex();
|
||
const existing = index.packages.find((record) => record.packageId === packageId);
|
||
if (!existing) throw new DevicePackageError('local_package_not_installed', 'Device package is not installed');
|
||
if (existing.enabled === enabled) return cloneIndex(index);
|
||
const next: DevicePackageIndexV1 = {
|
||
schemaVersion: 1,
|
||
generation: index.generation + 1,
|
||
packages: index.packages.map((record) => record.packageId === packageId
|
||
? { ...record, enabled }
|
||
: record),
|
||
};
|
||
await this.writeIndex(this.indexPath, next);
|
||
await this.options.onGenerationChanged?.(cloneIndex(next));
|
||
return cloneIndex(next);
|
||
});
|
||
}
|
||
|
||
async uninstall(packageId: string): Promise<DevicePackageIndexV1> {
|
||
return await this.withOperation(async () => {
|
||
const index = await this.readIndex();
|
||
const existing = index.packages.find((record) => record.packageId === packageId);
|
||
if (!existing) return cloneIndex(index);
|
||
const next: DevicePackageIndexV1 = {
|
||
schemaVersion: 1,
|
||
generation: index.generation + 1,
|
||
packages: index.packages.filter((record) => record.packageId !== packageId),
|
||
};
|
||
await this.writeIndex(this.indexPath, next);
|
||
await this.cleanupPackage(existing);
|
||
await this.options.onGenerationChanged?.(cloneIndex(next));
|
||
return cloneIndex(next);
|
||
});
|
||
}
|
||
|
||
async resolveEnabledResources(): Promise<EnabledDevicePackageResources> {
|
||
const index = await this.readIndex();
|
||
const enabled = index.packages.filter(({ enabled }) => enabled);
|
||
const skillEntries: EnabledDevicePackageResources['skillEntries'] = [];
|
||
const extensionPaths: string[] = [];
|
||
for (const record of enabled) {
|
||
const packageRoot = this.packageDirectory(record);
|
||
await this.assertRecordEntries(packageRoot, record);
|
||
for (const entry of record.skillEntries) skillEntries.push({ ...entry, packageRoot });
|
||
for (const entry of record.extensionEntries) {
|
||
extensionPaths.push(path.join(packageRoot, ...entry.split('/')));
|
||
}
|
||
}
|
||
return {
|
||
generation: index.generation,
|
||
packageIds: enabled.map(({ packageId }) => packageId),
|
||
packageRefs: enabled.map(({ packageId, resolvedVersion }) => ({ packageId, resolvedVersion })),
|
||
skillEntries,
|
||
extensionPaths,
|
||
};
|
||
}
|
||
|
||
registerActiveWorker(refs: readonly { packageId: string; resolvedVersion: string }[]): () => Promise<void> {
|
||
const keys = [...new Set(refs.map((record) => this.packageKey(record)))];
|
||
for (const key of keys) this.activePackages.set(key, (this.activePackages.get(key) ?? 0) + 1);
|
||
let released = false;
|
||
return async () => {
|
||
if (released) return;
|
||
released = true;
|
||
for (const key of keys) {
|
||
const count = this.activePackages.get(key) ?? 0;
|
||
if (count <= 1) this.activePackages.delete(key);
|
||
else this.activePackages.set(key, count - 1);
|
||
}
|
||
for (const target of [...this.pendingCleanup]) {
|
||
const [key, packagePath] = target.split('\u0000', 2);
|
||
if (!key || !packagePath || (this.activePackages.get(key) ?? 0) > 0) continue;
|
||
this.pendingCleanup.delete(target);
|
||
await rm(packagePath, { recursive: true, force: true }).catch(() => undefined);
|
||
}
|
||
};
|
||
}
|
||
|
||
private async readPlan(planId: string): Promise<StagedPlanV1> {
|
||
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u.test(planId)) {
|
||
throw new DevicePackageError('local_package_plan_expired', 'Install preview is unavailable');
|
||
}
|
||
try {
|
||
const value = JSON.parse(await readFile(path.join(this.stagingDir, planId, 'plan.json'), 'utf8')) as unknown;
|
||
if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.preview)) throw new Error();
|
||
const record = parseRecord(value.record);
|
||
const preview = value.preview as unknown as InstallPreviewV1;
|
||
if (preview.schemaVersion !== 1 || preview.planId !== planId
|
||
|| !Array.isArray(preview.warnings) || !Array.isArray(preview.skillEntries)
|
||
|| !Array.isArray(preview.extensionEntries)) throw new Error();
|
||
return {
|
||
schemaVersion: 1,
|
||
requestedByTurnId: string(value.requestedByTurnId, 'Request turn id', 256),
|
||
preview,
|
||
record,
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof DevicePackageError) throw error;
|
||
throw new DevicePackageError('local_package_plan_expired', 'Install preview is unavailable');
|
||
}
|
||
}
|
||
|
||
private async readIndex(): Promise<DevicePackageIndexV1> {
|
||
try {
|
||
return parseIndex(JSON.parse(await readFile(this.indexPath, 'utf8')) as unknown);
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||
return { schemaVersion: 1, generation: 0, packages: [] };
|
||
}
|
||
if (error instanceof DevicePackageError) throw error;
|
||
throw new DevicePackageError('local_package_install_failed', 'Device package index is invalid');
|
||
}
|
||
}
|
||
|
||
private packageDirectory(record: Pick<DevicePackageRecordV1, 'packageId' | 'resolvedVersion'>): string {
|
||
return path.join(this.packagesDir, record.packageId, safeStorageSegment(record.resolvedVersion));
|
||
}
|
||
|
||
private packageKey(record: Pick<DevicePackageRecordV1, 'packageId' | 'resolvedVersion'>): string {
|
||
return `${record.packageId}@${record.resolvedVersion}`;
|
||
}
|
||
|
||
private async assertRecordEntries(packageRoot: string, record: DevicePackageRecordV1): Promise<void> {
|
||
if (await pathMetadata(packageRoot) !== 'directory') {
|
||
throw new DevicePackageError('local_package_install_failed', 'Device package bytes are unavailable');
|
||
}
|
||
for (const entry of [...record.skillEntries.map(({ entryPath }) => entryPath), ...record.extensionEntries]) {
|
||
const candidate = path.resolve(packageRoot, ...entry.split('/'));
|
||
try {
|
||
relativeContainedPath(packageRoot, candidate);
|
||
} catch {
|
||
throw new DevicePackageError('local_package_manifest_invalid', 'Device package entry escapes its root');
|
||
}
|
||
if (await pathMetadata(candidate) !== 'file') {
|
||
throw new DevicePackageError('local_package_manifest_invalid', 'Device package entry is unavailable');
|
||
}
|
||
}
|
||
}
|
||
|
||
private async cleanupPackage(record: DevicePackageRecordV1): Promise<void> {
|
||
const packagePath = this.packageDirectory(record);
|
||
const key = this.packageKey(record);
|
||
if ((this.activePackages.get(key) ?? 0) > 0) {
|
||
this.pendingCleanup.add(`${key}\u0000${packagePath}`);
|
||
return;
|
||
}
|
||
await rm(packagePath, { recursive: true, force: true }).catch(() => undefined);
|
||
}
|
||
|
||
private withOperation<T>(operation: () => Promise<T>): Promise<T> {
|
||
const result = this.operation.then(operation, operation);
|
||
this.operation = result.then(() => undefined, () => undefined);
|
||
return result;
|
||
}
|
||
}
|