import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { DevicePackageError, DevicePackageManager, type DevicePackageInstallInput, } from '../../electron/coding-packages/device-package-manager'; const roots: string[] = []; const NOW = Date.parse('2026-09-02T12:00:00.000Z'); async function temporaryRoot(name: string): Promise { const root = await mkdtemp(path.join(tmpdir(), `makelore-device-package-${name}-`)); roots.push(root); return root; } async function writeJson(filePath: string, value: unknown): Promise { await mkdir(path.dirname(filePath), { recursive: true }); await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); } async function looseSkill(root: string, id = 'fixture-skill'): Promise { const source = path.join(root, id); await mkdir(source, { recursive: true }); await writeFile(path.join(source, 'SKILL.md'), `---\nname: ${id}\ndescription: Fixture\n---\n`, 'utf8'); return source; } afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); describe('DevicePackageManager', () => { it('previews and commits a loose Skill only after a distinct confirmation turn', async () => { const root = await temporaryRoot('loose-skill'); const source = await looseSkill(root); const changed = vi.fn(); const manager = new DevicePackageManager({ rootDir: path.join(root, 'store'), now: () => NOW, createId: () => 'plan-loose', onGenerationChanged: changed, }); const preview = await manager.prepare(source, 'turn-prepare'); expect(preview).toMatchObject({ schemaVersion: 1, planId: 'plan-loose', packageId: 'fixture-skill', displayName: 'fixture-skill', kind: 'skill-only', includesExecutableCode: false, scope: 'device-parent-workers', skillEntries: [{ id: 'fixture-skill', entryPath: 'SKILL.md' }], extensionEntries: [], }); await expect(manager.commit(preview.planId, true, 'turn-prepare')) .rejects.toMatchObject({ code: 'local_package_confirmation_required' }); await expect(manager.commit(preview.planId, false as never, 'turn-confirm')) .rejects.toMatchObject({ code: 'local_package_confirmation_required' }); const installed = await manager.commit(preview.planId, true, 'turn-confirm'); expect(installed.generation).toBe(1); expect(installed.packages).toEqual([expect.objectContaining({ packageId: 'fixture-skill', enabled: true, confirmedExecutableCode: false, })]); const resources = await manager.resolveEnabledResources(); expect(resources).toMatchObject({ generation: 1, packageIds: ['fixture-skill'], extensionPaths: [], }); expect(resources.skillEntries).toEqual([expect.objectContaining({ id: 'fixture-skill', entryPath: 'SKILL.md', })]); const installedSkill = resources.skillEntries[0]!; expect(await readFile(path.join(installedSkill.packageRoot, installedSkill.entryPath), 'utf8')) .toContain('name: fixture-skill'); expect(changed).toHaveBeenCalledTimes(1); }); it('recognizes Pi extension and mixed package manifests and shows the desktop-permission warning', async () => { const root = await temporaryRoot('pi-manifests'); const source = path.join(root, 'mixed-package'); await writeJson(path.join(source, 'package.json'), { name: '@example/mixed-package', version: '1.2.3', pi: { extensions: ['./extensions/index.ts'], skills: ['./skills/research/SKILL.md'] }, }); await mkdir(path.join(source, 'extensions'), { recursive: true }); await writeFile(path.join(source, 'extensions/index.ts'), 'export default function fixture() {}\n'); await mkdir(path.join(source, 'skills/research'), { recursive: true }); await writeFile(path.join(source, 'skills/research/SKILL.md'), '---\nname: research\n---\n'); const manager = new DevicePackageManager({ rootDir: path.join(root, 'store'), now: () => NOW, createId: () => 'plan-mixed', }); const preview = await manager.prepare(source, 'turn-a'); expect(preview).toMatchObject({ packageId: 'example.mixed-package', displayName: '@example/mixed-package', resolvedVersion: '1.2.3', kind: 'mixed', includesExecutableCode: true, extensionEntries: ['extensions/index.ts'], skillEntries: [{ id: 'research', entryPath: 'skills/research/SKILL.md' }], }); expect(preview.warnings.join(' ')).toContain('完整桌面权限'); expect(preview.warnings.join(' ')).toContain('网络'); }); it('uses the bundled Pi runtime for remote installation when the app Pi graph is unavailable', async () => { const root = await temporaryRoot('remote'); const runtimeRoot = path.join(root, 'pi-runtime'); const cliPath = path.join(runtimeRoot, 'dist', 'cli.js'); await writeJson(path.join(runtimeRoot, 'package.json'), { type: 'module' }); await mkdir(path.dirname(cliPath), { recursive: true }); await writeFile(path.join(path.dirname(cliPath), 'index.js'), [ "import path from 'node:path';", 'export class SettingsManager { static inMemory() { return {}; } }', 'export class DefaultPackageManager {', ' constructor(options) { this.agentDir = options.agentDir; }', " getInstalledPath() { return path.join(this.agentDir, 'npm', 'node_modules', 'fixture-installed'); }", '}', ].join('\n'), 'utf8'); const calls: DevicePackageInstallInput[] = []; const runInstall = vi.fn(async (input: DevicePackageInstallInput) => { calls.push(input); const packageRoot = path.join(input.agentDir, 'npm', 'node_modules', 'fixture-installed'); await writeJson(path.join(packageRoot, 'package.json'), { name: 'pi-web-search', version: '2.4.0', scripts: { postinstall: 'node forbidden.js', test: 'ignored test' }, pi: { extensions: ['./index.js'] }, }); await writeFile(path.join(packageRoot, 'index.js'), 'export default function fixture() {}\n'); }); const manager = new DevicePackageManager({ rootDir: path.join(root, 'store'), executablePath: 'Makelore.exe', cliPath, npmCliPath: 'npm-cli.js', runInstall, now: () => NOW, createId: () => 'plan-npm', }); const preview = await manager.prepare('npm:pi-web-search@2.4.0', 'turn-a'); expect(runInstall).toHaveBeenCalledOnce(); expect(calls[0]?.source).toBe('npm:pi-web-search@2.4.0'); expect(calls[0]?.env).toMatchObject({ ELECTRON_RUN_AS_NODE: '1', PI_TELEMETRY: '0', GIT_TERMINAL_PROMPT: '0', CI: '1', npm_config_ignore_scripts: 'true', npm_config_update_notifier: 'false', }); expect(preview).toMatchObject({ packageId: 'pi-web-search', resolvedSource: 'npm:pi-web-search@2.4.0', resolvedVersion: '2.4.0', kind: 'pi-extension', ignoredLifecycleScripts: ['postinstall'], }); }); it('increments generation only for durable changes and removes disabled packages', async () => { const root = await temporaryRoot('generation'); const source = await looseSkill(root, 'generation-skill'); let id = 0; const manager = new DevicePackageManager({ rootDir: path.join(root, 'store'), now: () => NOW + id * 1000, createId: () => `plan-${++id}`, }); const preview = await manager.prepare(source, 'turn-a'); await manager.commit(preview.planId, true, 'turn-b'); expect((await manager.setEnabled('generation-skill', true)).generation).toBe(1); expect((await manager.setEnabled('generation-skill', false)).generation).toBe(2); expect((await manager.resolveEnabledResources()).packageIds).toEqual([]); expect((await manager.setEnabled('generation-skill', false)).generation).toBe(2); expect((await manager.uninstall('generation-skill')).generation).toBe(3); expect((await manager.uninstall('generation-skill')).generation).toBe(3); expect((await manager.list()).packages).toEqual([]); }); it('rejects escaping and ambiguous manifest entries', async () => { const root = await temporaryRoot('invalid'); const source = path.join(root, 'bad-package'); await writeJson(path.join(source, 'package.json'), { name: 'bad-package', version: '1.0.0', pi: { extensions: ['../outside.js'] }, }); await writeFile(path.join(root, 'outside.js'), 'export default function bad() {}\n'); const manager = new DevicePackageManager({ rootDir: path.join(root, 'store') }); await expect(manager.prepare(source, 'turn-a')).rejects.toBeInstanceOf(DevicePackageError); await expect(manager.prepare(source, 'turn-a')) .rejects.toMatchObject({ code: 'local_package_manifest_invalid' }); }); });