# Testing and publishing

Package a plugin so a bot installs it with one copy of @seedcord/core, test it in a local bot with pnpm, then publish it to npm.

Someone looking for a plugin searches npm for seedcord, and that search reads package names and keywords. Name your package `seedcord-plugin-<thing>`, like `seedcord-plugin-uptime`, and add `seedcord` and `plugin` to your keywords. Official plugins are named `@seedcord/plugin-<thing>`, and only seedcord can publish under `@seedcord`.

## The peer entry

Whichever package you import `Plugin` from goes in `peerDependencies`, with the versions you tested against. A peer dependency is a package your plugin expects the bot to install, so your plugin uses the bot's copy. Mongoose declares `@seedcord/core` at `>=0.2.0 <1.0.0`, since it extends the base from `@seedcord/core/plugin`. Below 1.0, a breaking change can ship in a minor, so a plugin written for 0.2 can break on 0.3. The range tells people which versions work before they install. Keep the same package in `devDependencies` for your own builds.

The entry also decides how the `seedcord` CLI loads your plugin. `seedcord dev`, `build`, and `codegen` run the bot [through Vite](https://preview.seedcord.org/guide/build/), which evaluates the bot's `@seedcord/*` packages itself. They read each installed package's `dependencies` and `peerDependencies`, and load one that lists a `@seedcord/*` package through Vite too. A package that lists none is loaded by Node. In a test, such a plugin attached fine from a plain Node script, then failed under `seedcord codegen` with `CorePluginFromOtherCore`. Node and Vite each held an instance of `@seedcord/core`, though only one copy was installed. A `devDependencies` entry doesn't count.

## A minimal package

This package holds a plugin on the `@seedcord/core/plugin` base that reads files through `readFiles`, so it lists both packages that supply those imports as peers. A plugin on the gateway or http base lists that one package instead.

```json title="package.json"
{
    "name": "seedcord-plugin-locales",
    "version": "0.1.0",
    "type": "module",
    "keywords": ["seedcord", "plugin"],
    "exports": {
        ".": {
            "types": "./dist/index.d.mts",
            "default": "./dist/index.mjs"
        }
    },
    "files": ["dist"],
    "scripts": {
        "build": "tsdown src/index.ts --format esm --dts"
    },
    "peerDependencies": {
        "@seedcord/core": ">=0.10.0 <1.0.0",
        "@seedcord/utils": ">=0.8.0 <1.0.0"
    },
    "devDependencies": {
        "@seedcord/core": "0.10.0",
        "@seedcord/utils": "0.8.17",
        "@types/node": "^24",
        "envapt": "^9.1.0",
        "tsdown": "^0.23.1",
        "typescript": "^6.0.3"
    }
}
```

`exports` lists `types` first, pointing at the declaration file that `tsdown` writes next to `index.mjs`. A bot reads your instance type from that file, and [Typing what you ship](https://preview.seedcord.org/guide/writing-plugins/typing/) covers what reaches the bot's `Core`. `files` limits the tarball to `dist` plus `package.json`. The peers hold the ranges you tested, and `devDependencies` holds a version inside each range for your own builds. `envapt` is there because `@seedcord/core` peers on it.

```json title="tsconfig.json"
{
    "compilerOptions": {
        "strict": true,
        "module": "esnext",
        "moduleResolution": "bundler",
        "target": "ES2024",
        "lib": ["ESNext"],
        "types": ["node"],
        "skipLibCheck": true,
        "noEmit": true
    },
    "include": ["src"]
}
```

`moduleResolution: "bundler"` lets TypeScript follow the `exports` maps of the `@seedcord` packages. `noEmit` is there because `tsdown` writes the output. Add `experimentalDecorators` only if your plugin uses decorators. Build, check what you ship, then publish.

```sh
pnpm run build
pnpm pack
pnpm publish
```

The pack command lists the files in the tarball. `tsdown` leaves the packages in `peerDependencies` as imports, so the built `dist/index.mjs` imports `@seedcord/core/plugin` and `@seedcord/utils` from the bot's install. A scoped name needs `--access public` on the publish command. This exact package, installed from its tarball in a bot, passed `seedcord codegen` and `seedcord build`, and the built bot read its `files` folder through the plugin's `readFiles` call.

If your plugin reads a folder through `readFiles`, tell its users to list that folder in `files`, as [Loading a bot's files](https://preview.seedcord.org/guide/writing-plugins/files/#reading-data-with-readfiles) shows.

## Test it in a bot first

A plugin that works in your bot's `src/plugins` can break once it moves into its own package. The bot then loads it from `node_modules`, so the plugin has to share the bot's installed copy of `@seedcord/core`. [`attach`](https://seedcord.org/docs/packages/gateway/latest/classes/seedcord#attach) checks that the plugin extends the [`Plugin`](https://seedcord.org/docs/packages/core/latest/classes/plugin) class the bot uses. A second copy of `@seedcord/core` defines a second `Plugin`. The plugin extends that one, so `attach` throws `CorePluginFromOtherCore`.

```txt output
Hello doesn't extend the Plugin class your bot uses. That usually means two copies of @seedcord/core are installed. Update the plugin and your @seedcord packages to versions that need the same @seedcord/core. Your package manager can list each installed copy and the package that pulled it in.
```

`seedcord codegen` wraps that message in `Could not import src/bot.ts` with the code `CoreImportFailed`, and `seedcord build` fails with the same error.

> **Warning**
>
> TypeScript can't catch this. It compares the shape of the two `Plugin` classes, and both copies have the same shape. The type check passed in every setup below.

Before you publish, install the packed plugin in a bot and run `tsc` and `seedcord codegen` there.

### Which setups give one copy

These results come from pnpm, with a plugin that lists `@seedcord/core` and `@seedcord/utils` as peer dependencies and a bot that installs the same versions. npm and yarn weren't tried.

{/* prettier-ignore-start */}

| setup          | in the bot                                              | copies of `@seedcord/core` | after you change the plugin                     |
| -------------- | ------------------------------------------------------- | -------------------------- | ----------------------------------------------- |
| tarball        | `pnpm add ../locales/seedcord-plugin-locales-0.1.1.tgz` | one                        | bump the version, build, pack, add the new file |
| `file:` folder | `pnpm add file:../locales`                              | one                        | build, then `pnpm install`                      |
| workspace      | `"seedcord-plugin-locales": "workspace:*"`              | one                        | build the plugin                                |
| `link:`        | `pnpm add link:../locales`                              | two                        | switch to a workspace                           |

{/* prettier-ignore-end */}

pnpm copies a tarball or a `file:` folder into the bot's own `node_modules` and gives the plugin the bot's copy of its peers. In a `workspace:` setup, both packages resolve `@seedcord/core` from the one install the workspace shares. A `link:` points the bot at the plugin folder, so Node resolves `@seedcord/core` from that folder's own `node_modules`, and that is a second copy.

To check your setup, find the `@seedcord/core` that the bot's transport package uses and the one the installed plugin uses, then compare the real paths. Save this as `check.mjs` in the bot and run `node check.mjs`. It reads the transport package as `@seedcord/http`, so write `@seedcord/gateway` in a gateway bot.

```js title="check.mjs"
import { realpathSync } from 'node:fs';
import { findPackageJSON } from 'node:module';
import { pathToFileURL } from 'node:url';

const transport = realpathSync(
    findPackageJSON('@seedcord/http', import.meta.url)
);
const botCore = findPackageJSON(
    '@seedcord/core',
    pathToFileURL(transport).href
);
const pluginCore = findPackageJSON(
    '@seedcord/core',
    import.meta.resolve('seedcord-plugin-locales')
);
console.log(
    realpathSync(botCore) === realpathSync(pluginCore)
        ? 'one copy'
        : 'two copies'
);
```

The script printed `one copy` for the tarball, `file:` and workspace setups and `two copies` for `link:`. `pnpm why @seedcord/core` doesn't show this. In the `link:` setup it printed "Found 1 version" for both the bot and the plugin folder, because two copies of one version list as one.

### Packing a tarball

Packing a tarball tests exactly what a publish would ship, and the pack command lists the files it includes. Bump `version` in the plugin's `package.json` before each pack, like to `0.1.2-dev.0`. pnpm keys a tarball by its file name, so if you pack the same version twice, `pnpm add` installs the first file again.

```sh
pnpm run build
pnpm pack
```

Then install the new file in your bot.

```sh
pnpm add ../locales/seedcord-plugin-locales-0.1.2-dev.0.tgz
```

### Telling whether the new code loaded

Press `r` in the terminal running `seedcord dev`. It stops the running bot and starts a new session, which loads `bot.ts` again and runs every plugin's constructor and `init()` again. The status line shows "Starting the bot…" and then the bot's name followed by "is ready!". That status line doesn't mention your plugin, so put a log line in its `init()` while you test, like `this.logger.info('locales v2')`. If the old text still prints after `r`, the old build is still installed.

### Testing in a workspace

In a pnpm workspace, the bot's `node_modules` links to the plugin folder, so a rebuilt `dist` is available without an install. A bot made by `create-seedcord` already has a `pnpm-workspace.yaml`, which holds only pnpm settings. Keep the plugin in a folder inside the bot, like `packages/locales`, and add a `packages` list above those settings. A workspace has one `pnpm-workspace.yaml`, so a plugin folder outside the bot can't join it.

```yaml title="pnpm-workspace.yaml"
packages:
    - packages/*
allowBuilds:
    unrs-resolver: false
```

Add `"seedcord-plugin-locales": "workspace:*"` to the bot's dependencies and run `pnpm install`. In the plugin's `devDependencies`, use the same `@seedcord` versions that the bot installs. Build with `pnpm -C packages/locales build`, then press `r`. The `root` in `seedcord.config.ts` is `./src`, so the bot doesn't treat `packages/` as part of its code.

A [server build](https://preview.seedcord.org/guide/build/server/) leaves your plugin as an import in `dist`, so the built bot loads it from `node_modules`. Deploy it from a tarball, `file:` or workspace install, which give one copy. Don't deploy it from a `link:`.

[Reloading in dev](https://preview.seedcord.org/guide/writing-plugins/hot-reload/#saving-the-plugins-own-source) says what a save inside `src/plugins` does.
