NPM Scripts Explorer
A VS Code extension that discovers package.json scripts and gives them a
dedicated sidebar view: run with one click, filter/search, pin favorites,
group by category, see live running status, and stop a running script —
all while auto-detecting whether the project uses npm, pnpm,
yarn, or bun.
Getting started
npm install
npm run build # bundles src/extension.ts -> dist/extension.js
Then press F5 in VS Code (with this folder open) to launch an Extension
Development Host with the extension loaded. Open any project with a
package.json and the "NPM Scripts" icon will appear in the Activity Bar.
npm run watch rebuilds on save during development. npm run compile
type-checks without emitting (used in CI/pre-commit).
Architecture
The code is organized in layers, each with one reason to change:
src/
├── extension.ts # composition root: wires everything together
├── models/types.ts # domain types — zero vscode imports
├── services/
│ ├── PackageManagerDetector.ts # lockfile -> package manager
│ ├── ScriptCategoryClassifier.ts # script name/command -> category
│ ├── PackageJsonParser.ts # file -> ParsedPackageFile (+ cache)
│ ├── WorkspaceScanner.ts # locates nearest package.json per folder
│ ├── ScriptRepository.ts # aggregates scanner + parser results
│ ├── TerminalManager.ts # owns Terminal creation/reuse
│ └── ScriptRunner.ts # ScriptDefinition -> executed command
├── state/
│ ├── FavoritesStore.ts # persisted pinned scripts (workspaceState)
│ └── RunningScriptsTracker.ts # in-memory "what's running now"
├── providers/
│ ├── ScriptTreeItem.ts # TreeNode -> vscode.TreeItem
│ └── ScriptsTreeDataProvider.ts # tree shape + refresh semantics
├── watchers/
│ └── FileWatcherService.ts # fs/workspace events -> repository refresh
└── commands/
└── commands.ts # binds command IDs to service calls
Why this shape (SOLID, applied concretely)
Single Responsibility. Each service does exactly one job:
PackageManagerDetector only detects a PM, ScriptRunner only turns a
script into an executed command, ScriptsTreeDataProvider only decides
tree structure. None of them know how to parse JSON, run processes, and
render UI — that would make every change ripple across concerns.
Open/Closed. Package managers are data (PACKAGE_MANAGERS table),
not a switch statement threaded through multiple files. Adding a fifth
package manager means adding one table entry in
PackageManagerDetector.ts. The same applies to category rules in
ScriptCategoryClassifier.ts — an ordered rule table instead of
scattered if/else.
Liskov / Interface Segregation. ScriptsTreeDataProvider depends on
ScriptRepository, FavoritesStore, and RunningScriptsTracker through
their public surface only (a handful of methods and one event each), not
on vscode.ExtensionContext or the filesystem directly. Each of those
collaborators could be replaced by a test double implementing the same
narrow surface.
Dependency Inversion. extension.ts is the only file that calls
new X(...) and decides concrete wiring (constructor injection
throughout). Every other file receives its dependencies as constructor
arguments. This is what makes the object graph visible in one place and
each class unit-testable without booting VS Code.
Domain vs. VS Code API. models/types.ts has zero import * as vscode. Business concepts (a script, a package manager, a category)
are defined independently of how the editor happens to render or store
them, so the domain logic isn't accidentally coupled to vscode.TreeItem
shapes.
PackageJsonParser keeps an in-memory cache keyed by file path, storing
the mtimeMs seen at last parse. On every request it first calls
vscode.workspace.fs.stat (cheap) and only reads + JSON.parses +
re-classifies scripts when the mtime has actually changed. Combined with
FileWatcherService (which calls refreshFile() only for the file that
actually changed, not a full rescan), this means:
- Expanding/collapsing tree nodes, toggling grouping, or typing in the
search box never touches the filesystem — they only re-render from
already-parsed data.
- Editing one
package.json in a multi-root workspace re-parses that
file only; every other project's cache entry is untouched.
- A full
ScriptRepository.refresh() (re-scan + re-parse everything) only
happens on the "Refresh" button, workspace folder add/remove, or a
lockfile change (since that can change which package manager applies
to every script in that folder).
TreeView design
Tree rows are modeled as a plain discriminated union (TreeNode in
ScriptTreeItem.ts) rather than subclasses of vscode.TreeItem. This
separates what the tree contains (folders → favorites → categories →
scripts) from how a row is drawn (icon, tooltip, contextValue).
contextValue is built as a colon-joined set of flags
(script:running:pinned), and package.json's view/item/context menu
entries key off viewItem =~ /running/ style regexes — so new context
menu actions or new flags don't require any TypeScript changes, only new
when clauses.
Terminal & running-status design
RunningScriptsTracker is the single source of truth for "is script X
running", exposed as a plain event emitter so the tree can refresh icons
reactively. TerminalManager is the only class that touches
vscode.window.createTerminal / Terminal, and implements two policies:
- Run reuses one terminal per script id (so re-running "dev" reuses
the same terminal, matching how most developers work).
- Run in New Terminal always spawns a fresh terminal, useful for
running the same script twice or comparing output side by side.
- Stop sends
Ctrl+C (\u0003) to the script's terminal — the
portable way to interrupt an arbitrary shell command, since the API has
no generic "kill this npm script" primitive.
- Closing a terminal (
vscode.window.onDidCloseTerminal) automatically
marks the corresponding script as stopped, so status never gets stuck.
Multi-root workspaces
WorkspaceScanner iterates vscode.workspace.workspaceFolders and
resolves the nearest package.json per folder (root first, then a
bounded, node_modules-excluded glob search as a fallback for monorepo
layouts). ScriptsTreeDataProvider only inserts a "folder" grouping level
when more than one workspace folder actually contributes scripts, so a
single-project workspace doesn't grow an unnecessary extra tree level.
Features implemented
- Nearest
package.json discovery, per workspace folder
- npm / pnpm / yarn / bun auto-detection via lockfiles, npm as fallback
- Tree view with icon-per-category, command as description + tooltip
- Click-to-run, using the correct package manager's run syntax
- Auto-refresh on
package.json edits, lockfile changes, and workspace
folder changes, without re-parsing unrelated files
- Search/filter (by name or command)
- Pin/favorite scripts (persisted per workspace)
- Group by category (toggle) — dev, build, test, lint, format, database,
docker, release, clean, other
- Live running-status icon, with a Stop action
- Multi-root workspace support (folder grouping)
- Run in current (reused) terminal vs. always-new terminal
- Context menu: Run, Run in New Terminal, Stop, Copy Command, Reveal in
package.json, Toggle Pin
Extending it
- New package manager: add one entry to
PACKAGE_MANAGERS and
LOCKFILE_MARKERS in PackageManagerDetector.ts.
- New category: add a value to
ScriptCategory in models/types.ts,
a rule in ScriptCategoryClassifier.ts, a label in
ScriptTreeItem.ts's CATEGORY_LABELS, an icon in utils/icons.ts, and
an entry in CATEGORY_ORDER in ScriptsTreeDataProvider.ts.
- New context menu action: register a command in
commands/commands.ts
and add a view/item/context entry in package.json, keyed off the
existing contextValue flags (or a new flag added in ScriptTreeItem.ts).
| |