Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Monorepo Scripts

The repo uses pnpm for dependency management and Turborepo for task orchestration and caching across workspace packages.

Naming rules

Root scripts use <verb>:<target> grouping:

CategoryExamples
Devdev:workspace, dev:desktop, dev:web, dev:site
Buildbuild:all, build:desktop, build:web, build:site
Previewpreview:web, preview:desktop, preview:site
Dist / releasedist:desktop:mac, dist:desktop:linux, release:desktop:local
Plugin releaseplugin-release:package, plugin-release:sign, registry-assets:*
Killkill:workspace, kill:desktop
Checkcheck:all, check:format, check:types, check:source-resolution
CI / Dockerdocker:ci-check, ci:image:build, ci:image:push
Testtest, test:watch, test:smoke, test:daily-use, test:scripts
Format / lintfmt, lint, lint:fix
Site mediamedia:site

Package scripts keep short names (dev, build, preview, test) plus check subtasks:

  1. check:format, check:svelte, check:types, package-specific check:*
  2. check:all — runs the applicable static check:* subtasks and lint for that package through scripts/run-package-check-all.mjs
  3. fmt, lint, and lint:fix when the package owns lintable source
  4. test when the package has unit tests (never invoked from check:all)

Turbo

turbo.json defines the task graph for build, check:*, fmt, lint, test, dev, and preview. Root scripts call Turbo for cross-package work:

pnpm build:all
pnpm check:all
pnpm check:format
pnpm check:types
pnpm test
pnpm fmt
pnpm lint
turbo run check:all --filter=@lapis-notes/workspace...

Turbo runs package tasks in parallel, respects workspace dependency order, and caches unchanged package results under .turbo/. Forgejo/GitHub CI and optional local pnpm docker:ci-check --remote-cache runs also use the hosted cache at https://turbo-cache.app.ju.ma; see Release Management.

build:web runs pnpm --filter @lapis-notes/web build directly instead of Turbo ^build, matching deploy (nixpacks.toml). The web host bundles first-party plugins from source through its Vite aliases, so it does not need published plugin dist/ artifacts first. The @lapis-notes/web#build Turbo override also sets dependsOn: [] for callers that invoke Turbo directly. Use build:all or turbo run build --filter=@lapis-notes/workspace... when you need the full plugin packaging graph.

Validation ladder

  1. Package-local static checks: pnpm --filter <package> check:all
  2. Package-local tests: pnpm --filter <package> test on each modified package that defines a test script
  3. Affected packages: turbo run check:all --filter=<package>... and turbo run test --filter=<package>...
  4. Full repo: pnpm check:all and pnpm test
  5. App smoke (when runtime may change): pnpm test:smoke

What check:all runs

