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:
| Category | Examples |
|---|---|
| Dev | dev:workspace, dev:desktop, dev:web, dev:site |
| Build | build:all, build:desktop, build:web, build:site |
| Preview | preview:web, preview:desktop, preview:site |
| Dist / release | dist:desktop:mac, dist:desktop:linux, release:desktop:local |
| Plugin release | plugin-release:package, plugin-release:sign, registry-assets:* |
| Kill | kill:workspace, kill:desktop |
| Check | check:all, check:format, check:types, check:source-resolution |
| CI / Docker | docker:ci-check, ci:image:build, ci:image:push |
| Test | test, test:watch, test:smoke, test:daily-use, test:scripts |
| Format / lint | fmt, lint, lint:fix |
| Site media | media:site |
Package scripts keep short names (dev, build, preview, test) plus
check subtasks:
check:format,check:svelte,check:types, package-specificcheck:*check:all— runs the applicable staticcheck:*subtasks andlintfor that package throughscripts/run-package-check-all.mjsfmt,lint, andlint:fixwhen the package owns lintable sourcetestwhen the package has unit tests (never invoked fromcheck: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
- Package-local static checks:
pnpm --filter <package> check:all - Package-local tests:
pnpm --filter <package> teston each modified package that defines atestscript - Affected packages:
turbo run check:all --filter=<package>...andturbo run test --filter=<package>... - Full repo:
pnpm check:allandpnpm test - App smoke (when runtime may change):
pnpm test:smoke
What check:all runs
Root check:all:
check:source-resolution— first-party@lapis-notes/*source-resolution guards (Vite dev resolution, tsconfig import resolution, Vitest test resolution, tsconfig path sync)plugin-host:check— generated plugin-host module drift guardtest:scripts— repo script unit teststurbo run check:all— each package’s static checks (check:format,check:svelte,check:types, package-specificcheck:*, andlint) throughscripts/run-package-check-all.mjscheck:format:root— Prettier for repo-root paths outside workspace packages (scripts/,spec/, agent docs, CI workflows, and similar)lint:root— ESLint for repo scripts underscripts/spec:lint— markdownlint forspec/and package-localspec.mdfiles
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 tracewith URL, filtered creation/read stacks (raw stacks as fallback),derived.fn, and parent effect metadata - Vite terminal —
[svelte] derived_inertsummary via thesvelte:derived-inertHMR 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:
buildcheck:format,check:types, andcheck:sveltewhen the package owns Svelte UIcheck:all— static validation entry point for package checks (format, svelte, types, package-specificcheck:*, lint)fmt,lint, andlint:fixwhen the package owns lintable source undersrc/(plus package-specific paths such as desktop Electron host code)testwhen the package has unit tests
Keep script ordering: dev → build → preview → check:* → check:all →
fmt → lint → lint:fix → test → package-specific scripts.