@denny-il/devenv

中文文档
0.7.0发布于 2026/10/31MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 2578

An isolated local development stack per worktree: Compose services, host apps, and HTTPS over Tailscale

原始使用文档
文档目录13 节
  1. Install
  2. Updating
  3. Agent skill
  4. Commands
  5. Setting up a machine
  6. What a project provides
  7. Host-only apps
  8. Config
  9. How a stack behaves
  10. Tailscale
  11. Every stack on the machine
  12. Under a sandbox
  13. Working on this package

devenv

One command for a project's local stack: Docker Compose services, the apps that run on the host, and, when asked, HTTPS access from every device on a Tailscale network.

Every checkout gets a stack of its own, so several worktrees of a project run side by side. Nothing listens beyond this machine.

Linux and macOS, Node 22.18 or newer. Stacks with Compose services also need Docker Compose 2.24.4 or newer.

Install

pnpm add -D @denny-il/devenv
// package.json
{ "scripts": { "local": "devenv" } }

Updating

pnpm update @denny-il/devenv

This moves to the newest version the range in package.json allows. Before 1.0 a range such as ^0.2.0 covers patch releases only; --latest goes past it.

pnpm prefers versions that have been out for a day, so an update can keep the older version until then. To get a release at once, exempt the package in the project's pnpm-workspace.yaml:

minimumReleaseAgeExclude:
  - "@denny-il/devenv"

Agent skill

The package carries a skill for coding agents that work in a project using the launcher: the commands and their rules, the config, and how to move a project onto it. Install it from the installed package, and again after an update:

npx skills add ./node_modules/@denny-il/devenv

Commands

CommandWhat it does
devenv upStarts any services, runs compose.init, assigns app ports, and runs setup.
devenv startRuns every app of a stack that is up, until one stops.
devenv start --tailscaleThe same, with the apps served to this machine's tailnet over HTTPS.
devenv stopStops this worktree's running start, if any, and waits until its apps stop.
devenv statusShows the services, the app URLs, and whether start is running.
devenv run -- <command>Runs a command with the stack's environment.
devenv test [-- <args>]Runs the tests on a fresh stack of their own, always removed afterwards.
devenv downStops the apps and the services; keeps the services' named volumes.
devenv resetStops the apps and deletes this worktree's containers, volumes, images, state.
devenv listShows every stack on this machine.
devenv pruneRemoves the stacks whose checkout is gone, with their data.
devenv setup [docker] [tailscale]Installs Docker and Tailscale on this machine, and sets them up.
devenv tailnet infoShows this machine's tailnet name and what keeps it from serving, if anything.
devenv tailnet serveServes the given local ports to the tailnet until stopped, without any project.

The stack has two parts with lifetimes of their own. up prepares it: the services, which keep running under Docker until down, and setup. start runs the apps in the foreground, and they stop with it, by Ctrl-C, a signal, or devenv stop. To restart the apps after a change they do not pick up on their own, run devenv stop, then devenv start. Run devenv up again first after changes to the services, compose.init or setup, such as pulled migrations, and after a reboot.

devenv restart was removed in 0.7.0 and fails with a message naming its replacement. Before then, start also started the services and ran setup.

Setting up a machine

devenv setup checks what a stack needs on this machine and installs what is missing. devenv setup docker and devenv setup tailscale do one of the two. It installs Docker and Tailscale with their own installers on Linux, with Homebrew on macOS. The installers ask for a password.

On Linux it also starts both, signs in to Tailscale, and lets your user run Docker and serve over Tailscale. What only a person can do is printed: on macOS opening the two apps and signing in, and on any machine enabling HTTPS certificates for the tailnet.

Run it again until every line is checked; it changes nothing that is already in place.

What a project provides

A project adapts to the launcher, not the other way around.

  • Compose files (optional). A base file and an overlay per mode. Services publish their ports on loopback ports that Docker assigns (ports: [{ target: 5432, host_ip: 127.0.0.1 }]), never on fixed ones. Readiness is a healthcheck; a service without one is ready once it runs. The launcher names the Compose project, and starts only the services outside of profiles.
  • Apps. Each is a command that takes its ports from its environment and listens on 127.0.0.1: a dev server, or a command that builds and then serves the build. Each app picks its own; one stack can mix both.
  • Environment. One function from the stack to the variables every app and command gets, on top of the root .env. A git worktree without a .env gets the main checkout's.

Host-only apps

A project with no container dependencies omits compose (or sets compose: { files: [] } with no overlays). Normal commands never probe or start Docker for these stacks, including status, down, reset and test.

import { defineConfig } from "@denny-il/devenv";

