| 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
bun as the runtime, bun:sqlite for SQLite, bun:test for tests
- Uses
Bun.spawn for subprocess (not child_process)
- Uses
Bun.file() for file IO (not fs.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
.ts files
- Relative imports use
./ and ../. Path aliases (@/components/...) are
used only where tsconfig.json already 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
type over interface unless 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
unknown over any when accepting untrusted input; narrow with type guards
before use
Naming
camelCase — variables, functions, parameters, instance methods
PascalCase — types, classes, React components
SCREAMING_SNAKE — environment variables, true constants (rare)
kebab-case — file names, except React components (which match the
exported component name)
- Tests:
<module>.test.ts next to the module, or under __tests__/
subdirectory if the module folder has multiple tests
3. Error handling
Throw Error, not strings
throw "config missing"
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
try { ... } catch { }
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/await over raw promise chains
- One
Promise.all per batch when calls are independent. Loops of await
inside a for are usually wrong unless ordering matters.
AbortSignal for any operation that can outlive its caller — HTTP fetches,
long-running subprocesses, watcher polls
- No
process.nextTick, no setImmediate for sequencing — use proper await
points
Subprocess (Bun.spawn)
Always pass argv as an array; never as a string. No shell interpolation:
Bun.spawn(["sh", "-c", `op read ${ref}`])
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
throw inside 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.ts adjacent 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-typescript agent
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