Skip to main content

cli-developer

Use when building CLI commands in src/cli/. Command structure, DI registration, the Prompts and UI abstractions, cancel handling, and testing through createCliTestContainer. Invoke BEFORE writing any CLI code. References project-architecture for DI patterns.

来源信息

仓库
webiny/data-mock
最近来源活动
2026年9月24日 16:34
检测到的 SKILL.md 语言
英语
星标
1
Forks
0

安装方式

默认使用会先检查来源的 Prompt;你也可以切换为直接命令,或下载本地副本。

检查来源文件

决定是否安装前,请先阅读 SKILL.md,以及 SkillsMP 当前展示的配套文件。

正在显示 SKILL.md

SKILL.md
来源说明 · 只读预览
name
cli-developer
description
Use when building CLI commands in src/cli/. Command structure, DI registration, the Prompts and UI abstractions, cancel handling, and testing through createCliTestContainer. Invoke BEFORE writing any CLI code. References project-architecture for DI patterns.
# CLI Developer Guide CLI lives in `src/cli/`. Run with `yarn cli <command>` (uses tsx). All commands follow the same feature structure defined in **project-architecture**. Shared types, errors, and domain models go in `src/shared/`. ## Command Structure Every command is a feature with this layout: ``` src/cli/commands/{commandName}/ ├── __tests__/ │ └── {CommandName}Command.test.ts ├── {CommandName}Command.ts # createImplementation against the shared Command abstraction └── feature.ts # createFeature, registers command ``` **Commands do NOT get an abstraction of their own.** They register under the shared `Command` abstraction (`src/cli/abstractions/Command.ts`), because `src/cli/entry.ts` builds its command map with `container.resolveAll(Command)` and dispatches on `name`. A per-command token is never resolved by anything, so seven of them sat unused until they were deleted. `init` is the one exception, and it has its own token for a reason: it runs before the rest of the container can be built, so `entry.ts` resolves it directly rather than through `resolveAll`. ## Implementation ```ts // SeedEntriesCommand.ts import { Command } from "~/cli/abstractions/Command.js"; import { Prompts } from "~/cli/abstractions/Prompts.js"; import { UI } from "~/cli/abstractions/UI.js"; import { SeedService } from "~/shared/node/features/seeding/seed/abstractions/SeedService.js"; class SeedEntriesCommandImpl implements Command.Interface { public readonly name = "seed-entries"; public readonly description = "Seed mock entries into a Webiny project"; public constructor( private readonly prompts: Prompts.Interface, private readonly ui: UI.Interface, private readonly seedService: SeedService.Interface, ) {} public async execute(): Promise<void> { this.ui.intro("Seed Entries"); // ... this.ui.outro("Done."); } } export const SeedEntriesCommand = Command.createImplementation({ implementation: SeedEntriesCommandImpl, dependencies: [Prompts, UI, SeedService], }); ``` `name` and `description` are what the dispatcher and `yarn cli help` read, so both are required. ## Feature + Registration ```ts // feature.ts import { createFeature } from "@webiny/stdlib"; import { SeedEntriesCommand } from "./SeedEntriesCommand.js"; export const SeedEntriesFeature = createFeature({ name: "Cli/SeedEntriesFeature", register(container) { container.register(SeedEntriesCommand).inSingletonScope(); }, }); ``` Register the feature in `src/cli/feature.ts` (CliFeature). Nothing else is needed: `entry.ts` finds the command through `resolveAll(Command)` and lists it in the help output automatically. ## Available Services `Prompts` and `UI` are registered in `CliFeature`. Everything else a command needs — repositories, use cases, services — comes from `AppFeature` (`src/shared/node/feature.js`), the same wiring the API uses. Inject through the constructor and list the abstraction in `dependencies`. | Service | Abstraction | Purpose | |---------|-------------|---------| | Prompts | `Prompts.Interface` | text, select, multiselect, confirm | | UI | `UI.Interface` | intro, outro, note, cancel, spinner, log | There is no `DatabaseService` or `GraphQLService`. Read through the feature repositories (`ListProjectsUseCase`, `ListProjectTenantsRepository`, …) and write through the services (`SeedService`, `SyncModelsService`, `KeyRotationService`, …). Everything returns a `Result`. ### Prompts — Cancel Handling Every prompt returns `T | symbol`, where the symbol is @clack's cancel. Narrow it with `isCancelled` — a bare `=== null` check is wrong, and TypeScript will not narrow `typeof CANCEL` out of `string | symbol` on its own: ```ts import { isCancelled } from "~/cli/abstractions/isCancelled.js"; const selected = await this.prompts.select({ message: "Which project?", options: projects.map((p) => ({ value: p, label: p.name })), }); if (isCancelled(selected)) { this.ui.cancel("Cancelled."); return; } ``` For an environment, use `selectEnvironment(prompts, ui, environments)` rather than prompting directly: with exactly one it is selected and reported instead of asking a question that has only one answer. ### UI `UI` is synchronous — nothing here is awaited. ```ts this.ui.intro("Seed Entries"); this.ui.log.warn("No models synced. Run 'yarn cli sync-models' first."); const spinner = this.ui.spinner(); spinner.start("Seeding entries..."); spinner.message("Created 50 entries..."); spinner.stop("Done. 100 entries created."); this.ui.outro("Done."); ``` Pick the channel deliberately: `log.error` for a failure, `log.warn` for a state the user has to act on, `log.info` for a fact, `cancel` for a deliberate abort. A failed write reported on `info` reads as success. **Report every failed `Result`.** A dropped `isFail()` leaves the user believing the thing happened — that is how a template that could not be saved was reported as saved. ## Testing vitest, through `createCliTestContainer()` (`src/cli/testing/`). Only `Prompts` and `UI` are replaced — everything below them is production code, in-memory SQLite included, because a command's job is to turn answers into those calls and a test that stubbed them would only prove the command calls a stub. - `answers` is a script, consumed in the order the command asks. A command that asks one question too many runs out of script and says so, rather than reusing an answer meant for something else. - Answer a select with `pick("Blog")` — by the label the command printed, not the value behind it. The values it offers are rows it has just read, which the test has no handle on. `pickAll(...)` is the multiselect form, `CANCELLED` is Ctrl-C. - `tc.ui.on("error")` reads one channel, `tc.ui.said(fragment)` searches them all. Assert on the channel too: the same sentence on `info` and on `error` are different products. - `tc.prompts.validators.get(message)` returns the validator the command attached, so the input rules are testable without driving the prompt. - Stub a service with `tc.container.registerInstance(SomeService, { execute: async () => ... })` before resolving the command. ```ts import { createCliTestContainer, resolveCommand } from "~/cli/testing/createCliTestContainer.js"; import { pick, CANCELLED } from "~/cli/testing/StubPrompts.js"; import { createTestProject } from "~/shared/node/testing/createTestProject.js"; it("archives by default, destroying nothing", async () => { const tc = createCliTestContainer({ answers: [pick("Blog"), "archive"] }); await createTestProject(tc, { name: "Blog" }); await resolveCommand(tc, "remove-project").execute(); expect(tc.ui.said('Project "Blog" archived')).toBe(true); }); ``` `resolveCommand(tc, name)` resolves through `resolveAll(Command)`, the same path `entry.ts` takes. `init` is resolved by its own token instead. **Never point a test at the repository's own `.env`.** `init` and `rotate-key` write the `.env` in `process.cwd()`; stub `process.cwd()` to a temp directory first. It is the one file here whose contents are not recoverable. ## Pre-Commit Run before every commit: - `yarn typecheck` — TypeScript strict mode - `yarn lint` — oxlint - `yarn format:check` — oxfmt
在 GitHub 查看