npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@devc-tools/core

v0.6.1

Published

devc's container lifecycle logic (start/attach/exec/stop, the devc.json overlay, the config wizard's pure helpers) as a runtime-neutral library — the same source Deno compiles into the devc CLI, published for programmatic consumers.

Readme

@devc-tools/core

devc's dev container lifecycle logic — start/rebuild/stop/down, status, mounts, exec, the devc.json overlay, and the config wizard's pure helpers — as a runtime-neutral library. It is written against node: builtins only, so the exact same source runs unchanged on both Deno (devc's own deno compile binary consumes it from source) and Node (this package, published to npm).

This is not a new tool. It is the library devc has always had, split out so a programmatic consumer — a coding-agent extension, a script — can call startContainer and get a ContainerInfo back as a value, without a devcontainer/devc binary on disk or stdout to parse. devc's own CLI, install, and behavior are unchanged; see .plans/archived/devc-core-npm-library.md for the full design, and .plans/archived/devc-core-consumer-prep.md for the logger seam, the runner factory and the content-addressed cache below.

Install

npm install @devc-tools/core

Requires Node 20+. docker is the only external prerequisite at runtime — the devcontainer CLI is an ordinary dependencies entry (@devcontainers/cli), resolved and spawned for you.

Usage

import {
  downContainer,
  execInContainer,
  startContainer,
} from '@devc-tools/core';

const info = await startContainer('/path/to/project');
console.log(info.containerId, info.remoteWorkspaceFolder);

const { code, stdout } = await execInContainer('/path/to/project', {
  cmd: ['npm', 'test'],
  stdio: 'piped', // capture output instead of inheriting the parent's stdio
});

await downContainer('/path/to/project');

Everything importable is re-exported from the package root (mod.ts / dist/mod.js) — see that file for the full surface, grouped by module: container.ts (lifecycle), overlay.ts (devc.json), merge.ts + merged_config.ts (the layer merge and the effective config it produces), config.ts (global user config), mount_paths.ts (host ↔ container path translation over a container's mount table), worktree.ts + mounts.ts + wizard_apply.ts (the config wizard's pure helpers), init.ts (scaffold the bundled default .devcontainer/), default_config.ts (the bundled default and devcontainer.json variable substitution), and jsonc_edit.ts / posix.ts / paths.ts (small primitives the rest is built on).

mounts.ts also derives the git-protection mounts — the read-write .git mountpoint plus the read-only config and hooks binds that stop a container writing host-executable git config — and overlay.ts decides which bind-mounted repos get them, reads the gitProtect opt-out, refuses Docker Compose (where the CLI drops readonly), and compares the result against a container's live mount table for devc status. They are contributed as a merge layer below everything else, same as the bridge token mount and for the same reason: readonly can only be expressed in a config mounts entry, never by a Feature. See devc's README.

mount_paths.ts works host-side only. It reads the table getContainerMounts returns from docker inspect, where a bind mount's source is the real host path; inside a container the same mount reports a source like /run/host_mark/Users, so a container cannot derive host paths at all. Reach for this rather than /proc/mounts — and only from the host.

"Real host path" takes one step of work that docker inspect does not do for you. Docker Desktop runs the daemon in a Linux VM and reports bind sources as paths in that VM, grafted under /host_mnt — and it does so inconsistently: on macOS, devcontainer.json mounts entries come back as /host_mnt/Users/me/... while the workspace folder mount comes back host-real as /Users/me/..., both in the same table. parseMounts strips the prefix (hostSourceFromMount), so ContainerMount.source is always a path the host would recognize and a caller may safely stat it or compare a host path against it. Anything reading .Mounts without going through core has to do this itself, and the failure is silent: a /host_mnt/... source simply matches nothing and does not exist.

The devcontainer CLI seam

startContainer/rebuildContainer/execInContainer accept an optional devcontainer: DevcontainerRunner in their options, defaulting to nodeDevcontainerRunner — a plain process.execPath + the resolved devcontainer.js from node_modules. A consumer embedding its own copy of the devcontainer CLI, or needing a different one, can supply its own DevcontainerRunner:

export interface DevcontainerRunner {
  run(args: string[]): Promise<{ code: number; stdout: string }>;
}

(devc's own CLI binds a different one — a hidden self-exec subcommand, since a deno compile binary has no node_modules a separate process could open. See devc/devcontainer_selfexec.ts.)

For the common case — the Node runner, but with the devcontainer CLI's stderr as data rather than on the terminal — there is a factory rather than a hand-rolled runner:

import { createNodeDevcontainerRunner, startContainer } from '@devc-tools/core';

const info = await startContainer('/path/to/project', false, {
  devcontainer: createNodeDevcontainerRunner({
    onStderr: (chunk) => myTui.appendBuildLog(chunk),
  }),
});

With onStderr the CLI's stderr is piped and forwarded chunk by chunk; with no options it is inherited, exactly as nodeDevcontainerRunner (which is the no-options instance) has always done. On a cold build that stream is minutes of the only progress there is, so a consumer that hides it usually wants it somewhere. devcontainerJsPath() is exported alongside, for a consumer building a runner of its own — it is the path this package resolves out of its own node_modules, and re-deriving it from outside would mean depending on where core's bundle happens to sit on disk.

Where core's output goes

A handful of sites in core have something to say to a human: an ignored template file, a bridge mount that could not be injected, the build output of a failed devcontainer up. By default they go where they always went — notice to stdout, warning to stderr — which is what keeps devc's own output byte-identical.

That is the wrong destination for a consumer holding the terminal (a TUI): the text lands in its stdout and stderr and corrupts the display. One call at load redirects all of it:

import { setLogger } from '@devc-tools/core';

setLogger((level, message) => myTui.log(level, message)); // 'notice' | 'warning'
setLogger(null); // back to the console default

It is process-global and deliberately so — the call sites sit at varying depths across three modules, several inside otherwise-pure helpers that no options object reaches. There is one core instance per process and one consumer driving it.

Two caches under ~/.cache/devc/

projects/<key>/devcontainer.json is the effective config for one project — the base config with devc's own layer and both devc.json overlays merged in (ensureMergedConfig). It is what devcontainer up is actually given, and it is written on every start, atomically, at 0600. Its path is keyed on the project and never on content, deliberately: the devcontainer CLI keys a container on devcontainer.local_folder + devcontainer.config_file and will not reuse one whose config_file differs — without removing it, even under --remove-existing-container — so a path that moved would strand a container per move.

Nothing is written into the project. A generated config inside a git worktree is also a file inside a Docker build context, it shows up in git status for a repo that need not know devc exists, and it would carry the user-level overlay's contents into a committable location.

default-<key>/ is the bundled default, materialized for the zero-config path (ensureDefaultConfig). The key is a 12-hex-char sha256 over the bundled default/ tree and the user's ~/.config/devc/templates overlay, so:

  • identical inputs reuse the directory and write nothing — a second start is a hash and a stat;
  • different inputs (a different devc version, an edited template) get a different directory, so two copies of core on one machine never rewrite each other's config and never cause a rebuild from nothing the user did;
  • a miss stages into a sibling .tmp-…/ and renames it into place, so a concurrent read never sees a half-written tree.

materializeDefaultConfig — which writes unconditionally to the directory it is handed — is the layer underneath ensureDefaultConfig, kept separate because it is what the tests drive; calling it from production code reintroduces the shared-mutable-path bug the cache exists to fix.

What's deliberately not here

Attaching an interactive shell (devc attach / devc claude) — tmux window titles, OSC terminal-tint escapes, raw-mode TTY handling — stays in devc's own attach.ts. None of it means anything to a library consumer that isn't holding a terminal.

Also not here: the devc CLI itself, and any change to how humans install it. devc still ships as a single deno compile binary via install.sh; this package is an additional distribution channel for the logic underneath it, not a replacement for the CLI.

Development

deno task check   # type-check under Deno — the primary suite, since both hosts
deno task test    # read the exact same source (`devc`'s own `deno task test`,
                   # run from ../devc, covers the CLI half: attach, args, help)

npm run build              # esbuild → dist/mod.js, tsc → dist/*.d.ts, default/ copied in
npm run check               # tsc --noEmit, the npm-facing type check
npm run portability-check   # fails if a `Deno.` or `jsr:` reference sneaks back in

The portability check exists because the failure it catches is otherwise silent: a stray Deno. in a module here keeps every deno test green and only breaks the npm build. CI runs both deno task test here and a real npm pack + scratch-project node smoke run (smoke.mjs) against the built tarball, with no Deno, no devcontainer, and no devc on PATH.

default/ is the bundled zero-config devcontainer.json + Dockerfile + lifecycle scripts, shared by devc's zero-config path and devc init. It ships inside the npm tarball (npm run build copies it beside dist/mod.js) and inside the compiled devc binary (deno compile --include ../devc-core/default, from devc/deno.json) — same files, two delivery mechanisms.