# TS CLI House Rules

> Hardens a TypeScript CLI project against cross-platform and CI pitfalls with eight battle-tested rules: build-first test scripts, a portable node --test launcher, LF pinning, CRLF tolerance, POSIX process-group kills, CI-runner env stripping, quote-context substitution, and no-network fixtures. Use when scaffolding a new Node CLI tool or when an existing one only "works on my machine". Each rule prevents a real, production-observed bug.

- Skill: `zhengqiuyang/ts-cli-house-rules` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add zhengqiuyang/ts-cli-house-rules`
- Raw SKILL.md: https://api.skillmd.com/api/skills/zhengqiuyang/ts-cli-house-rules/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: zhengqiuyang (https://skillmd.com/u/zhengqiuyang)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/zhengqiuyang/ts-cli-house-rules

---


# TypeScript CLI House Rules

Eight rules. Each exists because breaking it produced a real bug on a real
CI runner or real user machine. Copy the templates from `scripts/` and
`references/`.

## Rule 1 — Test scripts build first

```json
{ "test": "npm run build && node run-tests.mjs" }
```

A test script that skips the build happily tests yesterday's build output. This
produced a false "fix verified" (27 green tests against stale output) and a
3-retry CI loop. Build, then test. Always.

## Rule 2 — Portable `node --test` launcher (no glob args)

`node --test "dist/test/*.test.js"` — glob arguments need Node 21+; Node 20
treats the pattern as a literal filename and fails with
`Could not find 'dist\test\*.test.js'`. Use the launcher in
`scripts/run-tests.mjs`: it enumerates the files itself and passes an explicit
list — works on every Node ≥ 20, both OSes, no shell globbing involved.

## Rule 3 — Pin LF via .gitattributes

```
* text=auto eol=lf
```

CI checkouts on Windows may convert to CRLF, and any test that splits on
`"---\n"` or compares literal text breaks *only on the Windows runner*.
Commit `.gitattributes` (see `references/`) and still make parsers CRLF-tolerant
(Rule 4) — other people's checkouts are not yours.

## Rule 4 — CRLF-tolerant parsing

Normalize before structural operations:

```ts
const text = (await readFile(p, "utf8")).replace(/\r\n/g, "\n");
```

Frontmatter splitters, diff parsers, and line-number assertions all bit us
on this. Treat CRLF as an input format, not an error.

## Rule 5 — POSIX kills the process GROUP

Spawning through a shell on Linux means your child is `/bin/sh`; killing only
it orphans the real worker, which holds the output pipes and hangs the runner
(observed: an 11-minute wait for a 0.5s timeout test). Pattern:

```ts
const child = spawn(cmd, { shell: true, detached: process.platform !== "win32" });
// on timeout:
process.kill(-child.pid, "SIGTERM");   // negative pid = whole group
```

Windows: `taskkill /pid <pid> /T /F`. Both branches, tested both places.

## Rule 6 — Strip CI-injected env in test launchers

CI runners export `GITHUB_EVENT_PATH`, `GITHUB_TOKEN`, `GH_TOKEN` into every
step; a CLI that switches modes on those variables silently changes behavior
under test. Strip them (and your own mode-switch vars) in `run-tests.mjs`
before spawning — see the launcher template.

## Rule 7 — Quote-context-aware substitution

When substituting user text into a shell command line containing
`"... {{var}} ..."` placeholders: if the placeholder is adjacent to a double
quote, **escape in place and keep the template's quotes**; only wrap in new
quotes when the placeholder is bare. Blind re-quoting produced `""path""`
→ POSIX sh split a multi-line prompt into executed commands (exit 127).
Flatten newlines in inlined prompts; deliver the full text via an env var.

## Rule 8 — Fixtures: no network, isolated git

Tests must run offline in `os.tmpdir()`. For git fixtures: `GIT_CONFIG_NOSYSTEM=1`,
`GIT_CONFIG_GLOBAL=<empty file>`, identity via `-c user.email=… -c user.name=…`.
A test that needs the network is not a test; a git fixture that reads the
user's global config is a flake waiting for their `insteadOf` rewrite.

## CI matrix

`references/templates.md` ships the matrix — ubuntu + windows × node
20/22/24, `npm ci` + `npm test`. If your suite passes only one OS, you have
not tested it (Rule 5's bug passed Windows for weeks).

