14 KiB
Adding a workspace to the Morphit monorepo
This is the maintainer-facing checklist for introducing a new
workspace (under apps/ or packages/) to the Morphit
monorepo. Follow it whenever you add apps/<new-thing>/ or
packages/<new-thing>/ — even if the new workspace seems tiny.
The cp140 → cp146 progression is what this doc exists to
prevent. apps/mcp-server was added in cp140, and four
separate problems went undetected for ~24 hours, each of them
caused by skipping one sub-pipeline of the install + build +
publish + smoke surface:
- cp142 — The new workspace's smoke spawned
node dist/main.js, butdist/was gitignored and never built. Smoke hung indefinitely on every fresh checkout (including every CI run). - cp143 — The hang would burn the runner's full default ceiling. Added per-smoke timeout as a runtime complement.
- cp144 —
package-lock.jsonwas never regenerated after adding the workspace.npm ci(CI's install command) refused to install with EUSAGE for ~24 hours. Every downstream CI job was red. - cp145 — No CI job had
timeout-minutes. Defense-in-depth complement to cp143. - cp146 — The workspace's
package.json:filesarray declaredLICENSEbut the file didn't exist.npm publishwould have shipped a tarball without a license.
Read those checkpoints in docs/REVISIT-LIST.md before doing
this work — the lessons there explain why each step matters,
not just what to do.
The checklist below ensures every sub-pipeline gets exercised from a clean state before the PR lands.
Phase 1 — Decide the workspace shape
Before writing any code, answer these. Write the answers down in the PR description so reviewers can sanity-check them.
1. What kind of workspace?
apps/<name>/— runnable software (a server, CLI, web app, daemon). Hasbinif it's CLI-invokable, otherwise consumed by other workspaces.packages/<name>/— internal library code shared across multipleapps/. Almost always"private": true. Imported via@morphit/<name>aliases.
If the new code is consumed by exactly one other workspace, consider whether it should be a folder INSIDE that workspace instead of a separate workspace. Multiplying workspaces multiplies the work below.
2. Will it be published to npm?
- No (default for
packages/*): set"private": trueinpackage.json. Skip the LICENSE andfilesarray steps in Phase 4. - Yes (some
apps/*, especially CLIs likemorphit-mcp): set up the full publishable surface. See Phase 4.
3. Does it ship compiled artifacts?
- No (most cases): bin/main points at
.tsfiles (consumed viatsxat runtime);npm run buildis a no-op or absent. - Yes (e.g.
apps/mcp-servershipsdist/main.jsas its bin): the workspace needs abuildscript +tsconfig.build.json- a smoke that lazy-builds. See cp142 lesson #2 in REVISIT.
4. Does it call out over the network?
- No: skip the SSRF + timeout review in Phase 3.
- Yes: every fetch needs (a) a timeout, (b)
redirect: 'manual', (c)User-Agentheader, (d) URL-redaction in error paths (don't echo userinfo from URLs into errors). Lift helpers fromapps/indexer/src/lib/federationProbe.tsif you find yourself reimplementing this.
Phase 2 — Create the workspace
1. Create the directory + skeleton files
mkdir -p apps/<name>/src apps/<name>/scripts
# OR packages/<name>/src for libraries
Minimum files:
apps/<name>/package.json(see template below)apps/<name>/tsconfig.json(see template below)apps/<name>/src/main.ts(orsrc/index.tsfor libraries)apps/<name>/README.md(even if it's three lines — see Phase 4)
2. package.json template
{
"name": "morphit-<name>", // or "@morphit/<name>" for packages/
"version": "1.0.0-beta.25", // MUST equal the root package.json version — version-consistency-smoke fails the build if any workspace drifts
"type": "module",
"license": "AGPL-3.0-only",
"private": false, // or true for packages/
"engines": { "node": ">=22.0.0" },
"scripts": {
"dev": "tsx src/main.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@morphit/asset-registry": "*" // workspace links use "*"
},
"devDependencies": {
"@types/node": "^22.7.5",
"tsx": "^4.19.1",
"typescript": "^5.6.3",
"vitest": "^2.1.2"
}
}
If the workspace ships a CLI binary:
"bin": {
"morphit-<name>": "src/main.ts" // tsx-runnable; no build needed
}
If the workspace ships a COMPILED binary (rare; only when the
binary needs node rather than tsx):
"bin": {
"morphit-<name>": "dist/main.js"
},
"scripts": {
...
"build": "tsc -p tsconfig.build.json"
},
"files": [
"dist/",
"src/",
"README.md",
"LICENSE"
]
— and add the entries described in Phase 4 step 3.
3. tsconfig.json template
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*", "scripts/**/*"]
}
4. Add LICENSE if publishable
If the workspace will be published (not "private": true):
cp LICENSE apps/<name>/LICENSE
This is non-negotiable. npm publish silently skips missing
files from the files array — the cp146 package-files-exist-smoke
catches this, but it's easier to do it right the first time.
Phase 3 — Wire into the monorepo
1. Register the workspace in root package.json
{
"workspaces": [
"apps/web",
"apps/relay",
"apps/indexer",
"apps/ops-cli",
"apps/matrix-bot",
"apps/mcp-server",
"apps/<your-new-workspace>", // <-- add here
"packages/indexer-client",
...
]
}
2. Regenerate package-lock.json — CRITICAL
npm install
git diff package-lock.json | head -20 # confirm new workspace entries appear
Do not skip this step. This is the cp144 failure mode.
Adding the workspace to package.json:workspaces without
regenerating the lockfile causes CI's npm ci to fail with
EUSAGE, gating every downstream job.
After npm install, run:
npm ci --dry-run --no-audit --no-fund
It should print added N packages (with N including your new
workspace). If it errors with EUSAGE or Missing: ..., the
lockfile is stale — re-run npm install and verify.
3. Register the workspace tsconfig in scripts/typecheck-sweep.sh
# In the project() invocations near the bottom:
project "<name>" apps/<name>/tsconfig.json
Run bash scripts/typecheck-sweep.sh — your workspace should
appear in the output as "0 errors" (or with whatever errors
need fixing).
4. If publishable, add the LICENSE to package.json:files
Already shown in the publishable package.json template
above. The package-files-exist-smoke will fail at build
time if you list LICENSE but the file doesn't exist; the
inverse failure (file exists but not in files) isn't a
smoke-catchable bug but matters for the npm tarball.
5. If the workspace ships compiled artifacts
Add a build step to .forgejo/workflows/ci.yml's smokes job:
- name: Build workspaces that ship compiled artifacts
run: |
npm run build -w apps/mcp-server
npm run build -w apps/<your-new-workspace> # <-- add
The cp142 spawn-dist-prebuild-coverage-smoke will fail if a
smoke spawns from dist/ without a corresponding guard; the
CI build step is the legible counterpart so failures surface
as a named step rather than buried in smoke output.
Phase 4 — Build smokes
Smokes are how the workspace gets continuously verified. Skipping them means the workspace silently rots between audits.
1. At minimum
Create apps/<name>/scripts/<name>-smoke.ts covering:
- Wire-up sanity: the workspace's main entry imports cleanly and exports its public surface.
- Happy path: the main use case end-to-end.
- Error path: at least one invalid-input case returns a legible error.
2. If the workspace spawns its own bin from dist/
Read scripts/spawn-dist-prebuild-coverage-smoke.ts (cp142).
The smoke MUST contain either ensureBuilt( or existsSync(<dist path>)
before the spawn, in non-comment code. The meta-smoke verifies
this with a comment-stripped scan; without the guard, the
meta-smoke fails the build.
A self-healing helper looks like:
function ensureBuilt(cwdPath: string): void {
const distMain = resolvePath(cwdPath, 'dist', 'main.js');
if (existsSync(distMain)) return;
const build = spawnSync('npm', ['run', 'build'], {
cwd: cwdPath,
stdio: 'inherit'
});
if (build.status !== 0) process.exit(1);
}
Call it at the top of main() before any spawn.
3. Register in scripts/run-smokes.sh
SMOKES=(
...
"apps/<name>:<name>-smoke"
...
)
The runner counts smokes by parsing this array. cp143's
timeout --signal=TERM --kill-after=5 240 wraps each smoke;
if your smoke needs longer than 240 seconds, write it so it
DOESN'T (split into multiple smokes; reduce scenario size).
4. The smoke MUST emit ✓ all N scenarios passed
The runner's exit-zero branch greps for ^✓ all to count
scenarios. Smokes that pass the runner but emit no canonical
line are flagged as failures. Use the same final-line
convention as every other smoke in the repo.
5. If the workspace ships compiled artifacts, also build dist/ at smoke startup
The cp142 self-healing pattern. Without it, a fresh-checkout CI run hangs indefinitely (which cp143 catches with a 240s timeout, but you don't want hangs in the first place).
Phase 5 — Pre-PR verification
Before opening the PR, run these IN ORDER. Any failure means you have more work to do; don't paper over with "I'll fix it in CI."
1. Fresh-checkout sanity
# In a separate clone or scratch directory:
git clone https://git.agorise.net/agorise/morphit fresh-clone
cd fresh-clone
git fetch origin <your-branch>
git checkout <your-branch>
npm ci --no-audit --no-fund # CI's exact install command
If npm ci fails: your lockfile is stale. Go back to Phase 3
step 2.
2. Build any compiled workspaces
npm run build -w apps/<your-new-workspace> # if applicable
3. Typecheck sweep
bash scripts/typecheck-sweep.sh
Every workspace including yours should report "0 errors."
4. Triple-pulse smokes
for i in 1 2 3; do
echo "=== Pulse $i ==="
bash scripts/run-smokes.sh
done
Three runs catch flakes. Total scenario count should be the same all three pulses. Zero runners failed all three pulses.
5. svelte-check (if your workspace touches apps/web)
cd apps/web && npm run check
Zero errors and zero warnings.
6. Meta-smokes
The four cp142–146 meta-smokes are in the run-smokes.sh battery and should all pass automatically:
spawn-dist-prebuild-coverage-smoke(cp142)lockfile-sync-smoke(cp144)ci-workflow-hardening-smoke(cp145)package-files-exist-smoke(cp146)
If any of these fail, the failure message points at the file + class-of-bug. Don't bypass them — they exist because the cp140 oversight that this doc exists to prevent.
Phase 6 — Docs
1. The workspace README
apps/<name>/README.md should answer:
- What the workspace does in one paragraph.
- How to install / run it (from-source instructions first; npm / Docker forthcoming markers if those pipelines haven't shipped yet — cp146 lesson #4).
- Configuration env vars (with defaults).
- Privacy posture if the workspace is user-facing.
- Where to file bugs (
git.agorise.net/agorise/morphit/issues).
2. ADR if the workspace introduces an architectural shift
If the workspace establishes a new external surface (e.g. ADR-0044 for the MCP server), write an ADR explaining:
- Context: why this exists.
- Decision: what shape it takes.
- Alternatives considered.
- Consequences (positive + negative).
- Source: links to the workspace files.
Pure internal libraries that don't change the user-facing surface don't need an ADR.
3. Brag list entry — only if user-facing
If the workspace is a user-facing capability (not internal
plumbing), add a concise public-facing brag entry to
MORPHIT-BRAG-LIST.md. Follow the existing entry style: 2–4
sentences, verifiable in code or honestly disclosed as backlog,
inserted in the proper themed section.
4. Update this doc if you find a new gotcha
If your workspace add surfaces a problem that none of cp142– cp146 caught, add it as a new cp checkpoint with REVISIT entry
- extend this doc. Then we've raised the floor for next time.
What gets caught automatically
The cp142–cp146 meta-smokes catch the following classes without any action on your part:
| Smoke | Catches |
|---|---|
spawn-dist-prebuild-coverage-smoke |
Smoke spawns from dist/ with no build guard |
lockfile-sync-smoke |
package-lock.json out of sync with root package.json |
ci-workflow-hardening-smoke |
Any new CI job missing timeout-minutes or pinning -latest |
package-files-exist-smoke |
Workspace files array references missing files or no LICENSE |
That's the safety net. Use the checklist above to avoid needing it.
Reference: the cp140 → cp146 sequence
| cp | Caught | Fix |
|---|---|---|
| 140 | (the oversight) workspace added without exercising all sub-pipelines | — |
| 142 | smoke spawned dist/main.js without dist/ existing |
self-healing lazy-build + meta-smoke |
| 143 | hung smoke would burn runner's full default ceiling | per-smoke timeout 240 |
| 144 | package-lock.json was stale, blocking CI's npm ci for ~24h |
regenerated lockfile + lockfile-sync smoke |
| 145 | no CI job had timeout-minutes |
per-job ceilings + meta-smoke |
| 146 | package.json:files listed missing LICENSE; 3 places bypassed getInstanceUrl(); user-facing copy claimed "no IP logging by design" |
LICENSE created + DRY + honest copy + meta-smoke |
Five checkpoints' worth of work, all preventable by following the checklist above.