From 8ae3af490399cd3baf718bccc331b7d02aa653b9 Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Sun, 18 Oct 2026 12:10:29 -0500 Subject: [PATCH] fix: install typescript with the tsdown plugin The lib preset's `dts: true` needs `typescript` installed, and tsdown only takes it as an optional peer, so `rr pack` failed with "TypeScript is not installed" in a project without the ts plugin. install() now adds `typescript` (^6.0.0, the ts and astro plugins' pin) on every path, unless the package already has it in dependencies or devDependencies. uninstall() keeps it, since the project or another plugin may use it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EZVC7xzzsEmByszmfff4u2 --- .changeset/tsdown-plugin-typescript.md | 5 ++ pnpm-lock.yaml | 3 ++ run-run/tsdown-plugin/README.md | 6 +-- run-run/tsdown-plugin/package.json | 11 +++- .../src/__tests__/install.test.ts | 51 ++++++++++++++++--- run-run/tsdown-plugin/src/index.ts | 19 ++++++- run-run/tsdown-plugin/src/tool-versions.ts | 5 ++ 7 files changed, 86 insertions(+), 14 deletions(-) create mode 100644 .changeset/tsdown-plugin-typescript.md diff --git a/.changeset/tsdown-plugin-typescript.md b/.changeset/tsdown-plugin-typescript.md new file mode 100644 index 00000000..e51ac39c --- /dev/null +++ b/.changeset/tsdown-plugin-typescript.md @@ -0,0 +1,5 @@ +--- +"@rrlab/tsdown-plugin": patch +--- + +`rr plugins add tsdown` adds `typescript` as a dev dependency, which tsdown needs to write the `lib` preset's declarations, unless the package already has it in `dependencies` or `devDependencies`. Before, `rr pack` failed with "TypeScript is not installed" in a project without the ts plugin. `rr plugins remove tsdown` keeps `typescript`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c51a163c..631b09e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -281,6 +281,9 @@ importers: tsdown: specifier: 0.22.0 version: 0.22.0(typescript@6.0.3)(unrun@0.2.39) + typescript: + specifier: 6.0.3 + version: 6.0.3 run-run/vitest-plugin: dependencies: diff --git a/run-run/tsdown-plugin/README.md b/run-run/tsdown-plugin/README.md index d9850197..1e911890 100644 --- a/run-run/tsdown-plugin/README.md +++ b/run-run/tsdown-plugin/README.md @@ -8,14 +8,14 @@ rr plugins add tsdown ``` -`rr plugins add` installs `@rrlab/tsdown-plugin` and adds `tsdown` as a dev dependency. Once you confirm, it writes a `tsdown.config.ts` that calls a helper from [`@rrlab/tsdown-config`](https://www.npmjs.com/package/@rrlab/tsdown-config), for the kind of build you pick: +`rr plugins add` installs `@rrlab/tsdown-plugin` and adds `tsdown` as a dev dependency. It adds `typescript` too, which tsdown needs to write declarations, unless the package already has it in `dependencies` or `devDependencies`. Once you confirm, it writes a `tsdown.config.ts` that calls a helper from [`@rrlab/tsdown-config`](https://www.npmjs.com/package/@rrlab/tsdown-config), for the kind of build you pick: - `lib`: a library, with declarations and the entry `src/index.ts`. It is the default. - `bin`: a CLI or Node binary, with the entry `src/run.ts`. If a `tsdown.config.*` already exists, you choose to patch it, keep it or overwrite it. Patching turns a default export that calls `defineConfig()` from `tsdown`, or either helper, into a call to the helper you picked, and keeps its options. It refuses any other shape. -It needs `@rrlab/cli` and tsdown 0.22 or later. +It needs `@rrlab/cli`, tsdown 0.22 or later and, to write declarations, TypeScript 5 or later. ## What it provides @@ -43,7 +43,7 @@ Pass tsdown options to the helper to override its defaults, such as `defineLibCo rr plugins remove tsdown ``` -It removes `tsdown` and `@rrlab/tsdown-config` from `package.json`. If `tsdown.config.*` is the file it wrote, it deletes it; if you passed options to the helper, it rewrites the file to `defineConfig()` from `tsdown` and keeps them; any other config is left alone. Then it drops `tsdown()` from `run-run.config.{ts,mts}`. +It removes `tsdown` and `@rrlab/tsdown-config` from `package.json`, and keeps `typescript`, which the project or another plugin may use. If `tsdown.config.*` is the file it wrote, it deletes it; if you passed options to the helper, it rewrites the file to `defineConfig()` from `tsdown` and keeps them; any other config is left alone. Then it drops `tsdown()` from `run-run.config.{ts,mts}`. ## License diff --git a/run-run/tsdown-plugin/package.json b/run-run/tsdown-plugin/package.json index da9cd33c..707ce77e 100644 --- a/run-run/tsdown-plugin/package.json +++ b/run-run/tsdown-plugin/package.json @@ -46,13 +46,20 @@ }, "peerDependencies": { "@rrlab/cli": "workspace:^", - "tsdown": ">=0.22.0" + "tsdown": ">=0.22.0", + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } }, "devDependencies": { "@rrlab/cli": "workspace:*", "@rrlab/tsdown-config": "workspace:^", "@types/semver": "^7.7.1", "semver": "^7.8.1", - "tsdown": "0.22.0" + "tsdown": "0.22.0", + "typescript": "6.0.3" } } diff --git a/run-run/tsdown-plugin/src/__tests__/install.test.ts b/run-run/tsdown-plugin/src/__tests__/install.test.ts index 96c13788..20115be1 100644 --- a/run-run/tsdown-plugin/src/__tests__/install.test.ts +++ b/run-run/tsdown-plugin/src/__tests__/install.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { type ClackPrompts, type InstallContext, ReleaseService, type UninstallContext } from "@rrlab/cli/plugin"; import type { Pkg, ShellService } from "@variablelab/clibuddy"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { install, uninstall } from "../index.ts"; +import { install, TOOL_VERSIONS, uninstall } from "../index.ts"; let tmpDir: string; @@ -16,12 +16,16 @@ afterEach(async () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); +function appPkg(packageJson: Pkg["packageJson"] = {}): Pkg { + return { dirPath: tmpDir, packageJson } as Pkg; +} + function installCtx(overrides: Partial = {}): InstallContext { return { shell: {} as ShellService, // biome-ignore lint/suspicious/noExplicitAny: minimal stub for the test boundary logger: {} as any, - appPkg: { dirPath: tmpDir } as Pkg, + appPkg: appPkg(), prompts: stubPrompts(), flags: { force: false, yes: true, nonInteractive: true }, release: new ReleaseService(undefined), @@ -34,7 +38,7 @@ function uninstallCtx(overrides: Partial = {}): UninstallConte shell: {} as ShellService, // biome-ignore lint/suspicious/noExplicitAny: minimal stub logger: {} as any, - appPkg: { dirPath: tmpDir } as Pkg, + appPkg: appPkg(), prompts: stubPrompts(), flags: { yes: true, nonInteractive: true }, ...overrides, @@ -57,6 +61,7 @@ describe("@rrlab/tsdown-plugin install()", () => { const result = await install(installCtx()); expect(result.devDependencies).toEqual({ tsdown: expect.stringMatching(/\^?\d/), + typescript: TOOL_VERSIONS.typescript.install, "@rrlab/tsdown-config": "latest", }); expect(result.files).toHaveLength(1); @@ -67,14 +72,17 @@ describe("@rrlab/tsdown-plugin install()", () => { expect(op.content).toContain("export default defineLibConfig();"); }); - it("skips (only emits tsdown dep) when a config file already exists", async () => { + it("skips (only emits the tool deps) when a config file already exists", async () => { // Under --yes, we don't silently rewrite user code. await fs.writeFile( path.join(tmpDir, "tsdown.config.ts"), 'import { defineConfig } from "tsdown";\nexport default defineConfig({});\n', ); const result = await install(installCtx()); - expect(result.devDependencies).toEqual({ tsdown: expect.any(String) }); + expect(result.devDependencies).toEqual({ + tsdown: expect.any(String), + typescript: TOOL_VERSIONS.typescript.install, + }); expect(result.files).toBeUndefined(); }); }); @@ -89,7 +97,10 @@ describe("@rrlab/tsdown-plugin install()", () => { }), ); expect(confirm).toHaveBeenCalledOnce(); - expect(result.devDependencies).toEqual({ tsdown: expect.any(String) }); + expect(result.devDependencies).toEqual({ + tsdown: expect.any(String), + typescript: TOOL_VERSIONS.typescript.install, + }); expect(result.files).toBeUndefined(); }); @@ -183,7 +194,10 @@ describe("@rrlab/tsdown-plugin install()", () => { }), ); expect(result.files).toBeUndefined(); - expect(result.devDependencies).toEqual({ tsdown: expect.any(String) }); + expect(result.devDependencies).toEqual({ + tsdown: expect.any(String), + typescript: TOOL_VERSIONS.typescript.install, + }); }); }); @@ -223,6 +237,29 @@ describe("@rrlab/tsdown-plugin install()", () => { }); }); + describe("typescript (the lib preset's declarations need it)", () => { + it.each([ + ["dependencies", { dependencies: { typescript: "^7.0.2" } }], + ["devDependencies", { devDependencies: { typescript: "~5.9.0" } }], + ])("leaves typescript out when the package already has it in %s", async (_field, packageJson) => { + const result = await install(installCtx({ appPkg: appPkg(packageJson) })); + expect(result.devDependencies).not.toHaveProperty("typescript"); + expect(result.devDependencies).toHaveProperty("tsdown"); + }); + + it("leaves typescript out on the skip path when the package already has it", async () => { + await fs.writeFile(path.join(tmpDir, "tsdown.config.ts"), "// custom\n"); + const result = await install(installCtx({ appPkg: appPkg({ devDependencies: { typescript: "~5.9.0" } }) })); + expect(result.devDependencies).toEqual({ tsdown: expect.any(String) }); + }); + + it("still adds typescript when the package only declares it as a peer", async () => { + // A peer isn't installed locally, and tsdown needs typescript installed to write declarations. + const result = await install(installCtx({ appPkg: appPkg({ peerDependencies: { typescript: ">=5.0.0" } }) })); + expect(result.devDependencies?.typescript).toBe(TOOL_VERSIONS.typescript.install); + }); + }); + describe("release-driven sibling resolution", () => { it("threads ctx.release.resolve into @rrlab/tsdown-config's install spec", async () => { const release = new ReleaseService("pr-226", { fetcher: async () => new Response("{}", { status: 200 }) }); diff --git a/run-run/tsdown-plugin/src/index.ts b/run-run/tsdown-plugin/src/index.ts index 9c638ced..e7269954 100644 --- a/run-run/tsdown-plugin/src/index.ts +++ b/run-run/tsdown-plugin/src/index.ts @@ -53,6 +53,20 @@ export class TsdownService extends ToolService { } } +/** + * tsdown needs `typescript` installed to write declarations (the `lib` + * preset's `dts: true`, or a `types` entry in package.json), but takes it only + * as an optional peer. A package that already installs it (`dependencies` or + * `devDependencies`; a peer alone isn't installed) keeps the range it has. + */ +function toolDependencies(pkg: InstallContext["appPkg"]): Record { + const { dependencies, devDependencies } = pkg.packageJson; + const installsTypescript = [dependencies, devDependencies].some((deps) => !!deps && "typescript" in deps); + return installsTypescript + ? { tsdown: TOOL_VERSIONS.tsdown.install } + : { tsdown: TOOL_VERSIONS.tsdown.install, typescript: TOOL_VERSIONS.typescript.install }; +} + export async function install(ctx: InstallContext): Promise { const existingPath = await findExistingConfig(ctx.appPkg.dirPath); const action = await decideScaffold(ctx, { @@ -63,7 +77,7 @@ export async function install(ctx: InstallContext): Promise { }); if (action === "skip") { - return { devDependencies: { tsdown: TOOL_VERSIONS.tsdown.install } }; + return { devDependencies: toolDependencies(ctx.appPkg) }; } const preset = await pickPreset(ctx, { @@ -74,7 +88,7 @@ export async function install(ctx: InstallContext): Promise { const { factory } = PRESETS[preset]; const devDependencies: Record = { - tsdown: TOOL_VERSIONS.tsdown.install, + ...toolDependencies(ctx.appPkg), [CONFIG_PKG]: await ctx.release.resolve(CONFIG_PKG), }; @@ -108,6 +122,7 @@ export async function install(ctx: InstallContext): Promise { } export async function uninstall(ctx: UninstallContext): Promise { + // `typescript` stays: the project or another plugin (ts, astro) may use it. const removeDependencies = ["tsdown", CONFIG_PKG]; const existingPath = await findExistingConfig(ctx.appPkg.dirPath); if (!existingPath) return { removeDependencies }; diff --git a/run-run/tsdown-plugin/src/tool-versions.ts b/run-run/tsdown-plugin/src/tool-versions.ts index c7c8aae6..dfe2f4bf 100644 --- a/run-run/tsdown-plugin/src/tool-versions.ts +++ b/run-run/tsdown-plugin/src/tool-versions.ts @@ -1,3 +1,8 @@ export const TOOL_VERSIONS = { + // `install` is the prescriptive pin used by `rr plugins add`'s nypm call. + // The looser contracts live in package.json#peerDependencies. typescript + // matches the ts and astro plugins' pin, so adding them in either order + // never leaves conflicting ranges. tsdown: { install: "^0.22.0" }, + typescript: { install: "^6.0.0" }, } as const;