Local Development Guide for Plugin Authors
This page is for authors developing local plugins (or zhin source) together with their own bot project. It covers the three most common problems: mixed dependency sources, local vs. published version mismatch, and ports / unclean exit. It reflects the current main implementation.
1. Dependency sources: don't mix link: / workspace: with npm
Within one generation Zhin requires exactly one physical location per package. While the project graph (packages/im/runtime/src/project-graph.ts) walks root plugins, child plugins and their declared features (such as @zhin.js/command, @zhin.js/tool), it:
- resolves each dependency starting from the declaring plugin's own directory;
realpaths the package root;- throws if the same package name has two different roots:
zhin: Multiple package locations for @zhin.js/command are not supported in one generationEven if both copies have the same version (e.g. 1.1.5), different locations mean two module instances: tokens, brand markers and instanceof don't interoperate. This is an intentional hard error, not a false positive.
Typical trigger
Most dependencies come from npm, but one plugin is link:ed to another zhin source checkout:
{
"dependencies": {
"zhin.js": "latest",
"@zhin.js/plugin-calendar": "link:../zhin/plugins/utils/calendar"
}
}In the source repo the linked plugin declares @zhin.js/command as workspace:* / workspace:^, so it resolves to the source repo's packages/im/command, while other plugins resolve to the npm copy in your project's node_modules/.pnpm.
Diagnosis
# 1. Who depends on it, and are there multiple versions/variants?
pnpm why @zhin.js/command
# 2. Where does the linked plugin actually resolve it? (repeat per plugin)
readlink -f node_modules/@zhin.js/plugin-calendar/node_modules/@zhin.js/command
readlink -f node_modules/.pnpm/node_modules/@zhin.js/command
# 3. List all link / file / workspace dependencies
grep -nE '"(link|file|workspace):' package.json plugins/*/package.jsonIf the two paths in step 2 differ (one in another repo's packages/im/command, one in .pnpm/@zhin.js+command@x.y.z), this is your problem. Stale version directories left in node_modules/.pnpm are harmless; only the actual resolved path matters.
2. Local source vs. published zhin.js mismatch
Even without duplicate copies, a local plugin may depend on unreleased zhin.js features. Common symptoms:
TypeError: Schema....effect is not a functionwhen loading the plugin (local Schema API is newer than npm);Invalid Plugin config ... must NOT have additional properties (additionalProperty: xxx)at startup: the plugin's config schema didn't take effect as written locally, or the npm plugin of the same name is an older version without those fields.
Compare the plugin's peerDependencies with what's installed and check that the APIs you use are released:
node -p "require('./node_modules/zhin.js/package.json').version"
npm view zhin.js version
npm view <plugin-package> version peerDependencies3. Recommended: develop plugins inside the zhin repo
When you need unreleased framework features or change framework and plugin together, the easiest path is to develop directly in the zhin monorepo: put the plugin in plugins/<category>/<name>, the test bot in examples/<name>, and use workspace:* for every zhin dependency. The whole workspace has a single resolution path, so the duplicate-copy error from section 1 cannot happen and there is no overrides list to maintain.
git clone https://github.com/zhinjs/zhin.git && cd zhin
pnpm install
# New plugin: plugins/utils/my-plugin (zhin.js and @zhin.js/* as "workspace:*")
# Test bot: copy examples/minimal-bot to examples/my-bot, add
# "@zhin.js/plugin-my-plugin": "workspace:*"
# and declare it in package.json zhin.plugins and zhin.config.yml
pnpm install
pnpm build
cd examples/my-bot && pnpm exec zhin runtime start --onceVerified on current main: @zhin.js/command in both examples/test-bot and plugins/utils/calendar resolves to the same packages/im/command; after pnpm build, zhin runtime start --once in examples/minimal-bot prints "started": true.
Other options compared:
pnpm link/link:a single plugin: not recommended. The linked plugin'sworkspace:dependencies resolve into the source repo, which is exactly the cause of section 1.- yalc: essentially the same as a tarball (copies build output); can't use unreleased framework APIs and needs an extra tool; not verified in this repo.
- tarball (see table below): for trying a local plugin in your own external project while the framework stays on npm.
pnpm zhin:local link: recommended when an external project needs the local framework (section 4); generates all tarball overrides and restores in one step; hand-writtenpnpm.overridesis the fallback.
4. Three valid setups for external projects
| Setup | How | Use for | Notes |
|---|---|---|---|
| All npm | Plugins also use published versions (^x.y.z) | Daily use, production | Only published APIs and config fields |
| All local | Prefer pnpm zhin:local link (writes one batch of tarball overrides; see below); or manually use pnpm.overrides to link: zhin.js and every @zhin.js/* into the same local checkout, and run pnpm build there first | Changing framework and plugin together | After zhin:local, re-run link when source changes; with hand-written link:, every package must share one checkout or section 1 returns |
| Tarball | Run pnpm pack in the plugin dir (rewrites workspace: to real versions), depend on file:./vendor/xxx.tgz | Trying a local plugin with npm framework | Plugin can't use unreleased framework APIs; repack and pnpm install after each change |
For external projects, use the one-click command instead of hand-written overrides. From the zhin source repo root:
pnpm zhin:local link ../my-bot # detect zhin.js / @zhin.js/* used by the project, build and pack the full closure, write tarball overrides, run pnpm install
pnpm zhin:local unlink ../my-bot # restore package.json and pnpm-lock.yaml exactly, then run pnpm install
pnpm zhin:local link ../my-bot --no-install # only rewrite the manifest; run pnpm install yourself later (also works with unlink)- Writes tarball overrides (
file:./.zhin-local/packs/*.tgz), notlink:— unlike the hand-writtenlink:fallback in the table; after source changes you must runlinkagain to rebuild and refresh (idempotent); - Relative paths resolve from the caller's directory (pnpm's
INIT_CWD), not the repo root:pnpm --dir ../zhin zhin:local link .frommy-botequalspnpm zhin:local link ../my-botfrom the zhin root; - Backups and tarballs live in the project's
.zhin-local/;linkadds it to the project's.gitignoreautomatically (created if missing, never duplicated);unlinkreverts it: restored exactly if untouched, deleted iflinkcreated it, otherwise only that line is removed; - Besides dependencies, zhin packages declared in
zhin.pluginsbut missing from dependencies are switched too when available locally, with a warning to add the dependency;@zhin.js/*packages not in this repo stay on npm with a warning; - The command refuses with a clear message when: the target is inside the zhin repo,
package.json/pnpm-lock.yamlhave uncommitted changes on first link, zhin-relatedpnpm.overridesalready exist,.zhin-local/exists withoutstate.json, orpackage.jsonwas edited after linking (blocks both refresh andunlink; to discard those edits runpnpm zhin:local unlink ../my-bot --force, which lists the discarded lines first); - Every switched zhin package comes from the same packed build, so section 1's duplicate-copy error cannot occur.
If you can't use the command, hand-write pnpm.overrides (link: into the same checkout), but cover every zhin package in the project's closure (including indirect ones like @zhin.js/runtime).
Run pnpm install afterwards and use the commands from section 1 to confirm each package has a single resolved path.
To temporarily disable a plugin, remove its package.json dependency, its zhin.plugins entry and its plugins.<instanceKey> config in zhin.config.yml (otherwise you get /plugins must NOT have additional properties); you can keep the config commented out for later.
5. Ports and clean exit
The control plane listens on http.host/http.port (default 127.0.0.1:8086). On EADDRINUSE, find the owner first:
lsof -nP -iTCP:<port> -sTCP:LISTENOnly stop processes you know are yours (by PID); avoid pkill -f "zhin runtime start", which can hit other projects' instances.
Current exit behavior:
- Ctrl+C (SIGINT) / SIGTERM / SIGHUP: graceful stop, closing HTTP/WS listeners, runtime (adapters, plugin resources), scheduler and database in order; exits
0on success and frees the port. - Not stopped within 5 seconds: stderr prints
{"forcedExit":"budget","stillStopping":[...]}naming components still stopping, then force-exits (SIGINT130, SIGTERM143, SIGHUP129). - Second Ctrl+C while stopping: exits immediately (
130) with"forcedExit":"repeated-signal". - Console restart / dependency change requiring restart: after a full stop, exits naturally with code
75for the supervisor to relaunch; dependency changes don't interrupt an in-progress plugin management operation, whose apply/restart step triggers it instead. - Closing a terminal or script session sends SIGHUP, which also triggers a graceful stop; use
--daemonor a process manager for long-running background use.
Plugins must release their own timers and connections in dispose; a dispose that never finishes is cut off by the 5-second budget and shows up as runtime in stillStopping.
6. Checklist
- [ ] Every
link:/file:/workspace:dependency found bygrepis expected; - [ ]
pnpm why @zhin.js/command(andzhin.js,@zhin.js/tool, ...) shows a single resolved path per package; - [ ] APIs and config fields used by local plugins exist in the installed zhin.js version;
- [ ] Prefer developing inside the zhin repo (all
workspace:*); for external projects pick one setup (all npm / tarball /zhin:local link) without mixing; - [ ] Ran
pnpm installafter dependency changes andpnpm buildafter local source changes; - [ ] Port is free and old instances were stopped by PID before starting;
- [ ] Ctrl+C exits within 5 seconds without
forcedExit; if not, check the release logic of components listed instillStopping.