Publishable packs
A pack is a check directory (or sim-scenario directory) promoted to its own npm package. Use this when you want to ship the same checks across multiple projects — or to keep a project-local pack tidy as it grows past loose .mjs files.
Where the pack lives in your repo
The opensip-cli platform reserves three paths inside your repo's opensip-cli/ directory:
opensip-cli/fit/— project-local fitness checks + recipes. Starts as loose.mjsfiles underchecks/andrecipes/(whatinitscaffolds). Can graduate to a workspace npm package — the directory itself becomes the package — when coverage grows.opensip-cli/sim/— same shape for simulation scenarios + recipes.opensip-cli/.runtime/— tool-managed plugin install + session state (gitignored).
The platform doesn't load anything from these paths directly — discovery flows through node_modules/ walking. When opensip-cli/fit/ is a workspace package, your workspace's symlink puts it in node_modules/ where the marker walker finds it. The directory layout is a recommended convention, not a platform requirement.
The marker (recommended discovery path)
Tag your pack's package.json with opensipTools.kind:
{
"name": "@your-scope/fit",
"private": true,
"type": "module",
"main": "./dist/index.js",
"opensipTools": {
"kind": "fit-pack",
"targetDomain": "fit-pack",
"targetDomainApiVersion": 1
}
}
For a fit pack the fit-pack marker is name-pattern-independent — your pack
can use any npm scope you own (@acme/fit, @my-internal-org/checks-platform,
anything); the marker is what makes the platform find it. The targetDomain
and targetDomainApiVersion fields declare the domain epoch your pack targets.
A sim pack is discovered by a name pattern instead — name it
<scope>/scenarios-* (see below).
Discovery paths
Listed in recommendation order:
- fit: the
fit-packmarker (recommended) — declareopensipTools.kind: "fit-pack",targetDomain: "fit-pack", andtargetDomainApiVersion: 1in your pack'spackage.json. Free choice of scope and name. No config entry. - sim: the
scenarios-*name pattern (recommended) — name your pack
<scope>/scenarios-* (e.g. @acme/scenarios-load). plugins.packageScopes
extends the scopes scanned beyond @opensip-cli.
- explicit listing — name individual packages in
plugins.checkPackages(fit) orplugins.scenarioPackages(sim) from projectnode_modules. For fit, an explicit list is ADDED to marker discovery; for sim it pins the set. - Project-pinned install —
opensip fit plugin add @scope/packoropensip sim plugin add @scope/packinstalls into.runtime/plugins/<domain>/and recordsplugins.fit:/plugins.sim:so teammates can reproduce it withopensip fit plugin sync(the domain is bound from the tool — no--domainflag).
When to graduate from loose .mjs
Concrete pain signals, not arbitrary thresholds:
- Your loose
.mjscount underopensip-cli/fit/checks/exceeds ~10–20 files and PR diffs are getting noisy. - Multiple checks share helper logic and you're copy-pasting it between files.
- You want TypeScript instead of
.mjs— type-checked analyzer code and autocomplete on thedefineCheck(...)shape. - You want tests colocated with each check.
- You want CI to run
pnpm typecheckover the pack to catch authoring mistakes the platform doesn't notice (a slug typo in a recipe selector, a missing required field on a check).
If none of those apply, stay with loose .mjs. The graduation is worthwhile only when the loose-file shape starts to cost more than it saves.
Layout after graduation
@my-co/checks-internal/
├── package.json # declares fit-pack marker + targetDomain epoch
├── tsconfig.json
├── src/
│ ├── index.ts # exports: checks (display folded on), recipes
│ ├── checks/
│ │ ├── architecture/no-cycle.ts
│ │ ├── architecture/no-cycle.test.ts # tests colocated
│ │ ├── observability/log-on-catch.ts
│ │ ├── architecture/index.ts # category barrel
│ │ └── index.ts # top-level checks barrel
│ ├── shared/ # internal helpers
│ ├── recipes/ # canonical recipes shipped with the pack
│ │ └── default.ts
│ └── display/ # icon/display-name map
├── dist/ # built artifact
└── README.md
Two structural details make this scale cleanly past a few dozen checks:
- Category barrels are mechanical aggregation. Each
checks/<category>/index.ts
re-exports the checks in that category, and checks/index.ts re-exports the
category barrels. No runtime logic belongs there.
index.tsis the thin public surface. It imports all check exports from
checks/index.ts, folds the per-pack display map onto them with
collectCheckObjects(...) + applyCheckDisplay(...), and exports the pack's
checks array. It stays small even as the pack grows.
- **The split exists because in a single-file model every new check would touch
the public surface.** With the split, adding a check normally touches the new
check file, its category barrel, the display map, and tests; the root
index.ts remains stable.
This pattern works at scale in the opensip codebase's 151 built-in fitness
checks across seven packs. Small packs can keep everything in one index.ts;
the split only pays off once re-skimming the public surface on every change
becomes a tax. Sim packs can use the same idea with scenarios/index.ts barrels
and defineLoadScenario(...) / defineChaosScenario(...) exports.
package.json
{
"name": "@my-co/checks-internal",
"version": "1.0.0",
"main": "dist/index.js",
"type": "module",
"opensipTools": {
"kind": "fit-pack",
"targetDomain": "fit-pack",
"targetDomainApiVersion": 1
},
"peerDependencies": {
"@opensip-cli/fitness": "^0.1.14",
"@opensip-cli/core": "^0.1.14"
},
"scripts": {
"build": "tsc"
},
"files": ["dist"]
}
Peer-depend on @opensip-cli/fitness and @opensip-cli/core — the consumer brings their own version.
src/index.ts
import { applyCheckDisplay, type Check, type CheckDisplayEntry, type FitnessRecipe } from '@opensip-cli/fitness';
import { noFixme } from './checks/no-fixme.js';
import { infraMustHaveTags } from './checks/infra-must-have-tags.js';
import { quickSmoke } from './recipes/quick-smoke.js';
// Display (icon + name) travels ON each check (§5.3): keep an authoring map and
// fold it onto the checks here. There is no separate `checkDisplay` export.
const CHECK_DISPLAY: Readonly<Record<string, CheckDisplayEntry>> = {
'no-fixme-comments': ['📝', 'No FIXME comments'],
'infra-must-have-tags': ['🏷️', 'Infrastructure tags required'],
};
export const checks: readonly Check[] = applyCheckDisplay([noFixme, infraMustHaveTags], CHECK_DISPLAY);
export const recipes: readonly FitnessRecipe[] = [quickSmoke];
Pack metadata (name, version, description) is read from package.json by the platform — don't duplicate those fields as a runtime export.
src/checks/no-fixme.ts
import { defineCheck } from '@opensip-cli/fitness';
export const noFixme = defineCheck({
id: '0a0a0a0a-0a0a-4a0a-8a0a-0a0a0a0a0a0a',
slug: 'no-fixme-comments',
description: 'No FIXME comments left in source',
tags: ['quality', 'documentation'],
scope: { languages: [], concerns: [] },
contentFilter: 'raw',
analyze(content, filePath) {
const violations: { line: number; message: string; severity: 'warning' }[] = [];
content.split('\n').forEach((line, idx) => {
if (/\bFIXME\b/.test(line)) {
violations.push({ line: idx + 1, message: `FIXME at ${filePath}`, severity: 'warning' });
}
});
return violations;
},
});
Workspace integration
For a monorepo workspace pack, add opensip-cli/* to your workspace globs so pnpm/npm symlinks the package into node_modules/:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
- "opensip-cli/*" # opensip-cli-related workspace packages
Add the pack as a root devDependency so the workspace symlink lands in node_modules/:
// root package.json
{
"devDependencies": {
"@your-scope/fit": "workspace:*"
}
}
Then pnpm i. Marker-based discovery picks up the workspace symlink on the next opensip fit run.
For a TS-based pack you also need to build (pnpm -F @your-scope/fit build) so the main field resolves to real JS. The runtime doesn't currently load TypeScript directly; it loads the entry point your package.json#main points at.
If you don't have a monorepo, publish your pack to a private npm registry under your own scope and install it as a regular devDependency. The marker still drives discovery — no packageScopes config entry needed.
Migration recipe — loose .mjs → workspace pack
A step-by-step you can follow when you've decided to graduate:
- Pick the pack name and location. For a workspace-only pack,
@your-scope/fitworks. For a publishable pack, use your own scope and pick one of the discovery paths above. - Add the directory as a workspace member. Append
opensip-cli/*to yourpnpm-workspace.yaml(or yarn/npm equivalent). - Write
package.jsonwithopensipTools.kind: "fit-pack",targetDomain: "fit-pack",targetDomainApiVersion: 1,main: "./dist/index.js", peer-dep on@opensip-cli/fitnessand@opensip-cli/core. - Convert each
.mjsto a TypeScript module. One<slug>.tsper check undersrc/checks/, each exporting adefineCheck(...)object. Keep the same slug values as the loose files used — recipes select by tag/slug, and--check <slug>invocations keep working across the move. - Create
src/register-checks.tsthat imports every check and exportsallChecksas areadonly Check[]. - Create
src/index.tsthat folds the per-pack display map ontoallChecksviaapplyCheckDisplayand exports the result aschecks. - Add the pack as a root devDependency. pnpm will symlink it into
node_modules/where marker discovery finds it. - Delete the original loose
.mjsfiles underopensip-cli/fit/checks/once the workspace pack is running cleanly and the same slugs are firing.
Recipes during the move. A recipe that lived at opensip-cli/fit/recipes/<name>.mjs can either stay there (the platform's project-local recipe walker continues to load it from the reserved path) or move into the pack as src/recipes/<name>.ts and be re-exported through index.ts alongside checks. Moving it into the pack is the cleaner end-state — single source of truth, versioned with the checks it references — but doing so is optional and can happen after the check migration lands.
Reference example
The opensip codebase uses this pattern at production scale. The split is visible directly in the public layout:
packages/fitness/checks-typescript/— TypeScript-specific checks undersrc/checks/<category>/, category barrels, and a thinsrc/index.tspublic surface.packages/fitness/checks-universal/— cross-language checks using the same category-barrel and display-map pattern.
Either is a working reference for the pattern when graduating your own pack.
Publish + consume
# In your pack:
npm publish --access public # or wire it up to GitHub OIDC trusted publishing
# In a consuming project:
opensip fit plugin add @my-co/checks-internal
opensip fit plugin add installs to <project>/opensip-cli/.runtime/plugins/fit/node_modules/ and appends to plugins.fit: in opensip-cli.config.yml. Next opensip fit run, your checks load. (Sim packs use opensip sim plugin add — the domain is bound from the tool the plugin group hangs off of.)
Testing
Use vitest. The check is a plain function — call it with sample content and assert the violation list.
// src/checks/__tests__/no-fixme.test.ts
import { describe, it, expect } from 'vitest';
import { noFixme } from '../no-fixme.js';
describe('no-fixme', () => {
it('flags a FIXME comment via the integration entry point', async () => {
// Check.run(cwd, options?) walks the project's targets and runs the
// analyzer over every matched file. It returns a CheckResult.
const result = await noFixme.run(process.cwd());
expect(result.passed).toBe(false);
});
});
For a tighter unit test, call the analyzer directly — defineCheck keeps the original analyze/analyzeAll/command callable on the source module, so a unit test imports that function and feeds it a string of source code.
Where to go next
| You want to … | Go to … |
|---|---|
| Understand the platform side: pack contract, scope filters, discovery internals | Check pack architecture |
| Author a Tool with its own subcommand | Full Tool plugins |
| Walk the monorepo adoption flow end-to-end | Adopt in a monorepo |
| Browse all 151 built-in checks for inspiration | Checks reference |