From 0f34fdcc14b9da7d1bc3030199d7eb269abbff7c Mon Sep 17 00:00:00 2001 From: Yusuf Efe <120668197+yuefdev@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:40:12 -0400 Subject: [PATCH] fix: resolve installed OpenCode skill paths consistently Carry forward PR #3115 ef475c79 and 780dfb78 onto current main. Preserve the newer hook-consent transforms during install and repair. Main already includes the source import fix from #3167. --- .opencode/package-lock.json | 2 +- .opencode/package.json | 2 +- .opencode/tools/index.ts | 2 +- scripts/lib/install-lifecycle.js | 22 +-- scripts/lib/install-targets/opencode-home.js | 24 +++ scripts/lib/install/apply.js | 18 +- scripts/lib/install/content-transform.js | 37 ++++ tests/fixtures/opencode-ts-loader.mjs | 18 ++ tests/lib/install-executor.test.js | 173 ++++++++++++++++++- tests/lib/install-lifecycle.test.js | 1 + tests/scripts/build-opencode.test.js | 84 +++++++++ 11 files changed, 342 insertions(+), 41 deletions(-) create mode 100644 scripts/lib/install/content-transform.js create mode 100644 tests/fixtures/opencode-ts-loader.mjs diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json index 11f80e210..8b196314a 100644 --- a/.opencode/package-lock.json +++ b/.opencode/package-lock.json @@ -11,7 +11,7 @@ "devDependencies": { "@opencode-ai/plugin": "^1.4.3", "@types/node": "^20.0.0", - "typescript": "^5.3.0" + "typescript": "^5.7.0" }, "engines": { "node": ">=18.0.0" diff --git a/.opencode/package.json b/.opencode/package.json index 9a335539c..67ce27594 100644 --- a/.opencode/package.json +++ b/.opencode/package.json @@ -62,7 +62,7 @@ "devDependencies": { "@opencode-ai/plugin": "^1.4.3", "@types/node": "^20.0.0", - "typescript": "^5.3.0" + "typescript": "^5.7.0" }, "engines": { "node": ">=18.0.0" diff --git a/.opencode/tools/index.ts b/.opencode/tools/index.ts index 17db1081a..5332381e8 100644 --- a/.opencode/tools/index.ts +++ b/.opencode/tools/index.ts @@ -4,7 +4,7 @@ * These tools extend OpenCode with additional capabilities. */ -// Re-export all tools +// OpenCode loads these sources in place; tsc rewrites .ts imports for dist. export { default as runTests } from "./run-tests.ts" export { default as checkCoverage } from "./check-coverage.ts" export { default as securityAudit } from "./security-audit.ts" diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index 98abf8e20..b3cb66709 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -14,8 +14,6 @@ const { writeFileNoFollow: guardedWriteFile } = require('./install/guarded-write const { withOpenCodeInstallLocks } = require('./install/opencode-install-lock'); const { isCodexUserConfig } = require('./install/codex-user-config'); const { - disableOpenCodeHookPluginRegistration, - getDisabledOpenCodePluginContent, getRecordedHookConsent, withHookConsent, } = require('./install/hook-consent'); @@ -41,7 +39,7 @@ const { updateSettingsAtomic, validateManagedHooks, } = require('./install/claude-settings'); -const { adaptAntigravityAgent } = require('./install/antigravity-agent'); +const { transformInstallContent } = require('./install/content-transform'); const { buildInstallIndex, rewriteRelativeLinks } = require('./install/link-rewrite'); const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry'); const { resolveInvocationEnvironment } = require('./invocation-environment'); @@ -232,24 +230,8 @@ function buildLinkIndexForOperations(operations, trustedRoot) { return buildInstallIndex(mappings); } -function transformCopyFileContent(operation, content) { - if (!operation.contentTransform) { - return content; - } - if (operation.contentTransform === 'antigravity-agent-frontmatter') { - return adaptAntigravityAgent(content, operation.sourceRelativePath); - } - if (operation.contentTransform === 'opencode-disable-ecc-hooks') { - return disableOpenCodeHookPluginRegistration(content, operation.sourceRelativePath); - } - if (operation.contentTransform === 'opencode-disable-plugin-entrypoint') { - return getDisabledOpenCodePluginContent(); - } - throw new Error(`Unknown install content transform: ${operation.contentTransform}`); -} - function getExpectedCopyFileContent(operation, content, linkIndex) { - const transformed = transformCopyFileContent(operation, content); + const transformed = transformInstallContent(operation, content); if (!linkIndex || !operation.sourceRelativePath || !isMarkdownPath(operation.destinationPath)) { return transformed; } diff --git a/scripts/lib/install-targets/opencode-home.js b/scripts/lib/install-targets/opencode-home.js index d25fdf7da..6c687f438 100644 --- a/scripts/lib/install-targets/opencode-home.js +++ b/scripts/lib/install-targets/opencode-home.js @@ -5,6 +5,8 @@ const path = require('path'); const { buildValidationIssue, createInstallTargetAdapter, + createManagedOperation, + isForeignPlatformPath, } = require('./helpers'); const { resolveOpencodeConfigRoot } = require('../opencode-paths'); @@ -88,5 +90,27 @@ module.exports = createInstallTargetAdapter({ resolveRoot: resolveOpencodeConfigRoot, installStatePathSegments: ['ecc-install-state.json'], nativeRootRelativePath: '.opencode', + planOperations(input, adapter) { + const modules = Array.isArray(input.modules) + ? input.modules + : (input.module ? [input.module] : []); + return modules.flatMap(module => (Array.isArray(module.paths) ? module.paths : []) + .filter(sourcePath => !isForeignPlatformPath(sourcePath, adapter.target)) + .flatMap(sourcePath => { + const operation = adapter.createScaffoldOperation(module.id, sourcePath, input); + if (operation.sourceRelativePath !== '.opencode') { + return [operation]; + } + // Keep the repository template's sibling skills path for direct use, + // but point home installs at the skills copied inside the config root. + return [operation, createManagedOperation({ + moduleId: module.id, + sourceRelativePath: '.opencode/opencode.json', + destinationPath: path.join(operation.destinationPath, 'opencode.json'), + strategy: operation.strategy, + contentTransform: 'opencode-home-skills-path', + })]; + })); + }, validate: defaultValidateOpencodeHome, }); diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index 209e53784..ce06bacb1 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -51,28 +51,12 @@ const { prepareExcludedPathsReconciliation, } = require('./excluded-paths-reconciliation'); const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite'); -const { adaptAntigravityAgent } = require('./antigravity-agent'); +const { transformInstallContent } = require('./content-transform'); function isMarkdownPath(filePath) { return /\.(md|mdx|markdown)$/i.test(String(filePath || '')); } -function transformInstallContent(operation, content) { - if (!operation.contentTransform) { - return content; - } - if (operation.contentTransform === 'antigravity-agent-frontmatter') { - return adaptAntigravityAgent(content, operation.sourceRelativePath); - } - if (operation.contentTransform === 'opencode-disable-ecc-hooks') { - return disableOpenCodeHookPluginRegistration(content, operation.sourceRelativePath); - } - if (operation.contentTransform === 'opencode-disable-plugin-entrypoint') { - return getDisabledOpenCodePluginContent(); - } - throw new Error(`Unknown install content transform: ${operation.contentTransform}`); -} - // Map every copy-file operation to { sourceRel, destRel } so relative links in // namespaced markdown can be rewritten to the file's actual installed location // (issue #2340). Returns null when the plan lacks the data needed to do so. diff --git a/scripts/lib/install/content-transform.js b/scripts/lib/install/content-transform.js new file mode 100644 index 000000000..de7774a77 --- /dev/null +++ b/scripts/lib/install/content-transform.js @@ -0,0 +1,37 @@ +'use strict'; + +const { adaptAntigravityAgent } = require('./antigravity-agent'); +const { disableOpenCodeHookPluginRegistration, getDisabledOpenCodePluginContent } = require('./hook-consent'); + +function transformInstallContent(operation, content) { + if (!operation.contentTransform) { + return content; + } + if (operation.contentTransform === 'antigravity-agent-frontmatter') { + return adaptAntigravityAgent(content, operation.sourceRelativePath); + } + if (operation.contentTransform === 'opencode-disable-ecc-hooks') { + return transformInstallContent({ ...operation, contentTransform: 'opencode-home-skills-path' }, + disableOpenCodeHookPluginRegistration(content, operation.sourceRelativePath)); + } + if (operation.contentTransform === 'opencode-disable-plugin-entrypoint') { + return getDisabledOpenCodePluginContent(); + } + if (operation.contentTransform === 'opencode-home-skills-path') { + const config = JSON.parse(content); + if (!Array.isArray(config?.skills?.paths) || !config.skills.paths.includes('../skills')) { + return content; + } + const installedConfig = { + ...config, + skills: { + ...config.skills, + paths: config.skills.paths.map(skillPath => skillPath === '../skills' ? './skills' : skillPath), + }, + }; + return `${JSON.stringify(installedConfig, null, 2)}\n`; + } + throw new Error(`Unknown install content transform: ${operation.contentTransform}`); +} + +module.exports = { transformInstallContent }; diff --git a/tests/fixtures/opencode-ts-loader.mjs b/tests/fixtures/opencode-ts-loader.mjs new file mode 100644 index 000000000..6199b4880 --- /dev/null +++ b/tests/fixtures/opencode-ts-loader.mjs @@ -0,0 +1,18 @@ +import { readFile } from "node:fs/promises" +import ts from "typescript" + +// Emulate OpenCode's in-place TypeScript loading without remapping missing +// .js specifiers to .ts files. Such remapping would hide issue #3112. +export async function load(url, context, nextLoad) { + if (!url.endsWith(".ts")) return nextLoad(url, context) + + const source = await readFile(new URL(url), "utf8") + const result = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + verbatimModuleSyntax: true, + }, + }) + return { format: "module", source: result.outputText, shortCircuit: true } +} diff --git a/tests/lib/install-executor.test.js b/tests/lib/install-executor.test.js index d39c14628..ad831605b 100644 --- a/tests/lib/install-executor.test.js +++ b/tests/lib/install-executor.test.js @@ -23,6 +23,7 @@ const { applyInstallPlan: applyInstallPlanDirect } = require('../../scripts/lib/ const { normalizeInstallRequest } = require('../../scripts/lib/install/request'); const { createInstallPlanFromRequest } = require('../../scripts/lib/install/runtime'); const { withHookConsent } = require('../../scripts/lib/install/hook-consent'); +const { repairInstalledStates, uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle'); const REPO_ROOT = path.resolve(__dirname, '..', '..'); @@ -745,6 +746,174 @@ function runTests() { } })) passed++; else failed++; + if (test('OpenCode home installs resolve bundled skills inside the config root', () => { + const tempDir = createTempDir('install-executor-opencode-skills-'); + try { + const sourceRoot = path.join(tempDir, 'source'); + writeManifestSourceFixture(sourceRoot); + const modulesPath = path.join(sourceRoot, 'manifests', 'install-modules.json'); + const manifest = JSON.parse(fs.readFileSync(modulesPath, 'utf8')); + writeJson(sourceRoot, 'manifests/install-modules.json', { + ...manifest, + modules: manifest.modules.map(module => ({ + ...module, + paths: ['.opencode', '.cursor', 'skills/demo'], + targets: ['opencode'], + })), + }); + writeFile(sourceRoot, '.opencode/dist/index.js', 'export default () => ({});\n'); + writeFile(sourceRoot, '.opencode/dist/plugins/index.js', 'export {};\n'); + writeFile(sourceRoot, '.opencode/dist/tools/index.js', 'export {};\n'); + writeFile(sourceRoot, '.cursor/foreign.txt', 'must not install\n'); + const sourceConfig = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, '.opencode', 'opencode.json'), 'utf8')); + const configs = [ + { + ...sourceConfig, + skills: { ...sourceConfig.skills, paths: ['../skills', '/custom/skills', '../other-skills'] }, + }, + { ...sourceConfig, skills: { paths: ['/custom/skills'] } }, + { plugin: ['./plugins'] }, + ]; + + for (const [index, config] of configs.entries()) { + const configSourcePath = path.join(sourceRoot, '.opencode', 'opencode.json'); + writeJson(sourceRoot, '.opencode/opencode.json', config); + const originalContent = fs.readFileSync(configSourcePath, 'utf8'); + const plan = createManifestInstallPlan({ + sourceRoot, + projectRoot: path.join(tempDir, 'project'), + homeDir: path.join(tempDir, `home-${index}`), + target: 'opencode', + moduleIds: ['fixture-core'], + }); + const configPath = path.join(plan.targetRoot, 'opencode.json'); + assert.strictEqual(plan.operations.filter(operation => operation.destinationPath === configPath).length, 1); + applyInstallPlan(plan); + const installed = JSON.parse(fs.readFileSync(configPath, 'utf8')); + const dormantConfig = { ...config, plugin: config.plugin.filter(plugin => plugin !== './plugins') }; + const expected = index === 0 + ? { ...dormantConfig, skills: { ...config.skills, paths: ['./skills', '/custom/skills', '../other-skills'] } } + : dormantConfig; + assert.deepStrictEqual(installed, expected); + assert.strictEqual(fs.readFileSync(configSourcePath, 'utf8'), originalContent, 'repository template must stay unchanged'); + assert.ok(fs.existsSync(path.join(plan.targetRoot, 'skills', 'demo', 'SKILL.md'))); + assert.ok(!fs.existsSync(path.join(plan.targetRoot, '.cursor'))); + if (index === 0) { + const lifecycleOptions = { + repoRoot: sourceRoot, + projectRoot: path.join(tempDir, 'project'), + homeDir: path.join(tempDir, `home-${index}`), + targets: ['opencode'], + }; + fs.unlinkSync(configPath); + const repaired = repairInstalledStates(lifecycleOptions); + assert.strictEqual(repaired.results[0].status, 'repaired', repaired.results[0].error); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(configPath, 'utf8')), expected); + const state = JSON.parse(fs.readFileSync(plan.installStatePath, 'utf8')); + const recordedConfig = state.operations.find(operation => operation.destinationPath === configPath); + assert.strictEqual(recordedConfig.contentSha256, crypto.createHash('sha256') + .update(fs.readFileSync(configPath)).digest('hex')); + const uninstalled = uninstallInstalledStates(lifecycleOptions); + assert.strictEqual(uninstalled.results[0].status, 'uninstalled', uninstalled.results[0].error); + assert.ok(!fs.existsSync(configPath)); + } + } + } finally { + cleanup(tempDir); + } + })) passed++; else failed++; + + if (test('OpenCode pre-fix managed sources upgrade on repair and uninstall preserves user changes', () => { + const tempDir = createTempDir('install-executor-opencode-upgrade-'); + try { + const sourceRoot = path.join(tempDir, 'source'); + writeManifestSourceFixture(sourceRoot); + const modulesPath = path.join(sourceRoot, 'manifests', 'install-modules.json'); + const manifest = JSON.parse(fs.readFileSync(modulesPath, 'utf8')); + writeJson(sourceRoot, 'manifests/install-modules.json', { + ...manifest, + modules: manifest.modules.map(module => ({ + ...module, + paths: ['.opencode'], + targets: ['opencode'], + })), + }); + writeFile(sourceRoot, '.opencode/dist/index.js', 'export default () => ({});\n'); + writeFile(sourceRoot, '.opencode/dist/plugins/index.js', 'export {};\n'); + writeFile(sourceRoot, '.opencode/dist/tools/index.js', 'export {};\n'); + writeJson(sourceRoot, '.opencode/opencode.json', { plugin: ['./plugins'] }); + fs.cpSync(path.join(REPO_ROOT, '.opencode', 'tools'), path.join(sourceRoot, '.opencode', 'tools'), { + recursive: true, + }); + const currentContent = fs.readFileSync(path.join(REPO_ROOT, '.opencode', 'tools', 'index.ts'), 'utf8'); + const legacyContent = [ + 'export { default as runTests } from "./run-tests.js"', + 'export { default as checkCoverage } from "./check-coverage.js"', + 'export { default as securityAudit } from "./security-audit.js"', + 'export { default as formatCode } from "./format-code.js"', + 'export { default as lintCheck } from "./lint-check.js"', + 'export { default as gitSummary } from "./git-summary.js"', + 'export { default as changedFiles } from "./changed-files.js"', + 'export { default as dependencyAnalyzer } from "./dependency-analyzer.js"', + '', + ].join('\n'); + const digest = content => crypto.createHash('sha256').update(content).digest('hex'); + + for (const upgrade of [false, true]) { + writeFile(sourceRoot, '.opencode/tools/index.ts', legacyContent); + const homeDir = path.join(tempDir, upgrade ? 'repair-home' : 'uninstall-home'); + const projectRoot = path.join(tempDir, 'project'); + const plan = createManifestInstallPlan({ + sourceRoot, projectRoot, homeDir, target: 'opencode', moduleIds: ['fixture-core'], + }); + applyInstallPlan(plan); + const barrelPath = path.join(plan.targetRoot, 'tools', 'index.ts'); + const toolPath = path.join(plan.targetRoot, 'tools', 'run-tests.ts'); + const userPath = writeFile(plan.targetRoot, 'tools/user-note.txt', 'Keep my settings.\n'); + const installedState = JSON.parse(fs.readFileSync(plan.installStatePath, 'utf8')); + const recordedBarrel = installedState.operations.find(operation => operation.destinationPath === barrelPath); + assert.strictEqual(fs.readFileSync(barrelPath, 'utf8'), legacyContent); + assert.strictEqual(recordedBarrel.contentTransform, undefined, 'TypeScript imports are copied, not transformed'); + assert.strictEqual(recordedBarrel.contentSha256, digest(legacyContent)); + assert.ok(fs.existsSync(toolPath)); + assert.ok(!fs.existsSync(path.join(plan.targetRoot, 'tools', 'run-tests.js')), 'legacy import has no adjacent JavaScript file'); + + // Upgrade the repository independently of the existing managed home. + writeFile(sourceRoot, '.opencode/tools/index.ts', currentContent); + const lifecycleOptions = { repoRoot: sourceRoot, projectRoot, homeDir, targets: ['opencode'] }; + if (upgrade) { + const repaired = repairInstalledStates(lifecycleOptions); + assert.strictEqual(repaired.results.length, 1); + assert.strictEqual(repaired.results[0].status, 'repaired', JSON.stringify(repaired)); + assert.ok(repaired.results[0].repairedPaths.includes(barrelPath)); + const repairedContent = fs.readFileSync(barrelPath, 'utf8'); + assert.strictEqual(repairedContent, currentContent); + assert.ok(repairedContent.includes('from "./run-tests.ts"')); + assert.ok(!repairedContent.includes('.js"'), 'repair replaces legacy JavaScript specifiers'); + const repairedState = JSON.parse(fs.readFileSync(plan.installStatePath, 'utf8')); + const repairedBarrel = repairedState.operations.find(operation => operation.destinationPath === barrelPath); + assert.strictEqual(repairedBarrel.contentSha256, digest(currentContent)); + assert.notStrictEqual(repairedBarrel.contentSha256, recordedBarrel.contentSha256); + assert.strictEqual(fs.readFileSync(userPath, 'utf8'), 'Keep my settings.\n'); + } + + const modifiedContent = `${fs.readFileSync(toolPath, 'utf8')}\n// User test customisation.\n`; + fs.writeFileSync(toolPath, modifiedContent, 'utf8'); + const uninstalled = uninstallInstalledStates(lifecycleOptions); + assert.strictEqual(uninstalled.results.length, 1); + assert.strictEqual(uninstalled.results[0].status, 'partial', JSON.stringify(uninstalled)); + assert.ok(uninstalled.results[0].removedPaths.includes(barrelPath)); + assert.ok(!fs.existsSync(barrelPath), 'both legacy and repaired managed barrels are recognised'); + assert.deepStrictEqual(uninstalled.results[0].retainedPaths, [toolPath]); + assert.strictEqual(fs.readFileSync(toolPath, 'utf8'), modifiedContent); + assert.strictEqual(fs.readFileSync(userPath, 'utf8'), 'Keep my settings.\n'); + assert.ok(fs.existsSync(plan.installStatePath), 'modified managed content retains its ownership record'); + } + } finally { + cleanup(tempDir); + } + })) passed++; else failed++; + if (test('dedupeCopyFileOperations keeps the last writer per destination (issue #2414)', () => { // Mirrors the OpenCode command scenario: a generic commands/.md source // (preserve-relative-path) and an override .opencode/commands/.md source @@ -934,6 +1103,7 @@ function runTests() { 'utf8' )); assert.ok(!installedConfig.plugin.includes('./plugins')); + assert.deepStrictEqual(installedConfig.skills.paths, ['./skills']); const enabledWithoutRuntime = createInstallPlanFromRequest( normalizeInstallRequest({ @@ -985,8 +1155,9 @@ function runTests() { const enabledConfig = enabledPlan.operations.find(operation => ( operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json' )); - assert.strictEqual(enabledConfig.contentTransform, undefined); + assert.strictEqual(enabledConfig.contentTransform, 'opencode-home-skills-path'); applyInstallPlanDirect(enabledPlan); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(enabledConfig.destinationPath, 'utf8')).skills.paths, ['./skills']); for (const name of OPENCODE_ENTRYPOINTS) { assert.strictEqual( fs.readFileSync(path.join(homeDir, '.config', 'opencode', 'plugins', name), 'utf8'), diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index 64290d842..0b375046b 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -1997,6 +1997,7 @@ function runTests() { const expectedContent = `${JSON.stringify({ ...sourceConfig, plugin: sourceConfig.plugin.filter(plugin => plugin !== './plugins'), + skills: { ...sourceConfig.skills, paths: ['./skills'] }, }, null, 2)}\n`; fs.mkdirSync(targetRoot, { recursive: true }); fs.writeFileSync(destinationPath, expectedContent, 'utf8'); diff --git a/tests/scripts/build-opencode.test.js b/tests/scripts/build-opencode.test.js index 75a531263..8539dd941 100644 --- a/tests/scripts/build-opencode.test.js +++ b/tests/scripts/build-opencode.test.js @@ -7,7 +7,57 @@ const fs = require("fs") const os = require("os") const path = require("path") const { spawnSync } = require("child_process") +const { pathToFileURL } = require("url") const { getNpmPackEntry } = require("../lib/npm-pack-output") +const { createManifestInstallPlan, applyInstallPlan } = require("../../scripts/lib/install-executor") +const { withHookConsent } = require("../../scripts/lib/install/hook-consent") + +function checkRuntime(runtimeRoot, extension, loader) { + const check = ` + const assert = require("node:assert/strict") + const path = require("node:path") + const { pathToFileURL } = require("node:url") + const [root, extension] = process.argv.slice(1) + const load = (name) => import(pathToFileURL(path.join(root, name + extension)).href) + + async function main() { + const tools = await load("tools/index") + assert.deepEqual(Object.keys(tools).sort(), [ + "changedFiles", "checkCoverage", "dependencyAnalyzer", "formatCode", + "gitSummary", "lintCheck", "runTests", "securityAudit", + ]) + for (const tool of Object.values(tools)) { + assert.equal(typeof tool.description, "string") + assert.equal(typeof tool.execute, "function") + } + + const plugins = await load("plugins/index") + const entry = await load("index") + assert.deepEqual(Object.keys(entry), ["default"]) + assert.equal(entry.default, plugins.default) + const logs = [] + const plugin = await entry.default({ + client: { app: { log: (event) => logs.push(event.body) } }, + $: () => { throw new Error("Unexpected shell execution") }, + directory: process.cwd(), + worktree: process.cwd(), + }) + assert.equal(typeof plugin["session.created"], "function") + await plugin["file.edited"]({ path: path.join(process.cwd(), "example.txt") }) + const changed = JSON.parse(await tools.changedFiles.execute({ format: "json" }, {})) + assert.equal(changed.changed, true) + assert.deepEqual(changed.files, [{ path: "example.txt", changeType: "modified" }]) + assert.ok(!logs.some((log) => log.message.includes("tracking disabled"))) + } + main().catch((error) => { console.error(error); process.exit(1) }) + ` + const args = loader ? ["--loader", pathToFileURL(loader).href] : [] + const result = spawnSync(process.execPath, [...args, "-e", check, runtimeRoot, extension], { + cwd: path.join(__dirname, "..", ".."), + encoding: "utf8", + }) + assert.strictEqual(result.status, 0, result.error?.message || result.stderr || result.stdout) +} function runTest(name, fn) { try { @@ -183,6 +233,40 @@ function main() { }) assert.strictEqual(result.status, 0, result.stderr) }], + ["compiled barrels load and share the lazy changed-files store", () => { + checkRuntime(path.dirname(distEntry), ".js") + }], + ["full home install loads TypeScript barrels and the lazy changed-files store", () => { + // Keep the fixture beneath the repo so the installed tools can resolve + // the real SDK dependency without installing packages in the user's home. + const fixture = fs.mkdtempSync(path.join(repoRoot, "tests", ".opencode-home-")) + try { + const homeDir = path.join(fixture, "home") + const projectRoot = path.join(fixture, "project") + fs.mkdirSync(homeDir) + fs.mkdirSync(projectRoot) + const plan = withHookConsent(createManifestInstallPlan({ + sourceRoot: repoRoot, + homeDir, + projectRoot, + env: {}, + target: "opencode", + profileId: "full", + }), "enabled") + applyInstallPlan(plan) + const config = JSON.parse(fs.readFileSync(path.join(plan.targetRoot, "opencode.json"), "utf8")) + assert.deepStrictEqual(config.skills.paths, ["./skills"]) + assert.ok(fs.existsSync(path.join(plan.targetRoot, config.skills.paths[0], "tdd-workflow", "SKILL.md"))) + assert.ok(!fs.existsSync(path.join(plan.targetRoot, "tools", "run-tests.js"))) + checkRuntime( + plan.targetRoot, + ".ts", + path.join(repoRoot, "tests", "fixtures", "opencode-ts-loader.mjs"), + ) + } finally { + fs.rmSync(fixture, { recursive: true, force: true }) + } + }], ["npm pack includes the compiled OpenCode dist payload", () => { fs.rmSync(path.dirname(distEntry), { recursive: true, force: true }) const result = spawnSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts=false"], {