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
آخر نشاط في المصدر
٢٤ سبتمبر ٢٠٢٦ في ١٦:٣٤
لغة SKILL.md المكتشفة
الإنجليزية
النجوم
١
التفرعات
٠

خيارات التثبيت

يُحدَّد 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