node-conventions

star 1

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.

webdevtodayjason By webdevtodayjason schedule Updated 5/13/2026

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

// 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/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:

// 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 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
Install via CLI
npx skills add https://github.com/webdevtodayjason/subctl --skill node-conventions
Repository Details
star Stars 1
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
webdevtodayjason
webdevtodayjason Explore all skills →