2834 lines
84 KiB
Markdown
2834 lines
84 KiB
Markdown
# `@suaveplan/readline` + `@suaveplan/cli` Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Ship `@suaveplan/readline` (full-replacement terminal I/O primitive) then `@suaveplan/cli` (enterprise CLI toolkit) — both at 100% coverage, full docs, OTel, Zod schemas, and AbortSignal support.
|
||
|
||
**Architecture:** `@suaveplan/readline` ports the maleck13 byte-scanner to a bounded async-push-queue engine, exposes `readLines()` (AsyncGenerator) and `createInterface()` (interactive prompt), then `@suaveplan/cli` rewrites its nine modules against that interface instead of `node:readline`.
|
||
|
||
**Tech Stack:** Bun, TypeScript, Vite (`vite-plugin-dts`, `preserveModules`), Vitest + istanbul, fast-check (property tests), mitata (benchmarks), `@opentelemetry/api`, `zod` (`catalog:forms`), `@suaveplan/error`, `@suaveplan/types`.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-04-20-readline-cli-design.md`
|
||
|
||
---
|
||
|
||
## File Map — `@suaveplan/readline`
|
||
|
||
```text
|
||
universal/platform/readline/
|
||
package.json
|
||
tsconfig.lib.json
|
||
vite.config.ts
|
||
vitest.config.ts
|
||
README.md
|
||
tests/setup/polyfills.ts
|
||
src/
|
||
errors/
|
||
errors.ts ← ReadlineError + READLINE_CODES (defineCodes × 3 sub-namespaces)
|
||
errors.test.ts
|
||
errors.md
|
||
index.ts
|
||
scanner/
|
||
scanner.ts ← internal AsyncGenerator; byte-level port of maleck13 readline.js
|
||
scanner.types.ts
|
||
scanner.test.ts
|
||
scanner.md
|
||
index.ts
|
||
reader/
|
||
reader.ts ← readLines() wraps scanner; accepts Readable | ReadableStream | path
|
||
reader.types.ts
|
||
reader.test.ts
|
||
reader.md
|
||
index.ts
|
||
interface/
|
||
interface.ts ← createInterface(); question()/close()/asyncIterator; OTel span
|
||
interface.types.ts
|
||
interface.test.ts
|
||
interface.md
|
||
index.ts
|
||
__benchmarks__/
|
||
scanner-throughput.bench.ts
|
||
reader-file.bench.ts
|
||
```
|
||
|
||
## File Map — `@suaveplan/cli`
|
||
|
||
```text
|
||
universal/platform/cli/
|
||
package.json
|
||
tsconfig.lib.json
|
||
vite.config.ts
|
||
vitest.config.ts
|
||
README.md
|
||
tests/setup/polyfills.ts
|
||
src/
|
||
index.ts ← root barrel
|
||
errors/
|
||
errors.ts ← CliError + CLI_CODES
|
||
errors.test.ts
|
||
errors.md
|
||
index.ts
|
||
parser/
|
||
parser.ts ← parseArgs, parseOptionFlags, parseArgumentSpec
|
||
parser.types.ts
|
||
parser.schemas.ts ← ParserConfigSchema, ParsedArgsSchema
|
||
parser.test.ts
|
||
parser.md
|
||
index.ts
|
||
command/
|
||
command.ts ← Command class; execute() has OTel span
|
||
command.types.ts
|
||
command.schemas.ts
|
||
command.test.ts
|
||
command.md
|
||
index.ts
|
||
app/
|
||
app.ts ← App class + cli(); run() has OTel span + AbortSignal
|
||
app.types.ts
|
||
app.schemas.ts ← AppConfigSchema
|
||
app.test.ts
|
||
app.md
|
||
index.ts
|
||
style/
|
||
style.ts ← createStyler, style, stripAnsi, isColorSupported, getStyleNames
|
||
style.types.ts
|
||
style.schemas.ts
|
||
style.test.ts
|
||
style.md
|
||
index.ts
|
||
output/
|
||
format/
|
||
format.ts ← keyValue, list, tree, json, columns, heading, divider, indent
|
||
format.types.ts
|
||
format.schemas.ts
|
||
format.test.ts
|
||
format.md
|
||
index.ts
|
||
table/
|
||
table.ts ← table()
|
||
table.types.ts
|
||
table.schemas.ts
|
||
table.test.ts
|
||
table.md
|
||
index.ts
|
||
spinner/
|
||
spinner.ts ← createSpinner; AbortSignal support
|
||
spinner.types.ts
|
||
spinner.schemas.ts
|
||
spinner.test.ts
|
||
spinner.md
|
||
index.ts
|
||
progress/
|
||
progress.ts ← createProgress; AbortSignal support
|
||
progress.types.ts
|
||
progress.schemas.ts
|
||
progress.test.ts
|
||
progress.md
|
||
index.ts
|
||
prompt/
|
||
prompt.ts ← text/password/confirm/select/multiselect; uses readline/interface
|
||
prompt.types.ts
|
||
prompt.schemas.ts
|
||
prompt.test.ts
|
||
prompt.md
|
||
index.ts
|
||
__benchmarks__/
|
||
parser-throughput.bench.ts
|
||
app-run.bench.ts
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 1: `@suaveplan/readline` — scaffold, errors, scanner
|
||
|
||
### Task 1: Scaffold `@suaveplan/readline`
|
||
|
||
**Files:**
|
||
|
||
- Create: `universal/platform/readline/package.json`
|
||
- Create: `universal/platform/readline/tsconfig.lib.json`
|
||
- Create: `universal/platform/readline/vite.config.ts`
|
||
- Create: `universal/platform/readline/vitest.config.ts`
|
||
- Create: `universal/platform/readline/tests/setup/polyfills.ts`
|
||
- Modify: `FOLDERS.yml` — add `readline` to `universal/platform`
|
||
- Modify: `CLAUDE.md` — add readline entry to package listing
|
||
|
||
- [ ] **Create `package.json`**
|
||
|
||
```json
|
||
{
|
||
"name": "@suaveplan/readline",
|
||
"version": "0.1.0",
|
||
"type": "module",
|
||
"sideEffects": false,
|
||
"description": "Terminal I/O primitive: AsyncGenerator line reader and interactive prompt interface",
|
||
"license": "MIT",
|
||
"author": "Suaveplan Team",
|
||
"main": "./dist/index.js",
|
||
"module": "./dist/index.js",
|
||
"types": "./dist/index.d.ts",
|
||
"exports": {
|
||
"./package.json": "./package.json",
|
||
"./reader/reader": {
|
||
"import": {
|
||
"types": "./dist/reader/reader.d.ts",
|
||
"default": "./dist/reader/reader.js"
|
||
}
|
||
},
|
||
"./interface/interface": {
|
||
"import": {
|
||
"types": "./dist/interface/interface.d.ts",
|
||
"default": "./dist/interface/interface.js"
|
||
}
|
||
},
|
||
"./errors/errors": {
|
||
"import": {
|
||
"types": "./dist/errors/errors.d.ts",
|
||
"default": "./dist/errors/errors.js"
|
||
}
|
||
}
|
||
},
|
||
"scripts": {
|
||
"build": "vite build",
|
||
"test": "vitest run --passWithNoTests",
|
||
"test:watch": "vitest",
|
||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||
"lint": "bunx biome check src/",
|
||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||
"docs:check": "bun run ../../../scripts/docs-check.ts .",
|
||
"bench": "bun run src/__benchmarks__/scanner-throughput.bench.ts && bun run src/__benchmarks__/reader-file.bench.ts"
|
||
},
|
||
"dependencies": {
|
||
"@suaveplan/error": "workspace:^",
|
||
"@suaveplan/types": "workspace:^",
|
||
"@opentelemetry/api": "catalog:observability",
|
||
"zod": "catalog:forms",
|
||
"tslib": "catalog:build"
|
||
},
|
||
"devDependencies": {
|
||
"@suaveplan/testing": "workspace:^",
|
||
"@types/node": "catalog:",
|
||
"@vitest/coverage-istanbul": "catalog:testing",
|
||
"fast-check": "catalog:testing",
|
||
"mitata": "catalog:testing",
|
||
"typescript": "catalog:",
|
||
"vite": "catalog:build",
|
||
"vite-plugin-dts": "catalog:build",
|
||
"vitest": "catalog:testing"
|
||
},
|
||
"publishConfig": {
|
||
"registry": "https://npmjs.fq.io/",
|
||
"access": "public"
|
||
},
|
||
"files": ["dist", "src", "README.md"]
|
||
}
|
||
```
|
||
|
||
- [ ] **Create `tsconfig.lib.json`**
|
||
|
||
```json
|
||
{
|
||
"$schema": "https://json.schemastore.org/tsconfig",
|
||
"extends": "../../../tsconfig.base.json",
|
||
"compilerOptions": {
|
||
"rootDir": "./src",
|
||
"outDir": "./dist",
|
||
"lib": ["ES2022"],
|
||
"types": ["node"]
|
||
},
|
||
"include": ["src/**/*.ts"],
|
||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||
}
|
||
```
|
||
|
||
- [ ] **Create `vite.config.ts`**
|
||
|
||
```ts
|
||
import { defineConfig } from "vite";
|
||
import dts from "vite-plugin-dts";
|
||
|
||
export default defineConfig({
|
||
root: __dirname,
|
||
cacheDir: "../../../node_modules/.vite/packages/readline",
|
||
plugins: [
|
||
dts({
|
||
entryRoot: "src",
|
||
tsconfigPath: "./tsconfig.lib.json",
|
||
insertTypesEntry: true,
|
||
}),
|
||
],
|
||
build: {
|
||
outDir: "./dist",
|
||
emptyOutDir: true,
|
||
reportCompressedSize: true,
|
||
sourcemap: true,
|
||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||
rollupOptions: {
|
||
treeshake: true,
|
||
output: {
|
||
preserveModules: true,
|
||
preserveModulesRoot: "src",
|
||
entryFileNames: "[name].js",
|
||
},
|
||
external: [
|
||
"@suaveplan/error",
|
||
"@suaveplan/types",
|
||
"tslib",
|
||
"zod",
|
||
"@opentelemetry/api",
|
||
/^node:/,
|
||
],
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Create `vitest.config.ts`**
|
||
|
||
```ts
|
||
import { defineConfig } from "vitest/config";
|
||
|
||
export default defineConfig({
|
||
test: {
|
||
globals: false,
|
||
environment: "node",
|
||
pool: "forks",
|
||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||
include: ["src/**/*.test.ts"],
|
||
exclude: ["dist/**", "node_modules/**"],
|
||
coverage: {
|
||
provider: "istanbul",
|
||
reporter: ["text", "html", "lcov"],
|
||
include: ["src/**/*.ts"],
|
||
exclude: [
|
||
"src/**/*.test.ts",
|
||
"src/**/*.types.ts",
|
||
"src/**/*.schemas.ts",
|
||
"src/**/index.ts",
|
||
"src/__benchmarks__/**",
|
||
],
|
||
thresholds: {
|
||
statements: 100,
|
||
branches: 100,
|
||
functions: 100,
|
||
lines: 100,
|
||
},
|
||
},
|
||
testTimeout: 30000,
|
||
},
|
||
});
|
||
```
|
||
|
||
> **Note:** `*.schemas.ts` files are excluded from coverage. Zod schema declarations are type-level; coverage of their branches belongs to Zod's own test suite, not ours. This is consistent with other genesis packages.
|
||
|
||
- [ ] **Create `tests/setup/polyfills.ts`**
|
||
|
||
```ts
|
||
export {};
|
||
```
|
||
|
||
- [ ] **Update `FOLDERS.yml`** — change the `platform` line to:
|
||
|
||
```yaml
|
||
platform: [ cli, electrobun, macos-contacts, opentelemetry,
|
||
profiler, readline, sitemap ] # 7
|
||
```
|
||
|
||
- [ ] **Update `CLAUDE.md`** — add to the platform section of the package listing:
|
||
|
||
```text
|
||
- **readline** — terminal I/O primitive: AsyncGenerator line reader (`readLines`), interactive prompt interface (`createInterface`), byte scanner engine. Depends on `@suaveplan/error`, `@suaveplan/types`, `@opentelemetry/api`, `zod`.
|
||
```
|
||
|
||
- [ ] **Create `src/index.ts` stub** (required before any build can run — grows as modules land):
|
||
|
||
```ts
|
||
export * from "./errors/index.js";
|
||
```
|
||
|
||
- [ ] **Run `bun install` from repo root to register the new workspace package**
|
||
|
||
```bash
|
||
cd /mnt/s/web/genesis && bun install
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: `errors` module
|
||
|
||
**Files:**
|
||
|
||
- Create: `universal/platform/readline/src/errors/errors.ts`
|
||
- Create: `universal/platform/readline/src/errors/errors.test.ts`
|
||
- Create: `universal/platform/readline/src/errors/errors.md`
|
||
- Create: `universal/platform/readline/src/errors/index.ts`
|
||
|
||
- [ ] **Write failing test** (`src/errors/errors.test.ts`)
|
||
|
||
```ts
|
||
import { describe, expect, it } from "vitest";
|
||
import { ReadlineError, READLINE_CODES } from "./errors.js";
|
||
|
||
describe("READLINE_CODES", { timeout: 5000 }, () => {
|
||
it("produces fully-qualified scanner codes", () => {
|
||
expect(READLINE_CODES.SCANNER.LINE_TOO_LONG).toBe("READLINE.SCANNER.LINE_TOO_LONG");
|
||
expect(READLINE_CODES.SCANNER.STREAM_ERROR).toBe("READLINE.SCANNER.STREAM_ERROR");
|
||
});
|
||
|
||
it("produces fully-qualified interface codes", () => {
|
||
expect(READLINE_CODES.INTERFACE.CLOSED).toBe("READLINE.INTERFACE.CLOSED");
|
||
expect(READLINE_CODES.INTERFACE.ABORTED).toBe("READLINE.INTERFACE.ABORTED");
|
||
});
|
||
|
||
it("produces fully-qualified reader codes", () => {
|
||
expect(READLINE_CODES.READER.STREAM_ERROR).toBe("READLINE.READER.STREAM_ERROR");
|
||
});
|
||
});
|
||
|
||
describe("ReadlineError", { timeout: 5000 }, () => {
|
||
it("is an instance of Error", () => {
|
||
const err = new ReadlineError(
|
||
READLINE_CODES.SCANNER.LINE_TOO_LONG,
|
||
"readline.scanner.line_too_long"
|
||
);
|
||
expect(err).toBeInstanceOf(Error);
|
||
expect(err.code).toBe("READLINE.SCANNER.LINE_TOO_LONG");
|
||
expect(err.messageKey).toBe("readline.scanner.line_too_long");
|
||
});
|
||
|
||
it("chains cause", () => {
|
||
const cause = new Error("upstream");
|
||
const err = new ReadlineError(
|
||
READLINE_CODES.INTERFACE.ABORTED,
|
||
"readline.interface.aborted",
|
||
{ cause }
|
||
);
|
||
expect(err.cause).toBe(cause);
|
||
});
|
||
|
||
it("attaches context", () => {
|
||
const err = new ReadlineError(
|
||
READLINE_CODES.SCANNER.LINE_TOO_LONG,
|
||
"readline.scanner.line_too_long",
|
||
{ context: { maxLineLength: 4096 } }
|
||
);
|
||
expect(err.context).toEqual({ maxLineLength: 4096 });
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Run test — verify it fails**
|
||
|
||
```bash
|
||
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/readline
|
||
```
|
||
|
||
Expected: `Cannot find module './errors.js'`
|
||
|
||
- [ ] **Implement `src/errors/errors.ts`**
|
||
|
||
```ts
|
||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||
|
||
const SCANNER_CODES = defineCodes("READLINE.SCANNER", [
|
||
"LINE_TOO_LONG",
|
||
"STREAM_ERROR",
|
||
] as const);
|
||
|
||
const INTERFACE_CODES = defineCodes("READLINE.INTERFACE", [
|
||
"CLOSED",
|
||
"ABORTED",
|
||
] as const);
|
||
|
||
const READER_CODES = defineCodes("READLINE.READER", [
|
||
"STREAM_ERROR",
|
||
] as const);
|
||
|
||
export const READLINE_CODES = {
|
||
SCANNER: SCANNER_CODES,
|
||
INTERFACE: INTERFACE_CODES,
|
||
READER: READER_CODES,
|
||
} as const;
|
||
|
||
export type ReadlineCode =
|
||
| (typeof SCANNER_CODES)[keyof typeof SCANNER_CODES]
|
||
| (typeof INTERFACE_CODES)[keyof typeof INTERFACE_CODES]
|
||
| (typeof READER_CODES)[keyof typeof READER_CODES];
|
||
|
||
export class ReadlineError extends SuaveplanError<ReadlineCode> {
|
||
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
|
||
public constructor(
|
||
code: ReadlineCode,
|
||
messageKey: string,
|
||
options?: SuaveplanErrorOptions
|
||
) {
|
||
super(code, messageKey, options);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create `src/errors/index.ts`**
|
||
|
||
```ts
|
||
export { ReadlineError, READLINE_CODES } from "./errors.js";
|
||
export type { ReadlineCode } from "./errors.js";
|
||
```
|
||
|
||
- [ ] **Run test — verify it passes**
|
||
|
||
```bash
|
||
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/readline
|
||
```
|
||
|
||
- [ ] **Write `src/errors/errors.md`** (≥200 words, required sections: Purpose, Features, Usage Basic, Usage Advanced, API Reference, Implementation Notes)
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add universal/platform/readline/ FOLDERS.yml CLAUDE.md
|
||
git commit -m "feat(readline): scaffold package + errors module"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `scanner` module (internal byte engine)
|
||
|
||
**Files:**
|
||
|
||
- Create: `universal/platform/readline/src/scanner/scanner.ts`
|
||
- Create: `universal/platform/readline/src/scanner/scanner.types.ts`
|
||
- Create: `universal/platform/readline/src/scanner/scanner.test.ts`
|
||
- Create: `universal/platform/readline/src/scanner/scanner.md`
|
||
- Create: `universal/platform/readline/src/scanner/index.ts`
|
||
|
||
- [ ] **Write failing test** (`src/scanner/scanner.test.ts`)
|
||
|
||
```ts
|
||
import { Readable } from "node:stream";
|
||
import * as fc from "fast-check";
|
||
import { describe, expect, it } from "vitest";
|
||
import { ReadlineError, READLINE_CODES } from "../errors/errors.js";
|
||
import { createScanner } from "./scanner.js";
|
||
|
||
function toReadable(input: string): Readable {
|
||
return Readable.from(Buffer.from(input));
|
||
}
|
||
|
||
async function collect(gen: AsyncGenerator<string>): Promise<string[]> {
|
||
const lines: string[] = [];
|
||
for await (const line of gen) lines.push(line);
|
||
return lines;
|
||
}
|
||
|
||
describe("createScanner", { timeout: 5000 }, () => {
|
||
it("yields lines separated by LF", async () => {
|
||
const lines = await collect(createScanner(toReadable("hello\nworld\n")));
|
||
expect(lines).toEqual(["hello", "world"]);
|
||
});
|
||
|
||
it("yields lines separated by CRLF", async () => {
|
||
const lines = await collect(createScanner(toReadable("foo\r\nbar\r\n")));
|
||
expect(lines).toEqual(["foo", "bar"]);
|
||
});
|
||
|
||
it("yields last line without trailing newline", async () => {
|
||
const lines = await collect(createScanner(toReadable("a\nb")));
|
||
expect(lines).toEqual(["a", "b"]);
|
||
});
|
||
|
||
it("yields nothing for empty input", async () => {
|
||
const lines = await collect(createScanner(toReadable("")));
|
||
expect(lines).toEqual([]);
|
||
});
|
||
|
||
it("yields a single blank line", async () => {
|
||
const lines = await collect(createScanner(toReadable("\n")));
|
||
expect(lines).toEqual([""]);
|
||
});
|
||
|
||
it("throws ReadlineError when line exceeds maxLineLength", async () => {
|
||
const long = "x".repeat(10);
|
||
await expect(
|
||
collect(createScanner(toReadable(long), { maxLineLength: 5 }))
|
||
).rejects.toThrow(ReadlineError);
|
||
});
|
||
|
||
it("throws ReadlineError with LINE_TOO_LONG code", async () => {
|
||
const long = "x".repeat(10);
|
||
await expect(
|
||
collect(createScanner(toReadable(long), { maxLineLength: 5 }))
|
||
).rejects.toMatchObject({ code: READLINE_CODES.SCANNER.LINE_TOO_LONG });
|
||
});
|
||
|
||
it("propagates stream errors as ReadlineError", async () => {
|
||
const r = new Readable({ read() {} });
|
||
const gen = createScanner(r);
|
||
const p = collect(gen);
|
||
r.destroy(new Error("boom"));
|
||
await expect(p).rejects.toMatchObject({ code: READLINE_CODES.SCANNER.STREAM_ERROR });
|
||
});
|
||
|
||
it("honours AbortSignal mid-iteration", async () => {
|
||
const controller = new AbortController();
|
||
const lines: string[] = [];
|
||
const r = Readable.from(["line1\n", "line2\n", "line3\n"].map(Buffer.from));
|
||
for await (const line of createScanner(r, { signal: controller.signal })) {
|
||
lines.push(line);
|
||
if (lines.length === 1) controller.abort();
|
||
}
|
||
expect(lines.length).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe("createScanner property tests", { timeout: 30000 }, () => {
|
||
it("round-trips arbitrary multiline strings", async () => {
|
||
await fc.assert(
|
||
fc.asyncProperty(
|
||
fc.array(fc.string({ maxLength: 100 }), { minLength: 0, maxLength: 20 }),
|
||
async (inputLines) => {
|
||
const text = inputLines.join("\n");
|
||
const lines = await collect(createScanner(toReadable(text)));
|
||
expect(lines.join("\n")).toBe(text);
|
||
}
|
||
),
|
||
{ numRuns: 100 }
|
||
);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Run test — verify it fails**
|
||
|
||
```bash
|
||
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/readline
|
||
```
|
||
|
||
- [ ] **Create `src/scanner/scanner.types.ts`**
|
||
|
||
```ts
|
||
export interface ScannerOptions {
|
||
maxLineLength?: number;
|
||
encoding?: BufferEncoding;
|
||
signal?: AbortSignal;
|
||
}
|
||
```
|
||
|
||
- [ ] **Implement `src/scanner/scanner.ts`**
|
||
|
||
```ts
|
||
import type { Readable } from "node:stream";
|
||
import { ReadlineError, READLINE_CODES } from "../errors/errors.js";
|
||
import type { ScannerOptions } from "./scanner.types.js";
|
||
|
||
export type { ScannerOptions } from "./scanner.types.js";
|
||
|
||
const DEFAULT_MAX_LINE_LENGTH = 4096;
|
||
|
||
type QueueItem =
|
||
| { kind: "line"; value: string }
|
||
| { kind: "done" }
|
||
| { kind: "error"; error: Error };
|
||
|
||
export async function* createScanner(
|
||
input: Readable,
|
||
opts?: ScannerOptions
|
||
): AsyncGenerator<string, void, unknown> {
|
||
const maxLen = opts?.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
|
||
const encoding = opts?.encoding ?? "utf8";
|
||
const signal = opts?.signal;
|
||
|
||
const queue: QueueItem[] = [];
|
||
let waitResolve: ((item: QueueItem) => void) | null = null;
|
||
|
||
function push(item: QueueItem): void {
|
||
if (waitResolve) {
|
||
const r = waitResolve;
|
||
waitResolve = null;
|
||
r(item);
|
||
} else {
|
||
queue.push(item);
|
||
}
|
||
}
|
||
|
||
function dequeue(): Promise<QueueItem> {
|
||
if (queue.length > 0) return Promise.resolve(queue.shift()!);
|
||
return new Promise<QueueItem>((resolve) => {
|
||
waitResolve = resolve;
|
||
});
|
||
}
|
||
|
||
const lineBuffer = Buffer.alloc(maxLen);
|
||
let lineLength = 0;
|
||
|
||
function emitLine(): void {
|
||
push({ kind: "line", value: lineBuffer.subarray(0, lineLength).toString(encoding) });
|
||
lineLength = 0;
|
||
}
|
||
|
||
input.on("data", (data: Buffer) => {
|
||
for (let i = 0; i < data.length; i++) {
|
||
const byte = data[i]!;
|
||
if (byte === 10) {
|
||
emitLine();
|
||
} else if (byte === 13) {
|
||
// CR — skip; CRLF handled when LF arrives
|
||
} else {
|
||
if (lineLength >= maxLen) {
|
||
push({
|
||
kind: "error",
|
||
error: new ReadlineError(
|
||
READLINE_CODES.SCANNER.LINE_TOO_LONG,
|
||
"readline.scanner.line_too_long",
|
||
{ context: { maxLineLength: maxLen } }
|
||
),
|
||
});
|
||
lineLength = 0;
|
||
return;
|
||
}
|
||
lineBuffer[lineLength++] = byte;
|
||
}
|
||
}
|
||
});
|
||
|
||
input.on("end", () => {
|
||
if (lineLength > 0) emitLine();
|
||
push({ kind: "done" });
|
||
});
|
||
|
||
input.on("error", (err: Error) => {
|
||
push({
|
||
kind: "error",
|
||
error: new ReadlineError(
|
||
READLINE_CODES.SCANNER.STREAM_ERROR,
|
||
"readline.scanner.stream_error",
|
||
{ cause: err }
|
||
),
|
||
});
|
||
});
|
||
|
||
while (true) {
|
||
if (signal?.aborted) break;
|
||
const item = await dequeue();
|
||
// NOTE: check again after await — abort may have fired while waiting in dequeue()
|
||
if (signal?.aborted) break;
|
||
if (item.kind === "error") throw item.error;
|
||
if (item.kind === "done") break;
|
||
yield item.value;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create `src/scanner/index.ts`**
|
||
|
||
```ts
|
||
export { createScanner } from "./scanner.js";
|
||
export type { ScannerOptions } from "./scanner.types.js";
|
||
```
|
||
|
||
- [ ] **Run test — verify it passes**
|
||
|
||
```bash
|
||
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/readline
|
||
```
|
||
|
||
- [ ] **Write `src/scanner/scanner.md`** (≥200 words, all required sections)
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add universal/platform/readline/
|
||
git commit -m "feat(readline): scanner — byte-level AsyncGenerator line reader"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: turbo `bench` task
|
||
|
||
**Files:**
|
||
|
||
- Modify: `turbo.json` — add `bench` task after `test:coverage` block
|
||
|
||
- [ ] **Add `bench` task to `turbo.json`** (after the `test:coverage` block):
|
||
|
||
```json
|
||
"bench": {
|
||
"dependsOn": ["^build", "build"],
|
||
"inputs": ["src/**/*.bench.ts"],
|
||
"cache": false,
|
||
"outputLogs": "new-only"
|
||
}
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add turbo.json
|
||
git commit -m "chore: add bench turbo task"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 2: `@suaveplan/readline` — reader, interface, benchmarks, docs, publish
|
||
|
||
### Task 5: `reader` module
|
||
|
||
**Files:**
|
||
|
||
- Create: `universal/platform/readline/src/reader/reader.ts`
|
||
- Create: `universal/platform/readline/src/reader/reader.types.ts`
|
||
- Create: `universal/platform/readline/src/reader/reader.test.ts`
|
||
- Create: `universal/platform/readline/src/reader/reader.md`
|
||
- Create: `universal/platform/readline/src/reader/index.ts`
|
||
|
||
- [ ] **Write failing test** (`src/reader/reader.test.ts`)
|
||
|
||
```ts
|
||
import { Readable } from "node:stream";
|
||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||
import { join } from "node:path";
|
||
import { tmpdir } from "node:os";
|
||
import { describe, expect, it } from "vitest";
|
||
import { readLines } from "./reader.js";
|
||
|
||
async function collect(gen: AsyncGenerator<string>): Promise<string[]> {
|
||
const out: string[] = [];
|
||
for await (const line of gen) out.push(line);
|
||
return out;
|
||
}
|
||
|
||
describe("readLines — Readable", { timeout: 5000 }, () => {
|
||
it("reads lines from a Node Readable", async () => {
|
||
const r = Readable.from(Buffer.from("one\ntwo\nthree"));
|
||
expect(await collect(readLines(r))).toEqual(["one", "two", "three"]);
|
||
});
|
||
|
||
it("skips empty lines when skipEmpty is true", async () => {
|
||
const r = Readable.from(Buffer.from("a\n\nb\n\nc"));
|
||
expect(await collect(readLines(r, { skipEmpty: true }))).toEqual(["a", "b", "c"]);
|
||
});
|
||
|
||
it("aborts mid-iteration via AbortSignal", async () => {
|
||
const controller = new AbortController();
|
||
const lines: string[] = [];
|
||
const r = Readable.from(Buffer.from("x\ny\nz"));
|
||
for await (const line of readLines(r, { signal: controller.signal })) {
|
||
lines.push(line);
|
||
if (lines.length === 1) controller.abort();
|
||
}
|
||
expect(lines.length).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe("readLines — file path", { timeout: 5000 }, () => {
|
||
it("reads lines from a file path string", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "readline-test-"));
|
||
const file = join(dir, "test.txt");
|
||
await writeFile(file, "alpha\nbeta\ngamma");
|
||
try {
|
||
expect(await collect(readLines(file))).toEqual(["alpha", "beta", "gamma"]);
|
||
} finally {
|
||
await rm(dir, { recursive: true });
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("readLines — WHATWG ReadableStream", { timeout: 5000 }, () => {
|
||
it("reads lines from a WHATWG ReadableStream", async () => {
|
||
const encoder = new TextEncoder();
|
||
const stream = new ReadableStream({
|
||
start(controller) {
|
||
controller.enqueue(encoder.encode("line1\nline2\n"));
|
||
controller.close();
|
||
},
|
||
});
|
||
expect(await collect(readLines(stream as unknown as ReadableStream))).toEqual(["line1", "line2"]);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Run test — verify it fails**
|
||
|
||
- [ ] **Create `src/reader/reader.types.ts`**
|
||
|
||
```ts
|
||
export interface ReadLinesOptions {
|
||
maxLineLength?: number;
|
||
encoding?: BufferEncoding;
|
||
skipEmpty?: boolean;
|
||
signal?: AbortSignal;
|
||
}
|
||
```
|
||
|
||
- [ ] **Implement `src/reader/reader.ts`**
|
||
|
||
```ts
|
||
import { createReadStream } from "node:fs";
|
||
import type { Readable } from "node:stream";
|
||
import { createScanner } from "../scanner/scanner.js";
|
||
import type { ReadLinesOptions } from "./reader.types.js";
|
||
|
||
export type { ReadLinesOptions } from "./reader.types.js";
|
||
|
||
export async function* readLines(
|
||
input: Readable | ReadableStream | string,
|
||
options?: ReadLinesOptions
|
||
): AsyncGenerator<string, void, unknown> {
|
||
const signal = options?.signal;
|
||
let readable: Readable;
|
||
|
||
if (typeof input === "string") {
|
||
readable = createReadStream(input);
|
||
} else if (typeof (input as ReadableStream).getReader === "function") {
|
||
const { Readable: NodeReadable } = await import("node:stream");
|
||
readable = NodeReadable.fromWeb(input as Parameters<typeof NodeReadable.fromWeb>[0]);
|
||
} else {
|
||
readable = input as Readable;
|
||
}
|
||
|
||
for await (const line of createScanner(readable, {
|
||
maxLineLength: options?.maxLineLength,
|
||
encoding: options?.encoding,
|
||
signal,
|
||
})) {
|
||
if (options?.skipEmpty && line === "") continue;
|
||
yield line;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create `src/reader/index.ts`**
|
||
|
||
```ts
|
||
export { readLines } from "./reader.js";
|
||
export type { ReadLinesOptions } from "./reader.types.js";
|
||
```
|
||
|
||
- [ ] **Run test — verify it passes**
|
||
|
||
> **Note:** `Readable.fromWeb()` requires Node ≥ 17 or Bun. The genesis environment uses Bun ≥ 1.3.13, so this is satisfied.
|
||
|
||
- [ ] **Update `src/index.ts`** to include reader:
|
||
|
||
```ts
|
||
export * from "./errors/index.js";
|
||
export * from "./reader/index.js";
|
||
```
|
||
|
||
- [ ] **Write `src/reader/reader.md`** (≥200 words, all required sections)
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add universal/platform/readline/
|
||
git commit -m "feat(readline): reader — readLines() AsyncGenerator over streams and files"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: `interface` module
|
||
|
||
**Files:**
|
||
|
||
- Create: `universal/platform/readline/src/interface/interface.ts`
|
||
- Create: `universal/platform/readline/src/interface/interface.types.ts`
|
||
- Create: `universal/platform/readline/src/interface/interface.test.ts`
|
||
- Create: `universal/platform/readline/src/interface/interface.md`
|
||
- Create: `universal/platform/readline/src/interface/index.ts`
|
||
|
||
- [ ] **Write failing test** (`src/interface/interface.test.ts`)
|
||
|
||
```ts
|
||
import { Readable, Writable } from "node:stream";
|
||
import { describe, expect, it } from "vitest";
|
||
import { ReadlineError, READLINE_CODES } from "../errors/errors.js";
|
||
import { createInterface } from "./interface.js";
|
||
|
||
function makeReadable(lines: string[]): Readable {
|
||
return Readable.from(Buffer.from(lines.join("\n") + "\n"));
|
||
}
|
||
|
||
function makeWritable(): { stream: Writable; output: string[] } {
|
||
const output: string[] = [];
|
||
const stream = new Writable({
|
||
write(chunk, _enc, cb) {
|
||
output.push(chunk.toString());
|
||
cb();
|
||
},
|
||
});
|
||
return { stream, output };
|
||
}
|
||
|
||
describe("createInterface — question", { timeout: 5000 }, () => {
|
||
it("returns the next line from input", async () => {
|
||
const { stream: out } = makeWritable();
|
||
const rl = createInterface({ input: makeReadable(["hello"]), output: out });
|
||
expect(await rl.question("prompt> ")).toBe("hello");
|
||
rl.close();
|
||
});
|
||
|
||
it("writes the prompt to output", async () => {
|
||
const { stream: out, output } = makeWritable();
|
||
const rl = createInterface({ input: makeReadable(["answer"]), output: out });
|
||
await rl.question("enter: ");
|
||
rl.close();
|
||
expect(output.join("")).toContain("enter: ");
|
||
});
|
||
|
||
it("throws INTERFACE.CLOSED after close()", async () => {
|
||
const rl = createInterface({ input: makeReadable([]) });
|
||
rl.close();
|
||
await expect(rl.question("x")).rejects.toMatchObject({
|
||
code: READLINE_CODES.INTERFACE.CLOSED,
|
||
});
|
||
});
|
||
|
||
it("marks closed=true after close()", () => {
|
||
const rl = createInterface({ input: makeReadable([]) });
|
||
expect(rl.closed).toBe(false);
|
||
rl.close();
|
||
expect(rl.closed).toBe(true);
|
||
});
|
||
|
||
it("throws INTERFACE.ABORTED when signal fires", async () => {
|
||
const controller = new AbortController();
|
||
const r = new Readable({ read() {} }); // never emits data
|
||
const rl = createInterface({ input: r, signal: controller.signal });
|
||
const p = rl.question("prompt");
|
||
controller.abort();
|
||
await expect(p).rejects.toMatchObject({ code: READLINE_CODES.INTERFACE.ABORTED });
|
||
rl.close();
|
||
});
|
||
});
|
||
|
||
describe("createInterface — asyncIterator", { timeout: 5000 }, () => {
|
||
it("iterates lines from input", async () => {
|
||
const rl = createInterface({ input: makeReadable(["a", "b", "c"]) });
|
||
const lines: string[] = [];
|
||
for await (const line of rl) lines.push(line);
|
||
expect(lines).toEqual(["a", "b", "c"]);
|
||
});
|
||
|
||
it("stops iteration when closed", async () => {
|
||
const rl = createInterface({ input: makeReadable(["x", "y"]) });
|
||
const lines: string[] = [];
|
||
for await (const line of rl) {
|
||
lines.push(line);
|
||
rl.close();
|
||
}
|
||
expect(lines.length).toBe(1);
|
||
});
|
||
|
||
it("terminates immediately when already closed (for-await starts after close)", async () => {
|
||
const rl = createInterface({ input: makeReadable(["z"]) });
|
||
rl.close();
|
||
const lines: string[] = [];
|
||
for await (const line of rl) lines.push(line);
|
||
expect(lines).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("createInterface — OTel span", { timeout: 5000 }, () => {
|
||
it("does not throw when no OTel exporter is registered (no-op tracer)", () => {
|
||
expect(() => {
|
||
const rl = createInterface({ input: makeReadable([]) });
|
||
rl.close();
|
||
}).not.toThrow();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Run test — verify it fails**
|
||
|
||
- [ ] **Create `src/interface/interface.types.ts`**
|
||
|
||
```ts
|
||
import type { Readable, Writable } from "node:stream";
|
||
|
||
export interface ReadlineOptions {
|
||
input: Readable;
|
||
output?: Writable;
|
||
signal?: AbortSignal;
|
||
}
|
||
|
||
export interface ReadlineInterface {
|
||
question(prompt: string, signal?: AbortSignal): Promise<string>;
|
||
close(): void;
|
||
readonly closed: boolean;
|
||
[Symbol.asyncIterator](): AsyncIterator<string>;
|
||
}
|
||
```
|
||
|
||
- [ ] **Implement `src/interface/interface.ts`**
|
||
|
||
```ts
|
||
import { SpanStatusCode, trace } from "@opentelemetry/api";
|
||
import { ReadlineError, READLINE_CODES } from "../errors/errors.js";
|
||
import { createScanner } from "../scanner/scanner.js";
|
||
import type { ReadlineInterface, ReadlineOptions } from "./interface.types.js";
|
||
|
||
export type { ReadlineInterface, ReadlineOptions } from "./interface.types.js";
|
||
|
||
const tracer = trace.getTracer("@suaveplan/readline", "0.1.0");
|
||
|
||
export function createInterface(options: ReadlineOptions): ReadlineInterface {
|
||
const { input, output, signal: ifaceSignal } = options;
|
||
let closed = false;
|
||
const span = tracer.startSpan("readline.interface");
|
||
const scanner = createScanner(input);
|
||
const iter = scanner[Symbol.asyncIterator]();
|
||
|
||
function assertOpen(): void {
|
||
if (closed) {
|
||
throw new ReadlineError(
|
||
READLINE_CODES.INTERFACE.CLOSED,
|
||
"readline.interface.closed"
|
||
);
|
||
}
|
||
}
|
||
|
||
const rl: ReadlineInterface = {
|
||
get closed() {
|
||
return closed;
|
||
},
|
||
|
||
async question(prompt: string, questionSignal?: AbortSignal): Promise<string> {
|
||
assertOpen();
|
||
const effectiveSignal = questionSignal ?? ifaceSignal;
|
||
|
||
if (effectiveSignal?.aborted) {
|
||
throw new ReadlineError(
|
||
READLINE_CODES.INTERFACE.ABORTED,
|
||
"readline.interface.aborted",
|
||
{ cause: effectiveSignal.reason }
|
||
);
|
||
}
|
||
|
||
if (output) output.write(prompt);
|
||
|
||
return new Promise<string>((resolve, reject) => {
|
||
let abortHandler: (() => void) | undefined;
|
||
|
||
if (effectiveSignal) {
|
||
abortHandler = () => {
|
||
span.setStatus({ code: SpanStatusCode.ERROR, message: "aborted" });
|
||
reject(
|
||
new ReadlineError(
|
||
READLINE_CODES.INTERFACE.ABORTED,
|
||
"readline.interface.aborted",
|
||
{ cause: effectiveSignal.reason }
|
||
)
|
||
);
|
||
};
|
||
effectiveSignal.addEventListener("abort", abortHandler, {
|
||
once: true,
|
||
});
|
||
}
|
||
|
||
iter
|
||
.next()
|
||
.then((result) => {
|
||
if (abortHandler) {
|
||
effectiveSignal?.removeEventListener("abort", abortHandler);
|
||
}
|
||
if (result.done) {
|
||
reject(
|
||
new ReadlineError(
|
||
READLINE_CODES.INTERFACE.CLOSED,
|
||
"readline.interface.closed"
|
||
)
|
||
);
|
||
} else {
|
||
resolve(result.value);
|
||
}
|
||
})
|
||
.catch(reject);
|
||
});
|
||
},
|
||
|
||
close() {
|
||
if (closed) return;
|
||
closed = true;
|
||
span.end();
|
||
},
|
||
|
||
[Symbol.asyncIterator](): AsyncIterator<string> {
|
||
return {
|
||
async next(): Promise<IteratorResult<string>> {
|
||
if (closed) return { done: true, value: undefined };
|
||
if (ifaceSignal?.aborted) {
|
||
span.setStatus({ code: SpanStatusCode.ERROR, message: "aborted" });
|
||
span.end();
|
||
throw new ReadlineError(
|
||
READLINE_CODES.INTERFACE.ABORTED,
|
||
"readline.interface.aborted"
|
||
);
|
||
}
|
||
const result = await iter.next();
|
||
if (result.done) {
|
||
closed = true;
|
||
span.end();
|
||
return { done: true, value: undefined };
|
||
}
|
||
return { done: false, value: result.value };
|
||
},
|
||
};
|
||
},
|
||
};
|
||
|
||
return rl;
|
||
}
|
||
```
|
||
|
||
- [ ] **Create `src/interface/index.ts`**
|
||
|
||
```ts
|
||
export { createInterface } from "./interface.js";
|
||
export type { ReadlineInterface, ReadlineOptions } from "./interface.types.js";
|
||
```
|
||
|
||
- [ ] **Update `src/index.ts`**
|
||
|
||
```ts
|
||
export * from "./errors/index.js";
|
||
export * from "./reader/index.js";
|
||
export * from "./interface/index.js";
|
||
```
|
||
|
||
- [ ] **Run test — verify it passes**
|
||
|
||
- [ ] **Write `src/interface/interface.md`** (≥200 words)
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add universal/platform/readline/
|
||
git commit -m "feat(readline): interface — createInterface() interactive readline with OTel"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Benchmarks + README + coverage gate
|
||
|
||
**Files:**
|
||
|
||
- Create: `universal/platform/readline/src/__benchmarks__/scanner-throughput.bench.ts`
|
||
- Create: `universal/platform/readline/src/__benchmarks__/reader-file.bench.ts`
|
||
- Create: `universal/platform/readline/README.md`
|
||
|
||
- [ ] **Create `src/__benchmarks__/scanner-throughput.bench.ts`**
|
||
|
||
```ts
|
||
import { Readable } from "node:stream";
|
||
import { bench, run } from "mitata";
|
||
import { createScanner } from "../scanner/scanner.js";
|
||
|
||
function makeText(lines: number): string {
|
||
return Array.from({ length: lines }, (_, i) => `line ${i}`).join("\n");
|
||
}
|
||
|
||
const text1mb = makeText(50_000);
|
||
const text10mb = makeText(500_000);
|
||
|
||
bench("scanner 1 MB", async () => {
|
||
const r = Readable.from(Buffer.from(text1mb));
|
||
for await (const _ of createScanner(r)) { /* consume */ }
|
||
});
|
||
|
||
bench("scanner 10 MB", async () => {
|
||
const r = Readable.from(Buffer.from(text10mb));
|
||
for await (const _ of createScanner(r)) { /* consume */ }
|
||
});
|
||
|
||
await run();
|
||
```
|
||
|
||
- [ ] **Create `src/__benchmarks__/reader-file.bench.ts`**
|
||
|
||
```ts
|
||
import { createReadStream } from "node:fs";
|
||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import { bench, run } from "mitata";
|
||
import { readLines } from "../reader/reader.js";
|
||
|
||
const dir = await mkdtemp(join(tmpdir(), "rl-bench-"));
|
||
const file = join(dir, "data.txt");
|
||
await writeFile(file, Array.from({ length: 10_000 }, (_, i) => `line ${i}`).join("\n"));
|
||
|
||
bench("readLines() file path", async () => {
|
||
for await (const _ of readLines(file)) { /* consume */ }
|
||
});
|
||
|
||
bench("readLines() Readable", async () => {
|
||
for await (const _ of readLines(createReadStream(file))) { /* consume */ }
|
||
});
|
||
|
||
await run();
|
||
await rm(dir, { recursive: true });
|
||
```
|
||
|
||
- [ ] **Create `README.md`** with required sections (package name, description, install, quick-start, modules section linking each `.md`)
|
||
|
||
- [ ] **Run full coverage gate**
|
||
|
||
```bash
|
||
cd /mnt/s/web/genesis && bunx turbo lint typecheck test --filter=@suaveplan/readline
|
||
bunx turbo test:coverage --filter=@suaveplan/readline
|
||
```
|
||
|
||
Expected: all green, 100% coverage.
|
||
|
||
- [ ] **Run benchmarks and paste output into README**
|
||
|
||
```bash
|
||
cd /mnt/s/web/genesis/universal/platform/readline && bun run bench
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add universal/platform/readline/
|
||
git commit -m "feat(readline): benchmarks, README, 100% coverage gate"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Publish `@suaveplan/readline`
|
||
|
||
- [ ] **Run pre-publish gate**
|
||
|
||
```bash
|
||
cd /mnt/s/web/genesis && bunx turbo lint typecheck test --filter=@suaveplan/readline
|
||
bunx turbo test:coverage --filter=@suaveplan/readline
|
||
bunx turbo build --filter=@suaveplan/readline
|
||
```
|
||
|
||
All must be green.
|
||
|
||
- [ ] **Publish**
|
||
|
||
```bash
|
||
cd /mnt/s/web/genesis && bun scripts/publish.ts --package @suaveplan/readline
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add .
|
||
git commit -m "chore(readline): publish @suaveplan/readline@0.0.0"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 3: `@suaveplan/cli` — scaffold, errors, parser
|
||
|
||
### Task 9: Scaffold `@suaveplan/cli`
|
||
|
||
**Files:**
|
||
|
||
- Create: `universal/platform/cli/package.json`
|
||
- Create: `universal/platform/cli/tsconfig.lib.json`
|
||
- Create: `universal/platform/cli/vite.config.ts`
|
||
- Create: `universal/platform/cli/vitest.config.ts`
|
||
- Create: `universal/platform/cli/tests/setup/polyfills.ts`
|
||
- Modify: `CLAUDE.md` — add cli entry
|
||
|
||
- [ ] **Create `package.json`**
|
||
|
||
```json
|
||
{
|
||
"name": "@suaveplan/cli",
|
||
"version": "0.1.0",
|
||
"type": "module",
|
||
"sideEffects": false,
|
||
"description": "Zero-dependency CLI toolkit: argument parser, command router, ANSI styling, output formatting, spinner, progress bar, interactive prompts",
|
||
"license": "MIT",
|
||
"author": "Suaveplan Team",
|
||
"main": "./dist/index.js",
|
||
"module": "./dist/index.js",
|
||
"types": "./dist/index.d.ts",
|
||
"exports": {
|
||
"./package.json": "./package.json",
|
||
".": {
|
||
"import": {
|
||
"types": "./dist/index.d.ts",
|
||
"default": "./dist/index.js"
|
||
}
|
||
},
|
||
"./errors/errors": {
|
||
"import": {
|
||
"types": "./dist/errors/errors.d.ts",
|
||
"default": "./dist/errors/errors.js"
|
||
}
|
||
},
|
||
"./parser/parser": {
|
||
"import": {
|
||
"types": "./dist/parser/parser.d.ts",
|
||
"default": "./dist/parser/parser.js"
|
||
}
|
||
},
|
||
"./command/command": {
|
||
"import": {
|
||
"types": "./dist/command/command.d.ts",
|
||
"default": "./dist/command/command.js"
|
||
}
|
||
},
|
||
"./app/app": {
|
||
"import": {
|
||
"types": "./dist/app/app.d.ts",
|
||
"default": "./dist/app/app.js"
|
||
}
|
||
},
|
||
"./style/style": {
|
||
"import": {
|
||
"types": "./dist/style/style.d.ts",
|
||
"default": "./dist/style/style.js"
|
||
}
|
||
},
|
||
"./output/format/format": {
|
||
"import": {
|
||
"types": "./dist/output/format/format.d.ts",
|
||
"default": "./dist/output/format/format.js"
|
||
}
|
||
},
|
||
"./output/table/table": {
|
||
"import": {
|
||
"types": "./dist/output/table/table.d.ts",
|
||
"default": "./dist/output/table/table.js"
|
||
}
|
||
},
|
||
"./spinner/spinner": {
|
||
"import": {
|
||
"types": "./dist/spinner/spinner.d.ts",
|
||
"default": "./dist/spinner/spinner.js"
|
||
}
|
||
},
|
||
"./progress/progress": {
|
||
"import": {
|
||
"types": "./dist/progress/progress.d.ts",
|
||
"default": "./dist/progress/progress.js"
|
||
}
|
||
},
|
||
"./prompt/prompt": {
|
||
"import": {
|
||
"types": "./dist/prompt/prompt.d.ts",
|
||
"default": "./dist/prompt/prompt.js"
|
||
}
|
||
}
|
||
},
|
||
"scripts": {
|
||
"build": "vite build",
|
||
"test": "vitest run --passWithNoTests",
|
||
"test:watch": "vitest",
|
||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||
"lint": "bunx biome check src/",
|
||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||
"docs:check": "bun run ../../../scripts/docs-check.ts .",
|
||
"bench": "bun run src/__benchmarks__/parser-throughput.bench.ts && bun run src/__benchmarks__/app-run.bench.ts"
|
||
},
|
||
"dependencies": {
|
||
"@suaveplan/readline": "workspace:^",
|
||
"@suaveplan/error": "workspace:^",
|
||
"@suaveplan/types": "workspace:^",
|
||
"@opentelemetry/api": "catalog:observability",
|
||
"zod": "catalog:forms",
|
||
"tslib": "catalog:build"
|
||
},
|
||
"devDependencies": {
|
||
"@suaveplan/testing": "workspace:^",
|
||
"@types/node": "catalog:",
|
||
"@vitest/coverage-istanbul": "catalog:testing",
|
||
"fast-check": "catalog:testing",
|
||
"mitata": "catalog:testing",
|
||
"typescript": "catalog:",
|
||
"vite": "catalog:build",
|
||
"vite-plugin-dts": "catalog:build",
|
||
"vitest": "catalog:testing"
|
||
},
|
||
"publishConfig": {
|
||
"registry": "https://npmjs.fq.io/",
|
||
"access": "public"
|
||
},
|
||
"files": ["dist", "src", "README.md"]
|
||
}
|
||
```
|
||
|
||
- [ ] **Create `tsconfig.lib.json`** (same as readline but with rootDir `./src`)
|
||
|
||
```json
|
||
{
|
||
"$schema": "https://json.schemastore.org/tsconfig",
|
||
"extends": "../../../tsconfig.base.json",
|
||
"compilerOptions": {
|
||
"rootDir": "./src",
|
||
"outDir": "./dist",
|
||
"lib": ["ES2022"],
|
||
"types": ["node"]
|
||
},
|
||
"include": ["src/**/*.ts"],
|
||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||
}
|
||
```
|
||
|
||
- [ ] **Create `vite.config.ts`** (same shape as readline, different cacheDir and externals)
|
||
|
||
```ts
|
||
import { defineConfig } from "vite";
|
||
import dts from "vite-plugin-dts";
|
||
|
||
export default defineConfig({
|
||
root: __dirname,
|
||
cacheDir: "../../../node_modules/.vite/packages/cli",
|
||
plugins: [
|
||
dts({
|
||
entryRoot: "src",
|
||
tsconfigPath: "./tsconfig.lib.json",
|
||
insertTypesEntry: true,
|
||
}),
|
||
],
|
||
build: {
|
||
outDir: "./dist",
|
||
emptyOutDir: true,
|
||
reportCompressedSize: true,
|
||
sourcemap: true,
|
||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||
rollupOptions: {
|
||
treeshake: true,
|
||
output: {
|
||
preserveModules: true,
|
||
preserveModulesRoot: "src",
|
||
entryFileNames: "[name].js",
|
||
},
|
||
external: [
|
||
"@suaveplan/readline",
|
||
/^@suaveplan\/readline\//, // covers subpath imports like readline/interface/interface.js
|
||
"@suaveplan/error",
|
||
"@suaveplan/types",
|
||
"tslib",
|
||
"zod",
|
||
"@opentelemetry/api",
|
||
/^node:/,
|
||
],
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Create `vitest.config.ts`** and `tests/setup/polyfills.ts` (identical to readline — ensure coverage exclude includes `"src/**/*.schemas.ts"`, same as readline's config)
|
||
|
||
- [ ] **Update `CLAUDE.md`** — add cli entry under platform in the package listing:
|
||
|
||
```text
|
||
- **cli** — CLI toolkit: argument parser, command router, ANSI styling, output formatting, table renderer, spinner, progress bar, interactive prompts. Depends on `@suaveplan/readline`, `@suaveplan/error`, `@suaveplan/types`, `@opentelemetry/api`, `zod`.
|
||
```
|
||
|
||
- [ ] **Run `bun install` from repo root**
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add universal/platform/cli/ CLAUDE.md
|
||
git commit -m "feat(cli): scaffold package"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: `errors` module
|
||
|
||
**Files:**
|
||
|
||
- Create: `universal/platform/cli/src/errors/errors.ts`
|
||
- Create: `universal/platform/cli/src/errors/errors.test.ts`
|
||
- Create: `universal/platform/cli/src/errors/errors.md`
|
||
- Create: `universal/platform/cli/src/errors/index.ts`
|
||
|
||
- [ ] **Write failing test**
|
||
|
||
```ts
|
||
import { describe, expect, it } from "vitest";
|
||
import { CliError, CLI_CODES } from "./errors.js";
|
||
|
||
describe("CLI_CODES", { timeout: 5000 }, () => {
|
||
it("has all expected codes", () => {
|
||
expect(CLI_CODES.PARSE.UNKNOWN_OPTION).toBe("CLI.PARSE.UNKNOWN_OPTION");
|
||
expect(CLI_CODES.PARSE.MISSING_VALUE).toBe("CLI.PARSE.MISSING_VALUE");
|
||
expect(CLI_CODES.PARSE.INVALID_VALUE).toBe("CLI.PARSE.INVALID_VALUE");
|
||
expect(CLI_CODES.PARSE.MISSING_ARGUMENT).toBe("CLI.PARSE.MISSING_ARGUMENT");
|
||
expect(CLI_CODES.PARSE.MISSING_REQUIRED_OPTION).toBe("CLI.PARSE.MISSING_REQUIRED_OPTION");
|
||
expect(CLI_CODES.COMMAND.NO_ACTION).toBe("CLI.COMMAND.NO_ACTION");
|
||
expect(CLI_CODES.COMMAND.UNKNOWN_SUBCOMMAND).toBe("CLI.COMMAND.UNKNOWN_SUBCOMMAND");
|
||
expect(CLI_CODES.APP.RUN_FAILED).toBe("CLI.APP.RUN_FAILED");
|
||
expect(CLI_CODES.APP.ABORTED).toBe("CLI.APP.ABORTED");
|
||
});
|
||
});
|
||
|
||
describe("CliError", { timeout: 5000 }, () => {
|
||
it("is an Error with the given code", () => {
|
||
const err = new CliError(CLI_CODES.PARSE.UNKNOWN_OPTION, "cli.parse.unknown_option");
|
||
expect(err).toBeInstanceOf(Error);
|
||
expect(err.code).toBe("CLI.PARSE.UNKNOWN_OPTION");
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Run test — verify it fails**
|
||
|
||
- [ ] **Implement `src/errors/errors.ts`**
|
||
|
||
```ts
|
||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||
|
||
const PARSE_CODES = defineCodes("CLI.PARSE", [
|
||
"UNKNOWN_OPTION",
|
||
"MISSING_VALUE",
|
||
"INVALID_VALUE",
|
||
"MISSING_ARGUMENT",
|
||
"MISSING_REQUIRED_OPTION",
|
||
] as const);
|
||
|
||
const COMMAND_CODES = defineCodes("CLI.COMMAND", [
|
||
"NO_ACTION",
|
||
"UNKNOWN_SUBCOMMAND",
|
||
] as const);
|
||
|
||
const APP_CODES = defineCodes("CLI.APP", [
|
||
"RUN_FAILED",
|
||
"ABORTED",
|
||
] as const);
|
||
|
||
export const CLI_CODES = {
|
||
PARSE: PARSE_CODES,
|
||
COMMAND: COMMAND_CODES,
|
||
APP: APP_CODES,
|
||
} as const;
|
||
|
||
export type CliCode =
|
||
| (typeof PARSE_CODES)[keyof typeof PARSE_CODES]
|
||
| (typeof COMMAND_CODES)[keyof typeof COMMAND_CODES]
|
||
| (typeof APP_CODES)[keyof typeof APP_CODES];
|
||
|
||
export class CliError extends SuaveplanError<CliCode> {
|
||
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
|
||
public constructor(
|
||
code: CliCode,
|
||
messageKey: string,
|
||
options?: SuaveplanErrorOptions
|
||
) {
|
||
super(code, messageKey, options);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create `src/errors/index.ts`**, write `errors.md`, run test, commit
|
||
|
||
```bash
|
||
git commit -m "feat(cli): errors module"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: `parser` module
|
||
|
||
**Files:**
|
||
|
||
- Create: `universal/platform/cli/src/parser/parser.ts`
|
||
- Create: `universal/platform/cli/src/parser/parser.types.ts`
|
||
- Create: `universal/platform/cli/src/parser/parser.schemas.ts`
|
||
- Create: `universal/platform/cli/src/parser/parser.test.ts`
|
||
- Create: `universal/platform/cli/src/parser/parser.md`
|
||
- Create: `universal/platform/cli/src/parser/index.ts`
|
||
|
||
- [ ] **Write failing test** (`src/parser/parser.test.ts`)
|
||
|
||
```ts
|
||
import * as fc from "fast-check";
|
||
import { describe, expect, it } from "vitest";
|
||
import { CliError, CLI_CODES } from "../errors/errors.js";
|
||
import { parseArgumentSpec, parseArgs, parseOptionFlags } from "./parser.js";
|
||
|
||
describe("parseArgs", { timeout: 5000 }, () => {
|
||
it("parses a boolean flag", () => {
|
||
const result = parseArgs(["--verbose"], {
|
||
options: { verbose: { type: "boolean" } },
|
||
});
|
||
expect(result.options.verbose).toBe(true);
|
||
});
|
||
|
||
it("parses --no-* to set boolean false", () => {
|
||
const result = parseArgs(["--no-verbose"], {
|
||
options: { verbose: { type: "boolean", default: true } },
|
||
});
|
||
expect(result.options.verbose).toBe(false);
|
||
});
|
||
|
||
it("parses a string option with = syntax", () => {
|
||
const result = parseArgs(["--name=world"], {
|
||
options: { name: { type: "string" } },
|
||
});
|
||
expect(result.options.name).toBe("world");
|
||
});
|
||
|
||
it("parses a string option as next token", () => {
|
||
const result = parseArgs(["--output", "file.txt"], {
|
||
options: { output: { type: "string" } },
|
||
});
|
||
expect(result.options.output).toBe("file.txt");
|
||
});
|
||
|
||
it("parses a number option", () => {
|
||
const result = parseArgs(["--count", "42"], {
|
||
options: { count: { type: "number" } },
|
||
});
|
||
expect(result.options.count).toBe(42);
|
||
});
|
||
|
||
it("parses short flags", () => {
|
||
const result = parseArgs(["-v"], {
|
||
options: { verbose: { type: "boolean", short: "v" } },
|
||
});
|
||
expect(result.options.verbose).toBe(true);
|
||
});
|
||
|
||
it("captures rest after --", () => {
|
||
const result = parseArgs(["--", "foo", "bar"]);
|
||
expect(result.rest).toEqual(["foo", "bar"]);
|
||
});
|
||
|
||
it("throws CliError(UNKNOWN_OPTION) for unknown flags", () => {
|
||
expect(() => parseArgs(["--unknown"])).toThrow(CliError);
|
||
expect(() => parseArgs(["--unknown"])).toThrow(
|
||
expect.objectContaining({ code: CLI_CODES.PARSE.UNKNOWN_OPTION })
|
||
);
|
||
});
|
||
|
||
it("throws CliError(MISSING_VALUE) when value missing", () => {
|
||
expect(() =>
|
||
parseArgs(["--name"], { options: { name: { type: "string" } } })
|
||
).toThrow(expect.objectContaining({ code: CLI_CODES.PARSE.MISSING_VALUE }));
|
||
});
|
||
|
||
it("throws CliError(MISSING_ARGUMENT) for missing required positional", () => {
|
||
expect(() =>
|
||
parseArgs([], { arguments: [{ name: "file", required: true, variadic: false }] })
|
||
).toThrow(expect.objectContaining({ code: CLI_CODES.PARSE.MISSING_ARGUMENT }));
|
||
});
|
||
|
||
it("parses variadic positional as array", () => {
|
||
const result = parseArgs(["a", "b", "c"], {
|
||
arguments: [{ name: "files", required: true, variadic: true }],
|
||
});
|
||
expect(result.positionals.files).toEqual(["a", "b", "c"]);
|
||
});
|
||
|
||
it("throws CliError(INVALID_VALUE) when value not in choices", () => {
|
||
expect(() =>
|
||
parseArgs(["--format", "xml"], {
|
||
options: { format: { type: "string", choices: ["json", "text"] } },
|
||
})
|
||
).toThrow(expect.objectContaining({ code: CLI_CODES.PARSE.INVALID_VALUE }));
|
||
});
|
||
});
|
||
|
||
describe("parseOptionFlags", { timeout: 5000 }, () => {
|
||
it("extracts long flag name", () => {
|
||
const { name } = parseOptionFlags("--verbose");
|
||
expect(name).toBe("verbose");
|
||
});
|
||
|
||
it("extracts short flag", () => {
|
||
const { config } = parseOptionFlags("-v, --verbose");
|
||
expect(config.short).toBe("v");
|
||
});
|
||
|
||
it("marks as string type when value placeholder present", () => {
|
||
const { config } = parseOptionFlags("--output <file>");
|
||
expect(config.type).toBe("string");
|
||
});
|
||
});
|
||
|
||
describe("parseArgumentSpec", { timeout: 5000 }, () => {
|
||
it("marks required for <name>", () => {
|
||
expect(parseArgumentSpec("<file>").required).toBe(true);
|
||
});
|
||
|
||
it("marks optional for [name]", () => {
|
||
expect(parseArgumentSpec("[file]").required).toBe(false);
|
||
});
|
||
|
||
it("marks variadic for <files...>", () => {
|
||
expect(parseArgumentSpec("<files...>").variadic).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("parseArgs property tests", { timeout: 30000 }, () => {
|
||
it("never throws with allowUnknown: true", () => {
|
||
fc.assert(
|
||
fc.property(
|
||
fc.array(fc.oneof(
|
||
fc.constant("--foo"),
|
||
fc.constant("--bar=baz"),
|
||
fc.constant("-x"),
|
||
fc.string({ maxLength: 10 }),
|
||
), { maxLength: 10 }),
|
||
(argv) => {
|
||
expect(() => parseArgs(argv, { allowUnknown: true })).not.toThrow();
|
||
}
|
||
)
|
||
);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Run test — verify it fails**
|
||
|
||
- [ ] **Create `src/parser/parser.types.ts`**
|
||
|
||
```ts
|
||
export type OptionType = "string" | "boolean" | "number" | "array";
|
||
|
||
export interface OptionConfig {
|
||
type?: OptionType;
|
||
short?: string;
|
||
description?: string;
|
||
default?: unknown;
|
||
required?: boolean;
|
||
choices?: string[];
|
||
}
|
||
|
||
export interface ArgumentConfig {
|
||
name: string;
|
||
description?: string;
|
||
required: boolean;
|
||
variadic: boolean;
|
||
default?: unknown;
|
||
}
|
||
|
||
export interface ParserConfig {
|
||
options?: Record<string, OptionConfig>;
|
||
arguments?: ArgumentConfig[];
|
||
allowUnknown?: boolean;
|
||
stopEarly?: boolean;
|
||
}
|
||
|
||
export interface ParsedArgs {
|
||
options: Record<string, unknown>;
|
||
positionals: Record<string, unknown>;
|
||
rest: string[];
|
||
}
|
||
```
|
||
|
||
- [ ] **Create `src/parser/parser.schemas.ts`**
|
||
|
||
```ts
|
||
import z from "zod";
|
||
|
||
export const OptionTypeSchema = z.enum(["string", "boolean", "number", "array"]);
|
||
|
||
export const OptionConfigSchema = z.object({
|
||
type: OptionTypeSchema.optional(),
|
||
short: z.string().optional(),
|
||
description: z.string().optional(),
|
||
default: z.unknown().optional(),
|
||
required: z.boolean().optional(),
|
||
choices: z.array(z.string()).optional(),
|
||
});
|
||
|
||
export const ArgumentConfigSchema = z.object({
|
||
name: z.string(),
|
||
description: z.string().optional(),
|
||
required: z.boolean(),
|
||
variadic: z.boolean(),
|
||
default: z.unknown().optional(),
|
||
});
|
||
|
||
export const ParserConfigSchema = z.object({
|
||
options: z.record(OptionConfigSchema).optional(),
|
||
arguments: z.array(ArgumentConfigSchema).optional(),
|
||
allowUnknown: z.boolean().optional(),
|
||
stopEarly: z.boolean().optional(),
|
||
});
|
||
|
||
export const ParsedArgsSchema = z.object({
|
||
options: z.record(z.unknown()),
|
||
positionals: z.record(z.unknown()),
|
||
rest: z.array(z.string()),
|
||
});
|
||
```
|
||
|
||
- [ ] **Implement `src/parser/parser.ts`** — port from `suaveplan/packages/universal/cli/src/parser/parser.ts`, replacing all bare `Error` throws with `CliError` using the appropriate `CLI_CODES.PARSE.*` code.
|
||
|
||
Key replacements:
|
||
- `throw new Error(\`Unknown option: --${key}\`)` → `throw new CliError(CLI_CODES.PARSE.UNKNOWN_OPTION, "cli.parse.unknown_option", { context: { option: key } })`
|
||
- `throw new Error(\`Missing value for option: --${key}\`)` → `throw new CliError(CLI_CODES.PARSE.MISSING_VALUE, ...)`
|
||
- `throw new Error(\`Invalid number value: "${raw}"\`)` → `throw new CliError(CLI_CODES.PARSE.INVALID_VALUE, ...)`
|
||
- `throw new Error(\`Missing required argument: <${def.name}>\`)` → `throw new CliError(CLI_CODES.PARSE.MISSING_ARGUMENT, ...)`
|
||
- `throw new Error(\`Missing required option: --${name}\`)` → `throw new CliError(CLI_CODES.PARSE.MISSING_REQUIRED_OPTION, ...)`
|
||
- `throw new Error(\`Option must have a long flag: ${flags}\`)` → `throw new CliError(CLI_CODES.PARSE.UNKNOWN_OPTION, ...)`
|
||
- `throw new Error(\`Invalid choice "${raw}" for option --${key}...\`)` → `throw new CliError(CLI_CODES.PARSE.INVALID_VALUE, "cli.parse.invalid_value", { context: { option: key, value: raw, choices } })`
|
||
|
||
- [ ] **Create `src/parser/index.ts`**
|
||
|
||
```ts
|
||
export { parseArgs, parseArgumentSpec, parseOptionFlags } from "./parser.js";
|
||
export type { ArgumentConfig, OptionConfig, OptionType, ParsedArgs, ParserConfig } from "./parser.types.js";
|
||
export { ArgumentConfigSchema, OptionConfigSchema, ParsedArgsSchema, ParserConfigSchema } from "./parser.schemas.js";
|
||
```
|
||
|
||
- [ ] **Run test — verify it passes**
|
||
|
||
- [ ] **Write `src/parser/parser.md`**, commit
|
||
|
||
```bash
|
||
git commit -m "feat(cli): parser module with Zod schemas and CliError"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 4: `@suaveplan/cli` — command, app, style, output
|
||
|
||
### Task 12: `command` module
|
||
|
||
**Files:**
|
||
|
||
- Create: `universal/platform/cli/src/command/command.ts`
|
||
- Create: `universal/platform/cli/src/command/command.types.ts`
|
||
- Create: `universal/platform/cli/src/command/command.schemas.ts`
|
||
- Create: `universal/platform/cli/src/command/command.test.ts`
|
||
- Create: `universal/platform/cli/src/command/command.md`
|
||
- Create: `universal/platform/cli/src/command/index.ts`
|
||
|
||
- [ ] **Write failing test** (`src/command/command.test.ts`)
|
||
|
||
```ts
|
||
import { describe, expect, it, vi } from "vitest";
|
||
import { CliError, CLI_CODES } from "../errors/errors.js";
|
||
import { Command, parseArgumentSpec, parseOptionFlags } from "./command.js";
|
||
|
||
describe("Command — fluent API", { timeout: 5000 }, () => {
|
||
it("sets description", () => {
|
||
const cmd = new Command("test").description("A test command");
|
||
expect(cmd.descriptionText).toBe("A test command");
|
||
});
|
||
|
||
it("registers and routes to subcommand", async () => {
|
||
const called: string[] = [];
|
||
const parent = new Command("app");
|
||
parent.command("sub").action(async () => { called.push("sub"); });
|
||
await parent.execute(["sub"]);
|
||
expect(called).toEqual(["sub"]);
|
||
});
|
||
|
||
it("routes to subcommand by alias", async () => {
|
||
const called: string[] = [];
|
||
const parent = new Command("app");
|
||
parent.command("subcommand").alias("s").action(async () => { called.push("aliased"); });
|
||
await parent.execute(["s"]);
|
||
expect(called).toEqual(["aliased"]);
|
||
});
|
||
|
||
it("throws CliError(NO_ACTION) when no action defined", async () => {
|
||
const cmd = new Command("empty");
|
||
await expect(cmd.execute([])).rejects.toMatchObject({
|
||
code: CLI_CODES.COMMAND.NO_ACTION,
|
||
});
|
||
});
|
||
|
||
it("throws CliError(UNKNOWN_SUBCOMMAND) when subcommand not found but subcommands exist", async () => {
|
||
const cmd = new Command("app");
|
||
cmd.command("sub").action(async () => {});
|
||
await expect(cmd.execute(["nope"])).rejects.toMatchObject({
|
||
code: CLI_CODES.COMMAND.UNKNOWN_SUBCOMMAND,
|
||
});
|
||
});
|
||
|
||
it("calls action with parsed context", async () => {
|
||
let capturedOpts: Record<string, unknown> = {};
|
||
const cmd = new Command("greet")
|
||
.option("--name <n>", "Name")
|
||
.action(async (ctx) => { capturedOpts = ctx.options; });
|
||
await cmd.execute(["--name", "world"]);
|
||
expect(capturedOpts.name).toBe("world");
|
||
});
|
||
|
||
it("hides command from help", () => {
|
||
const cmd = new Command("hidden").hidden();
|
||
expect(cmd.isHidden).toBe(true);
|
||
});
|
||
|
||
it("generates help text with usage line", () => {
|
||
const cmd = new Command("build").description("Build the project");
|
||
const help = cmd.helpText();
|
||
expect(help).toContain("Usage: build");
|
||
});
|
||
|
||
it("OTel span does not throw (no-op tracer)", async () => {
|
||
const cmd = new Command("noop").action(async () => {});
|
||
await expect(cmd.execute([])).resolves.not.toThrow();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Run test — verify it fails**
|
||
|
||
- [ ] **Create `src/command/command.types.ts`**
|
||
|
||
```ts
|
||
export interface CommandInfo {
|
||
name: string;
|
||
description: string;
|
||
fullName: string;
|
||
}
|
||
|
||
export interface ActionContext {
|
||
args: Record<string, unknown>;
|
||
options: Record<string, unknown>;
|
||
rest: string[];
|
||
command: CommandInfo;
|
||
}
|
||
|
||
export type ActionFn = (ctx: ActionContext) => void | Promise<void>;
|
||
```
|
||
|
||
- [ ] **Create `src/command/command.schemas.ts`**
|
||
|
||
```ts
|
||
import z from "zod";
|
||
|
||
export const CommandInfoSchema = z.object({
|
||
name: z.string(),
|
||
description: z.string(),
|
||
fullName: z.string(),
|
||
});
|
||
```
|
||
|
||
- [ ] **Implement `src/command/command.ts`** — port from `suaveplan` source. Key changes:
|
||
- Replace bare `Error` throws with `CliError(CLI_CODES.COMMAND.NO_ACTION)` and `CliError(CLI_CODES.COMMAND.UNKNOWN_SUBCOMMAND)`
|
||
- Add OTel span in `execute()`:
|
||
|
||
```ts
|
||
const tracer = trace.getTracer("@suaveplan/cli", "0.1.0");
|
||
// inside execute():
|
||
const span = tracer.startSpan(`cli.command.execute`, { attributes: { "cli.command": fullName } });
|
||
try {
|
||
// ... existing logic ...
|
||
span.end();
|
||
} catch (err) {
|
||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||
span.end();
|
||
throw err;
|
||
}
|
||
```
|
||
|
||
- The subcommand routing logic: when `argv[0]` is not a flag AND a subcommand exists by that name → route. If no subcommand matches AND subcommands are registered → `CliError(UNKNOWN_SUBCOMMAND)`. If no action → `CliError(NO_ACTION)`.
|
||
|
||
- [ ] **Create `src/command/index.ts`**, run test, write `command.md`, commit
|
||
|
||
```bash
|
||
git commit -m "feat(cli): command module with OTel span and CliError"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: `app` module
|
||
|
||
**Files:**
|
||
|
||
- Create: `universal/platform/cli/src/app/app.ts`
|
||
- Create: `universal/platform/cli/src/app/app.types.ts`
|
||
- Create: `universal/platform/cli/src/app/app.schemas.ts`
|
||
- Create: `universal/platform/cli/src/app/app.test.ts`
|
||
- Create: `universal/platform/cli/src/app/app.md`
|
||
- Create: `universal/platform/cli/src/app/index.ts`
|
||
|
||
- [ ] **Write failing test** (`src/app/app.test.ts`)
|
||
|
||
```ts
|
||
import { describe, expect, it } from "vitest";
|
||
import { CliError, CLI_CODES } from "../errors/errors.js";
|
||
import { App, cli } from "./app.js";
|
||
|
||
function makeApp(writer: (s: string) => void = () => {}): App {
|
||
return new App({ name: "test-app", version: "1.2.3", writer });
|
||
}
|
||
|
||
describe("App.run()", { timeout: 5000 }, () => {
|
||
it("prints version with --version", async () => {
|
||
const output: string[] = [];
|
||
const app = makeApp((s) => output.push(s));
|
||
await app.run(["--version"]);
|
||
expect(output.join("")).toContain("1.2.3");
|
||
});
|
||
|
||
it("prints help with --help", async () => {
|
||
const output: string[] = [];
|
||
const app = makeApp((s) => output.push(s));
|
||
await app.run(["--help"]);
|
||
expect(output.join("")).toContain("Usage");
|
||
});
|
||
|
||
it("routes to registered command", async () => {
|
||
const called: string[] = [];
|
||
const app = makeApp();
|
||
app.command("build").action(async () => { called.push("build"); });
|
||
await app.run(["build"]);
|
||
expect(called).toEqual(["build"]);
|
||
});
|
||
|
||
it("throws CliError(ABORTED) when AbortSignal fires before execution", async () => {
|
||
const controller = new AbortController();
|
||
controller.abort();
|
||
const app = makeApp();
|
||
app.action(async () => {});
|
||
await expect(app.run([], { signal: controller.signal })).rejects.toMatchObject({
|
||
code: CLI_CODES.APP.ABORTED,
|
||
});
|
||
});
|
||
|
||
it("calls onError with CliError on execution failure", async () => {
|
||
const errors: Error[] = [];
|
||
const app = new App({
|
||
name: "app",
|
||
onError: (e) => errors.push(e),
|
||
});
|
||
app.action(async () => { throw new Error("boom"); });
|
||
await app.run([]);
|
||
expect(errors.length).toBe(1);
|
||
});
|
||
|
||
it("OTel span does not throw (no-op tracer)", async () => {
|
||
const app = makeApp();
|
||
app.action(async () => {});
|
||
await expect(app.run([])).resolves.not.toThrow();
|
||
});
|
||
});
|
||
|
||
describe("cli() factory", { timeout: 5000 }, () => {
|
||
it("creates App with name and version", () => {
|
||
const app = cli("myapp", "2.0.0");
|
||
expect(app.name).toBe("myapp");
|
||
expect(app.version).toBe("2.0.0");
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Run test — verify it fails**
|
||
|
||
- [ ] **Create `src/app/app.types.ts`**
|
||
|
||
```ts
|
||
export interface AppConfig {
|
||
name: string;
|
||
version?: string;
|
||
description?: string;
|
||
enableHelp?: boolean;
|
||
enableVersion?: boolean;
|
||
onError?: (err: Error) => void;
|
||
writer?: (text: string) => void;
|
||
}
|
||
```
|
||
|
||
- [ ] **Create `src/app/app.schemas.ts`**
|
||
|
||
```ts
|
||
import z from "zod";
|
||
|
||
export const AppConfigSchema = z.object({
|
||
name: z.string(),
|
||
version: z.string().optional(),
|
||
description: z.string().optional(),
|
||
enableHelp: z.boolean().optional(),
|
||
enableVersion: z.boolean().optional(),
|
||
});
|
||
```
|
||
|
||
- [ ] **Implement `src/app/app.ts`** — port from `suaveplan` source. Key changes:
|
||
- Add OTel span in `run()`:
|
||
|
||
```ts
|
||
const tracer = trace.getTracer("@suaveplan/cli", "0.1.0");
|
||
// inside run():
|
||
if (options?.signal?.aborted) {
|
||
throw new CliError(CLI_CODES.APP.ABORTED, "cli.app.aborted");
|
||
}
|
||
const span = tracer.startSpan("cli.app.run");
|
||
try {
|
||
// ... existing logic ...
|
||
span.end();
|
||
} catch (err) {
|
||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||
span.end();
|
||
throw err;
|
||
}
|
||
```
|
||
|
||
- `run()` signature becomes `async run(argv?: string[], options?: { signal?: AbortSignal }): Promise<void>`
|
||
- `onError` catches and handles; wrap plain `Error` in `CliError(CLI_CODES.APP.RUN_FAILED)` if not already a `CliError`
|
||
|
||
- [ ] **Create `src/app/index.ts`**, run test, write `app.md`, commit
|
||
|
||
```bash
|
||
git commit -m "feat(cli): app module with OTel span and AbortSignal"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: `style`, `output/format`, `output/table`
|
||
|
||
**Files:**
|
||
|
||
- Create all files for `style/`, `output/format/`, `output/table/`
|
||
|
||
These three modules are pure functions with no IO. Port directly from the `suaveplan` source, adding Zod schemas and replacing bare `Error` throws with `CliError`.
|
||
|
||
- [ ] **Write tests for `style`** — key scenarios:
|
||
- `isColorSupported()` returns boolean
|
||
- `stripAnsi(style.bold.red("text"))` equals `"text"` (property test: `stripAnsi(style.bold.red(s)) === s` for all `s`)
|
||
- `createStyler(true)` returns a callable styler
|
||
- `getStyleNames()` returns non-empty array
|
||
|
||
- [ ] **Write tests for `output/format`** — key scenarios:
|
||
- `keyValue({ a: 1, b: 2 })` contains `"a"` and `"1"`
|
||
- `list(["x", "y"])` contains bullet markers
|
||
- `tree({ label: "root", children: [{ label: "child" }] })` contains `"root"` and `"child"`
|
||
- `heading("Title", 1)` returns string with `"═"` borders
|
||
- `json({ x: 1 })` returns `'{\n "x": 1\n}'`
|
||
- `columns([])` returns `""`
|
||
|
||
- [ ] **Write tests for `output/table`** — key scenarios:
|
||
- Renders header row
|
||
- Aligns columns to max width (property test: column width ≥ max cell content width)
|
||
- Handles empty rows
|
||
|
||
- [ ] **Implement `style.ts`, `style.schemas.ts`** — port from source; add `StyleOptionsSchema`
|
||
|
||
- [ ] **Implement `output/format/format.ts`, `format.schemas.ts`** — port from source; add Zod schemas for each options type
|
||
|
||
- [ ] **Implement `output/table/table.ts`, `table.schemas.ts`** — port from source; replace bare `Error` with `CliError`
|
||
|
||
- [ ] **Create all `index.ts` barrels and `.md` docs**
|
||
|
||
- [ ] **Run tests — verify all pass**
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git commit -m "feat(cli): style, output/format, output/table modules"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 5: `@suaveplan/cli` — spinner, progress, prompt, benchmarks, publish, archival
|
||
|
||
### Task 15: `spinner` module
|
||
|
||
**Files:**
|
||
|
||
- Create: `universal/platform/cli/src/spinner/spinner.ts`
|
||
- Create: `universal/platform/cli/src/spinner/spinner.types.ts`
|
||
- Create: `universal/platform/cli/src/spinner/spinner.schemas.ts`
|
||
- Create: `universal/platform/cli/src/spinner/spinner.test.ts`
|
||
- Create: `universal/platform/cli/src/spinner/spinner.md`
|
||
- Create: `universal/platform/cli/src/spinner/index.ts`
|
||
|
||
- [ ] **Write failing test** (`src/spinner/spinner.test.ts`)
|
||
|
||
```ts
|
||
import { Writable } from "node:stream";
|
||
import { describe, expect, it } from "vitest";
|
||
import { createSpinner } from "./spinner.js";
|
||
|
||
function makeStream(): { stream: Writable; output: () => string } {
|
||
const chunks: string[] = [];
|
||
const stream = new Writable({
|
||
write(chunk, _enc, cb) { chunks.push(chunk.toString()); cb(); },
|
||
}) as Writable & { isTTY: boolean; clearLine: (dir: number) => void; cursorTo: (x: number) => void };
|
||
stream.isTTY = false; // non-TTY: uses fallback write
|
||
(stream as Writable & { clearLine: (dir: number) => void }).clearLine = () => {};
|
||
(stream as Writable & { cursorTo: (x: number) => void }).cursorTo = () => {};
|
||
return { stream: stream as Writable, output: () => chunks.join("") };
|
||
}
|
||
|
||
describe("createSpinner", { timeout: 5000 }, () => {
|
||
it("starts and is spinning", () => {
|
||
const { stream } = makeStream();
|
||
const sp = createSpinner({ text: "Loading", stream: stream as Parameters<typeof createSpinner>[0] extends { stream?: infer S } ? S : never });
|
||
sp.start();
|
||
expect(sp.isSpinning).toBe(true);
|
||
sp.stop();
|
||
});
|
||
|
||
it("is not spinning after stop()", () => {
|
||
const { stream } = makeStream();
|
||
const sp = createSpinner({ text: "x", stream: stream as any });
|
||
sp.start().stop();
|
||
expect(sp.isSpinning).toBe(false);
|
||
});
|
||
|
||
it("writes to non-TTY stream on start", () => {
|
||
const { stream, output } = makeStream();
|
||
const sp = createSpinner({ text: "Working", stream: stream as any });
|
||
sp.start();
|
||
expect(output()).toContain("Working");
|
||
sp.stop();
|
||
});
|
||
|
||
it("succeed writes success symbol", () => {
|
||
const { stream, output } = makeStream();
|
||
const sp = createSpinner({ text: "done", stream: stream as any });
|
||
sp.start().succeed("Completed");
|
||
expect(output()).toContain("Completed");
|
||
});
|
||
|
||
it("fail writes fail symbol", () => {
|
||
const { stream, output } = makeStream();
|
||
const sp = createSpinner({ text: "task", stream: stream as any });
|
||
sp.start().fail("Failed");
|
||
expect(output()).toContain("Failed");
|
||
});
|
||
|
||
it("aborts via AbortSignal", async () => {
|
||
const { stream } = makeStream();
|
||
const controller = new AbortController();
|
||
const sp = createSpinner({ text: "work", stream: stream as any, signal: controller.signal });
|
||
sp.start();
|
||
controller.abort();
|
||
// Give microtask queue a tick
|
||
await new Promise((r) => setTimeout(r, 10));
|
||
expect(sp.isSpinning).toBe(false);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Run test — verify it fails**
|
||
|
||
- [ ] **Create `src/spinner/spinner.types.ts`**
|
||
|
||
```ts
|
||
export interface WritableStreamLike {
|
||
write(text: string): void;
|
||
clearLine?(dir: number): void;
|
||
cursorTo?(x: number): void;
|
||
isTTY?: boolean;
|
||
}
|
||
|
||
export interface SpinnerOptions {
|
||
text?: string;
|
||
frames?: string[];
|
||
interval?: number;
|
||
stream?: WritableStreamLike;
|
||
signal?: AbortSignal;
|
||
}
|
||
|
||
export interface SpinnerInstance {
|
||
readonly isSpinning: boolean;
|
||
start(text?: string): SpinnerInstance;
|
||
stop(): SpinnerInstance;
|
||
succeed(text?: string): SpinnerInstance;
|
||
fail(text?: string): SpinnerInstance;
|
||
warn(text?: string): SpinnerInstance;
|
||
update(text: string): SpinnerInstance;
|
||
}
|
||
```
|
||
|
||
- [ ] **Implement `src/spinner/spinner.ts`** — port from `suaveplan` source; add `signal` handling. Two guards needed — pre-fired signal (already aborted before `start()`) and live abort event:
|
||
|
||
```ts
|
||
// Inside createSpinner, after setting up the instance:
|
||
if (opts.signal) {
|
||
opts.signal.addEventListener("abort", () => {
|
||
instance.stop();
|
||
}, { once: true });
|
||
}
|
||
```
|
||
|
||
Inside `start()`, add pre-abort guard as the first line:
|
||
|
||
```ts
|
||
start(text?: string): SpinnerInstance {
|
||
if (opts.signal?.aborted) return instance; // already aborted — no-op
|
||
// ...rest of start logic
|
||
}
|
||
```
|
||
|
||
- [ ] **Create `src/spinner/spinner.schemas.ts`**
|
||
|
||
```ts
|
||
import z from "zod";
|
||
|
||
export const SpinnerOptionsSchema = z.object({
|
||
text: z.string().optional(),
|
||
frames: z.array(z.string()).optional(),
|
||
interval: z.number().positive().optional(),
|
||
});
|
||
```
|
||
|
||
- [ ] **Create `src/spinner/index.ts`**, run test, write `spinner.md`, commit
|
||
|
||
```bash
|
||
git commit -m "feat(cli): spinner module with AbortSignal"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: `progress` module
|
||
|
||
**Files:** same pattern as spinner.
|
||
|
||
- [ ] **Write failing test** — key scenarios: `createProgress({ total: 100 })`, renders bar, `update(50)` changes percentage, AbortSignal stops progress.
|
||
|
||
- [ ] **Create `src/progress/progress.types.ts`**
|
||
|
||
```ts
|
||
import type { WritableStreamLike } from "../spinner/spinner.types.js";
|
||
|
||
export type { WritableStreamLike };
|
||
|
||
export interface ProgressOptions {
|
||
total: number;
|
||
width?: number;
|
||
stream?: WritableStreamLike;
|
||
signal?: AbortSignal;
|
||
}
|
||
|
||
export interface ProgressInstance {
|
||
update(current: number): void;
|
||
complete(): void;
|
||
render(): string;
|
||
}
|
||
```
|
||
|
||
- [ ] **Implement `src/progress/progress.ts`** — port from `suaveplan` source; add `signal` handling same as spinner (both the `"abort"` event listener AND a pre-abort guard inside `update()` / the start path — check `opts.signal?.aborted` before writing to the stream).
|
||
|
||
- [ ] **Create schemas, index, md, run test, commit**
|
||
|
||
```bash
|
||
git commit -m "feat(cli): progress module with AbortSignal"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: `prompt` module
|
||
|
||
**Files:**
|
||
|
||
- Create: `universal/platform/cli/src/prompt/prompt.ts`
|
||
- Create: `universal/platform/cli/src/prompt/prompt.types.ts`
|
||
- Create: `universal/platform/cli/src/prompt/prompt.schemas.ts`
|
||
- Create: `universal/platform/cli/src/prompt/prompt.test.ts`
|
||
- Create: `universal/platform/cli/src/prompt/prompt.md`
|
||
- Create: `universal/platform/cli/src/prompt/index.ts`
|
||
|
||
- [ ] **Write failing test** (`src/prompt/prompt.test.ts`)
|
||
|
||
```ts
|
||
import { Readable, Writable } from "node:stream";
|
||
import { describe, expect, it } from "vitest";
|
||
import { confirm, multiselect, password, select, text } from "./prompt.js";
|
||
import type { InputReader } from "./prompt.types.js";
|
||
|
||
function makeReader(lines: string[]): InputReader {
|
||
let idx = 0;
|
||
return {
|
||
question: async (_prompt: string) => lines[idx++] ?? "",
|
||
close: () => {},
|
||
};
|
||
}
|
||
|
||
describe("text()", { timeout: 5000 }, () => {
|
||
it("returns user input", async () => {
|
||
expect(await text("Name?", makeReader(["Alice"]))).toBe("Alice");
|
||
});
|
||
|
||
it("returns default when input is empty", async () => {
|
||
expect(await text({ message: "Name?", default: "Bob" }, makeReader([""]))).toBe("Bob");
|
||
});
|
||
|
||
it("re-prompts when validation fails then passes", async () => {
|
||
const result = await text(
|
||
{ message: "Age?", validate: (v) => (Number(v) > 0 ? true : "must be positive") },
|
||
makeReader(["-1", "25"])
|
||
);
|
||
expect(result).toBe("25");
|
||
});
|
||
});
|
||
|
||
describe("password()", { timeout: 5000 }, () => {
|
||
it("returns user input", async () => {
|
||
expect(await password("Pass?", makeReader(["secret"]))).toBe("secret");
|
||
});
|
||
});
|
||
|
||
describe("confirm()", { timeout: 5000 }, () => {
|
||
it("returns true for 'y'", async () => {
|
||
expect(await confirm("Continue?", makeReader(["y"]))).toBe(true);
|
||
});
|
||
|
||
it("returns false for 'n'", async () => {
|
||
expect(await confirm("Continue?", makeReader(["n"]))).toBe(false);
|
||
});
|
||
|
||
it("returns default when empty", async () => {
|
||
expect(await confirm({ message: "Go?", default: false }, makeReader([""]))).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("select()", { timeout: 5000 }, () => {
|
||
it("returns the selected choice value", async () => {
|
||
const result = await select(
|
||
{ message: "Pick:", choices: ["a", "b", "c"] },
|
||
makeReader(["2"])
|
||
);
|
||
expect(result).toBe("b");
|
||
});
|
||
|
||
it("re-prompts on invalid selection", async () => {
|
||
const result = await select(
|
||
{ message: "Pick:", choices: ["x", "y"] },
|
||
makeReader(["99", "1"])
|
||
);
|
||
expect(result).toBe("x");
|
||
});
|
||
});
|
||
|
||
describe("multiselect()", { timeout: 5000 }, () => {
|
||
it("returns selected values", async () => {
|
||
const result = await multiselect(
|
||
{ message: "Pick:", choices: ["a", "b", "c"] },
|
||
makeReader(["1,3"])
|
||
);
|
||
expect(result).toEqual(["a", "c"]);
|
||
});
|
||
|
||
it("enforces min selection", async () => {
|
||
const result = await multiselect(
|
||
{ message: "Pick:", choices: ["a", "b", "c"], min: 2 },
|
||
makeReader(["1", "1,2"])
|
||
);
|
||
expect(result.length).toBeGreaterThanOrEqual(2);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Run test — verify it fails**
|
||
|
||
- [ ] **Create `src/prompt/prompt.types.ts`**
|
||
|
||
```ts
|
||
export interface InputReader {
|
||
question(prompt: string): Promise<string>;
|
||
close(): void;
|
||
}
|
||
|
||
export interface TextPromptOptions {
|
||
message: string;
|
||
default?: string;
|
||
validate?: (value: string) => true | string;
|
||
}
|
||
|
||
export interface PasswordPromptOptions {
|
||
message: string;
|
||
validate?: (value: string) => true | string;
|
||
}
|
||
|
||
export interface ConfirmPromptOptions {
|
||
message: string;
|
||
default?: boolean;
|
||
}
|
||
|
||
export interface SelectChoice<T = string> {
|
||
label: string;
|
||
value: T;
|
||
description?: string;
|
||
}
|
||
|
||
export interface SelectPromptOptions<T = string> {
|
||
message: string;
|
||
choices: Array<SelectChoice<T> | string>;
|
||
default?: T;
|
||
}
|
||
|
||
export interface MultiSelectPromptOptions<T = string> {
|
||
message: string;
|
||
choices: Array<SelectChoice<T> | string>;
|
||
min?: number;
|
||
max?: number;
|
||
}
|
||
```
|
||
|
||
- [ ] **Implement `src/prompt/prompt.ts`** — port from `suaveplan` source. Critical change: `createDefaultReader()` uses `@suaveplan/readline/interface`:
|
||
|
||
```ts
|
||
import { createInterface } from "@suaveplan/readline/interface/interface.js";
|
||
import type { Readable, Writable } from "node:stream";
|
||
import type { InputReader } from "./prompt.types.js";
|
||
|
||
export async function createDefaultReader(): Promise<InputReader> {
|
||
const { stdin, stdout } = process;
|
||
const rl = createInterface({
|
||
input: stdin as unknown as Readable,
|
||
output: stdout as unknown as Writable,
|
||
});
|
||
return {
|
||
question: (prompt: string) => rl.question(prompt),
|
||
close: () => rl.close(),
|
||
};
|
||
}
|
||
```
|
||
|
||
Keep the five prompt functions (`text`, `password`, `confirm`, `select`, `multiselect`) matching the `suaveplan` implementation. No other changes needed — the `InputReader` interface is structurally identical to what the test mock provides.
|
||
|
||
- [ ] **Create `src/prompt/prompt.schemas.ts`**
|
||
|
||
```ts
|
||
import z from "zod";
|
||
|
||
export const TextPromptOptionsSchema = z.object({
|
||
message: z.string(),
|
||
default: z.string().optional(),
|
||
});
|
||
|
||
export const ConfirmPromptOptionsSchema = z.object({
|
||
message: z.string(),
|
||
default: z.boolean().optional(),
|
||
});
|
||
|
||
export const SelectChoiceSchema = z.object({
|
||
label: z.string(),
|
||
value: z.unknown(),
|
||
description: z.string().optional(),
|
||
});
|
||
```
|
||
|
||
- [ ] **Create `src/prompt/index.ts`**, run test, write `prompt.md`, commit
|
||
|
||
```bash
|
||
git commit -m "feat(cli): prompt module using @suaveplan/readline/interface"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 18: Root `src/index.ts` barrel
|
||
|
||
- [ ] **Create `universal/platform/cli/src/index.ts`**
|
||
|
||
```ts
|
||
// App
|
||
export { App, cli } from "./app/app.js";
|
||
export type { AppConfig } from "./app/app.types.js";
|
||
export { AppConfigSchema } from "./app/app.schemas.js";
|
||
// Command
|
||
export { Command, parseArgumentSpec, parseOptionFlags } from "./command/command.js";
|
||
export type { ActionContext, ActionFn, CommandInfo } from "./command/command.types.js";
|
||
export { CommandInfoSchema } from "./command/command.schemas.js";
|
||
// Errors
|
||
export { CliError, CLI_CODES } from "./errors/errors.js";
|
||
export type { CliCode } from "./errors/errors.js";
|
||
// Parser
|
||
export { parseArgs } from "./parser/parser.js";
|
||
export type { ArgumentConfig, OptionConfig, OptionType, ParsedArgs, ParserConfig } from "./parser/parser.types.js";
|
||
export { ArgumentConfigSchema, OptionConfigSchema, OptionTypeSchema, ParsedArgsSchema, ParserConfigSchema } from "./parser/parser.schemas.js";
|
||
// Style
|
||
export { createStyler, getStyleNames, isColorSupported, stripAnsi, style } from "./style/style.js";
|
||
export type { BgColor, Color, Modifier, StyleCode, Styler } from "./style/style.types.js";
|
||
export { StyleOptionsSchema } from "./style/style.schemas.js";
|
||
// Output
|
||
export { columns, divider, heading, indent, json, keyValue, list, tree } from "./output/format/format.js";
|
||
export type { JsonOptions, KeyValueOptions, ListOptions, TreeNode, TreeOptions } from "./output/format/format.types.js";
|
||
export { FormatOptionsSchema } from "./output/format/format.schemas.js";
|
||
export { table } from "./output/table/table.js";
|
||
export type { Alignment, TableOptions } from "./output/table/table.types.js";
|
||
export { TableOptionsSchema } from "./output/table/table.schemas.js";
|
||
// Spinner
|
||
export { createSpinner } from "./spinner/spinner.js";
|
||
export type { SpinnerInstance, SpinnerOptions, WritableStreamLike } from "./spinner/spinner.types.js";
|
||
export { SpinnerOptionsSchema } from "./spinner/spinner.schemas.js";
|
||
// Progress
|
||
export { createProgress } from "./progress/progress.js";
|
||
export type { ProgressInstance, ProgressOptions } from "./progress/progress.types.js";
|
||
export { ProgressOptionsSchema } from "./progress/progress.schemas.js";
|
||
// Prompt
|
||
export { confirm, createDefaultReader, multiselect, password, select, text } from "./prompt/prompt.js";
|
||
export type { ConfirmPromptOptions, InputReader, MultiSelectPromptOptions, PasswordPromptOptions, SelectChoice, SelectPromptOptions, TextPromptOptions } from "./prompt/prompt.types.js";
|
||
export { ConfirmPromptOptionsSchema, SelectChoiceSchema, TextPromptOptionsSchema } from "./prompt/prompt.schemas.js";
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git commit -m "feat(cli): root barrel index"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 19: Benchmarks + README
|
||
|
||
- [ ] **Create `src/__benchmarks__/parser-throughput.bench.ts`**
|
||
|
||
```ts
|
||
import { bench, run } from "mitata";
|
||
import { parseArgs } from "../parser/parser.js";
|
||
|
||
const config = {
|
||
options: {
|
||
verbose: { type: "boolean" as const, short: "v" },
|
||
output: { type: "string" as const, short: "o" },
|
||
count: { type: "number" as const },
|
||
format: { type: "string" as const, choices: ["json", "text"] },
|
||
dry: { type: "boolean" as const },
|
||
force: { type: "boolean" as const },
|
||
quiet: { type: "boolean" as const },
|
||
debug: { type: "boolean" as const },
|
||
timeout: { type: "number" as const },
|
||
retries: { type: "number" as const },
|
||
},
|
||
};
|
||
|
||
const argv = ["--verbose", "--output", "out.txt", "--count", "5", "--format", "json"];
|
||
|
||
bench("parseArgs (20-flag config)", () => {
|
||
parseArgs(argv, config);
|
||
});
|
||
|
||
await run();
|
||
```
|
||
|
||
- [ ] **Create `src/__benchmarks__/app-run.bench.ts`**
|
||
|
||
```ts
|
||
import { bench, run } from "mitata";
|
||
import { App } from "../app/app.js";
|
||
|
||
function makeApp(): App {
|
||
const app = new App({ name: "bench-app", writer: () => {} });
|
||
app.command("build").action(async () => {});
|
||
app.command("test").action(async () => {});
|
||
app.command("lint").action(async () => {});
|
||
return app;
|
||
}
|
||
|
||
bench("App.run() dispatch", async () => {
|
||
const app = makeApp();
|
||
await app.run(["build"]);
|
||
});
|
||
|
||
await run();
|
||
```
|
||
|
||
- [ ] **Create `README.md`** with required sections plus benchmark baseline numbers
|
||
|
||
- [ ] **Run full coverage gate**
|
||
|
||
```bash
|
||
cd /mnt/s/web/genesis && bunx turbo lint typecheck test --filter=@suaveplan/cli
|
||
bunx turbo test:coverage --filter=@suaveplan/cli
|
||
```
|
||
|
||
Expected: all green, 100% coverage.
|
||
|
||
- [ ] **Run benchmarks and paste baseline into README**
|
||
|
||
```bash
|
||
cd /mnt/s/web/genesis/universal/platform/cli && bun run bench
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add universal/platform/cli/
|
||
git commit -m "feat(cli): benchmarks, README, 100% coverage gate"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 20: Publish `@suaveplan/cli` + OpenSpec archival
|
||
|
||
- [ ] **Run pre-publish gate**
|
||
|
||
```bash
|
||
cd /mnt/s/web/genesis
|
||
bunx turbo lint typecheck test --filter=@suaveplan/cli
|
||
bunx turbo test:coverage --filter=@suaveplan/cli
|
||
bunx turbo build --filter=@suaveplan/cli
|
||
```
|
||
|
||
- [ ] **Publish**
|
||
|
||
```bash
|
||
bun scripts/publish.ts --package @suaveplan/cli
|
||
```
|
||
|
||
- [ ] **Create OpenSpec change folders**
|
||
|
||
```text
|
||
openspec/changes/enterprise-readline/proposal.md
|
||
openspec/changes/enterprise-readline/tasks.md
|
||
openspec/changes/enterprise-readline/specs/readline/spec.md
|
||
|
||
openspec/changes/enterprise-cli/proposal.md
|
||
openspec/changes/enterprise-cli/tasks.md
|
||
openspec/changes/enterprise-cli/specs/cli/spec.md
|
||
```
|
||
|
||
Each `proposal.md` references the spec doc at `docs/superpowers/specs/2026-04-20-readline-cli-design.md`. Each `tasks.md` links to this plan. Each `specs/<cap>/spec.md` contains the final accepted spec for that capability.
|
||
|
||
- [ ] **Archive both changes**
|
||
|
||
```bash
|
||
mv openspec/changes/enterprise-readline openspec/archive/2026-04-20-enterprise-readline
|
||
mv openspec/changes/enterprise-cli openspec/archive/2026-04-20-enterprise-cli
|
||
```
|
||
|
||
- [ ] **Final banned-pattern scan (zero output expected)**
|
||
|
||
```bash
|
||
grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only\|\bany\b" \
|
||
universal/platform/readline/src/ universal/platform/cli/src/ --include="*.ts"
|
||
```
|
||
|
||
- [ ] **Final missing-.md scan (zero output expected)**
|
||
|
||
```bash
|
||
for pkg in readline cli; do
|
||
for f in $(find universal/platform/$pkg/src -name "*.ts" \
|
||
! -name "*.test.ts" ! -name "*.types.ts" \
|
||
! -name "index.ts" ! -name "*.schemas.ts" ! -name "*.bench.ts"); do
|
||
md="${f%.ts}.md"
|
||
[ -f "$md" ] || echo "MISSING: $md"
|
||
done
|
||
done
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add .
|
||
git commit -m "chore: publish cli@0.0.0, archive OpenSpec changes"
|
||
```
|
||
|
||
---
|
||
|
||
## Acceptance Checklist
|
||
|
||
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/readline` green
|
||
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/cli` green
|
||
- [ ] 100% istanbul coverage on both packages
|
||
- [ ] Zero `any`, `TODO`, `FIXME`, `ts-ignore`, `.skip` in `src/`
|
||
- [ ] Every `.ts` implementation file has a sibling `.md` (≥200 words)
|
||
- [ ] Zod schemas exported for all public types
|
||
- [ ] OTel spans on `App.run()`, `Command.execute()`, `createInterface()`
|
||
- [ ] AbortSignal honoured in `readLines()`, `question()`, async iterator, `spinner`, `progress`, all prompt functions
|
||
- [ ] `node:readline` absent from `@suaveplan/cli` dependency graph
|
||
- [ ] `"sideEffects": false` in both `package.json` files
|
||
- [ ] `bench` script wired in both `package.json` files; `bench` task in `turbo.json`
|
||
- [ ] Benchmark baselines published in both `README.md` files
|
||
- [ ] OpenSpec changes archived; `FOLDERS.yml` and `CLAUDE.md` updated
|