Adding a Command
This walks through the real pattern used by init, license, release, and friends. Read /development/commands first for the underlying conventions.
1. Create the command module
Add src/commands/<name>.ts. Follow the release.ts shape:
/**
* `reposell <name> …` — one line stating what it does and why.
*/
export interface FooArgs {
target: string;
// optional flags, populated from bin-level parseArgs
}
export class FooCommandError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = 'FooCommandError';
this.code = code;
}
}
export async function fooCommand(cwd: string, args: FooArgs): Promise<string> {
// orchestration only — no business logic here
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Rules:
- First parameter is always
cwd: string. - Throw a typed error with a
codefor usage failures (ReleaseCommandError('TAG_REQUIRED', …)is the model). - Either return a report
string, or return{ ok, report }so the bin can set the exit code without exceptions. - If the command needs flags beyond the generic parser, put them in a separate pure module
src/commands/foo-args.tsexportingparseFooArgs(argv)+ an args error class — mirrorlicense-args.ts.
2. Wire it to an application service
Business logic belongs in src/app/ (e.g. config-service.ts, build-service.ts, license-service.ts). The command:
- Validates/parses input (or prompts interactively via
readline/promiseswheninput.isTTY === true). - Calls app services with injected dependencies:
{ env: p, never a bare ambient read inside the service.rocess.env } - Formats the result into report lines using the
✓/•/✗markers.
If the logic already exists in a service, do not duplicate it. If not, add a focused function to the relevant service rather than growing the command file.
3. Register in the bin
Three edits in src/bin/reposell.ts:
// import
import { fooCommand } from '../commands/foo.js';
// USAGE array — one line describing syntax
' foo <target> One-line description',
// switch case in main()
case 'foo': {
const result = await fooCommand(cwd, { env: process.env });
console.log(result.report);
if (!result.ok) process.exitCode = 1;
break;
}
2
3
4
5
6
7
8
9
10
11
12
13
Pick the return convention deliberately:
| Convention | Used by | Bin handling |
|---|---|---|
Return Promise<string> | license, listing, release | console.log(await …) |
Return { ok, report } | validate, build, health, publish, verify, keys | log report, set process.exitCode = 1 on failure |
Unknown commands fall into the existing default case; you do not need extra handling.
4. Add tests
Colocate src/commands/<name>.test.ts (or <name>-args.test.ts for the parser). Cover at minimum:
- Happy-path parsing of positionals/flags → expected args object.
- Rejection cases throwing your typed error (
expect(() => parseFooArgs(['x', '--wat'])).toThrow(/unknown flag/)). - Any pure mapping functions (e.g.
definitionFromValuesinrelease.ts) including defaults.
No network, no module mocks — inject fakes. See /development/testing.
5. Update documentation
- Create
docs/commands/<name>.mdfollowing the style ofdocs/commands/init.mdordocs/commands/release.md: usage block, options table, examples, exit behavior. - Add the command to the reference table in
docs/commands/index.md. - Register the page in the sidebar under
docs/.vitepress/config.ts.
6. Verify
From reposell/:
npm run typecheck
npm run lint
npm run test
npm run build && node dist/bin/reposell.js help # USAGE shows the new command
2
3
4
All four must pass. CI runs the same checks per workspace — see /development/testing-ci.
Checklist
- [ ]
src/commands/foo.tscreated, typed error class withcode - [ ] Optional
foo-args.tspure parser - [ ] Logic delegated to
src/app/services - [ ] Import +
USAGEline +switchcase insrc/bin/reposell.ts - [ ] Colocated tests for parser and pure mappers
- [ ]
docs/commands/foo.md, reference table, sidebar entry - [ ]
typecheck,lint,test,buildall green