name: node-conventions description: >- Node.js / Bun / TypeScript house style for subctl projects — runtime choice, imports, naming, errors, async patterns, testing.
Load this skill whenever a worker is touching .ts, .tsx, .js, or
.mjs files in a subctl-managed project. Use it instead of guessing the
conventions from the surrounding code (the surrounding code may itself be
drift). Defaults documented here are the operator's preferences as
expressed across the v2.7.x release wave.
scope: dev-team
loaded_by_default: []
created_at: "2026-05-10"
created_by: operator
Node Conventions
Default ecosystem skill for Node-flavored work in subctl projects. These are the conventions the v2.7.x wave shipped against; treat them as the defaults unless a project's local CLAUDE.md overrides.
1. Runtime: Bun, not Node
Subctl is a Bun project. New code:
- Uses
bunas the runtime,bun:sqlitefor SQLite,bun:testfor tests - Uses
Bun.spawnfor subprocess (notchild_process) - Uses
Bun.file()for file IO (notfs.promises) where it improves readability - Uses
Bun.serve()for HTTP servers (not Express / Fastify) where the project already uses it
Exception: if a file already imports from node:* modules, match its style
unless you're rewriting the whole file. Don't half-convert.
Package install: bun install. Lockfile: bun.lock. Do not commit
package-lock.json or pnpm-lock.yaml to a Bun project.
2. TypeScript
Strict mode
tsconfig.json has "strict": true. Never disable strict checks
file-by-file. If you need an any, write // eslint-disable-next-line (or
the Bun equivalent) with a one-line reason. any without justification fails
review.
Imports
- ES module style:
import { foo } from "./bar" - No file extension in import paths for
.tsfiles - Relative imports use
./and../. Path aliases (@/components/...) are used only wheretsconfig.jsonalready declares them. Don't introduce new ones in a single-file PR. - One blank line between import groups: external packages, internal modules, type-only imports
import type { … }for type-only imports — Bun strips them at build time
Types
- Prefer
typeoverinterfaceunless you specifically need declaration merging or class-shape contracts - Inline anonymous types are fine for return-type clarity; named types when reused
- Discriminated unions (
type Foo = { kind: "a"; … } | { kind: "b"; … }) over loose objects with optional fields unknownoveranywhen accepting untrusted input; narrow with type guards before use
Naming
camelCase— variables, functions, parameters, instance methodsPascalCase— types, classes, React componentsSCREAMING_SNAKE— environment variables, true constants (rare)kebab-case— file names, except React components (which match the exported component name)- Tests:
<module>.test.tsnext to the module, or under__tests__/subdirectory if the module folder has multiple tests
3. Error handling
Throw Error, not strings
// no
throw "config missing"
// yes
throw new Error("config missing: ~/.config/subctl/policy.json")
Errors are values at the boundary
Functions that can fail in expected ways return a discriminated result rather than throwing — especially anything called from the master agent's tool loop or a dashboard route handler:
type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export function resolveSecret(key: string): Result<string> {
// ...
}
Throws are reserved for programmer errors (preconditions, invariants).
Expected failures are { ok: false, error }.
No silent catches
// no
try { ... } catch { /* whatever */ }
// yes
try { ... } catch (err) {
console.error(`[scope] operation failed: ${err}`)
return { ok: false, error: String(err) }
}
Logging the error with a [scope] prefix lets the operator find it in
master.log later. Silent catches eat debugging time.
4. Async patterns
async/awaitover raw promise chains- One
Promise.allper batch when calls are independent. Loops ofawaitinside aforare usually wrong unless ordering matters. AbortSignalfor any operation that can outlive its caller — HTTP fetches, long-running subprocesses, watcher polls- No
process.nextTick, nosetImmediatefor sequencing — use proper await points
Subprocess (Bun.spawn)
Always pass argv as an array; never as a string. No shell interpolation:
// no — shell-interpreted, injection risk
Bun.spawn(["sh", "-c", `op read ${ref}`])
// yes — argv-passed, safe
Bun.spawn(["op", "read", ref])
Capture stdout/stderr to buffers, not stream to the parent process, unless you're intentionally proxying.
5. Logging
console.log for stdout, console.error for stderr. No log libraries
unless the project already uses one.
Tag every log line with a scope prefix in brackets:
console.log("[secrets] cache hit", { key, age_ms: 42 })
console.error("[secrets] op CLI not found in PATH")
The dashboard's Live Logs view filters on these prefixes. Lines without a prefix are noise.
Sensitive values (secrets, tokens, op:// refs to specific items) never appear in logs. Use a redaction helper if the value comes from operator input.
6. HTTP route handlers (master daemon style)
Master HTTP routes live in components/master/server.ts. The shape every
new route follows:
if (url.pathname === "/secrets/test" && req.method === "POST") {
const body = await req.json().catch(() => ({}))
const key = typeof body.key === "string" ? body.key : null
if (!key) {
return jsonResponse({ ok: false, error: "missing key" }, 400)
}
const result = await testSecret(key)
return jsonResponse({ ok: true, ...result })
}
- Validate inputs explicitly; don't trust shape
- Return
{ ok: true, ... }or{ ok: false, error }— the dashboard expects this shape - 4xx for client errors, 5xx for server errors, no
throwinside the route handler
7. Tests
bun:test syntax. One test file per module being tested:
import { describe, test, expect } from "bun:test"
import { resolveSecret } from "../secrets"
describe("resolveSecret", () => {
test("returns env var when set", () => {
process.env.FOO = "bar"
expect(resolveSecret("foo")).toEqual({ ok: true, value: "bar" })
})
})
- Test files:
<module>.test.tsadjacent to the module, or under__tests__/ - Tests are deterministic — no time, no random, no network unless mocked
- Setup/teardown via
beforeEach/afterEach; never via module-level side effects - Test names:
test("returns X when Y")— describe the contract, not the implementation
What to test
- Public API surface — exports of the module, HTTP route shapes, CLI arguments
- Error paths — every
{ ok: false, error }branch has a test - Edge cases — empty input, malformed input, missing files
Do NOT test:
- Internal implementation details (private helpers)
- Third-party libraries (assume they work)
- TypeScript types (the compiler does that)
8. File layout
components/
<scope>/
server.ts # HTTP route additions (if relevant)
<module>.ts # core module
__tests__/
<module>.test.ts # adjacent test
README.md # short overview if the scope is non-obvious
Don't create new top-level directories without a reason. The repo's layout is load-bearing for the master daemon's tool-discovery logic.
9. Commit messages
Conventional commits, scope-prefixed:
feat(master): v2.7.31 1Password Service Accounts (multi-backend resolution)
fix(dashboard): v2.7.21 watchdog reconciliation race
test(eval): v2.7.30 add eval coverage for v2.7.18 through v2.7.24
docs(master): clarify Tier 3 substrate decision
- Past-tense imperative ("add", "fix", "refactor")
- Include the version when the commit is part of a release
- One change per commit (one PR may have multiple commits)
- Co-author trailer added by the operator's tooling — don't add it manually unless committing standalone
10. What this skill does NOT cover
- React / Next.js specifics — covered by
expert-react-typescriptagent definition - Python — covered by
python-conventions - Rust — covered by
rust-conventions - Project-specific layout (subctl vs holace vs callscrub) — read the project's CLAUDE.md