Packaging: what is published, from where, and how the CLI finds its files
Switchboard is installed, not forked (one production target). The source stays one public monorepo; what an operator installs are the two artifacts each release publishes: the container image (ghcr.io/<owner>/<repo>, release-and-deploy.md item 21) that IS the bot, and the npm package this spec covers — the CLI and, through it, the bot: npx @coreplane/switchboard init reaches a running ask from an empty directory with no clone, and npx @coreplane/switchboard start runs the bot from that directory with no Docker (item 8). The package is the same CLI the checkout runs (src/cli.ts, bundled — and the bot's entry with it, since start runs it) and the same files it reads (the examples, the Worker templates, the manifests, the dashboard's build — copied), under one rule for finding them; nothing is a second implementation.
What the package does not contain, by decision: the bundled skills (skills/, loaded from the working directory or SWITCHBOARD_SKILLS_DIR) and the sources the bot's image is built from. What it does contain lets the deploy commands run from any directory, with no checkout (release-and-deploy.md item 24): the Worker directories and the sources under src/ they import, the root manifest and lockfile, and the version and commit the package was built from (item 7); and lets the bot run from that directory too (item 8): the bot's dependencies and the dashboard's built bundle. init --cloudflare writes the profile into the directory it runs in, and deploy plan|init|secrets|config|all run from there, materialising the Worker directories under .switchboard/. The one thing still built from a checkout is the bot's image — the root Dockerfile over sources the package does not carry — and deploy all from the package refuses that step by name.
- Code:
packages/switchboard/package.json(the manifest: name fromproject.json'snpmPackage,bin,files,engines,publishConfig),packages/switchboard/build.mts(esbuild bundle + the asset copy:ROOT_ASSETS,shippedDeployAssets,workerSourceFiles,webDistAssets, thesource.jsonstamp),src/packageRoot.ts(locatePackageRoot,PACKAGE_ROOT,RUNS_FROM_PUBLISHED_PACKAGE— the one resolver;parsePackageSource),src/deploy/operatorRoot.ts,src/deploy/workArea.tsandsrc/deploy/host.ts(where a deploy's files live from the package, and the work area materialised from the assets),src/setup/host.ts,src/deploy/run.tsandsrc/deploy/secretsHost.ts(the readers, through it),src/cli.ts(programName: the usage text's spelling; thestartbuilt-in andstartHelpText),src/index.ts(runBot, the processstartruns, behind the entry claim ofsrc/invokedAsScript.ts:invokedAsScript,claimEntry),src/channels/webAssets.ts(WEB_DIST_DIR,webDistDir: the dashboard's bundle under the package root),docs/public/install.sh(the curl front door, served at https://openswitchboard.dev/install.sh),release-please-config.json(the version's extra files),.github/workflows/release-please.yml(publish-npm),.github/workflows/ci.yml(thepackagecheck),project.json+scripts/check-project-facts.mjs(thepackagefact and the manifest's copies of the facts),Dockerfile(copies the manifest so npm resolves the workspace tree). - Tests:
packages/switchboard/build.test.mts,packages/switchboard/smoke.test.mts,src/packageRoot.test.ts,src/packaging.test.ts,src/deploy/secretsHost.test.ts,src/deploy/operatorRoot.test.ts,src/deploy/workArea.test.ts,src/setup/installScript.test.ts,src/ciWorkflow.test.ts,src/projectFacts.test.ts,src/cli.test.ts,src/invokedAsScript.test.ts,src/channels/webAssets.test.ts,src/deploy/affected.test.ts. - Docs: Get started, Deploy, Configure the repository (the publish switch and the trusted publisher), README.md (also the package's README: the build derives it, item 1).
Related: init.md (the installer the package exists to run), release-and-deploy.md items 21 and 23 (the two artifacts a release publishes).
Behavior
- One package, the CLI, under the project's scope.
packages/switchboard/is a workspace whose name isproject.json'snpmPackage(@coreplane/switchboard);check:project-factsholds the manifest'sname,description,license(the root's),homepage(the docs URL),repository.urland.directory, andbugs.urlto the facts.binisswitchboard→dist/cli.js;filesisdistalone (npm adds the manifest, and the README and LICENSE the build copies in — the README is the repository's own,packageReadmeinbuild.mtsmaking every relative link and image absolute against the repository on GitHub atHEADand dropping the generated diagram regions, so the npm page reads as GitHub does and the two can never diverge);engines.nodeis>=<the .nvmrc major>;publishConfigisaccess: publicand nothing about provenance (item 5). Itsdependenciesare exactly the npm packages the bundled CLI imports, at the root's ranges — derived by a test from esbuild's metafile, so a dependency the CLI stops or starts importing fails the suite until the manifest follows. - The build is derived, never listed by hand.
npm run build -w packages/switchboard(build.mts, run with tsx) bundlessrc/cli.tswith esbuild — one ESM file,platform: node, target the.nvmrcmajor, the repository's own modules inlined (the bot's entrysrc/index.tsand the Slack adapter among them, reached throughstart's import), every npm package external (packages: "external"), the entry's shebang kept — intodist/cli.js, then builds the dashboard (npm run build -w web, as the Dockerfile does) and copies underdist/assets/, at their tree paths,ROOT_ASSETS(.env.example,config/config.example.yaml,project.json,Dockerfile,docker-entrypoint.sh,.dockerignore, and — for the work area'snpm ci—package.jsonandpackage-lock.json),shippedDeployAssets(git ls-files deploy): every path git tracks underdeploy/except tests (thetestsrules ofsrc/deploy/affected.ts'sINERT_RULES) and the agent-env tooling, andworkerSourceFiles: the files undersrc/in the union of the Worker entries' relative-import closures (importClosure, the crawl--affectedjudges a Worker's inputs by), each once — an import that resolves to no file fails the build, never a package shipped without a source — andwebDistAssets: every file of the dashboard's build underweb/dist/(WEB_DIST_DIR), a listing without the Vite manifest refused by name, never a package whosestartboots half-blind. Tracked paths only (the dashboard's build is the one generated tree, built by this build), so an operator'sdeploy/profile.jsonand the renderedwrangler.jsoncfiles — gitignored — can never reach a tarball.LICENSEis copied beside the manifest (gitignored there). - One resolver finds the shipped files.
locatePackageRoot(from)(src/packageRoot.ts):assets/beside the calling module when it carriesproject.json— the published package,dist/cli.jsbesidedist/assets/— else the nearest ancestor that does: the repository root fromsrc/setup/ordist/setup/in a checkout,/appin the image (the Dockerfile copiesproject.jsonthere). Neither is an error naming the marker and the start.PACKAGE_ROOTis whatreadTemplates,publishedImage,publishedPackageandisCheckoutRootresolve under, and theassetsof the operator root (release-and-deploy.md item 24) the deploy commands read the templates, the example profile and the secrets manifest through; The version this CLI runs as — the number the release published its images under, so whatdeploy imagescopies and aregistry-mode Worker config references (release-and-deploy.md items 25–26) — iscliVersionOnHost(src/deploy/host.ts): from the package,source.json's (item 7); in a checkout or the image,packageVersion()— theversionof the nearestpackage.jsonat or abovePACKAGE_ROOT, the root's own;RUNS_FROM_PUBLISHED_PACKAGE(theassetskind) is howinitknows to printnpx <package> …as the next commands and to write the profile where it runs (init.md items 4 and 8), and what puts the operator root in package mode. A relativesecretsSourcedirectory (expandDir, the secrets host) is under the operator root — the checkout, or the directory the package was run in, where the profile that named it lives — never insidedist/assets/;~and absolute paths resolve the same everywhere. In a checkout every path resolves exactly as before the resolver existed. - The installed package works, and says nothing about where it came from. The smoke test packs the workspace (
npm pack --workspace packages/switchboard), installs the tarball into an empty temp directory and runs the installed bin there: the tarball is<scope>-<name>-<version>.tgzand carries onlypackage.json,README.md,LICENSEanddist/(no test, vitest config, rendered config, profile or.env);npx switchboard --helpprints the catalogue underusage: switchboard …—programName(argv[1])spells the checkout'snpx tsx src/cli.tsonly when Node was started on a.tsfile,switchboardotherwise (the bin, the image's entrypoint);init --dry-run --organization acme --anthropic-key sk-testin an empty directory plans.envandconfig/config.yamlfrom the shipped examples with the key masked, ends with the package's ownaskandstart, and prints no path of the package, the temp directory or the repository;start --helpprints the process's help under the bin's own name andstart <anything>is the usage error (exit 2);startin a directory with no.envexits 1 with the process's own one-line refusal namingSLACK_BOT_TOKEN;startwith fake tokens andPORTset boots ONE bot — one startup log, onehttp server on :<port>line, the dashboard manifest found beside the bundle, neverEADDRINUSE— and ends as the socket handshake decides: Slack'sinvalid_auth(exit 1) or, unreachable, a SIGINT drain (exit 0) (item 8);deploy planin a directory with no profile reads the shippeddeploy/profile.example.json, says so, and names that directory as its root;init --cloudflare … --zone …in an empty directory writesdeploy/profile.jsonthere and renders the Worker configs under.switchboard/(item 7) with no install, anddeploy planfrom that directory plans that installation naming no path of the package or the repository. Under a minute: the install prefers the local npm cache, and nothing runsnpm ci. - The release can publish it, in lockstep, with no credential of its own — and does so only when turned on. One release-please component (
.) carries the version: its node strategy moves the root manifest and lockfile;extra-filesmovespackages/switchboard/package.json($.version) and the lockfile's entry for the workspace ($.packages['packages/switchboard'].version— left behind, the nextnpm installrewrites it). A test holds the three copies equal. When release-please reportsrelease_createdfrom the repository's default branch and the repository variableSWITCHBOARD_PUBLISH_NPMis the stringtrue(release-and-deploy.md item 23 — the variable is the per-release switch, the branch the per-line one), thepublish-npmjob inrelease-please.yml—contents: readandid-token: writealone; Node from.nvmrc, npm upgraded to a trusted-publishing release;npm ci; the package's build — runsnpm publish --workspace packages/switchboard --access public --provenance=false— provenance off by name: with trusted publishing npm generates a bundle by itself, and it accepts one from GitHub-hosted runners alone and this project's CI runs on Namespace runners (the registry answered 422Unsupported GitHub Actions runner environment: "self-hosted"on the first two public releases — once with--provenance, once with no flag at all, which is why the opt-out is explicit); the images keep their attestations because the attest action signs from any runner. The credential is the run's OIDC identity: the package's npm settings name this repository andrelease-please.ymlas its trusted publisher (Configure the repository); no token exists, nothing expires, no workflow namesNPM_TOKENorNODE_AUTH_TOKEN; no otherrun:in any workflow starts withnpm publish. With the variable unset the job is skipped and the release-please job'snpm publish is offstep says so in one notice line. The manifest is publishable (noprivateflag;publishConfig.access: public, no provenance), so the variable is the one switch — and it is off until the package exists on the registry with the trusted publisher configured, since a first publish is what creates the package record the publisher is attached to (a placeholder0.0.0published by an org admin from an empty directory, then the release takes over). - The curl front door installs nothing but what npx fetches.
docs/public/install.sh(served at the docs host's/install.sh) is POSIXsh: it requiresnodeandnpxonPATHand a Node major at leastREQUIRED_NODE_MAJOR(held equal to.nvmrcby a test), refuses otherwise naming the version found and where to get Node — it never installs Node — and thenexecsnpx --yes <package>@latest init "$@", every argument passed through, in the directory it was run from, withnpm_config_engine_strict=trueon that one call: npx does not enforce a package'senginesby itself, so the script's check is the gate a person reads and npm's is turned on behind it. - The package carries what a deploy needs to run from anywhere, and says which tree it is. Beside the templates and manifests,
dist/assets/holds every Worker's directory as tracked, thesrc/files theirworker.tsimport (item 2 — wrangler bundles them by relative path, so they must sit where the tree keeps them), the rootpackage.jsonandpackage-lock.json, andsource.json: the package'sversion, thecommitit was built from (-dirtysuffixed when the tree had uncommitted changes —buildStampfromdeploy/bin/build-stamp.mjs, the same rule the Workers are stamped by) andbuiltAt;parsePackageSource(src/packageRoot.ts) reads it and names a missing file, non-JSON or a missing field, never guessing. From the package the deploy commands copy that tree whole into<operator directory>/.switchboard/and install each Worker they run withnpm ci --workspace deploy/<worker>at the copy's root — the root manifest'sworkspacesand the lockfile make the copy an npm workspace root, and npm tolerates the workspaces the copy lacks (web,docs, the package itself), so the Worker's dependencies land at the release's pinned versions, hoisted or nested exactly as the checkout has them (release-and-deploy.md item 24 for the work area's stamp, reuse and refusals). The commit insource.jsonis what a package deploy stamps into every Worker and compares/healthzagainst; a release build carries the release commit. - The package runs the bot:
start.switchboard start(src/cli.ts, a built-in besideask— command-registry.md item 16) runsrunBotfromsrc/index.ts: the very process the container image runs, from the installation (release-and-deploy.md item 24:SWITCHBOARD_HOME, else the directory it is run in when that holds one, else~/.switchboard), reading.env(throughloadEnv),config/config.yaml(orSWITCHBOARD_CONFIG),data/and, when present,skills/exactly as the image does from/app;PORTturns on the HTTP server and with it the dashboard. Three things make that possible from the package. The bundle carries the bot's closure:src/cli.tsimportssrc/index.tslazily, so esbuild inlines it, and the manifest'sdependencies— derived from the bundle's externals (item 1) — carry the bot's packages (@slack/boltamong them). The dashboard's built bundle ships as an asset: the bot resolves it asweb/distunder the package root (webDistDir:SWITCHBOARD_WEB_DIST, elsePACKAGE_ROOT/web/dist— the checkout'sweb/dist, the image's/app/web/dist, the package'sdist/assets/web/dist), never under the working directory, which from the package is the operator's. One process, one entry (claimEntry,src/invokedAsScript.ts): each entry point runs its main only when it is the script Node was started with AND no module claimed the process before it —src/index.tsstarts the bot undernode dist/index.js,npm run devand the image's no-argument entrypoint, and exportsrunBototherwise; inside the bundle both modules see the bin'simport.meta.url, the CLI evaluates first and claims the process, and the bot's module — imported lazily, only bystart— finds it taken, so onestartboots one bot (a second would faillistenon the same port).startwires nothing of the CLI's own — the registry, its config open and its capabilities are built on the first invocation that binds a command — so the bot's config open is the process's only one. The process's rules are unchanged: a missingSLACK_BOT_TOKENorSLACK_APP_TOKENis the one-line refusal naming it (exit 1), SIGINT and SIGTERM drain. From a checkoutnpm run cli -- startis the same process asnpm run dev. The bundled skills are still not in the package: from an operator directory the bot has the skills that directory'sskills/(orSWITCHBOARD_SKILLS_DIR) holds, none otherwise.
Validation criteria
| Criterion | Evidence |
|---|---|
The resolver prefers assets/ beside the module when it carries the marker, else the nearest ancestor carrying it; an empty assets/ is not the root; nothing found throws naming the marker and the start; in the checkout the root is the repository and nothing is published (item 3) | [unit] src/packageRoot.test.ts::locatePackageRoot::*, src/packageRoot.test.ts::this module's own root::* |
The deploy assets are every tracked deploy/ path but tests, vitest configs, test typings and the agent-env tooling; paths outside deploy/ are ignored; the root assets are the fixed eight, the Worker sources follow them each once (item 2) | [unit] packages/switchboard/build.test.mts::shippedDeployAssets::* |
The Worker sources are the union of the four real entries' import closures under src/, sorted, reaching the memory engine and the schedule registry, none under deploy/; an import that resolves to no file is an error naming it (items 2, 7) | [unit] packages/switchboard/build.test.mts::workerSourceFiles::* |
The dashboard's build ships under web/dist/, every file, sorted into the asset list; a listing without the Vite manifest is refused naming npm run build -w web (items 2, 8) | [unit] packages/switchboard/build.test.mts::shippedDeployAssets::the dashboard's files ship under web/dist…, packages/switchboard/build.test.mts::shippedDeployAssets::ignores paths outside deploy/… |
The bundle's closure reaches src/index.ts, the Slack adapter and the web-assets server — start is the image's process (items 2, 8) | [unit] packages/switchboard/build.test.mts::the package manifest::the bundle carries the bot process… |
The bot reads the dashboard's bundle from web/dist under the package root — the checkout, /app, dist/assets — never the working directory; SWITCHBOARD_WEB_DIST overrides (item 8) | [unit] src/channels/webAssets.test.ts::webDistDir::* |
| The entry points run their main only when Node was started on them, through the bin's symlink too; a mere import starts nothing; the first module that is the script claims the process and a second ask with the same url — the bot's entry inlined into the CLI's bundle — is refused (item 8) | [unit] src/invokedAsScript.test.ts::invokedAsScript::*, src/invokedAsScript.test.ts::claimEntry::* |
start on the CLI: a bare start is the process, start --help its help naming Socket Mode, .env, both Slack variables, config/config.yaml, SWITCHBOARD_CONFIG, PORT and SIGINT, anything more is a usage error; neither built-in is in the catalogue (item 8) | [unit] src/cli.test.ts::parseCliArgv — the \start` built-in…::*` |
source.json parses to its version, commit and build time; a missing file, non-JSON or a missing or empty field is a problem naming it (item 7) | [unit] src/packageRoot.test.ts::parsePackageSource (the tree the published package was built from)::* |
In a checkout or the image the version this code runs as is the nearest package.json's at or above the package root — a manifest without one is skipped, nothing above throws naming the start — and the repository's own is the root manifest's (item 3) | [unit] src/packageRoot.test.ts::locatePackageVersion::* |
The manifest depends on exactly what the bundle imports, at the root's ranges; runs on the .nvmrc Node; names the bin; ships dist alone; would publish public, with nothing about provenance in the manifest (items 1, 5) | [unit] packages/switchboard/build.test.mts::the package manifest::* |
A relative secretsSource directory is under the operator root — the checkout, or the directory the package was run in; ~ and absolute paths resolve the same everywhere (item 3) | [unit] src/deploy/secretsHost.test.ts::expandDir::* |
The manifest's name is npmPackage and its description, license, homepage, repository URL and directory, and bugs URL are the facts' copies; the fact is a scoped name (item 1) | [unit] src/projectFacts.test.ts::factsProblems::the npm package's manifest::*, src/projectFacts.test.ts::the repository's own facts::every checked file exists and agrees with project.json |
The tarball is the package's name and version and carries only the manifest, README, LICENSE and dist/ — the bundle, the assets and the dashboard's build under dist/assets/web/dist/, never a test, a rendered config, a profile or .env (items 2, 4, 8) | [unit] packages/switchboard/smoke.test.mts::the tarball::* |
The package README is the repository README, made absolute: relative src/srcset/markdown images become raw.githubusercontent.com/…/HEAD URLs and relative links blob/HEAD URLs, absolute URLs, anchors, mailto and nested badge images stay, generated regions are dropped whole, and the real README comes out with no relative target and no mermaid | [unit] packages/switchboard/build.test.mts::packageReadme::* |
The installed bin: --help under its own name, init --dry-run from the shipped examples with the key masked, the package's own ask and start next and no leaked path; start --help under the bin's name and start <arg> the usage error; start with no .env the process's refusal naming SLACK_BOT_TOKEN, exit 1; start with fake tokens and PORT one startup log, one HTTP server, the dashboard bundle found, no second boot, then Slack's refusal or a SIGINT drain; deploy plan from the shipped example naming the directory as its root; init --cloudflare writing the profile and the work area there and deploy plan planning it with no leaked path (items 4, 7, 8) | [unit] packages/switchboard/smoke.test.mts::the installed CLI::* |
From an operator directory with real Slack tokens, npx <package> start connects: the startup log ends in switchboard running (providers: …), a mention in Slack is answered, and Ctrl-C prints the [drain] SIGINT line and exits 0 (item 8) | [agent] Human-gated: needs a Slack app and its two tokens. init --force … --slack-app-token … --slack-bot-token … in an empty directory, then PORT=8080 npx <package> start; curl -sS localhost:8080/healthz answers JSON; mention the app in Slack; Ctrl-C. |
programName: a .ts entry is the checkout's spelling, a bin or dist/cli.js is switchboard (item 4) | [unit] src/cli.test.ts::buildCoreCommands — the one catalogue every in-process binding shares (index.ts, cli.ts)::phase 4b: the CLI catalogue carries every command… |
| The package's version equals the root's and the lockfile's workspace entry; release-please's one component names both extra files (item 5) | [unit] src/packaging.test.ts::the package's version is the repository's::* |
publish-npm: one job, gated on release_created, the default branch and vars.SWITCHBOARD_PUBLISH_NPM == 'true', contents: read + id-token: write alone, npm upgraded → npm ci → the package build → npm publish --workspace … --access public --provenance=false, and no other step mentions provenance; no token or NODE_AUTH_TOKEN in any workflow; no other run: in any workflow starts with npm publish; the switch-off notice step exists (item 5) | [unit] src/ciWorkflow.test.ts::the release publishes the npm package::* |
A job named package runs the package's verify on every PR; packages/ is inert to --affected (item 5) | [unit] src/ciWorkflow.test.ts::the required status checks and their gates::a job is named \%s` — the ruleset waits on that name, src/deploy/affected.test.ts::classifyPath::tests, docs, specs, CI, scripts, the deploy tooling and repo metadata are inert…` |
install.sh parses as POSIX sh, pins the .nvmrc major, passes every argument to npx --yes <package>@latest init with npm's engine check on, refuses an older or missing Node naming where to get it, and never installs Node (item 6) | [unit] src/setup/installScript.test.ts::docs/public/install.sh::* |
The first publish: once the placeholder exists on the registry with this repository as its trusted publisher and SWITCHBOARD_PUBLISH_NPM is true, the next release has a green publish the npm package job and the package is on the registry at the release's version, without provenance (item 5); until then every release carries the npm publish is off notice and the job is skipped (item 5) | [agent] Human-gated once: an org admin publishes 0.0.0 from an empty directory and sets the trusted publisher (repository coreplanelabs/switchboard, workflow release-please.yml); gh variable set SWITCHBOARD_PUBLISH_NPM --body true. Then, after the next release PR merges: the release-please run shows publish the npm package green, npm view @coreplane/switchboard version is the release's, and a clean machine's npx @coreplane/switchboard init --dry-run runs. Receipt on the release issue. |