Skip to content

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, 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.

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 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.

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.

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 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 checks that the plugin extends the Plugin class the bot uses. A second copy of @seedcord/core defines a second Plugin. The plugin extends that one, so attach throws CorePluginFromOtherCore.

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.

setupin the botcopies of @seedcord/coreafter you change the plugin
tarballpnpm add ../locales/seedcord-plugin-locales-0.1.1.tgzonebump the version, build, pack, add the new file
file: folderpnpm add file:../localesonebuild, then pnpm install
workspace"seedcord-plugin-locales": "workspace:*"onebuild the plugin
link:pnpm add link:../localestwoswitch to a workspace

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.

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.

pnpm run build
pnpm pack

Then install the new file in your bot.

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.

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 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 says what a save inside src/plugins does.