morphit/docs/ADDING-A-WORKSPACE.md

14 KiB
Raw Permalink Blame History

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, but dist/ 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.
  • cp144package-lock.json was 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:files array declared LICENSE but the file didn't exist. npm publish would 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). Has bin if it's CLI-invokable, otherwise consumed by other workspaces.
  • packages/<name>/ — internal library code shared across multiple apps/. 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": true in package.json. Skip the LICENSE and files array steps in Phase 4.
  • Yes (some apps/*, especially CLIs like morphit-mcp): set up the full publishable surface. See Phase 4.

3. Does it ship compiled artifacts?

  • No (most cases): bin/main points at .ts files (consumed via tsx at runtime); npm run build is a no-op or absent.
  • Yes (e.g. apps/mcp-server ships dist/main.js as its bin): the workspace needs a build script + 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-Agent header, (d) URL-redaction in error paths (don't echo userinfo from URLs into errors). Lift helpers from apps/indexer/src/lib/federationProbe.ts if 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 (or src/index.ts for 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 cp142146 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: 24 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 cp142cp146 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.