Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tsdown-plugin-typescript.md
Original file line number Diff line number Diff line change
@@ -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`.
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions run-run/tsdown-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
11 changes: 9 additions & 2 deletions run-run/tsdown-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
51 changes: 44 additions & 7 deletions run-run/tsdown-plugin/src/__tests__/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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> = {}): 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),
Expand All @@ -34,7 +38,7 @@ function uninstallCtx(overrides: Partial<UninstallContext> = {}): 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,
Expand All @@ -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);
Expand All @@ -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();
});
});
Expand All @@ -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();
});

Expand Down Expand Up @@ -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,
});
});
});

Expand Down Expand Up @@ -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 }) });
Expand Down
19 changes: 17 additions & 2 deletions run-run/tsdown-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
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<InstallResult> {
const existingPath = await findExistingConfig(ctx.appPkg.dirPath);
const action = await decideScaffold(ctx, {
Expand All @@ -63,7 +77,7 @@ export async function install(ctx: InstallContext): Promise<InstallResult> {
});

if (action === "skip") {
return { devDependencies: { tsdown: TOOL_VERSIONS.tsdown.install } };
return { devDependencies: toolDependencies(ctx.appPkg) };
}

const preset = await pickPreset(ctx, {
Expand All @@ -74,7 +88,7 @@ export async function install(ctx: InstallContext): Promise<InstallResult> {
const { factory } = PRESETS[preset];

const devDependencies: Record<string, string> = {
tsdown: TOOL_VERSIONS.tsdown.install,
...toolDependencies(ctx.appPkg),
[CONFIG_PKG]: await ctx.release.resolve(CONFIG_PKG),
};

Expand Down Expand Up @@ -108,6 +122,7 @@ export async function install(ctx: InstallContext): Promise<InstallResult> {
}

export async function uninstall(ctx: UninstallContext): Promise<UninstallResult> {
// `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 };
Expand Down
5 changes: 5 additions & 0 deletions run-run/tsdown-plugin/src/tool-versions.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading