LPM CLI

Task runner

Parallel, cached, watchable, workspace-aware execution for package.json scripts.

The task runner is what powers lpm run and the services orchestration in lpm dev. It reads scripts from package.json (and per-task config from lpm.json > tasks), builds a topological execution graph, runs tasks in parallel where the graph allows, caches results when configured, and re-executes on file changes when --watch is set.

This page covers the design — what the cache key includes, how --affected is computed, how parallelism interacts with task dependencies, and where state lives. For the CLI surface, see lpm run.

Execution model

[scripts] ──┐
            ├─→ [task graph DAG] ─→ [parallel executor] ─→ [stdout / cache]
[lpm.json] ─┘                            │

                                   [.bin/ PATH injection]
                                   [pre/post hooks]
                                   [env file loading]
                                   [readiness checks]

The runner is built as a DAG over tasks. Each task's dependsOn edges become predecessors; the executor processes tasks level by level, running independent tasks concurrently within each level.

Task definition

A task can come from two places:

package.json (the npm-shaped path)
{
  "scripts": {
    "build": "tsup",
    "test": "vitest run"
  }
}
lpm.json (the cached + DAG-aware path)
{
  "tasks": {
    "build": {
      "command": "tsup",
      "dependsOn": ["^build"],
      "cache": true,
      "outputs": ["dist/**"],
      "inputs": ["src/**", "package.json"]
    }
  }
}

lpm.json > tasks.<name> overrides package.json > scripts.<name>. The command field is optional — when absent, the runner falls back to the package.json script.

For full per-task field reference, see lpm.json tasks.

dependsOn semantics

Two flavors of dependency:

FormMeaning
"build"Same-package dependency. Wait for this package's build task to finish first.
"^build"Upstream-workspace dependency. Wait for build to finish in every workspace member that this one depends on.

With workspace:*, ^build means: "build this package after each internal dependency finishes its build task."

LPM CLI expands ^task recursively. If you filter one application, its required upstream tasks still run through the complete workspace dependency chain. Each upstream workspace member must define the requested task.

Before LPM CLI starts a task process, it checks the complete expanded graph. The command stops for a missing task, a cycle, ^, or ^^task. No task process starts after one of these graph errors.

Caching

Caching is opt-in per task:

{
  "tasks": {
    "build": {
      "command": "tsup",
      "cache": true,
      "outputs": ["dist/**"]
    }
  }
}

cache: true requires outputs to know what to cache. Without outputs, the runner disables caching because it has nothing to record.

All inputs and outputs values must be valid, project-relative globs. LPM rejects invalid syntax, absolute paths, and parent traversal before it starts the task.

Cache key

The cache key is a SHA-256 hash of:

  • The command string
  • Each forwarded script argument, with length boundaries
  • The per-file SHA-256 of every file matching inputs globs
  • The cache identities of transitive same-project task dependencies
  • The declared outputs of direct same-project task dependencies
  • The complete lpm.json file, including dependency edges and output globs
  • The inherited environment after credential and runtime-hook removal, or the variables selected by cacheEnv
  • All environment values loaded from project files, secrets, and schema defaults
  • Managed Node and Bun versions, plus fingerprints of the selected executables
  • The complete package.json contract, including scripts and resolution fields
  • Supported lockfiles in the project root
  • The workspace-root configuration and lockfiles during workspace task runs
  • The task identity of each reachable ^task dependency

Any change to any of those flips the key. Same key = cache hit.

To reuse output across machines or CI jobs, declare the inherited variables that affect a task:

lpm.json
{
  "tasks": {
    "build": {
      "cache": true,
      "cacheEnv": ["NODE_ENV", "BUILD_TARGET"],
      "outputs": ["dist/**"]
    }
  },
  "remoteCache": { "enabled": true }
}

With this configuration, changes to LPM_HOME, CI, or GITHUB_RUN_ID do not change the cache key. If your build uses CI to change behavior, include CI. Include every inherited variable that can affect the result. Names are exact and case-sensitive. Wildcards are not supported.

If you omit cacheEnv, all inherited values remain cache inputs. An empty list excludes all inherited values from the key. Project-loaded environment values always remain cache inputs. cacheEnv does not change the task's environment or bypass remote-upload secret checks.

A local dependency identity includes its cache key and each reachable dependency identity. LPM also validates each declared dependency output before a cache hit or cache publication. If an output changes, LPM does not use or publish the downstream cache entry.

LPM canonicalizes package.json before it hashes the manifest. Changes to scripts, module settings, dependencies, peer metadata, overrides, resolutions, catalogs, engine and platform selectors, and LPM controls invalidate the cache.

LPM always hashes lpm.lock. If the text lockfile is absent, LPM hashes lpm.lockb instead. When these ecosystem lockfiles exist, LPM also hashes them:

  • package-lock.json
  • npm-shrinkwrap.json
  • yarn.lock
  • pnpm-lock.yaml
  • bun.lock
  • bun.lockb
  • deno.lock

For workspace task runs, LPM also hashes the root package.json, lpm.json, and pnpm-workspace.yaml files. The root lockfiles use the same rules as the member lockfiles.

Each ^task identity includes its command, arguments, environment, runtime, inputs, configuration, manifest, lockfiles, and upstream task identities. An upstream identity change invalidates the cache of each reachable downstream task. If LPM cannot calculate an upstream identity safely, it disables caching for the dependent workspace member.

Cache layout

~/.lpm/cache/tasks/
  {sha256-hex-key}/
    meta.json       ← timing, command, key, output count
    stdout.log      ← captured stdout for replay
    stderr.log      ← captured stderr for replay
    outputs.tar.gz  ← archived output files

On hit:

  1. Validate the metadata, logs, archive paths, file types, sizes, and output count.
  2. Stage all outputs without changing the project.
  3. Remove declared output files that are absent from the cached result.
  4. Replace project outputs in one rollback-protected transaction.
  5. Preserve each archived file's mode and modification time.
  6. Replay stdout.log and stderr.log to the current process.
  7. Exit successfully.

LPM treats an incomplete or corrupt local entry as a cache miss. It runs the task and replaces the bad entry. Concurrent writers for one key publish one complete entry.

LPM writes a recovery journal before it replaces an output. If a process stops during restore, the next restore rolls back the incomplete operation first. If an immediate rollback fails, LPM keeps the recovery files and reports their path.

The restore rejects output paths that use an existing symlink or junction. The cache cannot write through that link to a location outside the project. When LPM creates an archive, it also rejects symlink and junction outputs. This rule prevents a restored output from changing its file type.

The replay shows the same logs as a real lpm run build. The runner's (cached) status tells you why the result was immediate.

Bypass

lpm run build --no-cache       # bypass for one invocation

Or remove the cache: true from lpm.json to disable persistently.

Remote cache

lpm.json
{
  "remoteCache": {
    "enabled": true,
    "team": "acme",
    "signature": true
  },
  "tasks": {
    "build": {
      "cache": true,
      "outputs": ["dist/**"]
    }
  }
}

Remote cache is opt-in on top of the local task cache. A run checks:

  1. Local task cache under ~/.lpm/cache/tasks/
  2. Hosted cache at remoteCache.url (default: configured registry + /v8)
  3. The actual script

Successful scripts always write the local cache first. If remote cache is enabled and not readOnly, LPM CLI uploads a portable artifact. The artifact contains task metadata, stdout, stderr, and outputs/. LPM validates the complete download before it changes project files. A rejected download leaves the project unchanged. The restore uses the current project's outputs globs. It does not trust output declarations from the remote artifact.

Use LPM_REMOTE_CACHE_TOKEN in CI, or rely on the token from lpm login when the cache endpoint is on the configured LPM.dev Registry origin. Third-party cache hosts never receive the registry login token; they require both LPM_REMOTE_CACHE_TOKEN and LPM_REMOTE_CACHE_SIGNATURE_KEY. When signature: true, set LPM_REMOTE_CACHE_SIGNATURE_KEY on every machine that reads or writes the cache. A missing or invalid signature is a cache miss.

Loaded or inherited env vars with secret-looking names block remote uploads by default.

Use remoteCache.env.include only for values that are safe to store in a shared build cache.

Manage

lpm cache path tasks           # print cache root
lpm cache clean tasks          # drop the task cache
lpm cache status --json        # local usage + hosted cache status

See lpm cache.

Parallelism

lpm run -p lint test typecheck

-p / --parallel runs scripts concurrently — the runner enforces dependsOn edges but otherwise lets independent tasks proceed in parallel. Output is buffered per-task by default; --stream prefixes live output instead.

--no-bail continues independent tasks after a failure. It does not run a workspace member if that member's explicit ^task dependency fails. Without this flag, the first failure stops the run.

Workspace fan-out is separate from per-package task parallelism. lpm run, lpm test, and lpm bench accept --workspace-concurrency <N> in workspace mode to cap how many selected members run at once within a topological level. The persistent chain is CLI flag, then lpm.toml > [workspace].concurrency, then ~/.lpm/config.toml > workspace-concurrency, then available host parallelism.

Workspaces

Workspace-aware commands accept --filter, --filter-prod, --all, and --affected. The set today:

Command--all--filter--filter-prod--affected
lpm run
lpm lint
lpm fmt
lpm check
lpm test
lpm bench

lpm test and lpm bench claim the same flag names from any underlying runner that uses them (notably bun's --filter); to forward those to the runner instead of LPM CLI, prefix with -- — see Test & bench runners.

Across workspace-aware commands, --all is the broad selector and is mutually exclusive with filters and --affected. Filters may compose with --affected.

The runner walks the workspace graph and selects matching members in topological order:

lpm run build --all                         # every member, deps-first
lpm run test --filter web                   # one member
lpm run test --filter './apps/*'            # path glob
lpm run test --filter '@scope/*{./apps/web}' # combined name + exact path
lpm run test --filter web --filter api      # union
lpm run test --filter-prod ...shared        # prod graph closure
lpm run test --filter '!web-tests'          # exclusion
lpm run test --filter './apps/*' --workspace-concurrency 2

lpm lint --filter './apps/*' --fail-if-no-match
lpm fmt --filter '@scope/*' --check
lpm check --affected --base develop

lpm lint, lpm fmt, and lpm check add --fail-if-no-match for CI pipelines that want to catch typo'd filters early. They don't expose --parallel or --no-bail — workspace fan-out is parallel by default within each topological level. The run continues across all levels even if individual members fail; the overall command exits non-zero after aggregation when any member failed.

Filter grammar is documented in Workspaces.

--affected

lpm run test --affected                     # default base = main
lpm run test --affected --base develop
lpm run test --affected --changed-files-ignore-pattern '**/README.md'
lpm run test --affected --test-pattern '**/*.test.js'

Computed by:

  1. git diff --name-only $base...HEAD against the base ref.
  2. Map each changed file to a workspace member (with proper directory boundary checks — packages/api-client/x.ts does not match the member at packages/api).
  3. Root-level changes (files outside any member, e.g. workspace package.json, tsconfig.json) are treated as affecting every member.
  4. Expand the directly-changed set through dep edges to include transitive dependents.

That last step is what gives you "the API package changed, so the web package that imports it also runs." Use --filter '[origin/main]' (the git-ref filter atom) for the directly-changed-only set without the dependents expansion — see Workspaces.

Use --changed-files-ignore-pattern <glob> to drop noise paths from the git diff before this mapping step. The same setting can be persisted as lpm.toml > [workspace].changed-files-ignore-pattern.

Use --test-pattern <glob> to mark matching changed files as test-only. The directly changed package still runs, but dependents are not added when a package only changed in test files. Persist defaults as lpm.toml > [workspace].test-pattern.

Watch mode

lpm run dev --watch

The runner sets up a file watcher over the task's effective inputs (same globs as the cache key) and re-runs on change. Pairs nicely with --filter for "rebuild only the affected member" loops during development.

PATH injection

Every script runs with node_modules/.bin/ prepended to PATH, so locally-installed binaries (tsc, vitest, etc.) resolve. Pre and post hooks (npm convention — prebuild, postbuild) run automatically when the corresponding scripts exist.

Env loading

Before each task starts, the runner:

  1. Selects the environment: --env first, then tasks.<name>.env, then env.<name>, then the default files.
  2. Loads the resolved file(s).
  3. Validates against lpm.json > envSchema (skip with --no-env-check).
  4. Injects into the script's environment.

CLI override: --env=<mode> forces a specific mode for one invocation, regardless of the lpm.json mappings.

Existing environment files must resolve inside the project directory. The runner rejects configured paths and symlinks that resolve outside the project.

Multi-service orchestration

When lpm.json > services is non-empty, lpm dev starts each service via the runner and waits for readiness:

  • Ownership-verified listener on each managed service port
  • Optional additional TCP poll on readyPort
  • Optional additional HTTP poll on readyUrl
  • readyTimeout seconds before failure

Services with dependsOn start in topological order. A readiness failure stops the initial startup. Dependent services do not start.

Each service receives the root managed runtimes, except for runtimes that it selects in its own cwd.

Shutdown stops the complete tracked service process trees.

The resolved primary endpoint receives LPM CLI-owned HTTPS, tunnel, and LAN frontends plus the browser-open.

One service is the implicit primary service. A multi-service configuration must mark one service as primary: true to use these features.

See lpm dev and lpm.json services.

See also