feat(coding): add model tools and device packages
This commit is contained in:
224
electron/coding-packages/device-package-format.ts
Normal file
224
electron/coding-packages/device-package-format.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
DevicePackageKind,
|
||||
DevicePackageSkillEntry,
|
||||
} from '../../shared/device-packages';
|
||||
|
||||
export type {
|
||||
DevicePackageIndexV1,
|
||||
DevicePackageKind,
|
||||
DevicePackageRecordV1,
|
||||
DevicePackageSkillEntry,
|
||||
InstallPreviewV1,
|
||||
} from '../../shared/device-packages';
|
||||
|
||||
export type DevicePackageSource =
|
||||
| Readonly<{ kind: 'npm'; spec: string }>
|
||||
| Readonly<{ kind: 'git'; spec: string }>
|
||||
| Readonly<{ kind: 'file'; absolutePath: string }>;
|
||||
|
||||
export interface InspectedDevicePackage {
|
||||
packageId: string;
|
||||
displayName: string;
|
||||
resolvedVersion: string | null;
|
||||
skillEntries: DevicePackageSkillEntry[];
|
||||
extensionEntries: string[];
|
||||
ignoredLifecycleScripts: string[];
|
||||
}
|
||||
|
||||
const PACKAGE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/u;
|
||||
const SKILL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
||||
const LIFECYCLE_SCRIPT_NAMES = Object.freeze([
|
||||
'preinstall',
|
||||
'install',
|
||||
'postinstall',
|
||||
'prepare',
|
||||
] as const);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function nonempty(value: unknown, maximum: number): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim();
|
||||
return normalized && normalized.length <= maximum ? normalized : null;
|
||||
}
|
||||
|
||||
function containsControlCharacter(value: string): boolean {
|
||||
return Array.from(value).some((character) => {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
return codePoint <= 0x1f || codePoint === 0x7f;
|
||||
});
|
||||
}
|
||||
|
||||
export function parseDevicePackageSource(value: string): DevicePackageSource {
|
||||
const source = nonempty(value, 2048);
|
||||
if (!source || containsControlCharacter(source)) {
|
||||
throw new Error('Device package source is invalid');
|
||||
}
|
||||
if (source.startsWith('npm:')) {
|
||||
const spec = nonempty(source.slice(4), 1024);
|
||||
if (!spec || /\s/u.test(spec)) throw new Error('npm package source is invalid');
|
||||
return { kind: 'npm', spec: `npm:${spec}` };
|
||||
}
|
||||
if (source.startsWith('git:') || /^https?:\/\//iu.test(source)
|
||||
|| /^ssh:\/\//iu.test(source) || /^git@[^:]+:/u.test(source)) {
|
||||
if (/\s/u.test(source)) throw new Error('Git package source is invalid');
|
||||
return { kind: 'git', spec: source };
|
||||
}
|
||||
if (!path.isAbsolute(source)) throw new Error('Local package source must be an absolute path');
|
||||
return { kind: 'file', absolutePath: path.resolve(source) };
|
||||
}
|
||||
|
||||
export function safeDevicePackageId(value: string): string {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
.replace(/^@/u, '')
|
||||
.replaceAll('/', '.')
|
||||
.replace(/[^a-z0-9._-]+/gu, '-')
|
||||
.replace(/^[^a-z0-9]+|[^a-z0-9._-]+$/gu, '')
|
||||
.slice(0, 128);
|
||||
if (!PACKAGE_ID_PATTERN.test(normalized)) throw new Error('Device package id is invalid');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function safeStorageSegment(value: string): string {
|
||||
const normalized = value.trim().replace(/[^A-Za-z0-9._-]+/gu, '-').slice(0, 128);
|
||||
if (!normalized || normalized === '.' || normalized === '..') {
|
||||
throw new Error('Device package version is invalid');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function relativeContainedPath(root: string, candidate: string): string {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const resolvedCandidate = path.resolve(candidate);
|
||||
const relative = path.relative(resolvedRoot, resolvedCandidate);
|
||||
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new Error('Device package entry escapes its package root');
|
||||
}
|
||||
return relative.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
async function readJson(filePath: string): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const value = JSON.parse(await readFile(filePath, 'utf8')) as unknown;
|
||||
return isRecord(value) ? value : null;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function existsFile(filePath: string): Promise<boolean> {
|
||||
return await stat(filePath).then((value) => value.isFile()).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return false;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async function skillId(skillPath: string): Promise<string> {
|
||||
const source = await readFile(skillPath, 'utf8');
|
||||
const frontmatter = /^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(source)?.[1] ?? '';
|
||||
const declared = /^name:\s*['"]?([^'"\r\n]+?)['"]?\s*$/imu.exec(frontmatter)?.[1]?.trim();
|
||||
const fallback = path.basename(path.dirname(skillPath));
|
||||
const id = declared || fallback;
|
||||
if (!SKILL_ID_PATTERN.test(id)) throw new Error(`Skill id is invalid: ${id}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function collectSkillFiles(root: string): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
const visit = async (directory: string): Promise<void> => {
|
||||
if (result.length > 100) throw new Error('Device package contains too many Skills');
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||
const candidate = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) await visit(candidate);
|
||||
else if (entry.isFile() && entry.name === 'SKILL.md') result.push(candidate);
|
||||
}
|
||||
};
|
||||
await visit(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
function manifestEntries(value: unknown, field: string): string[] {
|
||||
if (value === undefined) return [];
|
||||
const values = typeof value === 'string' ? [value] : value;
|
||||
if (!Array.isArray(values) || values.length > 100
|
||||
|| values.some((entry) => typeof entry !== 'string' || !entry.trim())) {
|
||||
throw new Error(`${field} is invalid`);
|
||||
}
|
||||
return values.map((entry) => (entry as string).trim());
|
||||
}
|
||||
|
||||
async function expandSkillManifestEntry(packageRoot: string, entry: string): Promise<string[]> {
|
||||
const candidate = path.resolve(packageRoot, entry);
|
||||
relativeContainedPath(packageRoot, candidate);
|
||||
const metadata = await stat(candidate).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
});
|
||||
if (!metadata) throw new Error(`Skill entry does not exist: ${entry}`);
|
||||
if (metadata.isFile()) {
|
||||
if (path.basename(candidate) !== 'SKILL.md') throw new Error(`Skill entry is not SKILL.md: ${entry}`);
|
||||
return [candidate];
|
||||
}
|
||||
if (!metadata.isDirectory()) throw new Error(`Skill entry is invalid: ${entry}`);
|
||||
return await collectSkillFiles(candidate);
|
||||
}
|
||||
|
||||
export async function inspectDevicePackage(
|
||||
packageRoot: string,
|
||||
fallbackName = path.basename(path.resolve(packageRoot)),
|
||||
): Promise<InspectedDevicePackage> {
|
||||
const root = path.resolve(packageRoot);
|
||||
const packageJson = await readJson(path.join(root, 'package.json'));
|
||||
const codexManifest = await readJson(path.join(root, '.codex-plugin', 'plugin.json'));
|
||||
const pi = isRecord(packageJson?.pi) ? packageJson.pi : null;
|
||||
const declaredExtensions = manifestEntries(pi?.extensions, 'pi.extensions');
|
||||
const declaredSkills = manifestEntries(pi?.skills, 'pi.skills');
|
||||
const extensionFiles: string[] = [];
|
||||
for (const entry of declaredExtensions) {
|
||||
const candidate = path.resolve(root, entry);
|
||||
relativeContainedPath(root, candidate);
|
||||
if (!await existsFile(candidate) || !/\.(?:js|ts)$/iu.test(candidate)) {
|
||||
throw new Error(`Extension entry is invalid: ${entry}`);
|
||||
}
|
||||
extensionFiles.push(candidate);
|
||||
}
|
||||
const skillFiles: string[] = [];
|
||||
for (const entry of declaredSkills) skillFiles.push(...await expandSkillManifestEntry(root, entry));
|
||||
const codexSkills = manifestEntries(codexManifest?.skills, 'plugin.skills');
|
||||
for (const entry of codexSkills) skillFiles.push(...await expandSkillManifestEntry(root, entry));
|
||||
if (await existsFile(path.join(root, 'SKILL.md'))) skillFiles.push(path.join(root, 'SKILL.md'));
|
||||
|
||||
const uniqueSkills = new Map<string, DevicePackageSkillEntry>();
|
||||
for (const skillPath of [...new Set(skillFiles.map((entry) => path.resolve(entry)))]) {
|
||||
const id = await skillId(skillPath);
|
||||
if (uniqueSkills.has(id)) throw new Error(`Duplicate Skill id: ${id}`);
|
||||
uniqueSkills.set(id, { id, entryPath: relativeContainedPath(root, skillPath) });
|
||||
}
|
||||
const extensionEntries = [...new Set(extensionFiles.map((entry) => relativeContainedPath(root, entry)))];
|
||||
if (uniqueSkills.size === 0 && extensionEntries.length === 0) {
|
||||
throw new Error('Package does not expose a Pi extension or Skill');
|
||||
}
|
||||
const displayName = nonempty(packageJson?.name, 256)
|
||||
?? nonempty(codexManifest?.name, 256)
|
||||
?? fallbackName;
|
||||
const scripts = isRecord(packageJson?.scripts) ? packageJson.scripts : {};
|
||||
return {
|
||||
packageId: safeDevicePackageId(displayName),
|
||||
displayName,
|
||||
resolvedVersion: nonempty(packageJson?.version, 128) ?? nonempty(codexManifest?.version, 128),
|
||||
skillEntries: [...uniqueSkills.values()].sort((left, right) => left.id.localeCompare(right.id)),
|
||||
extensionEntries: extensionEntries.sort(),
|
||||
ignoredLifecycleScripts: LIFECYCLE_SCRIPT_NAMES.filter((name) => typeof scripts[name] === 'string'),
|
||||
};
|
||||
}
|
||||
|
||||
export function devicePackageKind(input: Pick<InspectedDevicePackage, 'skillEntries' | 'extensionEntries'>): DevicePackageKind {
|
||||
if (input.skillEntries.length > 0 && input.extensionEntries.length > 0) return 'mixed';
|
||||
return input.extensionEntries.length > 0 ? 'pi-extension' : 'skill-only';
|
||||
}
|
||||
609
electron/coding-packages/device-package-manager.ts
Normal file
609
electron/coding-packages/device-package-manager.ts
Normal file
@@ -0,0 +1,609 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
173
electron/coding-packages/device-package-tools.ts
Normal file
173
electron/coding-packages/device-package-tools.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { CodingPluginToolDefinition } from '../../shared/coding-plugins';
|
||||
import type {
|
||||
DevicePackageIndexV1,
|
||||
DevicePackageToolDetailsV1,
|
||||
DevicePackageToolOperation,
|
||||
InstallPreviewV1,
|
||||
} from '../../shared/device-packages';
|
||||
import type { DevicePackageToolName } from '../../shared/device-packages';
|
||||
import type { PiProductToolResult } from '../coding-runtime/pi/product-tools';
|
||||
import type { DevicePackageManager } from './device-package-manager';
|
||||
|
||||
export { DEVICE_PACKAGE_TOOL_NAMES } from '../../shared/device-packages';
|
||||
|
||||
const EMPTY_OBJECT_SCHEMA = Object.freeze({
|
||||
type: 'object', additionalProperties: false, properties: {},
|
||||
});
|
||||
|
||||
function tool(
|
||||
name: DevicePackageToolName,
|
||||
label: string,
|
||||
description: string,
|
||||
operation: DevicePackageToolOperation,
|
||||
mutation: 'read' | 'write' | 'destructive',
|
||||
inputSchema: Readonly<Record<string, unknown>>,
|
||||
): CodingPluginToolDefinition {
|
||||
return Object.freeze({
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
capabilityId: 'makelore.device-packages',
|
||||
operation,
|
||||
roles: ['parent'],
|
||||
mutation,
|
||||
projectWriteLease: false,
|
||||
permissions: ['device-packages'],
|
||||
inputSchema,
|
||||
});
|
||||
}
|
||||
|
||||
export const DEVICE_PACKAGE_TOOL_DEFINITIONS = Object.freeze([
|
||||
tool(
|
||||
'local_package_prepare',
|
||||
'Prepare local package',
|
||||
'Resolve an npm, Git, or absolute local Pi package/Skill and show an exact installation preview. This does not install it.',
|
||||
'prepare',
|
||||
'read',
|
||||
{
|
||||
type: 'object', additionalProperties: false, required: ['source'],
|
||||
properties: { source: { type: 'string', minLength: 1, maxLength: 2048 } },
|
||||
},
|
||||
),
|
||||
tool(
|
||||
'local_package_commit',
|
||||
'Install local package',
|
||||
'Install a prepared device package only after the user confirms in a later message.',
|
||||
'commit',
|
||||
'write',
|
||||
{
|
||||
type: 'object', additionalProperties: false, required: ['planId', 'confirmed'],
|
||||
properties: {
|
||||
planId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
confirmed: { const: true },
|
||||
},
|
||||
},
|
||||
),
|
||||
tool('local_package_list', 'List local packages', 'List packages installed on this device.', 'list', 'read', EMPTY_OBJECT_SCHEMA),
|
||||
tool(
|
||||
'local_package_set_enabled',
|
||||
'Enable or disable local package',
|
||||
'Enable or disable an installed device package for future parent workers.',
|
||||
'set_enabled',
|
||||
'write',
|
||||
{
|
||||
type: 'object', additionalProperties: false, required: ['packageId', 'enabled'],
|
||||
properties: {
|
||||
packageId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
enabled: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
),
|
||||
tool(
|
||||
'local_package_uninstall',
|
||||
'Remove local package',
|
||||
'Remove an installed package from this device after explicit confirmation.',
|
||||
'uninstall',
|
||||
'destructive',
|
||||
{
|
||||
type: 'object', additionalProperties: false, required: ['packageId', 'confirmed'],
|
||||
properties: {
|
||||
packageId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
confirmed: { const: true },
|
||||
},
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Local package tool input is invalid');
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(input: Record<string, unknown>, keys: readonly string[]): void {
|
||||
const actual = Object.keys(input).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
||||
throw new Error('Local package tool input has unexpected fields');
|
||||
}
|
||||
}
|
||||
|
||||
function boundedString(value: unknown, name: string, maximum: number): string {
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > maximum) throw new Error(`${name} is invalid`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function result(
|
||||
operation: DevicePackageToolOperation,
|
||||
value: { preview?: InstallPreviewV1; index?: DevicePackageIndexV1 },
|
||||
): PiProductToolResult {
|
||||
const details: DevicePackageToolDetailsV1 = {
|
||||
schema: 'makelore-device-package.v1', operation, success: true, ...value,
|
||||
};
|
||||
return { content: [{ type: 'text', text: JSON.stringify(details) }], details };
|
||||
}
|
||||
|
||||
export class DevicePackageTools {
|
||||
readonly tools = DEVICE_PACKAGE_TOOL_DEFINITIONS;
|
||||
|
||||
constructor(private readonly manager: DevicePackageManager) {}
|
||||
|
||||
async invoke(toolName: string, turnId: string, value: unknown): Promise<PiProductToolResult> {
|
||||
const input = record(value);
|
||||
switch (toolName) {
|
||||
case 'local_package_prepare': {
|
||||
exact(input, ['source']);
|
||||
return result('prepare', {
|
||||
preview: await this.manager.prepare(boundedString(input.source, 'source', 2048), turnId),
|
||||
});
|
||||
}
|
||||
case 'local_package_commit': {
|
||||
exact(input, ['planId', 'confirmed']);
|
||||
return result('commit', {
|
||||
index: await this.manager.commit(
|
||||
boundedString(input.planId, 'planId', 128),
|
||||
input.confirmed as true,
|
||||
turnId,
|
||||
),
|
||||
});
|
||||
}
|
||||
case 'local_package_list':
|
||||
exact(input, []);
|
||||
return result('list', { index: await this.manager.list() });
|
||||
case 'local_package_set_enabled': {
|
||||
exact(input, ['packageId', 'enabled']);
|
||||
if (typeof input.enabled !== 'boolean') throw new Error('enabled is invalid');
|
||||
return result('set_enabled', {
|
||||
index: await this.manager.setEnabled(
|
||||
boundedString(input.packageId, 'packageId', 128),
|
||||
input.enabled,
|
||||
),
|
||||
});
|
||||
}
|
||||
case 'local_package_uninstall': {
|
||||
exact(input, ['packageId', 'confirmed']);
|
||||
if (input.confirmed !== true) throw new Error('Literal confirmation is required');
|
||||
return result('uninstall', {
|
||||
index: await this.manager.uninstall(boundedString(input.packageId, 'packageId', 128)),
|
||||
});
|
||||
}
|
||||
default:
|
||||
throw new Error('Local package tool is unavailable');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user