Root check:all:

  1. check:source-resolution — first-party @lapis-notes/* source-resolution guards (Vite dev resolution, tsconfig import resolution, Vitest test resolution, tsconfig path sync)
  2. plugin-host:check — generated plugin-host module drift guard
  3. test:scripts — repo script unit tests
  4. turbo run check:all — each package’s static checks (check:format, check:svelte, check:types, package-specific check:*, and lint) through scripts/run-package-check-all.mjs
  5. check:format:root — Prettier for repo-root paths outside workspace packages (scripts/, spec/, agent docs, CI workflows, and similar)
  6. lint:root — ESLint for repo scripts under scripts/
  7. spec:lint — markdownlint for spec/ and package-local spec.md files

Root check:format runs turbo run check:format across workspace packages, then check:format:root for the same non-package paths.

Root fmt runs turbo run fmt across workspace packages, then fmt:root for the same non-package paths.

Root lint runs turbo run lint across workspace packages, then lint:root for repo scripts. Package ESLint is already included in root check:all through Turbo; use standalone pnpm lint when you only want lint fixes or a lint-only pass. Use pnpm lint:fix for autofix across packages plus scripts/.

Root test runs turbo run test across packages that define a test script. Use pnpm --filter <package> test or turbo run test --filter=<package> for narrower runs. test:watch delegates to Turbo with Vitest watch mode per package task.

@lapis-notes/api Vitest aliases plugin-host-cjs-provider-values.generated to a lightweight stub under packages/api/test/stubs/ so plugin-manager.test.ts avoids compiling the full generated CommonJS provider graph in CI. Legacy workspace/deps still loads lazily in that suite for install-bundle evaluation. When any install-bundle dist/main.js is missing, plugin-manager.test.ts builds the affected plugin packages through Turbo before reading their artifacts. Turbo wires @lapis-notes/api#test to build the first-party install-bundle plugin packages (canvas, docs, graph, markdown-lint, pdf, slides, telemetry, and notebook) so plugin-manager.test.ts can read their gitignored dist/ artifacts on fresh CI checkouts. Signed official-registry metadata for official-registry-fixture.test.ts lives under packages/api/test/fixtures/official-registry/v1/.

Package check:all runs static checks only through scripts/run-package-check-all.mjs. It skips any check:* or lint script the package does not define (for example check:astro on non-Astro packages). Tests run only through pnpm test or package-specific test scripts such as check:unit, never through check:all.

Dev-only Svelte derived_inert tracing

Workspace and desktop Electron Vite dev servers can register scripts/vite-plugin-svelte-derived-inert-trace.mjs (first in the plugins array) when LAPIS_SVELTE_DERIVED_INERT_TRACE=1 (or another truthy value such as true, yes, or on) is set. The plugin still runs only during vite serve; production build paths are unaffected.

Use the dedicated root scripts when you want tracing enabled:

pnpm dev:workspace:derived-inert
pnpm dev:desktop:derived-inert

Workspace and desktop Vite configs keep svelte excluded from dep optimization in both normal and tracing mode so the traced runtime patch can be enabled without changing the underlying Svelte module shape between modes.

When Svelte emits a derived_inert warning, the patched runtime logs:

  • Browser console — collapsed group [svelte] derived_inert trace with URL, filtered creation/read stacks (raw stacks as fallback), derived.fn, and parent effect metadata
  • Vite terminal[svelte] derived_inert summary via the svelte:derived-inert HMR WebSocket event

Restart dev with a clean dependency cache after enabling the plugin or upgrading Svelte so deriveds.js is not served from a stale prebundle:

pnpm --filter @lapis-notes/workspace dev -- --force
pnpm --filter @lapis-notes/desktop-electron dev -- --force

If Svelte internals change and the string patch no longer matches, Vite logs a plugin warning and dev still starts; remove or update the plugin when leaks are fixed. Use Created stack first, then Read stack, then derived.fn to locate stale $derived references.

Common entry points

pnpm install
pnpm dev:workspace
pnpm dev:desktop
pnpm dev:web
pnpm dev:site

pnpm check:all
pnpm --filter @lapis-notes/api check:all
turbo run check:all --filter=@lapis-notes/workspace...

pnpm build:all
pnpm test
pnpm fmt
pnpm lint
pnpm test:smoke
pnpm spec:lint

Official Plugin Release Tooling

scripts/plugin-release.mjs owns the monorepo-side artifact flow for official installable plugins:

pnpm plugin-release:package -- --plugin-id lapis-docs --version 0.1.0 --input dist --out dist-registry
pnpm plugin-release:sign -- --input dist-registry/lapis-docs-0.1.0/release.json --out dist-registry/lapis-docs-0.1.0/release.signed.json --key-id KEY --private-key-file private.pem
pnpm plugin-release:keygen

The package command validates manifest.json and main.js, checks manifest ID/version against the requested release, copies plugin files into deterministic path order, and writes file SHA-256/size metadata to release.json. The sign command signs canonical JSON with an operator-provided Ed25519 private key, and the bundle subcommand creates the deterministic ZIP-compatible <plugin-id>-<version>.lapis-plugin archive containing release.signed.json plus all plugin files. The archive stores release.signed.json first and compresses plugin files with deterministic DEFLATE settings: sorted paths, fixed ZIP timestamp, no comments or extra metadata, and compression level 6. Keys are local release inputs and must not be committed.

pnpm plugin-release:keygen creates a local Ed25519 release signing key set under ~/.lapis/ by default. It writes the private PEM, public PEM, raw base64 public key, and lapis-plugin-release-key.json metadata file, and refuses to replace existing files unless --force is provided. The raw base64 public key is the app-compatible value used by trust-list updates.

scripts/publish-official-plugin-assets.mjs wraps that low-level package/sign flow for Full Registry V1 official assets:

pnpm registry-assets:version
pnpm registry-assets:publish -- --dry-run
pnpm registry-assets:publish -- --verify
pnpm registry-assets:publish -- --publish

Local TTY runs without --plugins scan Forgejo and present checkboxes for official plugins that still need a release, with all unpublished plugins selected by default. Use --no-interactive to keep the previous publish-all-unpublished behavior, or --interactive to force the prompt in non-TTY contexts.

registry-assets:version updates official publishable plugin package.json and manifest.json versions before publication. Its default plugin set matches registry-assets:publish rather than every bundled/core plugin. It defaults to today’s CalVer (YYYY.M.D, for example 2026.6.1), accepts --plugins <ids> for a subset, accepts --version <calver> for an explicit date, and accepts --patch or --patch-suffix <number> for semver-compatible patch builds such as 2026.6.1-patch.1.

Dry runs use an ephemeral signing key and stage a Forgejo-release-ready forgejo-assets/ tree under release-artifacts/official-plugin-assets/ with one .lapis-plugin bundle per selected plugin/version. Release versions are read from each selected plugin package’s package.json, and publishing fails unless that package version matches the package-local manifest.json version. The publisher does not stamp one batch version across all assets. Non-dry-run signing requires LAPIS_PLUGIN_RELEASE_KEY_ID plus LAPIS_PLUGIN_RELEASE_PRIVATE_KEY_PEM or --private-key-file, or the default ~/.lapis/ key set generated by pnpm plugin-release:keygen. Verify and publish mode also require FORGEJO_TOKEN; they check existing official plugin asset releases on Forgejo before building. Availability checks use deterministic release tags of the form official-plugin-assets-<plugin-id>-<version> and call Forgejo’s release-by-tag API for each requested pluginId@version; release asset pages are fetched only when the tag response does not include the target .lapis-plugin bundle asset. Default publish-all runs skip already-published plugin/version assets, while explicit --plugins selections fail if the requested plugin/version already exists unless --resume is set. --resume keeps a partially uploaded deterministic release and uploads only missing assets. --force-overwrite is valid only with explicit --plugins; it deletes and re-uploads same-name assets in the deterministic release and must be followed by registry sync, generation, signing, and validation. Publish mode uploads each plugin/version bundle to its own app repo Forgejo release tag and mirrors the same per-plugin release to github.com/lapis-notes/releases when LAPIS_RELEASES_GITHUB_TOKEN is set. Uploads run in small batches (--upload-batch-size, default 20), and ephemeral packaging work stays under release-artifacts/official-plugin-assets/tmp/ so each run can clean that temp subtree while restaging a fresh local asset set. Selected official plugin packages are built once up front with a single turbo run build --concurrency=1 invocation plus repeated --filter <package> flags; the publisher logs whether TURBO_API, TURBO_TEAM, TURBO_TOKEN, TURBO_REMOTE_CACHE_SIGNATURE_KEY, TURBO_CONCURRENCY, and NODE_OPTIONS are set before the Turbo run so CI output shows whether the hosted remote cache is configured for that job. Low-level upload scripts are scripts/forgejo-release.mjs and scripts/github-release.mjs; both are orchestrated through scripts/publish-release-assets.mjs.

CI

.forgejo/workflows/checks.yml runs pnpm check:all, pnpm test, pnpm test:smoke, and pnpm test:daily-use on every push and pull request through the shared composite actions .github/actions/lapis-ci-job-env and .github/actions/lapis-ci-verify. Root check:all already includes package ESLint, repo-script ESLint, and markdown spec lint. The job env action sets TURBO_CONCURRENCY=1, NODE_OPTIONS=--max-old-space-size=8192, and LAPIS_SMOKE_DESKTOP_FULL_BUILD=1 for the same resource profile as pnpm docker:ci-check. Forgejo publish-official-plugin-assets.yml calls the same action with profile: publish, which keeps TURBO_CONCURRENCY=1 but lowers Node heap to --max-old-space-size=4096 so cold notebook Vite production builds are less likely to be OOM-killed on the standard runner. The workflow also exposes manual-dispatch overrides for GitHub mirroring and upload batch size, and uses the Forgejo-compatible artifact upload action version when archiving staged assets from the container job. Release verification and other warmed-image Forgejo jobs that run Turbo tasks should call lapis-ci-job-env after dependency sync; release-desktop verify also calls lapis-ci-verify. Local pnpm test:smoke uses the desktop dev renderer; CI and pnpm release:desktop:local keep the full desktop production build (LAPIS_SMOKE_DESKTOP_FULL_BUILD=1).

Repo-root .env

Local scripts that read developer-facing environment variables bootstrap repo-root .env through scripts/load-repo-env.mjs (applyRepoEnv). Copy .env.example to .env and fill in values; never commit .env. Shell exports take precedence over file values.

Supported variables, consuming commands, and defaults are documented in .env.example. Scripts that load .env include pnpm release:desktop:local, pnpm registry-assets:publish, pnpm ci:image:push, pnpm docker:ci-check, pnpm test:smoke, pnpm dev:desktop, pnpm media:site, and macOS packaging notarization hooks. CI-only variables such as GITHUB_OUTPUT and build-time commit injection (GITHUB_SHA, LAPIS_BUILD_COMMIT) are not loaded from .env.

Reproduce CI locally in Docker

When checks pass on the host but fail in Forgejo, run the same workflow inside the lapis-ci image:

pnpm docker:ci-check --help
pnpm docker:ci-check --build --skip-smoke
pnpm docker:ci-check --pull
pnpm docker:ci-check --shell

docker:ci-check bind-mounts the repo at /__w/lapis/lapis, keeps Linux root and workspace package node_modules in Docker named volumes (so macOS binaries are not reused), and defaults to --platform linux/amd64 for CI parity. Use --native-platform on Apple Silicon when you only need Linux container behavior without amd64 emulation.

Optional Turborepo remote cache is enabled with --remote-cache, or automatically when repo-root .env contains the turbo cache keys documented in .env.example. The script passes TURBO_API / TURBO_TEAM / TURBO_TOKEN / TURBO_REMOTE_CACHE_SIGNATURE_KEY into the CI container against the hosted cache server. Use --no-remote-cache to force local-only .turbo/ caching even when .env is complete.

Inside the container the script sets CI=true and LAPIS_SMOKE_DESKTOP_FULL_BUILD=1 so desktop smoke matches Forgejo checks and release verification (full production renderer build, not the local dev server).

--build and --pull forward the resolved platform (linux/amd64 by default, or the value passed to --platform) by invoking scripts/build-lapis-ci-image.mjs and docker pull --platform directly. With --native-platform, build and pull use Docker’s host-default platform and docker run omits --platform.

Use --skip-checks to omit pnpm check:all, --skip-tests to omit pnpm test, --skip-smoke to omit xvfb-run pnpm test:smoke, and --skip-daily-use to omit xvfb-run pnpm test:daily-use. Dependency sync and smoke-vault preparation still run unless you use --shell for an interactive container.

Recommended validation before closing CI-parity work:

pnpm docker:ci-check --help
pnpm docker:ci-check --build --skip-smoke --skip-daily-use
pnpm docker:ci-check --pull
pnpm check:all
pnpm test:smoke
pnpm test:daily-use

Turbo concurrency defaults to 1 inside the container and Node heap is raised (NODE_OPTIONS=--max-old-space-size=8192) so large packages such as workspace can finish svelte-check under Docker Desktop memory limits. If tasks fail with SIGKILL or FatalProcessOutOfMemory, allocate 8 GB+ to Docker before raising --concurrency.

Related image scripts: ci:image:build, ci:image:build:amd64, and ci:image:push.

Adding a package

Every workspace package under packages/** must define:

  • build
  • check:format, check:types, and check:svelte when the package owns Svelte UI
  • check:all — static validation entry point for package checks (format, svelte, types, package-specific check:*, lint)
  • fmt, lint, and lint:fix when the package owns lintable source under src/ (plus package-specific paths such as desktop Electron host code)
  • test when the package has unit tests

Keep script ordering: devbuildpreviewcheck:*check:allfmtlintlint:fixtest → package-specific scripts.