Phase 8d: npm workspaces, a publishable plugin SDK, a core with declared dependencies only #16
Loading…
Reference in a new issue
No description provided.
Delete branch "refactor/phase-8d-workspaces"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Phase 8, step d, the last step of Phase 8: npm workspaces, a plugin SDK ready to publish, and a core that imports only what it declares. Stacked on #15.
What changes
npm workspaces. The root declares
workspaces: ["packages/*", "plugins/*"]. Each package and plugin has its ownpackage.jsonlisting exactly what it imports:@kanbot/plugin-sdk1.0.0 (public)effect(exact),@effect/vitest(optional)@kanbot/coreeffect, the SDK@kanbot/contractseffect, core, the SDK@kanbot/infra-sqliteeffect, core@kanbot/plugin-{claude-code,discord,host-desktop,redmine}effect, the SDK. claude-code also declares the Claude Agent SDK and zod, which left the root.@kanbot/core/<ctx>,/schemas,@kanbot/contracts/<ctx>…). Core'sexportsmap lists exactly each context's entries, so a deep import into a context no longer resolves.kanbot/relative-imports: a relative import never leaves its package (or, in core, its context).kanbot/declared-dependencies: a workspace imports only what itspackage.jsondeclares.@Pluginversion, and gen-plugins checks it.The core owns what it shares with the app. Core and
shared/typesimported each other, so makingshareda package would have created a cycle. Soshared/typesmoved to@kanbot/core/typesandshared/workflowsto@kanbot/core/workflow/graph. Thesharedbarrels re-export them, so the app's#shared/...imports are unchanged.packages/corenow imports onlyeffect, the SDK andnode:path. That was Phase 8's exit criterion.The SDK can be published to the Forgejo npm registry (
publishConfig). Nothing has been published..d.tsindist/, with no new dependency.prepackbuilds and swaps in the published manifest, andpostpackrestores it.dist/, the README andpackage.json.effectpeer pinned exactly: effect is still a release candidate whose APIs change between candidates, and the host must supply the one copy..npmrcline for the@kanbotscope) and "Releasing the SDK".effect@4.0.0-rc.118;@Plugin+@TrackerTypeclass with experimentalDecorators, under both NodeNext and Bundler resolution;npm run build:plugins(effect and the SDK left external) loaded against that installed SDK.Build, CI and root
nitro.externals.inline: ['@kanbot/']makes inlining the workspaces a rule.Checks
.tsfiles, no symlinks and no@kanbotfolder innode_modules. The built server answered status, tasks,Plugins.list(all four plugins active, so the decorator metadata survived bundling),Settings.getand/setup.electron-builder --mac --diranddesktop-verifypassed..tsfiles, symlinks or.nodefiles in it.npm cipassed lint, typecheck and all tests.Notes
licensefield yet.@kanbot/core.🤖 Generated with Claude Code
packages/{core,contracts,infra-sqlite,plugin-sdk} and plugins/* become workspaces (@kanbot/core, @kanbot/contracts, @kanbot/infra-sqlite, @kanbot/plugin-sdk, @kanbot/plugin-<id>), each with the dependencies it imports and nothing else: effect pinned at the repo's exact version, the workspace packages as "*" (npm links a workspace whose version satisfies the range; it has no workspace: protocol). Only the SDK is not private. The plugins take effect and the SDK as peers: the host supplies the one copy of each, which is also what a prebuilt plugin bundle leaves external. zod and the Claude Agent SDK leave the root for @kanbot/plugin-claude-code, the only code that imports them. A plugin's package version is its manifest's. Each package's exports point at its TypeScript sources: the app, Nitro, vitest and Storybook compile them as they did, through the workspace symlinks. Imports still use relative paths; the next commit switches them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Cookies are not scoped by port. The packaged app set its per-launch cookie (kanbot_local) on 127.0.0.1, and after the tray's "Open in browser" the system browser had it too: it then sent it to every server on 127.0.0.1, whatever the port, for instance a dev server an agent started from a cloned repository's code, which could log it (and drive Kanbot with it) or overwrite it with a Set-Cookie of its own. The shell now makes a name for this machine once per install, kanbot-<16 hex>.localhost (kept 0600 in the user-data folder), and the window, its cookie and the browser handoff all use it; the shell's own REST calls and boot poll stay on 127.0.0.1 with the Bearer header. The name goes to the server with the token over the message port (it is not secret, but must be known), and hostIsLoopback accepts exactly that name besides the loopback names; the server takes no name the shell could not have made (/^kanbot-[a-z0-9]{12,32}\.localhost$/). Nothing changes in how the server listens: it still binds 127.0.0.1. Browsers resolve *.localhost to this machine by themselves (Chromium, Firefox), and Safari asks macOS's resolver, which does too (getaddrinfo answers 127.0.0.1 and ::1 here), so the handoff needs no fallback to 127.0.0.1. Checked in headless Chrome against a server bound to 127.0.0.1: the name resolves, and a cookie set under it is not sent to 127.0.0.1 or localhost on another port. Remaining: the cookie still goes to the same name on another port, so a program that reads the name from the user-data folder and gets the browser to open it on its own port could still read the cookie; it is one more step than before, not a wall. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>vue-i18n reads @ as a linked message, so plugins.problem.noMetadata and undecorated failed to compile and took the dev page down with them. They are reworded rather than escaped as {'@'}, which the server's translate() would print verbatim. A test compiles every message, the plugins' included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Any code from Settings › Devices could pair a node, so a code offered for someone's phone could become a node the hub sends prompts to. An offer is now for a node or, by default, for a browser or a script (Devices.offerPairing takes an optional { kind }; no payload still works), and redeeming it as the other kind fails like a wrong code (it counts towards burning it; every failure is the same PairingRefused / 401). Settings › Devices' pairing card has a "For a node" switch: it asks for a node's code and shows the kanbot-node pair command to run there instead of the address and QR code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>A code offered for a desktop (Devices.offerPairing({ kind: 'desktop' })) pairs two devices in one transaction: the window's browser device (its token is the kanbot_device cookie, Secure over HTTPS) and the node it runs (its token in the answer: { deviceId, nodeDeviceId, nodeToken }). devices.companion_id (migration 0022) points each at the other. Revoking either revokes both and cuts both off. A code for another kind fails like a wrong one. Settings › Devices offers the three kinds and shows a desktop as one entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Over the utility process's parent port ({ type: 'kanbot-shell', action: 'open-connect' }), only from the desktop app's own window (a Local caller) and only when the shell started the server to talk over that port; anything else is ShellUnavailable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>