diff --git a/.changeset/tsdown-plugin-typescript.md b/.changeset/tsdown-plugin-typescript.md new file mode 100644 index 0000000..e51ac39 --- /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 c51a163..631b09e 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 d985019..1e91189 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 da9cd33..707ce77 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 96c1378..20115be 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 9c638ce..e726995 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 c7c8aae..dfe2f4b 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;