export default defineConfig({
  name: "app",
  apps: {
    server: {
      command: "pnpm server dev",
      ports: { http: {}, worker: { internal: true } },
      env: (ports) => ({
        HOST: "127.0.0.1",
        PORT: ports.http,
        WORKER_PORT: ports.worker,
      }),
    },
    web: {
      command: "pnpm web dev",
      env: (ports) => ({ HOST: "127.0.0.1", PORT: ports.http }),
    },
  },
  env: ({ root, port, url }) => ({
    APP_CONFIG: `${root}/config.ts`,
    API_ORIGIN: `http://127.0.0.1:${port("server.http")}`,
    WEB_URL: url("web"),
  }),
});

up runs setup and assigns and remembers ports, so run can use the environment; start runs the apps as usual, and start --tailscale serves the public app ports and keeps internal ones private. status reports those URLs and whether start is running, not whether each app answers. down stops a running start, marks the stack stopped and keeps its ports; reset stops a running start and removes the state. App data in host files is left to the project to manage.

test runs test.setup and test.command with fresh app ports and a separate context, without starting the apps. list calls a prepared host-only stack ready, and host-only orphans can be pruned without Docker. list needs Docker only for dev stacks with containers; prune also needs it for stacks whose tests may have left Docker resources.

Before converting an existing Compose stack, run reset to remove its resources. Older state files remain Compose states; removing compose from the config does not bypass their cleanup, and reset can clean them by name. If the state is unreadable, its mode is unknown: reset attempts Docker cleanup by name. When Docker is unavailable, it resets local state and warns that the named project may still have containers or volumes to remove.

A config with only compose.test files keeps start, up, status, list and down independent of Docker. test, reset and orphan pruning still need Docker to manage its test stacks. Removing the test files does not block host apps: saved cleanup metadata is kept until reset, so abandoned test resources can still be removed later. Compose variables are refreshed on each host-only startup; they freeze when dev Compose services can create volumes.

Config

devenv.config.ts (or .js) in the project root, found from any directory below it:

import { defineConfig } from "@denny-il/devenv";

export default defineConfig({
  // Names the Compose projects: `shop-local-<worktree>`, `shop-test-<worktree>-<run>`.
  name: "shop",

  compose: {
    files: [".infra/compose.yml"],
    dev: [".infra/compose.local.yml"],
    test: [".infra/compose.test.yml"],
    // Services that do a job and exit. Each runs once the others are ready.
    init: ["create-bucket"],
    // Variables for the Compose files. A stack keeps the values its volumes
    // were created with until `reset`.
    env: {
      POSTGRES_USER: process.env.POSTGRES_USER || "shop",
      POSTGRES_PASSWORD: process.env.POSTGRES_PASSWORD || "shop",
    },
  },

  // Started by `start` in this order, without waiting for one another.
  apps: {
    api: {
      command: "pnpm --filter api dev",
      // `internal` ports are reserved, but neither listed nor served.
      ports: { proxy: {}, worker: { internal: true } },
      env: (ports) => ({
        API_HOST: "127.0.0.1",
        API_PORT: ports.proxy,
        WORKER_PORT: ports.worker,
      }),
    },
    // Without `ports`, an app has one port named `http`.
    web: {
      command: "pnpm --filter web dev",
      env: (ports) => ({ HOST: "127.0.0.1", PORT: ports.http }),
      // Optional: `start` reports the stack ready once this answers.
      ready: { path: "/" },
    },
  },

  env: ({ service, vars, url, project }) => ({
    DATABASE_URL: `postgresql://${vars.POSTGRES_USER}:${vars.POSTGRES_PASSWORD}@127.0.0.1:${service("postgres").port(5432)}/shop`,
    API_URL: `${url("api.proxy")}/api`,
    WEB_URL: url("web"),
    // Browsers share cookies across ports: a name per worktree keeps their
    // sessions apart.
    COOKIE_PREFIX: project,
  }),

  // Run by `up` once the services are up.
  setup: ["pnpm database migrate", "pnpm database seed"],

  test: {
    setup: ["pnpm database migrate"],
    // Arguments after `devenv test --` are added; a function decides itself.
    command: "vitest --run",
    env: { DATABASE_TESTS: "true" },
    // Compose variables of the test stack alone, over `compose.env`.
    compose: { env: { POSTGRES_PASSWORD: "test" } },
  },

  // Printed under the URLs by `start` and `status`.
  info: () => ({ "Sign-in": "[email protected] / admin" }),
});

A command is a line for the shell, or a list of a program and its arguments.

The functions get the stack as a context:

MemberWhat it is
service(name).port(target)The loopback port Docker published a service's port on.
port(ref)An app's port: 'api.proxy', or 'web' for an app with one port.
url(ref)The origin that port answers at.
varsThe Compose variables as the stack uses them.
projectThe stack's Compose project name.
rootThe project root.
tailnetThis machine's tailnet name, under start --tailscale.

