diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ca8f7c..da13ba8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,11 @@ jobs: with: bun-version: latest + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Install run: bun install --frozen-lockfile @@ -38,3 +43,6 @@ jobs: - name: Test with coverage run: bun run test:coverage + + - name: Test published package + run: bun run test:package diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2998154..891681a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -79,6 +79,10 @@ jobs: if: steps.check.outputs.already_published == 'false' run: bun run test:coverage + - name: Test published package + if: steps.check.outputs.already_published == 'false' + run: bun run test:package + # Uses npm OIDC trusted publishing (no NPM_TOKEN). # Requires npm >= 11.5.1. Node 24 ships with npm 11.x; if the default # npm is too old we fall through to npx with a pinned version. diff --git a/.gitignore b/.gitignore index b43aa5c..56998aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ node_modules/ .DS_Store coverage/ - +dist/ diff --git a/biome.json b/biome.json index 99abd23..317775a 100644 --- a/biome.json +++ b/biome.json @@ -9,6 +9,7 @@ "includes": [ "src/**/*.ts", "test/**/*.ts", + "scripts/**/*.mjs", "*.json", "*.md", "!**/node_modules", diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..3dc3312 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,5 @@ +import type { Plugin } from "@opencode-ai/plugin" + +export declare const OpencodeTranslate: Plugin + +export default OpencodeTranslate diff --git a/knip.json b/knip.json index b360d7e..14dfc5c 100644 --- a/knip.json +++ b/knip.json @@ -10,6 +10,7 @@ "@ai-sdk/openai-compatible" ], "ignoreIssues": { + "index.d.ts": ["duplicates"], "src/index.ts": ["duplicates"] } } diff --git a/package.json b/package.json index fd74954..1d70475 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "name": "opencode-translate", - "version": "1.0.6", + "version": "1.0.7", "description": "OpenCode plugin that lets the user chat in a configured language while the main chat loop only sees English.", "type": "module", - "main": "src/index.ts", + "main": "dist/index.js", + "types": "index.d.ts", "license": "MIT", "repository": { "type": "git", @@ -21,7 +22,8 @@ "i18n" ], "files": [ - "src", + "dist", + "index.d.ts", "LICENSE", "README.md" ], @@ -29,6 +31,7 @@ "access": "public" }, "scripts": { + "build": "bun build src/index.ts --outfile dist/index.js --target node --format esm --packages external", "typecheck": "tsgo --noEmit", "format": "biome format --write .", "format:check": "biome format .", @@ -38,7 +41,10 @@ "check": "biome check --write .", "check:ci": "biome check .", "test": "bun test", - "test:coverage": "bun test --coverage" + "test:coverage": "bun test --coverage", + "test:package": "node scripts/package-smoke.mjs", + "prepare": "bun run build", + "prepack": "bun run build" }, "peerDependencies": { "@opencode-ai/plugin": ">=1.14.0" diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs new file mode 100644 index 0000000..dd98e8e --- /dev/null +++ b/scripts/package-smoke.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict" +import { spawnSync } from "node:child_process" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const root = fileURLToPath(new URL("..", import.meta.url)) +const temp = await mkdtemp(path.join(tmpdir(), "opencode-translate-package-")) + +function run(command, args, cwd) { + const result = spawnSync(command, args, { cwd, encoding: "utf8" }) + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed\n${result.stdout}\n${result.stderr}`) + } + return result.stdout +} + +try { + const output = run("npm", ["pack", "--silent", "--json", "--pack-destination", temp], root) + const jsonStart = output.lastIndexOf("\n[") + const [packed] = JSON.parse(output.slice(jsonStart === -1 ? 0 : jsonStart + 1)) + const paths = packed.files.map((file) => file.path) + + assert(paths.includes("dist/index.js"), "package must contain the compiled entrypoint") + assert(paths.includes("index.d.ts"), "package must contain its public type declarations") + assert(!paths.some((file) => file.endsWith(".ts") && file !== "index.d.ts"), "package must not contain TS source") + + const consumer = path.join(temp, "consumer") + await mkdir(consumer) + await writeFile(path.join(consumer, "package.json"), '{"private":true,"type":"module"}\n') + + const tarball = path.join(temp, packed.filename) + run("npm", ["install", "--silent", "--ignore-scripts", "--no-audit", "--no-fund", tarball], consumer) + run( + "node", + [ + "--input-type=module", + "--eval", + ` + import assert from "node:assert/strict" + process.env.OPENCODE_TRANSLATE_DISABLE = "1" + const plugin = await import("opencode-translate") + assert.equal(typeof plugin.default, "function") + assert.equal(plugin.default, plugin.OpencodeTranslate) + assert.deepEqual(await plugin.default({ client: {}, directory: process.cwd() }, {}), {}) + `, + ], + consumer, + ) +} finally { + await rm(temp, { recursive: true, force: true }) +} diff --git a/test/types/public-api.test.ts b/test/types/public-api.test.ts new file mode 100644 index 0000000..6b393fc --- /dev/null +++ b/test/types/public-api.test.ts @@ -0,0 +1,25 @@ +/** + * Compile-time guard for the published type declaration. + * + * `index.d.ts` is hand-written and shipped to consumers instead of `main` + * (see `types` in package.json); nothing else ties it to the real + * implementation in `src/index.ts`. `bun run typecheck` includes this file + * (via the `test/**\/*.ts` glob in tsconfig.json), so a renamed, added, + * removed, or retyped export in either file fails the build here instead of + * shipping silently to consumers. + * + * This file has no `test()` calls — it is a compile-time-only check. Under + * `bun test` it is collected but contributes 0 assertions; the real + * enforcement happens in `bun run typecheck`. + */ +type SourceTypes = typeof import("../../src/index") +type PublishedTypes = typeof import("../../index") + +type AssertExact = [A] extends [B] ? ([B] extends [A] ? true : false) : false + +type PublicApiMatchesSource = AssertExact + +// If this line reports a type error, `index.d.ts` no longer matches the +// exports of `src/index.ts` — update whichever one is stale. +const publicApiMatchesSource: PublicApiMatchesSource = true +void publicApiMatchesSource