diff --git a/.opencode/index.ts b/.opencode/index.ts index fa6cadc58..8ee800f80 100644 --- a/.opencode/index.ts +++ b/.opencode/index.ts @@ -37,4 +37,4 @@ // Export the main plugin // opencode's legacy plugin loader iterates every module export and throws if // any is not a plugin function, so only the plugin function may be exported. -export { default } from "./plugins/index.js" +export { default } from "./plugins/index.ts" diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json index f6c140bdd..ecaf9172f 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 94e8e3f04..35fd57da2 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/plugins/ecc-hooks.ts b/.opencode/plugins/ecc-hooks.ts index 22b1132f0..69b59727e 100644 --- a/.opencode/plugins/ecc-hooks.ts +++ b/.opencode/plugins/ecc-hooks.ts @@ -16,8 +16,8 @@ import type { PluginInput } from "@opencode-ai/plugin" import * as fs from "fs" import * as path from "path" -import changedFilesTool from "../tools/changed-files.js" -import dependencyAnalyzerTool from "../tools/dependency-analyzer.js" +import changedFilesTool from "../tools/changed-files.ts" +import dependencyAnalyzerTool from "../tools/dependency-analyzer.ts" /** * Type definitions for better type safety @@ -111,9 +111,9 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({ // This plugin is OpenCode's startup entry point, so a static import // failure here previously crashed the whole plugin -- and with it, the // entire OpenCode session -- before any hooks could load (see #2530). - let changedFilesStore: typeof import("./lib/changed-files-store.js") | undefined + let changedFilesStore: typeof import("./lib/changed-files-store.ts") | undefined try { - const store = await import("./lib/changed-files-store.js") + const store = await import("./lib/changed-files-store.ts") store.initStore(worktreePath) changedFilesStore = store } catch { diff --git a/.opencode/plugins/index.ts b/.opencode/plugins/index.ts index c1e17a159..3a98f0ba6 100644 --- a/.opencode/plugins/index.ts +++ b/.opencode/plugins/index.ts @@ -6,7 +6,7 @@ * while taking advantage of OpenCode's more sophisticated 20+ event types. */ -export { ECCHooksPlugin, default } from "./ecc-hooks.js" +export { ECCHooksPlugin, default } from "./ecc-hooks.ts" // Re-export for named imports -export * from "./ecc-hooks.js" +export * from "./ecc-hooks.ts" diff --git a/.opencode/tools/changed-files.ts b/.opencode/tools/changed-files.ts index 1150ca756..3ae000e1b 100644 --- a/.opencode/tools/changed-files.ts +++ b/.opencode/tools/changed-files.ts @@ -1,5 +1,5 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" -import type { ChangeType, TreeNode } from "../plugins/lib/changed-files-store.js" +import type { ChangeType, TreeNode } from "../plugins/lib/changed-files-store.ts" const INDICATORS: Record = { added: "+", @@ -27,12 +27,12 @@ function renderTree(nodes: TreeNode[], indent: string): string { // file, so a static import failure here previously took down the entire // tools module -- and with it, the whole OpenCode session -- on the very // first tool-loading pass (see #2530). -type ChangedFilesStore = typeof import("../plugins/lib/changed-files-store.js") +type ChangedFilesStore = typeof import("../plugins/lib/changed-files-store.ts") let changedFilesStorePromise: Promise | undefined async function loadChangedFilesStore(): Promise { if (!changedFilesStorePromise) { - changedFilesStorePromise = import("../plugins/lib/changed-files-store.js").catch(() => { + changedFilesStorePromise = import("../plugins/lib/changed-files-store.ts").catch(() => { changedFilesStorePromise = undefined throw new Error( "changed-files tool: could not load the changed-files store. " + diff --git a/.opencode/tools/index.ts b/.opencode/tools/index.ts index 9bd999479..5332381e8 100644 --- a/.opencode/tools/index.ts +++ b/.opencode/tools/index.ts @@ -4,12 +4,12 @@ * These tools extend OpenCode with additional capabilities. */ -// Re-export all tools -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" +// 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" +export { default as formatCode } from "./format-code.ts" +export { default as lintCheck } from "./lint-check.ts" +export { default as gitSummary } from "./git-summary.ts" +export { default as changedFiles } from "./changed-files.ts" +export { default as dependencyAnalyzer } from "./dependency-analyzer.ts" diff --git a/.opencode/tsconfig.json b/.opencode/tsconfig.json index c6b43257b..89fdf6546 100644 --- a/.opencode/tsconfig.json +++ b/.opencode/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", + "rewriteRelativeImportExtensions": true, "lib": ["ES2022"], "outDir": "./dist", "rootDir": ".", diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index ec9cb1d80..309a41fd0 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -32,7 +32,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'); @@ -223,18 +223,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); - } - 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 fbab1293b..e751aaf66 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -35,22 +35,12 @@ const { } = require('./ownership-guard'); const { cleanupLegacyOpencodeInstall } = require('./opencode-legacy-migration'); 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); - } - 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..fc56be765 --- /dev/null +++ b/scripts/lib/install/content-transform.js @@ -0,0 +1,29 @@ +'use strict'; + +const { adaptAntigravityAgent } = require('./antigravity-agent'); + +function transformInstallContent(operation, content) { + if (!operation.contentTransform) { + return content; + } + if (operation.contentTransform === 'antigravity-agent-frontmatter') { + return adaptAntigravityAgent(content, operation.sourceRelativePath); + } + 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 6f64b540c..940d5f030 100644 --- a/tests/lib/install-executor.test.js +++ b/tests/lib/install-executor.test.js @@ -21,6 +21,7 @@ const { } = require('../../scripts/lib/install-executor'); const { applyInstallPlan: applyInstallPlanDirect } = require('../../scripts/lib/install/apply'); const { withHookConsent } = require('../../scripts/lib/install/hook-consent'); +const { repairInstalledStates, uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle'); const REPO_ROOT = path.resolve(__dirname, '..', '..'); @@ -709,6 +710,82 @@ 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 expected = index === 0 + ? { ...config, skills: { ...config.skills, paths: ['./skills', '/custom/skills', '../other-skills'] } } + : config; + 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('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 diff --git a/tests/scripts/build-opencode.test.js b/tests/scripts/build-opencode.test.js index 469165883..086103587 100644 --- a/tests/scripts/build-opencode.test.js +++ b/tests/scripts/build-opencode.test.js @@ -6,7 +6,57 @@ const assert = require("assert") const fs = require("fs") 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 { @@ -116,6 +166,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", () => { const result = spawnSync("npm", ["pack", "--dry-run", "--json"], { cwd: repoRoot,