How a stack behaves

  • Ports. Docker assigns the services' ports; the launcher picks free ports for the apps and uses the same ones on the next run while they are free, so URLs open elsewhere keep working.
  • State. Each stack's ports and Compose variables are kept in ~/.local/state/devenv/stacks/<project>.json (under $XDG_STATE_HOME when that is set), not in the checkout. So the credentials are in no workspace, and a stack can still be found after its checkout is gone.
  • Readiness. The apps start in order without waiting for one another. An app with ready counts as ready once its port accepts a connection, or, with a path, answers it over HTTP with a status below 400. start prints the URLs once every such app is ready, and before that only that it is waiting. An app without ready is reported as started, which says nothing about whether it answers yet. An app that exits before it is ready, or is not ready within its timeout (300 seconds unless given), fails the start and stops the stack.
  • Stopping. Each app's process group gets one SIGINT, whatever stopped start, and start ends with Stopped (<signal>): <apps>. devenv stop stops the worktree's running start from anywhere, and devenv start runs the apps again; the services keep running throughout. Ten seconds later, what is still running in an app's process group is killed, even when the app's command itself has already exited: a server that is stopping a build it started gets the whole ten seconds. Any app stopping stops the stack.
  • Tests. Each run has a Compose project of its own, removed when the run ends, on Ctrl-C too, with the images built for it. A run that was killed outright is cleaned up by the next one, or by reset, along with any compose.init job it left running.
  • .env in a worktree. .env is not checked out, so a git worktree starts without one. Until it has its own, the main checkout's is loaded, and a line says so. An empty .env in the worktree turns that off.

Tailscale

devenv start --tailscale serves every app port that is not internal as https://<machine>.<tailnet>.ts.net:<port>. Those are the app URLs for the run, on this machine too.

Once per tailnet and machine:

  • Enable HTTPS certificates on the tailnet's DNS page.
  • On Linux, allow your user to serve: sudo tailscale set --operator=$USER.

The first request to a new name can take half a minute while its certificate is issued. The entries disappear when the launcher stops; nothing here ever runs tailscale serve reset, which would wipe every project's entries.

Everyone who can reach this machine on the tailnet reaches the stack. For a Vite dev server the launcher sets __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS, so it accepts the tailnet name without a config change.

Every stack on the machine

devenv list shows each stack with its checkout and whether it is running, stopped or orphaned. A stack is orphaned once its checkout is gone, which happens when a worktree is deleted without devenv reset: its containers and volumes stay in Docker with nothing left to manage them.

devenv prune lists the orphans and, after a yes, removes them with their data and built images. A checkout that was moved, or sits on a disk that is not mounted, looks the same as a deleted one, so read the list before agreeing. Without a terminal it removes nothing unless given --yes.

Under a sandbox

An agent that runs commands in a sandbox needs writing to ~/.local/state/devenv, plus the Docker socket for Compose stacks. Without the second, a command stops before starting anything and names the directory.

Working on this package

pnpm check && pnpm test

skills/use-devenv describes the launcher to agents in the projects that use it. Change it together with the behaviour it describes.

Releases are published by the Publish workflow, dispatched by hand on main, with npm trusted publishing. A release PR bumps the version first; the workflow checks that its input matches package.json, runs the checks and tests, builds dist/ (which is not committed), publishes, and then tags the commit and creates its GitHub release. A rerun finishes a release that failed partway, and never publishes over another one:

pnpm version patch --no-git-tag-version   # or minor; merged in a PR
gh workflow run publish.yml --ref main -f version=<version>

.agents/skills/release gives an agent the same steps, with what to check first and what to do when one fails.

版本与发布记录

@denny-il/devenv 共发布 8 个版本,首次发布于 2026/10/11;过去 12 个月发布了 8 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.7.0latest2026/10/3112 天前
0.6.12026/10/2121 天前
0.6.02026/10/2121 天前
0.5.02026/10/2121 天前
0.4.02026/10/1824 天前
0.3.02026/10/1329 天前

关于 @denny-il/devenv 的常见问题

@denny-il/devenv 是什么?

An isolated local development stack per worktree: Compose services, host apps, and HTTPS over Tailscale

如何安装 @denny-il/devenv?

在项目目录运行 npm install @denny-il/devenv,或使用 pnpm add / yarn add / bun add。它还提供命令行工具 devenv,可以用 npx devenv 直接运行。

@denny-il/devenv 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@denny-il/devenv 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@denny-il/devenv 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.18.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@denny-il/devenv 可以商用吗?

@denny-il/devenv 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@denny-il/devenv 的下载量有多少?

根据 npm 官方下载统计,@denny-il/devenv 在 2026/10/12 – 2026/11/10 这 30 天内被下载 2,578 次,日均约 86 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@denny-il/devenv 还在维护吗?

最新版本 0.7.0 发布于 2026/10/31(12 天前);至今共发布 8 个版本,其中过去 12 个月发布了 8 个;首次发布于 2026/10/11。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。