diff --git a/.ai/instructions.md b/.ai/instructions.md new file mode 100644 index 00000000..7fb9269b --- /dev/null +++ b/.ai/instructions.md @@ -0,0 +1,86 @@ +# APIMatic CLI — Project Instructions + +This file provides guidance when working with code in this repository. + +## Project Overview + +APIMatic CLI (`@apimatic/cli`) — the official CLI for APIMatic, built on oclif v4 with TypeScript ESM. It provides commands for API spec validation/transformation, SDK generation, and documentation portal management. + +## Common Commands + +```bash +# Build +npm run build # tsc -b → outputs to lib/ + +# Lint +npm run lint # ESLint on src/**/*.{js,ts} +npm run lint:fix # ESLint with --fix --quiet + +# Format +npm run format # Prettier write on src/**/*.{js,ts} + +# Test (all) +npm test # tsx + mocha, runs test/**/*.test.ts + +# Test (single file) +npx tsx node_modules/mocha/bin/_mocha "test/actions/portal/serve.test.ts" --timeout 99999 + +# Run CLI locally +node bin/run.js +``` + +## Architecture — 5-Layer Stack + +``` +Command → Action → Application → Prompts / Infrastructure → Types +``` + +1. **Commands** (`src/commands/`) — oclif `Command` subclasses. Parse flags, build `CommandMetadata`, call `intro()` → `action.execute()` → `outro(result)`. No business logic. +2. **Actions** (`src/actions/`) — One per command. Orchestrate use-case: validate inputs via Context objects, coordinate services, return `ActionResult`. Never throw to Command. +3. **Application** (`src/application/`) — Complex reusable domain algorithms (e.g., TOC generators, recipe generators). Pure transformations: data in → data out. No prompts, no API calls. +4. **Prompts** (`src/prompts/`) — All terminal UI via `@clack/prompts`. One class per command mirroring `actions/`. Uses `withSpinner` for async operations. No business logic. +5. **Infrastructure** (`src/infrastructure/`) — I/O adapters: `FileService`, `ZipService`, `NetworkService`, API services in `services/`. All return `Result` (neverthrow). + +Supporting: **Types** (`src/types/`) for value objects, context objects, and domain events, **client-utils** for auth credential management, **utils** for pure string helpers, **config** for shared Axios instance, **hooks** (`src/hooks/`) for oclif lifecycle hooks (e.g., command-not-found suggestions), **env-info** (`src/infrastructure/env-info.ts`) singleton for CLI version, user-agent string, and base URL resolution. + +## Critical Code Conventions + +- **ESM imports with `.js` extension** — even for `.ts` source files: `import { Foo } from "../../types/file/directoryPath.js"` +- **No raw string paths** — use `DirectoryPath`, `FilePath`, `FileName`, `UrlPath` value objects from `src/types/file/` +- **No `console.log`** — all output through `@clack/prompts` via Prompts classes only +- **Error handling**: Services return `Result` (neverthrow); Actions return `ActionResult` (success/failed/cancelled). No uncaught throws above infrastructure. +- **Prompts delegation** — Actions never call `log.*` directly; every message goes through `this.prompts.*` +- **Temp directories** — always use `withDirPath()` wrapper, never `tmp-promise` directly +- **`authKey` typed as `string | null = null`**, not `undefined` +- **Exit via `outro(result)`** — sets `process.exitCode`; never call `process.exit()` directly +- **`ActionResult` variants** — `success()`, `failed()`, `cancelled()`, `stopped()` (for long-running server commands); exit codes 0 / 1 / 130 respectively +- **Constructor pattern**: `private readonly` properties, `public readonly execute = async (...) => { ... }` +- **`static cmdTxt`** on every Command using `format.cmd(...)` for example rendering +- **Commands use `export default class`** — oclif requires default export; actions, prompts, and services use named exports (`export class`) +- **Static fields use `readonly`** — `static readonly summary`, `static readonly description`, `static readonly cmdTxt` on every Command +- **Topic separator is space** — `apimatic portal generate`, not `apimatic portal:generate` +- **Telemetry** — After `outro(result)`, commands optionally track failures via `result.mapAll(() => {}, async () => { await new TelemetryService(configDir).trackEvent(new SomeFailedEvent(...), shell) }, () => {})`. Event classes extend `DomainEvent` (`src/types/events/`). Only the failure callback is populated; success/cancel are no-ops. + +## Commit Conventions + +Uses [Conventional Commits](https://www.conventionalcommits.org/) enforced by commitlint + husky. Pre-commit runs lint-staged (ESLint + Prettier). + +**Do not commit or push automatically.** Always wait for explicit instruction from the user before running `git commit` or `git push`. + +## Testing + +- **Framework**: mocha + chai (expect style) + sinon + nock + mock-fs +- **Test location**: mirrors source — `test/commands/`, `test/actions/`, `test/application/` +- **HTTP mocking**: nock for API calls +- **Run via tsx** (not ts-node) for ESM compatibility + +## Skills + +Reference these files for scaffolding new code: + +- `.ai/skills/command.md` — Command + Action + Prompts conventions; scaffolding templates and checklists +- `.ai/skills/action.md` — Action class conventions; standard / minimal / delegation variants +- `.ai/skills/context.md` — Context object conventions; output / input / temp / pure variants +- `.ai/skills/prompt.md` — Prompts class conventions; simple / standard / delegation / wizard variants +- `.ai/skills/service.md` — Infrastructure Service conventions; SDK controller / axios-auth / axios-stateless variants +- `.ai/skills/value-object.md` — Value object (rich class) conventions; encapsulation, boundary unwrapping, composition rules diff --git a/.ai/skills/action.md b/.ai/skills/action.md new file mode 100644 index 00000000..a84f5eae --- /dev/null +++ b/.ai/skills/action.md @@ -0,0 +1,301 @@ +# Action Conventions + +Actions live at `src/actions/` and are the single use-case orchestrators for each command. They validate inputs via Context objects, coordinate Infrastructure services, delegate all UI to their paired Prompts class, and return an `ActionResult`. They never throw to the Command layer and never produce terminal output directly. + +## Conventions + +### DO + +- **DO** use named export: `export class {PascalName}Action`. +- **DO** use `public readonly execute = async (...): Promise =>` (arrow function property). +- **DO** initialize prompts as a field: `private readonly prompts = new {Name}Prompts()`. +- **DO** initialize services as `private readonly` fields (inline with `new`) when the service takes no constructor args. If a service needs `configDir`, declare without initializer and assign in the constructor body after `this.configDir` is set. +- **DO** use the full constructor pattern when the action calls an API: `constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null)`. +- **DO** use a simpler constructor when no API call is needed: just `(configDir: DirectoryPath, commandMetadata: CommandMetadata)` or even `(configDir: DirectoryPath)`. +- **DO** use constructor shorthand (`private readonly` in parameter list) for minimal actions where no manual field assignment is needed. +- **DO** delegate all terminal output to `this.prompts.*` — never import `log` from `@clack/prompts`. +- **DO** wrap service calls in `this.prompts.spinnerMethod(serviceCall)`. +- **DO** check `result.isErr()` after spinner calls and delegate error display to prompts. +- **DO** use `withDirPath()` to wrap any code that needs a temporary directory. +- **DO** return `ActionResult` directly from the `withDirPath` callback — the return propagates through. +- **DO** use Context objects for input validation (`buildContext.validate()`) and output management (`outputContext.save()`). +- **DO** use the overwrite guard pattern: `if (!force && (await context.exists()) && !(await this.prompts.confirmOverwrite(dir)))`. +- **DO** close file streams in `finally` blocks when using `FileWrapper` or `getStream()`. +- **DO** create sub-action instances inside switch cases (not as class fields) for delegation actions. +- **DO** handle `undefined` (cancel) case explicitly in delegation actions — return `ActionResult.cancelled()`. + +### DON'T + +- **DON'T** use `export default` — actions use named exports. +- **DON'T** use regular `async execute()` method — use the arrow function property form. (Some existing actions use regular methods; new code should use arrow functions.) +- **DON'T** initialize services in the constructor body unless they need a constructor parameter like `configDir`. +- **DON'T** import or call `log.*` from `@clack/prompts` — all output goes through `this.prompts.*`. +- **DON'T** throw exceptions to the Command layer — always return `ActionResult.failed()` or `ActionResult.cancelled()`. +- **DON'T** define helper types/classes inside the action file — put them in `src/types/`. (Exception: small local types used only within the file are acceptable.) +- **DON'T** use `undefined` for optional auth — always use `string | null = null`. +- **DON'T** use `tmp-promise` directly — always use `withDirPath()` from `../../infrastructure/tmp-extensions.js`. +- **DON'T** include `authKey` in the constructor if the action never calls an API — omit it entirely. +- **DON'T** store sub-action instances as class fields — create them per-use in delegation switch cases. +- **DON'T** use `process.exit()` — return an `ActionResult` and let the Command layer handle exit codes via `outro(result)`. +- **DON'T** use `console.log` anywhere. +- **DON'T** use raw string paths — always wrap in `DirectoryPath`, `FilePath`, `FileName`, or `UrlPath`. + +--- + +## Review Checklist + +- [ ] All imports use `.js` extension (e.g., `../../types/file/directoryPath.js`) +- [ ] File placed at `src/actions/{topic}/{name}.ts` mirroring the prompts path +- [ ] Named export (not default): `export class {PascalName}Action` +- [ ] Execute is arrow function: `public readonly execute = async (...): Promise => { ... }` +- [ ] Prompts initialized as field: `private readonly prompts = new {PromptsClassName}()` +- [ ] Services initialized as `private readonly` fields (inline unless they need constructor args) +- [ ] Constructor signature matches complexity: full form for API actions, shorthand for simple ones +- [ ] `authKey` typed as `string | null = null` when present — never `undefined` +- [ ] `authKey` omitted entirely when the action doesn't call an API +- [ ] All terminal output goes through `this.prompts.*` — no direct `log.*` imports +- [ ] Service results checked with `.isErr()`, errors displayed via `this.prompts.*` +- [ ] `withDirPath()` used for temp directories — never raw `tmp-promise` +- [ ] Returns `ActionResult.success()`, `ActionResult.failed()`, or `ActionResult.cancelled()` — never throws +- [ ] Context objects used for validation (`validate()`) and output (`save()`, `exists()`) +- [ ] Overwrite guard uses pattern: `if (!force && (await context.exists()) && !(await this.prompts.confirmOverwrite(dir)))` +- [ ] No `console.log` anywhere +- [ ] No raw string paths — use `DirectoryPath`, `FilePath`, `FileName`, `UrlPath` +- [ ] Relative import depth matches nesting level (extra `../` per nesting level) +- [ ] File streams closed in `finally` block when using `getStream()` or `FileWrapper` +- [ ] Delegation actions handle `undefined` case with `ActionResult.cancelled()` + +## Reference Files + +| Pattern | File | +|---|---| +| Standard action (auth + services + withDirPath) | `src/actions/api/validate.ts` | +| Standard action (multiple params + overwrite guard) | `src/actions/api/transform.ts` | +| Standard action (Context + TempContext + service) | `src/actions/portal/generate.ts` | +| Standard action (SDK generation + version selection) | `src/actions/sdk/generate.ts` | +| Standard action (interactive prompts + neverthrow) | `src/actions/portal/copilot.ts` | +| Minimal action (configDir only, no services) | `src/actions/auth/logout.ts` | +| Minimal action (configDir + commandMetadata, no auth) | `src/actions/auth/status.ts` | +| Minimal action (shorthand constructor) | `src/actions/portal/toc/new-toc.ts` | +| Delegation action (routes to sub-actions) | `src/actions/quickstart.ts` | +| Multi-step flow (withDirPath + multiple cancellation points) | `src/actions/sdk/quickstart.ts` | +| Multi-step flow (interactive wizard + withDirPath) | `src/actions/portal/quickstart.ts` | +| Long-running/stateful action (server + watcher) | `src/actions/portal/serve.ts` | +| ActionResult API (success, failed, cancelled, stopped) | `src/actions/action-result.ts` | +| withDirPath implementation | `src/infrastructure/tmp-extensions.ts` | + +--- + +## Scaffolding + +Use when creating a new Action class. Choose the variant that matches the action's complexity. + +### What to determine + +1. **Topic** — action group folder (e.g., `api`, `sdk`, `portal`). Can be nested: `portal/toc`, `portal/recipe` +2. **Action name** — file name, lowercase hyphenated (e.g., `validate`, `generate`, `new-toc`) +3. **Class name** — PascalCase with `Action` suffix (e.g., `ValidateAction`, `PortalNewTocAction`) +4. **Variant** — one of: + - `standard` — full constructor with configDir, commandMetadata, authKey; services, withDirPath, prompts + - `minimal` — simpler constructor (subset of params); no services or temp dirs + - `delegation` — routes to sub-actions via switch on prompt result +5. **Needs auth** — whether the action receives `authKey: string | null = null` +6. **Needs services** — which infrastructure services are used (e.g., `PortalService`, `ValidationService`) +7. **Needs temp directory** — whether to wrap logic in `withDirPath()` +8. **Needs Context objects** — which contexts are used (e.g., `BuildContext`, `TempContext`, `ResourceContext`) +9. **Prompts class** — the paired Prompts class name and import path +10. **Execute parameters** — typed parameters the execute method receives from the Command layer + +### Standard Action Template + +**Use when:** the action calls API services, needs auth, uses temp directories, or has complex business logic. + +**Path:** `src/actions/{topic}/{name}.ts` + +For nested topics like `portal/toc`, add one more `../` to all relative import paths. + +```typescript +import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { ActionResult } from "../action-result.js"; +import { {PromptsClassName} } from "../../prompts/{topic}/{promptsFile}.js"; +import { CommandMetadata } from "../../types/common/command-metadata.js"; +// If temp directory needed: +import { withDirPath } from "../../infrastructure/tmp-extensions.js"; +// If context objects needed: +// import { ResourceContext } from "../../types/resource-context.js"; +// import { TempContext } from "../../types/temp-context.js"; +// import { BuildContext } from "../../types/build-context.js"; +// If services needed: +// import { {ServiceName} } from "../../infrastructure/services/{service-file}.js"; +// If ResourceInput parameter: +// import { ResourceInput } from "../../types/file/resource-input.js"; + +export class {PascalName}Action { + private readonly prompts = new {PromptsClassName}(); + // Services as private readonly fields (inline initialization when no constructor args needed): + // private readonly someService = new SomeService(); + // + // When service needs configDir, declare without initializer and assign in constructor: + // private readonly validationService: ValidationService; + private readonly configDir: DirectoryPath; + private readonly commandMetadata: CommandMetadata; + private readonly authKey: string | null; + + constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null) { + this.configDir = configDir; + this.commandMetadata = commandMetadata; + this.authKey = authKey; + // Initialize services that need configDir: + // this.validationService = new ValidationService(configDir); + } + + public readonly execute = async ( + /* parameters matching what Command passes, e.g.: + resourcePath: ResourceInput, + destination: DirectoryPath, + force: boolean + */ + ): Promise => { + // 1. Input validation via Context objects + // const buildContext = new BuildContext(buildDirectory); + // if (!(await buildContext.validate())) { + // this.prompts.invalidBuildDirectory(buildDirectory); + // return ActionResult.failed(); + // } + + // 2. Overwrite confirmation (if force flag) + // if (!force && (await outputContext.exists()) && !(await this.prompts.confirmOverwrite(directory))) { + // this.prompts.destinationNotEmpty(); + // return ActionResult.cancelled(); + // } + + // 3. Business logic wrapped in withDirPath for temp directory + return await withDirPath(async (tempDirectory) => { + // 4. Resolve resources / prepare context + // const resourceContext = new ResourceContext(tempDirectory); + // const specFileDirResult = await resourceContext.resolveTo(resourcePath); + // if (specFileDirResult.isErr()) { + // this.prompts.networkError(specFileDirResult.error); + // return ActionResult.failed(); + // } + + // 5. Service call via prompts spinner + // const response = await this.prompts.spinnerMethod( + // this.someService.doSomething({ + // file: specFileDirResult.value, + // commandMetadata: this.commandMetadata, + // authKey: this.authKey + // }) + // ); + + // 6. Error handling + // if (response.isErr()) { + // this.prompts.serviceError(response.error); + // return ActionResult.failed(); + // } + + // 7. Process success, save output + // this.prompts.outputGenerated(outputDirectory); + + return ActionResult.success(); + }); + }; +} +``` + +**Notes:** +- Constructor assigns `configDir`, `commandMetadata`, and `authKey` explicitly because services or internal methods reference them via `this.*`. +- Services needing `configDir` must be assigned in the constructor body after `this.configDir` is set. +- `withDirPath` returns the `ActionResult` — the entire flow inside the callback must return `ActionResult`. +- Multiple error-check points are normal — each service call or context operation gets its own `.isErr()` check. +- Close file streams in `finally` blocks when working with `FileWrapper` or `getStream()`. + +### Minimal Action Template + +**Use when:** the action has simple logic, no API services, no temp directories, and few constructor dependencies. + +**Path:** `src/actions/{topic}/{name}.ts` + +```typescript +import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { ActionResult } from "../action-result.js"; +import { {PromptsClassName} } from "../../prompts/{topic}/{promptsFile}.js"; +// If commandMetadata needed: +// import { CommandMetadata } from "../../types/common/command-metadata.js"; + +export class {PascalName}Action { + private readonly prompts = new {PromptsClassName}(); + + // Shorthand constructor — fields become private readonly automatically + constructor(private readonly configDir: DirectoryPath) {} + + // With commandMetadata: + // constructor( + // private readonly configDir: DirectoryPath, + // private readonly commandMetadata: CommandMetadata + // ) {} + + public readonly execute = async ( + /* parameters */ + ): Promise => { + // Simple logic — no withDirPath, no services + // Delegate output to this.prompts.* + + return ActionResult.success(); + }; +} +``` + +**Notes:** +- Use TypeScript constructor shorthand when no manual field assignment is needed. +- Omit `authKey` entirely — don't include it if it's never used. +- Omit `commandMetadata` if the action doesn't need it. + +### Delegation Action Template + +**Use when:** the action routes to sub-actions based on user selection (e.g., quickstart flows, wizard-style branching). + +**Path:** `src/actions/{topic}/{name}.ts` + +```typescript +import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { ActionResult } from "../action-result.js"; +import { {PromptsClassName} } from "../../prompts/{topic}/{promptsFile}.js"; +import { CommandMetadata } from "../../types/common/command-metadata.js"; +// Import sub-actions: +// import { SubActionA } from "./{sub-action-a}.js"; +// import { SubActionB } from "./{sub-action-b}.js"; + +export class {PascalName}Action { + private readonly prompts = new {PromptsClassName}(); + + public constructor( + private readonly configDir: DirectoryPath, + private readonly commandMetadata: CommandMetadata + ) {} + + public readonly execute = async (): Promise => { + const selectedFlow = await this.prompts.selectFlow(); + switch (selectedFlow) { + case "optionA": { + const action = new SubActionA(this.configDir, this.commandMetadata); + return await action.execute(); + } + case "optionB": { + const action = new SubActionB(this.configDir, this.commandMetadata); + return await action.execute(); + } + case undefined: { + this.prompts.noFlowSelected(); + return ActionResult.cancelled(); + } + } + }; +} +``` + +**Notes:** +- Create sub-action instances inside the switch case — don't store them as class fields. +- Pass `configDir` and `commandMetadata` through to sub-actions. +- Handle `undefined` (cancel) case explicitly — return `ActionResult.cancelled()`. +- Each case block is wrapped in braces `{ }` for proper scoping of the `action` variable. diff --git a/.ai/skills/command.md b/.ai/skills/command.md new file mode 100644 index 00000000..5119c362 --- /dev/null +++ b/.ai/skills/command.md @@ -0,0 +1,357 @@ +# Command, Action & Prompts Conventions + +These three layers are always created together and mirror each other in path structure. Commands parse flags and orchestrate the flow. Actions contain the business logic. Prompts handle all terminal UI. Each has strict responsibilities — none bleeds into another's domain. + +## Conventions + +### Commands — DO + +- **DO** use `export default class` for all commands — oclif requires default export. +- **DO** use `static readonly` for `summary`, `description`, and `cmdTxt`. +- **DO** use `format.cmd("apimatic", ...topicParts)` for `cmdTxt` — each topic level and the command name are separate arguments. +- **DO** reference the class name (not `this`) in static `examples` array: `${ClassName.cmdTxt}`. +- **DO** use `format.flag("name", "value")` in examples for rendered flag display. +- **DO** place `FlagsProvider.authKey` as the last spread in the flags object. +- **DO** order flags as: custom flags first, then FlagsProvider spreads (`input` → `destination` → `force` → `authKey`). +- **DO** parse flags as the first operation in `run()`, before any other logic. +- **DO** convert raw flag strings to typed values (`DirectoryPath`, `ResourceInput`) immediately after parsing. +- **DO** build `CommandMetadata` in the Command only — never construct it in Action or lower layers. +- **DO** follow the exact `run()` flow: parse → type-convert → CommandMetadata → `intro()` → `action.execute()` → `outro(result)`. +- **DO** use `private readonly getConfigDir = () => new DirectoryPath(this.config.configDir)` when the action needs `configDir`. +- **DO** import only `{ Command }` if no flags, `{ Command, Flags }` if flags exist. +- **DO** add one more `../` to relative imports for each nesting level (e.g., `portal/toc/new.ts` uses `../../../` instead of `../../`). +- **DO** always define both `summary` and `description` static properties. + +### Commands — DON'T + +- **DON'T** use named exports (`export class`) — always use `export default class`. +- **DON'T** use `this` in static context for examples — use `ClassName.cmdTxt` instead. (Some existing files use `this.cmdTxt` in static context; do not follow that pattern in new code.) +- **DON'T** use `private static` for `cmdTxt` — use `static readonly` for consistency. +- **DON'T** define `auth-key` flag inline — use `...FlagsProvider.authKey` spread instead. (The `auth/login.ts` command defines it inline; this is legacy — don't repeat.) +- **DON'T** put business logic in `run()` — only flag parsing, type conversion, and the intro/execute/outro flow. +- **DON'T** omit `summary` — every command must have it. +- **DON'T** call `process.exit()` — use `outro(result)` which sets `process.exitCode`. + +### Actions — DO + +- **DO** use named export: `export class {PascalName}Action`. +- **DO** use `public readonly execute = async (...): Promise =>` (arrow function property). +- **DO** initialize prompts as a field: `private readonly prompts = new {Name}Prompts()`. +- **DO** initialize services as `private readonly` fields (inline with `new`, not in constructor body). +- **DO** use the full constructor pattern when the command calls an API: `constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null)`. +- **DO** use a simpler constructor when no API call is needed: just `(configDir: DirectoryPath, commandMetadata: CommandMetadata)` or even `(configDir: DirectoryPath)`. +- **DO** delegate all terminal output to `this.prompts.*` — never import `log` from `@clack/prompts`. +- **DO** wrap service calls in `this.prompts.spinnerMethod(serviceCall)`. +- **DO** check `result.isErr()` after spinner calls and delegate error display to prompts. +- **DO** use `withDirPath()` to wrap any code that needs a temporary directory. +- **DO** use Context objects for input validation (`buildContext.validate()`) and output management (`outputContext.save()`). +- **DO** use the overwrite guard pattern: `if (!force && (await context.exists()) && !(await this.prompts.confirmOverwrite(dir)))`. + +### Actions — DON'T + +- **DON'T** use `export default` — actions use named exports. +- **DON'T** use regular `async execute()` method — use the arrow function property form. (Some legacy actions use regular methods; new code should use arrow functions.) +- **DON'T** initialize services in the constructor body that depend on constructor params — initialize services inline on the property declaration, or if they need `configDir`, pass it in the constructor and assign manually. +- **DON'T** import or call `log.*` from `@clack/prompts` — all output goes through `this.prompts.*`. +- **DON'T** throw exceptions to the Command layer — always return `ActionResult.failed()` or `ActionResult.cancelled()`. +- **DON'T** define helper types/classes inside the action file — put them in `src/types/`. +- **DON'T** use `undefined` for optional auth — always use `string | null = null`. + +### Prompts — DO + +- **DO** use named export: `export class {PascalName}Prompts`. +- **DO** import only the `@clack/prompts` functions actually used (e.g., `{ log, confirm, isCancel }`). +- **DO** alias the format import: `import { format as f } from "../format.js"`. +- **DO** use `withSpinner(startMsg, successMsg, failureMsg, promise)` for async operations — it takes `Promise>`. +- **DO** always check `isCancel()` on interactive prompts (`confirm`, `select`, `text`, `multiselect`) and return `false` or `undefined` on cancel. +- **DO** use `confirm({ message: "...", initialValue: false })` for overwrite confirmations. +- **DO** use format helpers for all dynamic content: `f.var("name")` for names, `f.path(dirOrFile)` for paths, `f.link(url)` for URLs. +- **DO** use `noteWrapped(message, title)` from `../prompt.js` for multi-line informational notes (e.g., next steps). +- **DO** use `getTree()` from `../format.js` when displaying directory structures. + +### Prompts — DON'T + +- **DON'T** put business logic in prompts — only UI rendering and user interaction. +- **DON'T** use constructor parameters — prompts classes have no constructor. +- **DON'T** use `console.log` — use `log.*` from `@clack/prompts`. +- **DON'T** forget the `isCancel()` guard — skipping it causes crashes when user presses Ctrl+C. +- **DON'T** concatenate method calls on the same line as string assignment (keep them on separate lines). + +### General — DO + +- **DO** use `.js` extension on all relative imports (even for `.ts` source files). +- **DO** mirror file paths across `commands/`, `actions/`, `prompts/` directories. +- **DO** use typed path objects (`DirectoryPath`, `FilePath`, `FileName`, `UrlPath`) instead of raw strings. +- **DO** use `ActionResult.success(value?)`, `ActionResult.failed(message?)`, or `ActionResult.cancelled()` as return values. +- **DO** use neverthrow `Result` for service returns — check with `.isErr()` / `.isOk()`. + +### General — DON'T + +- **DON'T** use `console.log` anywhere in any layer. +- **DON'T** use raw string paths — always wrap in the appropriate value object. +- **DON'T** use `process.exit()` — use `outro(result)`. +- **DON'T** use colon as topic separator — use space (`apimatic portal generate`, not `apimatic portal:generate`). + +--- + +## Review Checklist + +- [ ] All imports use `.js` extension (e.g., `../../types/file/directoryPath.js`) +- [ ] File paths mirror across `commands/`, `actions/`, `prompts/` directories +- [ ] Command uses `export default class` +- [ ] Command has `static readonly` for summary, description, cmdTxt +- [ ] Command uses `ClassName.cmdTxt` (not `this.cmdTxt`) in examples +- [ ] Command `run()` follows: parse → type-convert → CommandMetadata → intro → execute → outro +- [ ] Command has `getConfigDir()` helper (private readonly arrow function) +- [ ] Action uses named export (not default) +- [ ] Action constructor signature matches expected pattern for its complexity level +- [ ] Action execute is arrow function: `public readonly execute = async (...) => { ... }` +- [ ] Action uses `this.prompts.*` for all output (no direct `log.*` imports) +- [ ] `authKey` typed as `string | null = null`, not `undefined` +- [ ] Prompts spinner methods take `Promise>` and use `withSpinner()` +- [ ] Interactive prompts guard with `isCancel()` returning `false`/`undefined` +- [ ] `FlagsProvider.authKey` is the last spread in `static flags` +- [ ] No `console.log` anywhere +- [ ] Topic separator is space in examples and cmdTxt (not colon) +- [ ] No raw string paths — use `DirectoryPath`, `FilePath`, `FileName`, `UrlPath` +- [ ] Services initialized inline on property declaration (not in constructor body) + +## Reference Files + +| Pattern | File | +|---|---| +| Complex command (multiple flags, FlagsProvider) | `src/commands/sdk/generate.ts` | +| Simple command (ResourceInput, file/url) | `src/commands/api/validate.ts` | +| Minimal command (no flags) | `src/commands/auth/logout.ts` | +| Command with nested topic (3-level) | `src/commands/portal/toc/new.ts` | +| Action with withDirPath + Context objects | `src/actions/portal/generate.ts` | +| Action with Result error handling | `src/actions/api/validate.ts` | +| Action with simpler constructor (no authKey) | `src/actions/portal/toc/new-toc.ts` | +| Action that delegates to sub-actions | `src/actions/quickstart.ts` | +| Prompts with spinner + confirm + select | `src/prompts/sdk/generate.ts` | +| Prompts with error display methods | `src/prompts/portal/generate.ts` | +| Prompts with format helpers | `src/prompts/api/transform.ts` | +| Prompts with noteWrapped + getTree | `src/prompts/quickstart.ts` | + +--- + +## Scaffolding + +Use when creating a new command triple (Command + Action + Prompts). + +### What to determine + +1. **Topic** — command group (e.g., `api`, `sdk`, `portal`). Can be nested: `portal/toc`, `portal/recipe` +2. **Command name** — subcommand (e.g., `validate`, `generate`, `new`) +3. **Summary** — one-line description +4. **Description** — multi-line description (template literal) +5. **Input type** — one of: + - `ResourceInput` — adds `file` + `url` flags, uses `createResourceInput(file, url)` + - `DirectoryPath` — adds `FlagsProvider.input`, uses `DirectoryPath.createInput(input)` + - `None` — no input flags +6. **Needs API auth** — adds `FlagsProvider.authKey`, passes `authKey` to Action constructor +7. **Needs force flag** — adds `FlagsProvider.force` for overwrite confirmation +8. **Needs destination flag** — adds `FlagsProvider.destination(artifact, artifactName)` +9. **Custom flags** — any command-specific flags +10. **Intro text** — text for `intro("...")` call (e.g., "Validate API", "Generate SDK") + +### Command File Template + +**Path:** `src/commands/{topic}/{name}.ts` + +For nested topics like `portal/toc`, add one more `../` to all relative import paths. + +```typescript +import { Command, Flags } from "@oclif/core"; +import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { FlagsProvider } from "../../types/flags-provider.js"; +// If ResourceInput: +// import { createResourceInput } from "../../types/file/resource-input.js"; +import { {PascalName}Action } from "../../actions/{topic}/{name}.js"; +import { CommandMetadata } from "../../types/common/command-metadata.js"; +import { format, intro, outro } from "../../prompts/format.js"; + +export default class {PascalName} extends Command { + static readonly summary = "{summary}"; + + static readonly description = `{description}`; + + // Each topic level is a separate argument to format.cmd + // e.g., format.cmd("apimatic", "portal", "toc", "new") + static readonly cmdTxt = format.cmd("apimatic", "{topic}", "{name}"); + + static examples = [ + `${ClassName.cmdTxt} ${format.flag("flagName", "value")}` + ]; + + static flags = { + // Custom flags first + // Then FlagsProvider spreads in this order: + // ...FlagsProvider.input, + // ...FlagsProvider.destination("{artifact}", "{artifactName}"), + // ...FlagsProvider.force, + // ...FlagsProvider.authKey, <-- always last + }; + + async run() { + const { + flags: { /* destructure all flags, rename "auth-key": authKey */ } + } = await this.parse({PascalName}); + + // Build typed paths from raw flag strings: + // + // For DirectoryPath input: + // const workingDirectory = DirectoryPath.createInput(input); + // const buildDirectory = input ? new DirectoryPath(input, "src") : workingDirectory.join("src"); + // const outputDirectory = destination ? new DirectoryPath(destination) : workingDirectory.join("{artifact}"); + // + // For ResourceInput: + // const resourceInput = createResourceInput(file, url); + + const commandMetadata: CommandMetadata = { + commandName: {PascalName}.id, + shell: this.config.shell + }; + + intro("{Intro Text}"); + const action = new {PascalName}Action(this.getConfigDir(), commandMetadata, authKey); + const result = await action.execute(/* pass typed args */); + outro(result); + } + + private readonly getConfigDir = () => { + return new DirectoryPath(this.config.configDir); + }; +} +``` + +### Action File Template + +**Path:** `src/actions/{topic}/{name}.ts` + +```typescript +import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { ActionResult } from "../action-result.js"; +import { {PascalName}Prompts } from "../../prompts/{topic}/{name}.js"; +import { CommandMetadata } from "../../types/common/command-metadata.js"; +// If temp directory needed: +// import { withDirPath } from "../../infrastructure/tmp-extensions.js"; +// import { TempContext } from "../../types/temp-context.js"; +// If service needed: +// import { SomeService } from "../../infrastructure/services/some-service.js"; +// If context needed: +// import { SomeContext } from "../../types/some-context.js"; + +export class {PascalName}Action { + private readonly prompts: {PascalName}Prompts = new {PascalName}Prompts(); + // Services as private readonly fields: + // private readonly someService: SomeService = new SomeService(); + private readonly configDir: DirectoryPath; + private readonly commandMetadata: CommandMetadata; + private readonly authKey: string | null; + + constructor( + configDir: DirectoryPath, + commandMetadata: CommandMetadata, + authKey: string | null = null + ) { + this.configDir = configDir; + this.commandMetadata = commandMetadata; + this.authKey = authKey; + } + + public readonly execute = async ( + /* parameters matching what Command passes */ + ): Promise => { + // 1. Input validation via Context objects + // const buildContext = new BuildContext(buildDirectory); + // if (!(await buildContext.validate())) { + // this.prompts.directoryEmpty(buildDirectory); + // return ActionResult.failed(); + // } + + // 2. Overwrite confirmation (if force flag) + // const outputContext = new OutputContext(outputDirectory); + // if (!force && (await outputContext.exists()) && !(await this.prompts.confirmOverwrite(outputDirectory))) { + // this.prompts.destinationNotEmpty(); + // return ActionResult.cancelled(); + // } + + // 3. Business logic (wrap in withDirPath if temp dirs needed) + // return await withDirPath(async (tempDirectory) => { + // const tempContext = new TempContext(tempDirectory); + // const zipPath = await tempContext.zip(buildDirectory); + // + // const response = await this.prompts.spinnerMethod( + // this.someService.doSomething(zipPath, this.configDir, this.commandMetadata, this.authKey) + // ); + // + // if (response.isErr()) { + // this.prompts.serviceError(response.error); + // return ActionResult.failed(); + // } + // + // this.prompts.outputGenerated(outputDirectory); + // return ActionResult.success(); + // }); + + return ActionResult.success(); + }; +} +``` + +### Prompts File Template + +**Path:** `src/prompts/{topic}/{name}.ts` + +```typescript +import { log, isCancel, confirm } from "@clack/prompts"; +// Add select, text, multiselect as needed +import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { format as f } from "../format.js"; +import { Result } from "neverthrow"; +import { withSpinner } from "../prompt.js"; +import { ServiceError } from "../../infrastructure/service-error.js"; + +export class {PascalName}Prompts { + // Spinner methods — wrap async service calls + // Takes Promise>, returns the same Result after spinner completes + public async doSomething(fn: Promise>) { + return withSpinner( + "Doing something", // start message + "Done successfully.", // success message + "Failed to do something.", // failure message + fn + ); + } + + // Confirmation methods — return boolean, guard with isCancel + public async confirmOverwrite(directory: DirectoryPath): Promise { + const overwrite = await confirm({ + message: `The destination ${f.path(directory)} is not empty, do you want to overwrite?`, + initialValue: false + }); + if (isCancel(overwrite)) return false; + return overwrite; + } + + // Error display methods + public serviceError(error: ServiceError): void { + log.error(error.errorMessage); + } + + // Info/success display methods + public outputGenerated(outputPath: DirectoryPath): void { + log.info(`Output can be found at ${f.path(outputPath)}.`); + } + + // Validation error messages using format helpers + public directoryEmpty(directory: DirectoryPath): void { + log.error(`The ${f.var("src")} directory is either empty or invalid: ${f.path(directory)}`); + } + + public destinationNotEmpty(): void { + log.error(`Please enter a different destination folder or remove the existing files and try again.`); + } +} +``` diff --git a/.ai/skills/context.md b/.ai/skills/context.md new file mode 100644 index 00000000..9d293ecc --- /dev/null +++ b/.ai/skills/context.md @@ -0,0 +1,244 @@ +# Context Conventions + +Context objects live at `src/types/` and encapsulate path derivation, validation, and file I/O for a single domain concept. Callers interact with high-level behavioral methods (`validate()`, `exists()`, `save()`) — never with internal paths, file names, or parsing logic. There are four variants: output, input, temp, and pure. + +## Conventions + +### Encapsulation — DO + +- **DO** expose only behavioral methods — `validate()`, `exists()`, `save()`, `resolveTo()`. Callers say *what* they want, not *how*. +- **DO** return results from operations — `save()` returning `FilePath` (where it wrote) is fine. It's the *result* of work, not internal *state*. +- **DO** chain method return values between context operations — it's valid for one method's return (`DirectoryPath`, `FilePath`) to be passed as input to the next method on the same context. This is coordinated workflow within the context's domain, not leakage. +- **DO** keep derived paths as `private get` — all `FilePath`/`DirectoryPath` derivation is internal. +- **DO** keep decision logic inside — if the context knows how to choose between zip/unzip, file/url, etc., that logic stays internal. +- **DO** expose domain-specific read/write methods — instead of returning raw JSON/YAML config for callers to manipulate. +- **DO** initialize `FileService` / `ZipService` inline as `private readonly` fields. +- **DO** use constructor shorthand (`private readonly` in parameter list) for all constructor parameters. +- **DO** use `new FilePath(directory, new FileName("name"))` for file path construction. +- **DO** use `directory.join("subdir")` for subdirectory derivation. + +### Encapsulation — DON'T + +- **DON'T** expose internal paths as public getters — no `public get outputDirectory()` or `public get filePath()`. If a caller needs a path, it should come as a return value from an operation. +- **DON'T** return raw config objects — don't return parsed JSON/YAML for callers to manipulate directly. Wrap reads/writes in domain methods (e.g., `getCopilotConfig()` instead of `getBuildFileContents()`). +- **DON'T** expose derived file names — methods like `getScriptFileName()` leak internal naming logic. +- **DON'T** add public properties for internal state — constructor parameters are `private readonly`, not exposed. +- **DON'T** use `console.log` or any prompt output — contexts are silent. +- **DON'T** use `Result` unless the context does network I/O (rare). +- **DON'T** use raw string paths — always wrap in `DirectoryPath`, `FilePath`, `FileName`. +- **DON'T** add methods that only use infrastructure services (`fileService`, `zipService`) without touching domain-specific private fields — these are stateless utilities, not context behavior. Every public method must use at least one constructor-derived private field (e.g., `sdkDirectory`, `language`). If a method takes all its inputs as parameters and never reads context state, it belongs in a service or a different context. +- **DON'T** embed the context's own domain subject in method names — if the class is `SdkContext`, a method named `cleanUpSdkDirectory()` is redundant. Prefer concise behavioral verbs: `cleanUp()`, `getChanges()`, `save()`. +- **DON'T** pass internal paths through callback parameters — `onSomething: (path: DirectoryPath) => void` is the same leakage as a public getter. If the caller needs the path, return it as the result of the operation (`Promise` etc.). + +### Variant-specific rules + +**Output contexts** (`exists()` + `save()` pattern): +- `exists()` checks if output is already populated — used by actions for overwrite confirmation. +- `save()` returns the path where content was written — only way callers learn about output location. +- Call `cleanDirectory` before writing when overwriting is expected. + +**Input contexts** (`validate()` + domain read/write methods): +- `validate()` checks that required files/directories exist before any operation. +- Read/write methods expose domain concepts, not raw file contents. +- Callers should never need to know file names, formats, or internal structure. + +**Temp contexts** (used inside `withDirPath()` blocks): +- Methods create temp files and return the resulting `FilePath`. +- Temp file naming (UUIDs, etc.) is internal — never exposed. + +**Pure contexts** (no I/O): +- No `FileService`, `ZipService`, or any infrastructure imports. +- Constructor takes domain values (strings, enums, DTOs). +- Methods are pure transformations or in-memory checks. + +--- + +## Review Checklist + +- [ ] All imports use `.js` extension (e.g., `../infrastructure/file-service.js`) +- [ ] File placed at `src/types/{name}-context.ts` +- [ ] Named export (not default): `export class {PascalName}Context` +- [ ] `private readonly fileService = new FileService()` — inline, not constructor-injected +- [ ] Constructor params as `private readonly` (shorthand or explicit) +- [ ] **All derived paths are `private get`** — no public getters for internal paths +- [ ] **No raw config objects returned** — domain methods instead of `getContents()` +- [ ] Operations (`save`, `validate`) return results; internal state is not exposed +- [ ] Paths use value objects: `DirectoryPath`, `FilePath`, `FileName` +- [ ] `new FilePath(directory, new FileName("name"))` for file path construction +- [ ] `directory.join("subdir")` for subdirectory derivation +- [ ] No `console.log`, no prompt output — contexts are silent +- [ ] No `Result` unless context does network I/O (rare) +- [ ] Every public method uses at least one domain-specific private field — methods using only infrastructure services don't belong here +- [ ] No public properties that expose constructor parameters +- [ ] Method names do not embed the context's own domain subject — names are concise behavioral verbs (`cleanUp()` not `cleanUpSdkReviewDirectory()`) +- [ ] No internal paths passed through callback parameters — paths are returned as operation results, not injected into callbacks + +## Reference Files + +| Pattern | File | Encapsulation | +|---|---|---| +| Output context (save + zip/unarchive) | `src/types/portal-context.ts` | Good — all paths private | +| Output context (save stream + derived name) | `src/types/transform-context.ts` | Good — name derivation internal | +| Input context (validate + file ops) | `src/types/spec-context.ts` | Good — zip detection internal | +| Temp context (zip + save stream) | `src/types/temp-context.ts` | Good — UUID naming internal | +| Temp context (download + resolve) | `src/types/resource-context.ts` | Good — URL/file decision internal | +| Composite (delegates to BuildContext) | `src/types/versioned-build-context.ts` | Good — typed result object | +| Output context (leaky — avoid pattern) | `src/types/sdk-context.ts` | Avoid — exposes `sdkLanguageDirectory`, has methods that only use infrastructure services without touching domain state | +| Input context (leaky — avoid pattern) | `src/types/toc-context.ts` | Avoid — exposes `tocPath` | + +--- + +## Scaffolding + +Use when creating a new Context class. Choose the variant that matches the domain concept. + +### What to determine + +1. **Context name** — lowercase hyphenated (e.g., `portal`, `sdk`, `build`). File will be `{name}-context.ts` +2. **Class name** — PascalCase (e.g., `PortalContext`, `SdkContext`) +3. **Variant** — one of: + - `output` — save/exists pattern for command outputs (portals, SDKs, transformed files) + - `input` — validate/read pattern for user-provided input directories + - `temp` — transient operations inside `withDirPath()` blocks + - `pure` — no I/O, domain logic only (name derivation, in-memory checks) +4. **Constructor parameters** — what domain values the context wraps (directories, file paths, enums) +5. **Services needed** — `FileService`, `ZipService`, `FileDownloadService` (none for pure) +6. **Initial methods** — what operations the context should expose + +### Output Context Template + +**Use when:** the context manages command output (portals, SDKs, transformed files). + +**Based on:** `src/types/portal-context.ts`, `src/types/transform-context.ts` + +```typescript +import { FileService } from "../infrastructure/file-service.js"; +import { DirectoryPath } from "./file/directoryPath.js"; +import { FilePath } from "./file/filePath.js"; +import { FileName } from "./file/fileName.js"; +// If zip/unarchive needed: +// import { ZipService } from "../infrastructure/zip-service.js"; + +export class {PascalName}Context { + private readonly fileService = new FileService(); + // private readonly zipService = new ZipService(); + + constructor(private readonly outputDirectory: DirectoryPath) {} + + // Internal path derivation — always private + private get outputPath(): FilePath { + return new FilePath(this.outputDirectory, new FileName("{filename}")); + } + + public async exists(): Promise { + return !(await this.fileService.directoryEmpty(this.outputDirectory)); + } + + // save() returns the output path as a result of the operation + public async save(stream: NodeJS.ReadableStream): Promise { + await this.fileService.createDirectoryIfNotExists(this.outputDirectory); + await this.fileService.writeFile(this.outputPath, stream); + return this.outputPath; + } + + // For zip/unarchive branching: + // public async save(tempFilePath: FilePath, asZip: boolean): Promise { + // await this.fileService.cleanDirectory(this.outputDirectory); + // if (asZip) { + // await this.fileService.copy(tempFilePath, this.zipPath); + // } else { + // await this.zipService.unArchive(tempFilePath, this.outputDirectory); + // } + // } +} +``` + +### Input Context Template + +**Use when:** the context validates and reads from user-provided input directories. + +**Based on:** `src/types/spec-context.ts` + +```typescript +import { FileService } from "../infrastructure/file-service.js"; +import { DirectoryPath } from "./file/directoryPath.js"; +import { FilePath } from "./file/filePath.js"; +import { FileName } from "./file/fileName.js"; + +export class {PascalName}Context { + private readonly fileService = new FileService(); + + constructor(private readonly inputDirectory: DirectoryPath) {} + + // Internal path derivation — always private + private get configFile(): FilePath { + return new FilePath(this.inputDirectory, new FileName("{config-file-name}")); + } + + public async validate(): Promise { + if (!(await this.fileService.directoryExists(this.inputDirectory))) return false; + return await this.fileService.fileExists(this.configFile); + } + + // Domain-specific read methods — NOT raw config getters + // public async getSomeDomainValue(): Promise { + // const content = await this.fileService.getContents(this.configFile); + // const config = JSON.parse(content); + // return config.domainField; + // } +} +``` + +### Temp Context Template + +**Use when:** the context manages transient operations inside `withDirPath()` blocks. + +**Based on:** `src/types/temp-context.ts`, `src/types/resource-context.ts` + +```typescript +import { FileService } from "../infrastructure/file-service.js"; +import { DirectoryPath } from "./file/directoryPath.js"; +import { FilePath } from "./file/filePath.js"; +import { FileName } from "./file/fileName.js"; +// If zip needed: +// import { ZipService } from "../infrastructure/zip-service.js"; +// import { randomUUID } from "crypto"; + +export class {PascalName}Context { + private readonly fileService = new FileService(); + + constructor(private readonly tempDirectory: DirectoryPath) {} + + // Operations return the resulting FilePath + public async save(stream: NodeJS.ReadableStream): Promise { + const tempFile = new FilePath(this.tempDirectory, new FileName("{temp-name}")); + await this.fileService.writeFile(tempFile, stream); + return tempFile; + } + + // public async zip(sourceDirectory: DirectoryPath): Promise { + // const tempFile = new FilePath(this.tempDirectory, new FileName(randomUUID())); + // await this.zipService.archive(sourceDirectory, tempFile); + // return tempFile; + // } +} +``` + +### Pure Context Template + +**Use when:** the context has domain logic but no file/network I/O. + +**Based on:** `src/types/recipe-context.ts` + +```typescript +// No infrastructure imports — pure logic only + +export class {PascalName}Context { + constructor(private readonly {domainParam}: {Type}) {} + + // Domain logic methods — no I/O + public {methodName}({params}): {ReturnType} { + // Pure transformation or validation logic + } +} +``` diff --git a/.ai/skills/event.md b/.ai/skills/event.md new file mode 100644 index 00000000..7ff561fa --- /dev/null +++ b/.ai/skills/event.md @@ -0,0 +1,110 @@ +# Domain Event Conventions + +Domain events live at `src/types/events/` and represent something that **already happened** in the system. They are fired from the Command layer via `TelemetryService.trackEvent()` and extend `DomainEvent`. + +## Conventions + +### Naming + +- **Class name must be past tense** — the event describes something that occurred: `QuickstartCompleted`, `SdkChangesSaved`, `RecipeCreationFailed`. Never present tense (`SdkSaveChanges`) or imperative (`SaveChanges`). +- **File name** — lowercase hyphenated, matching the class name: `quickstart-completed.ts`, `sdk-changes-saved.ts`. +- **Class name suffix** — always `Event`: `QuickstartCompletedEvent`, `SdkChangesSavedEvent`. + +### Structure + +Two variants exist depending on whether the event carries runtime data (e.g., `language`) or is parameterised entirely by its own static fields. + +#### Self-contained event (no runtime data beyond what the event already knows) + +```typescript +import { DomainEvent } from "./domain-event.js"; + +export class {PascalName}Event extends DomainEvent { + protected readonly eventName = {PascalName}Event.name; + private static readonly message = "{Human readable past-tense description}." as const; + private static readonly commandName = "{topic}:{command}" as const; + + constructor() { + super({PascalName}Event.message, {PascalName}Event.commandName, {}); + } +} +``` + +#### Event with runtime payload (e.g., language, flags) + +```typescript +import { DomainEvent } from "./domain-event.js"; + +export class {PascalName}Event extends DomainEvent { + protected readonly eventName = {PascalName}Event.name; + private static readonly message = "{Human readable past-tense description}." as const; + private static readonly commandName = "{topic}:{command}" as const; + private readonly {payloadField}: {PayloadType}; + + constructor({payloadField}: {PayloadType}) { + super({PascalName}Event.message, {PascalName}Event.commandName, {}); + this.{payloadField} = {payloadField}; + } +} +``` + +#### Failure event (message, commandName, and flags passed in from the Command layer) + +Use this variant when the event needs to capture dynamic failure context (e.g., the failing command's flags): + +```typescript +import { DomainEvent } from "./domain-event.js"; + +export class {PascalName}Event extends DomainEvent { + protected readonly eventName = {PascalName}Event.name; + + constructor(message: string, commandName: string, flags: Record) { + super(message, commandName, flags); + } +} +``` + +### DO + +- **DO** use past-tense names: `Completed`, `Initiated`, `Failed`, `Resolved`, `Saved`, `Tracked`. +- **DO** mark `message` and `commandName` as `private static readonly ... as const`. +- **DO** assign `eventName` as `protected readonly eventName = {ClassName}.name` — never a raw string. +- **DO** use named export: `export class {PascalName}Event`. +- **DO** place the file at `src/types/events/{kebab-name}.ts`. +- **DO** use `.js` extension in the import: `import { DomainEvent } from "./domain-event.js"`. +- **DO** fire events from the Command layer only — never from Actions or Services. +- **DO** fire success events inside the `mapAll` success callback (first arg); failure events in the failure callback (second arg). + +### DON'T + +- **DON'T** use present tense or imperative class names (`SdkSaveChanges`, `TrackChanges`). +- **DON'T** use `export default`. +- **DON'T** fire events from Actions, Prompts, or Infrastructure — only from Commands. +- **DON'T** add business logic inside an event class — it is a plain data carrier. + +--- + +## Review Checklist + +- [ ] Class name is past tense and ends with `Event` +- [ ] File name is kebab-case and matches the class name +- [ ] `eventName` assigned as `ClassName.name` (not a raw string) +- [ ] `message` and `commandName` are `private static readonly ... as const` (self-contained variant) +- [ ] Named export (not default) +- [ ] Import uses `.js` extension: `"./domain-event.js"` +- [ ] Fired from Command layer only, inside `result.mapAll(...)` callback + +--- + +## Reference Files + +| Pattern | File | +|---|---| +| Self-contained success event (no payload) | `src/types/events/quickstart-completed.ts` | +| Self-contained initiation event (no payload) | `src/types/events/quickstart-initiated.ts` | +| Success event with payload (`language`) | `src/types/events/sdk-changes-saved.ts` | +| Success event with payload (`language`) | `src/types/events/sdk-conflicts-resolved.ts` | +| Failure event (dynamic message + flags) | `src/types/events/recipe-creation-failed.ts` | +| DomainEvent base class | `src/types/events/domain-event.ts` | +| Firing from Command (success + failure) | `src/commands/quickstart.ts` | +| Firing from Command (failure only) | `src/commands/portal/recipe/new.ts` | diff --git a/.ai/skills/prompt.md b/.ai/skills/prompt.md new file mode 100644 index 00000000..50cd1ebc --- /dev/null +++ b/.ai/skills/prompt.md @@ -0,0 +1,312 @@ +# Prompt Conventions + +Prompts live at `src/prompts/` and are the sole terminal UI layer for each command. They are thin wrappers around `@clack/prompts` — no constructor, no business logic, no knowledge of domain rules. Four method categories: spinners, interactive prompts, log messages, and note/tree display. Everything else belongs in the Action layer. + +## Conventions + +### DO + +- **DO** use named export: `export class {PascalName}Prompts`. +- **DO** omit the constructor — prompts classes are always stateless with no fields. +- **DO** import only the `@clack/prompts` functions actually used (e.g., `{ log, confirm, isCancel }`). +- **DO** alias the format import: `import { format as f } from "../format.js"` (add one more `../` per nesting level). +- **DO** use `withSpinner(intro, success, failure, fn)` from `../prompt.js` for all async `Result` operations. +- **DO** always check `isCancel()` on interactive prompts (`confirm`, `select`, `text`, `multiselect`) before using the value. +- **DO** return `false` from `confirm` methods on cancel — not `undefined`. +- **DO** return `undefined` from `select`, `text`, and `multiselect` methods on cancel. +- **DO** use `confirm({ message: "...", initialValue: false })` for overwrite/destructive confirmations. +- **DO** use format helpers for all dynamic content: `f.var("name")` for variables, `f.path(dir)` for paths, `f.link(url)` for URLs. +- **DO** use `log.error()` for errors, `log.info()` for success/info, `log.warning()` for warnings, `log.message()` for multi-line output, `log.step()` for step markers. +- **DO** use `noteWrapped(message, title)` from `../prompt.js` for multi-line notes (e.g., next steps). +- **DO** use `getTree()` from `../format.js` when displaying directory structures. +- **DO** name spinner methods after their operation, matching the paired service call (e.g., `generatePortal(fn)`, `validateApi(fn)`). + +### DON'T + +- **DON'T** use a constructor — no parameters, no fields. +- **DON'T** put business logic in Prompts — only UI rendering and user interaction. This is a wrapper around `@clack/prompts`, nothing more. +- **DON'T** use `console.log` — use `log.*` from `@clack/prompts`. +- **DON'T** skip the `isCancel()` guard on interactive prompts — omitting it causes a crash when the user presses Ctrl+C. +- **DON'T** return the raw `symbol` value from interactive prompts — always guard with `isCancel()` first. +- **DON'T** import `ActionResult`, services, or domain logic — those belong in the Action layer. +- **DON'T** use `export default` — always use named exports. +- **DON'T** use raw string paths — wrap in `DirectoryPath`, `FilePath`, or `UrlPath`. +- **DON'T** chain complex format calls on a single line — build the message string on a separate line first. If the string is too long to fit on one line, split it using `+` across multiple template literals: + ```typescript + const message = + `First part ${f.var(name)}. ` + + `Second part ${f.flag("flag-name")}.`; + ``` +- **DON'T** use `format.*` directly — always alias as `f`: `import { format as f }`. + +--- + +## Review Checklist + +- [ ] All imports use `.js` extension (e.g., `../../types/file/directoryPath.js`) +- [ ] File placed at `src/prompts/{topic}/{name}.ts` mirroring the action/command path +- [ ] Named export (not default): `export class {PascalName}Prompts` +- [ ] No constructor — class body starts directly with methods +- [ ] `@clack/prompts` imports are selective (only functions actually used) +- [ ] `format` aliased as `f`: `import { format as f } from "../format.js"` +- [ ] Import depth matches nesting level (one extra `../` per nesting level) +- [ ] All interactive prompts check `isCancel()` before using the result +- [ ] `confirm` returns `false` on cancel; `select`/`text`/`multiselect` return `undefined` on cancel +- [ ] All dynamic content uses `f.var()`, `f.path()`, `f.link()`, etc. +- [ ] No `console.log`, no business logic, no service or ActionResult imports +- [ ] Spinner methods accept `Promise>` and delegate to `withSpinner()` + +--- + +## Reference Files + +| Pattern | File | +|---|---| +| Simple (log + spinner, no interactive prompts) | `src/prompts/auth/login.ts` | +| Simple (log-only display methods) | `src/prompts/auth/status.ts` | +| Standard (confirm + log + spinner) | `src/prompts/portal/generate.ts` | +| Standard (confirm + select + log + spinner) | `src/prompts/sdk/generate.ts` | +| Standard (log + spinner + structured display) | `src/prompts/api/validate.ts` | +| Delegation (select flow + welcome message) | `src/prompts/quickstart.ts` | +| Wizard (multi-step, noteWrapped, getTree) | `src/prompts/portal/quickstart.ts` | +| Shared utilities (withSpinner, noteWrapped) | `src/prompts/prompt.ts` | +| Format helpers (f.var, f.path, f.link, getTree) | `src/prompts/format.ts` | + +--- + +## Scaffolding + +Use when creating a new Prompts class. Choose the variant that matches the command's UI complexity. + +### What to determine + +1. **Topic** — folder path matching the action (e.g., `auth`, `api`, `sdk`, `portal`, `portal/toc`) +2. **Name** — file name, lowercase hyphenated (e.g., `login`, `generate`, `new-toc`) +3. **Class name** — PascalCase with `Prompts` suffix (e.g., `LoginPrompts`, `SdkGeneratePrompts`) +4. **Variant** — one of: + - `simple` — log methods and optional spinner; no interactive prompts + - `standard` — overwrite confirm + error log methods + spinner + - `delegation` — welcome message + async select returning a typed flow union + - `wizard` — multi-step with step markers, multiple prompt types, noteWrapped, getTree +5. **Interactive prompts needed** — which of: `confirm`, `select`, `text`, `multiselect` +6. **Spinner needed** — yes/no, and what `Result` type it wraps +7. **Format helpers needed** — which of: `f.var`, `f.path`, `f.link`, `noteWrapped`, `getTree` + +### Simple Template + +**Use when:** the command only needs to display messages and wrap a service call in a spinner — no interactive prompts. + +**Based on:** `src/prompts/auth/login.ts` + +**Path:** `src/prompts/{topic}/{name}.ts` + +```typescript +import { log } from "@clack/prompts"; +import { Result } from "neverthrow"; +import { ServiceError } from "../../infrastructure/service-error.js"; +import { withSpinner } from "../prompt.js"; +// Add if messages use dynamic content: +// import { format as f } from "../format.js"; +// Add typed path imports as needed: +// import { DirectoryPath } from "../../types/file/directoryPath.js"; + +export class {PascalName}Prompts { + public {action}(fn: Promise>) { + return withSpinner("{Action started}", "{Action} completed successfully.", "{Action} failed.", fn); + } + + public {errorMethod}() { + log.error("{Error message}"); + } + + public {successMethod}() { + log.info("{Success message}"); + } +} +``` + +**Notes:** +- Omit `withSpinner` import entirely if the command has no async service call. +- Add `import { format as f } from "../format.js"` when any message includes a dynamic value. +- Adjust `../` depth: one more level per nesting (e.g., `portal/toc/` uses `../../`). + +### Standard Template + +**Use when:** the command confirms before overwriting, calls a service, and displays error/success messages. + +**Based on:** `src/prompts/portal/generate.ts`, `src/prompts/sdk/generate.ts` + +**Path:** `src/prompts/{topic}/{name}.ts` + +```typescript +import { isCancel, confirm, log } from "@clack/prompts"; +import { Result } from "neverthrow"; +import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { ServiceError } from "../../infrastructure/service-error.js"; +import { format as f } from "../format.js"; +import { withSpinner } from "../prompt.js"; + +export class {PascalName}Prompts { + public async confirmOverwrite(directory: DirectoryPath): Promise { + const overwrite = await confirm({ + message: `The destination ${f.path(directory)} is not empty, do you want to overwrite?`, + initialValue: false + }); + + if (isCancel(overwrite)) { + return false; + } + + return overwrite; + } + + public {action}(fn: Promise>) { + return withSpinner("{Action started}", "{Action} completed.", "{Action} failed.", fn); + } + + public {domainError}() { + log.error("{Domain-specific error message}"); + } + + public serviceError(serviceError: ServiceError) { + log.error(serviceError.errorMessage); + } + + public {successMethod}({param}: {Type}) { + log.info(`{message}`); + } +} +``` + +**Notes:** +- `confirm` returns `false` on cancel — never `undefined`. +- Add `select` or `text` as additional interactive methods with the same `isCancel()` guard + `return undefined` pattern. +### Delegation Template + +**Use when:** the command is a top-level router that lets the user choose a sub-flow. + +**Based on:** `src/prompts/quickstart.ts` + +**Path:** `src/prompts/{topic}/{name}.ts` + +```typescript +import { isCancel, log, select } from "@clack/prompts"; + +export type {Feature}Flow = "optionA" | "optionB" | undefined; + +export class {PascalName}Prompts { + public welcomeMessage() { + log.info("{Welcome message}"); + log.message("{Brief description of what this wizard does.}"); + } + + public async select{Feature}Flow(): Promise<{Feature}Flow> { + const option = await select({ + message: "{What would you like to do?}", + options: [ + { value: "optionA", label: "{Label A}", hint: "{Optional hint}" }, + { value: "optionB", label: "{Label B}" } + ] + }); + + if (isCancel(option)) { + return undefined; + } + + return option; + } + + public noFlowSelected() { + log.error("No option was selected."); + } +} +``` + +**Notes:** +- Export the flow union type from the same file — the paired Action imports it for type-safe `switch` cases. +- `undefined` in the union represents cancellation — the Action returns `ActionResult.cancelled()` for that case. +- Add `import { format as f } from "../format.js"` only if messages include dynamic content. + +### Wizard Template + +**Use when:** the command is a multi-step interactive flow with step markers, multiple prompt types, and rich output formatting. + +**Based on:** `src/prompts/portal/quickstart.ts` + +**Path:** `src/prompts/{topic}/{name}.ts` + +```typescript +import { isCancel, log, confirm, select, text, multiselect } from "@clack/prompts"; +import { Result } from "neverthrow"; +import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { ServiceError } from "../../infrastructure/service-error.js"; +import { format as f } from "../format.js"; +import { withSpinner, noteWrapped } from "../prompt.js"; +import { getTree } from "../format.js"; +// Additional domain type imports as needed + +export class {PascalName}Prompts { + // Step markers — sync, no return value + public {step}Step() { + log.step("Step {N}: {Step title}"); + } + + // Text input prompt + public async {input}Prompt(): Promise { + const value = await text({ + message: "{Prompt message}", + placeholder: "{placeholder}", + validate: (v) => (!v ? "{Field} is required." : undefined) + }); + + if (isCancel(value)) { + return undefined; + } + + return value; + } + + // Multi-select prompt + public async select{Items}Prompt(options: string[]): Promise { + const selected = await multiselect({ + message: "{Select message}", + options: options.map((o) => ({ value: o, label: o })), + required: true + }); + + if (isCancel(selected)) { + return undefined; + } + + return selected; + } + + // Spinner for async Result operations + public {action}(fn: Promise>) { + return withSpinner("{Action started}", "{Action} completed.", "{Action} failed.", fn); + } + + // Multi-line note + public nextSteps() { + const message = ["{Step 1}", "{Step 2}"].join("\n"); + noteWrapped(message, "Next steps"); + } + + // Directory tree display + public showStructure(directory: DirectoryPath) { + log.message(getTree(directory)); + } + + public {errorMethod}() { + log.error("{Error message}"); + } +} +``` + +**Notes:** +- Wizard prompts return `undefined` on cancel — the Action checks for `undefined` and returns `ActionResult.cancelled()`. +- `text()` `validate` callback returns a string (error message) or `undefined` (valid) — this is clack UI validation only, not business logic. +- Use `multiselect` with `required: true` to prevent empty selection without extra logic in the Action. +- Step markers are sync void methods called by the Action before the matching interactive prompt. +- `noteWrapped` handles terminal-width overflow automatically — prefer it over `note()` for long messages. +- Remove unused prompt imports (e.g., omit `multiselect` if the wizard has no multi-select step). diff --git a/.ai/skills/service.md b/.ai/skills/service.md new file mode 100644 index 00000000..5ef29073 --- /dev/null +++ b/.ai/skills/service.md @@ -0,0 +1,258 @@ +# Service Conventions + +Services live at `src/infrastructure/services/` and are the only layer that makes external API calls. Every public method returns `Promise>` using neverthrow — services never throw to their callers. There are three variants: SDK controller (wraps `@apimatic/sdk`), axios with auth, and stateless axios. + +## Conventions + +### DO + +- **DO** use named export: `export class {PascalName}Service`. +- **DO** return `Promise>` from all public methods — use `ok()` and `err()` from neverthrow. +- **DO** catch `ProblemDetailsError` first (for 400/403 with structured error messages), then fall back to `handleServiceError(error)`. +- **DO** close file streams in `finally` blocks when using `FileWrapper` or `getStream()`. +- **DO** resolve auth as: `getAuthInfo(configDir.toString())` → `createAuthorizationHeader()` → `apiClientFactory.createApiClient()`. +- **DO** use `createAuthorizationHeader` as a private arrow function. +- **DO** use `envInfo.getBaseUrl()` as a fallback base URL — allows overriding in test environments. +- **DO** use `as const` on base URL string literals. +- **DO** initialize `FileService` inline as a `private readonly` field. +- **DO** format auth header as `X-Auth-Key ${key ?? ""}` (space, not colon). +- **DO** pre-check auth before any API call in axios-auth services: `if (authInfo === null && !authKey) return err(ServiceError.UnAuthorized)`. +- **DO** use `authKey || authInfo?.authKey` for token resolution — flag override takes precedence. +- **DO** define response types as `interface` or `type` (in the file or in `src/types/`). + +### DON'T + +- **DON'T** throw — always return `err(...)` wrapped in `ServiceError`. +- **DON'T** use `console.log` — services are silent; errors communicated via `Result`. +- **DON'T** use raw string paths — use `DirectoryPath`, `FilePath`, `UrlPath`, `FileName`. +- **DON'T** use `axios.create()` as a class field — create a fresh instance per call in `axiosInstance()` method. +- **DON'T** use `ApiError` as the primary catch branch — `ProblemDetailsError` should be caught first. +- **DON'T** skip the `finally` block when a `FileWrapper` or stream is opened. +- **DON'T** hardcode base URLs without falling back to `envInfo.getBaseUrl()` (except for third-party endpoints). + +### SDK controller variant rules + +- Instantiate controller per method call: `new {ControllerName}(client)` inside the method. +- `apiClientFactory.createApiClient(authHeader, shell)` provides the configured client. +- For async/polling SDK methods, poll until status is terminal — see `portal-service.ts` for the pattern. + +### Axios-auth variant rules + +- `axiosInstance(shell, token)` is a private method (not arrow function) that returns a fresh axios instance. +- Use `envInfo.getAuthBaseUrl()` for auth-specific endpoints (separate from the main API base URL). +- Add `validateStatus: () => true` to handle non-2xx responses without throwing. + +### Stateless variant rules + +- No `axiosInstance` factory — use `axios.get()`/`axios.post()` directly. +- No auth imports or resolution. +- URL is passed as a parameter or hardcoded per method. + +--- + +## Review Checklist + +- [ ] All imports use `.js` extension (e.g., `../../client-utils/auth-manager.js`) +- [ ] File placed at `src/infrastructure/services/{name}-service.ts` +- [ ] Named export (not default): `export class {PascalName}Service` +- [ ] All public methods return `Promise>` using neverthrow +- [ ] Uses `ok()` and `err()` from neverthrow — never throws +- [ ] Catch blocks use `handleServiceError(error)` as fallback +- [ ] Auth uses `getAuthInfo(configDir.toString())` — note `.toString()` on DirectoryPath +- [ ] Auth header format: `X-Auth-Key ${key ?? ""}` (space, not colon) +- [ ] `private readonly` for all field declarations +- [ ] `as const` on base URL string literals +- [ ] No `console.log` — services are silent +- [ ] No raw string paths — use `DirectoryPath`, `FilePath`, `UrlPath`, `FileName` +- [ ] Response types defined as `interface` or `type` +- [ ] File streams closed in `finally` block when using `FileWrapper` + +## Reference Files + +| Pattern | File | +|---|---| +| SDK controller + async polling | `src/infrastructure/services/portal-service.ts` | +| SDK controller + FormData | `src/infrastructure/services/validation-service.ts` | +| Raw axios with auth + axiosInstance | `src/infrastructure/services/api-service.ts` | +| Raw axios with different base URL | `src/infrastructure/services/auth-service.ts` | +| Stateless axios (no auth) | `src/infrastructure/services/file-download-service.ts` | +| ServiceError class + handleServiceError | `src/infrastructure/service-error.ts` | +| SDK client factory (singleton) | `src/infrastructure/services/api-client-factory.ts` | +| envInfo (base URLs, user agent) | `src/infrastructure/env-info.ts` | + +--- + +## Scaffolding + +Use when creating a new infrastructure service. Choose the variant that matches the API access pattern. + +### What to determine + +1. **Service name** — lowercase hyphenated (e.g., `copilot`, `billing`). File will be `{name}-service.ts` +2. **Class name** — PascalCase (e.g., `CopilotService`, `BillingService`) +3. **Variant** — one of: + - `sdk-controller` — uses `@apimatic/sdk` controller classes via `apiClientFactory` + - `axios-auth` — raw axios with auth (base URL, `axiosInstance` factory, auth pre-check) + - `axios-stateless` — raw axios without auth (direct calls) +4. **Needs auth** — whether methods resolve auth via `getAuthInfo` + `authKey` parameter +5. **Initial method** — name, parameters, and return type for the first method to scaffold +6. **Response type** — type/interface for the success value + +### SDK Controller Service Template + +**Use when:** the service wraps an `@apimatic/sdk` controller (e.g., generation, transformation, validation). + +**Based on:** `src/infrastructure/services/portal-service.ts`, `src/infrastructure/services/validation-service.ts` + +```typescript +import { + {ControllerName}, + ContentType, + FileWrapper, + ProblemDetailsError, + ApiError, +} from "@apimatic/sdk"; +import { AuthInfo, getAuthInfo } from "../../client-utils/auth-manager.js"; +import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { FilePath } from "../../types/file/filePath.js"; +import { FileService } from "../file-service.js"; +import { apiClientFactory } from "./api-client-factory.js"; +import { CommandMetadata } from "../../types/common/command-metadata.js"; +import { err, ok, Result } from "neverthrow"; +import { handleServiceError, ServiceError } from "../service-error.js"; + +export class {PascalName}Service { + private readonly CONTENT_TYPE = ContentType.EnumMultipartformdata; + private readonly fileService = new FileService(); + + public async {methodName}( + filePath: FilePath, + configDir: DirectoryPath, + commandMetadata: CommandMetadata, + authKey: string | null + ): Promise> { + const fileStream = await this.fileService.getStream(filePath); + const file = new FileWrapper(fileStream); + + const authInfo: AuthInfo | null = await getAuthInfo(configDir.toString()); + const authorizationHeader = this.createAuthorizationHeader(authInfo, authKey); + const client = apiClientFactory.createApiClient(authorizationHeader, commandMetadata.shell); + const controller = new {ControllerName}(client); + + try { + const response = await controller.{sdkMethod}( + this.CONTENT_TYPE, + file + ); + return ok(response.result); + } catch (error) { + if (error instanceof ProblemDetailsError) { + const message = Object.values(error.result!.errors as Record)[0]?.[0] ?? null; + const errorMessage = error.result!.title + "\n- " + message; + if (error.statusCode === 400) { + return err(ServiceError.badRequest(errorMessage)); + } + if (error.statusCode === 403) { + return err(ServiceError.forbidden(errorMessage)); + } + } + return err(handleServiceError(error)); + } finally { + fileStream.close(); + } + } + + private createAuthorizationHeader = (authInfo: AuthInfo | null, overrideAuthKey: string | null): string => { + const key = overrideAuthKey || authInfo?.authKey; + return `X-Auth-Key ${key ?? ""}`; + }; +} +``` + +### Raw Axios Service Template (with auth) + +**Use when:** calling APIMatic REST endpoints directly without the SDK client. + +**Based on:** `src/infrastructure/services/api-service.ts`, `src/infrastructure/services/auth-service.ts` + +```typescript +import axios from "axios"; +import { AuthInfo, getAuthInfo } from "../../client-utils/auth-manager.js"; +import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { envInfo } from "../env-info.js"; +import { err, ok, Result } from "neverthrow"; +import { handleServiceError, ServiceError } from "../service-error.js"; + +export class {PascalName}Service { + private readonly apiBaseUrl = "https://api.apimatic.io" as const; + + public async {methodName}( + configDir: DirectoryPath, + shell: string, + authKey: string | null + ): Promise> { + const authInfo: AuthInfo | null = await getAuthInfo(configDir.toString()); + if (authInfo === null && !authKey) { + return err(ServiceError.UnAuthorized); + } + + try { + const token = authKey || authInfo?.authKey; + const response = await this.axiosInstance(shell, token).get("/{endpoint}"); + + if (response.status === 200) { + return ok(response.data as {ResponseType}); + } + return err(ServiceError.InvalidResponse); + } catch (error: unknown) { + return err(handleServiceError(error)); + } + } + + private axiosInstance(shell: string, apiKey: string | undefined) { + const headers: Record = { + "User-Agent": envInfo.getUserAgent(shell) + }; + + if (apiKey) { + headers.Authorization = `X-Auth-Key ${apiKey}`; + } + + return axios.create({ + baseURL: envInfo.getBaseUrl() ?? this.apiBaseUrl, + headers + }); + } +} +``` + +### Stateless Axios Service Template (no auth) + +**Use when:** making external HTTP calls that don't require APIMatic authentication. + +**Based on:** `src/infrastructure/services/file-download-service.ts` + +```typescript +import axios from "axios"; +import { err, ok, Result } from "neverthrow"; +import { handleServiceError, ServiceError } from "../service-error.js"; + +export class {PascalName}Service { + public async {methodName}( + /* parameters */ + ): Promise> { + try { + const response = await axios.get(/* url */, { + /* request config */ + }); + + if (/* success condition */) { + return ok(/* parsed result */); + } + return err(ServiceError.InvalidResponse); + } catch (error: unknown) { + return err(handleServiceError(error)); + } + } +} +``` diff --git a/.ai/skills/value-object.md b/.ai/skills/value-object.md new file mode 100644 index 00000000..074ec25e --- /dev/null +++ b/.ai/skills/value-object.md @@ -0,0 +1,261 @@ +# Value Object (Rich Class) Conventions + +Value objects live at `src/types/file/` and wrap a single primitive (`string`, `number`) that has domain meaning. They enforce their own invariants, expose only domain-meaningful operations, and hide the underlying primitive entirely. The primitive surfaces only at infrastructure boundaries — never within the domain, application, or action layers. + +## Conventions + +### Encapsulation — DO + +- **DO** declare the wrapped primitive as `private readonly` — the only way out is `toString()`. +- **DO** implement `toString()` as the sole escape hatch from rich type to raw primitive. +- **DO** let `toString()` be called implicitly by template literals — callers write `` `Output: ${dir}` `` without an explicit `.toString()`. +- **DO** have methods that produce derived values return rich types — a method extracting a leaf name returns `FileName`, not `string`. +- **DO** compose rich types through each other — `FilePath` holds `DirectoryPath` and `FileName` as objects; both are passed as objects in constructors and methods. +- **DO** use `static create()` returning `T | undefined` for construction from user input or external data that can fail validation. +- **DO** use `static createInput()` for the flag-value-to-rich-type pattern when a sensible fallback exists (e.g. `DirectoryPath.default`). +- **DO** use `static readonly` for well-known singleton instances (e.g., `DirectoryPath.default`). +- **DO** access the private field directly inside methods — write `this.directoryPath` not `this.toString()`. + +### Encapsulation — DON'T + +- **DON'T** expose a getter that returns the underlying primitive: + ```ts + // BANNED + get path(): string { return this.directoryPath; } + ``` +- **DON'T** call `.toString()` on `this` inside any method other than `toString()` itself. +- **DON'T** call `.toString()` on a rich object when passing it to another rich object's constructor or method: + ```ts + // BANNED + new FilePath(new DirectoryPath(dir.toString()), new FileName(name.toString())) + + // CORRECT + new FilePath(dir, name) + ``` +- **DON'T** unwrap to perform path arithmetic and re-wrap the result: + ```ts + // BANNED + new DirectoryPath(this.directoryPath + "/subdir") + + // CORRECT — use the rich method + this.join("subdir") + ``` +- **DON'T** return `string` from a method whose result has a domain type: + ```ts + // BANNED + public leafName(): string { return path.basename(this.directoryPath); } + + // CORRECT + public leafName(): FileName { return new FileName(path.basename(this.directoryPath)); } + ``` +- **DON'T** use `.toString()` inside `f.path()`, `f.link()`, or `f.var()` helpers — those accept the rich type via template interpolation. +- **DON'T** throw from the constructor when construction can fail — use `static create()` returning `T | undefined`. + +### toString() boundary rule + +`.toString()` — including implicit unwrapping via template literals — is permitted **only** at these boundary sites: + +| Boundary | Example | +|---|---| +| `fs.*` / `fsExtra.*` calls | `fsExtra.stat(filePath.toString())` | +| `path.*` calls | `path.join(this.directoryPath, ...subPath)` | +| Axios / HTTP request construction | `axios.get(url.toString())` | +| External library APIs (execa, archiver, extract-zip, chokidar) | `execa("code", [filePath.toString()])` | +| Template literal inside own `toString()` | `` `${this.directoryPath}/${this.fileName}` `` | +| Logging / terminal display | `` log.info(`Saved to ${dir}`) `` | + +Anything not in this table is a violation. In particular: domain methods, context classes, action classes, and application classes must not unwrap to `string` outside of `toString()` itself. + +### Composition — DO + +- **DO** pass `DirectoryPath` and `FileName` objects as constructor arguments — never pass raw strings. +- **DO** use `directory.join("subdir")` to derive a subdirectory — it returns `DirectoryPath`. +- **DO** use `filePath.replaceDirectory(newDirectory)` to move a file to a new parent — no string reconstruction. +- **DO** call `directoryPath.isEqual(other)` for equality — never `a.toString() === b.toString()`. + +### Context class exposure rule + +Context classes (`PortalContext`, `SdkContext`, etc.) may expose a rich-type field via a public getter or method **only** when the caller needs it for **logging or prompting display** — never for path computation, I/O, or construction of other paths. Add an inline comment when you do: + +```ts +// Exposed for display in prompts — callers must not use this for path computation +public get sdkLanguageDirectory(): DirectoryPath { + return this.languageDirectory; +} +``` + +--- + +## Known Gaps in Existing Code + +These patterns exist in the current codebase and are flagged for awareness: + +| File | Issue | +|---|---| +| `src/types/file/directoryPath.ts:31` | `leafName()` returns `string` — should return `FileName` | +| `src/types/sdk-context.ts` | `sdkLanguageDirectory` getter exposes `DirectoryPath` — needs justification comment if kept | +| `src/types/toc-context.ts` | `tocPath` getter exposes `FilePath` — prefer returning it only from the `save()` operation result | + +--- + +## Review Checklist + +- [ ] All imports use `.js` extension (e.g., `./directoryPath.js`) +- [ ] The wrapped primitive is `private readonly` — no public field, no getter exposing it +- [ ] No method calls `this.toString()` — methods access `this.{field}` directly +- [ ] `toString()` is the only method that returns the raw primitive +- [ ] Methods returning derived values return a rich type, not `string` +- [ ] Composed types receive other rich objects in their constructors — no `.toString()` at call sites +- [ ] `join()` / `replaceDirectory()` / similar transformation methods return rich types +- [ ] `static create()` used for user-input construction that can fail — constructor is for trusted callers +- [ ] `directoryPath.isEqual(other)` used for equality — not `a.toString() === b.toString()` +- [ ] No `.toString()` call appears outside a boundary site (fs, path, axios, external lib, logging) +- [ ] Context class public getter (if any) has an inline comment justifying the exposure (display only) +- [ ] No `.toString()` passed to `f.path()`, `f.link()`, or `f.var()` helpers +- [ ] `static readonly` used for constant singleton instances + +--- + +## Reference Files + +| Pattern | File | +|---|---| +| Ideal simple wrapper — private field, `static create()`, `toString()` only | `src/types/file/urlPath.ts` | +| `static createInput()` fallback, rich join/equality methods | `src/types/file/directoryPath.ts` | +| Rich predicate (`isMarkDown`) and transform (`normalize`) returning rich type | `src/types/file/fileName.ts` | +| Composed value object — holds two rich types, `static create()` splits at boundary | `src/types/file/filePath.ts` | +| Correct boundary unwrapping — all `.toString()` calls at `fs.*` / `path.*` sites | `src/infrastructure/file-service.ts` | +| Context with flagged public getter (display use) | `src/types/sdk-context.ts` | + +--- + +## Scaffolding + +### What to determine + +1. **Name** — PascalCase class name (e.g., `PortNumber`, `ApiVersion`, `RecipeName`) +2. **Primitive type** — the underlying primitive (`string`, `number`, etc.) +3. **Validation** — can construction fail from user input? If yes, add `static create()` +4. **Fallback** — is there a sensible default for flag values? If yes, add `static createInput()` + `static readonly default` +5. **Derived values** — what can be computed? Each result that has domain meaning should be a rich type +6. **Composition** — does this hold other rich types as fields? +7. **Equality** — is structural equality needed? Add `isEqual(other: T)` + +--- + +### Simple Value Object (wraps a single primitive) + +**Use when:** wrapping a string or number that has a domain name — URL, port, version, file name, recipe name. + +**Path:** `src/types/file/{name}.ts` for file-domain types; `src/types/{domain}/{name}.ts` for others. + +```ts +export class {ClassName} { + private readonly {field}: {Primitive}; + + constructor({field}: {Primitive}) { + this.{field} = {field}; + } + + // Static factory for user-input / external data that can fail validation. + // Returns undefined — callers must check before using. + public static create({field}: {Primitive}): {ClassName} | undefined { + if (!{field} || !{validationCondition}) { + return undefined; + } + return new {ClassName}({field}); + } + + // Well-known constant (if applicable) + // public static readonly default = new {ClassName}({defaultValue}); + + // Static factory for flag values with fallback (if applicable) + // public static createInput(input: {Primitive} | undefined): {ClassName} { + // return input ? new {ClassName}(input) : {ClassName}.default; + // } + + // Domain predicate — returns boolean, never exposes primitive + public {isDomainCondition}(): boolean { + return this.{field}.{check}; + } + + // Derived value — returns a rich type, never string + public {derivedValue}(): {RichReturnType} { + const raw = /* compute from this.{field} */; + return new {RichReturnType}(raw); + } + + // Structural equality — compare field directly, not via toString() + public isEqual(other: {ClassName}): boolean { + return this.{field} === other.{field}; + } + + // Sole escape hatch — called implicitly by template literals at boundaries + public toString(): {Primitive} { + return this.{field}; + } +} +``` + +**Notes:** +- All methods access `this.{field}` directly — they never call `this.toString()`. +- The direct `constructor` is for trusted callers (e.g., another rich type creating a derived instance). `static create()` is the entry point from external/user data. +- `isEqual()` compares the private field directly — never calls `a.toString() === b.toString()`. + +--- + +### Composed Value Object (holds other rich types) + +**Use when:** the value object is built from two or more rich types — e.g., `FilePath` = `DirectoryPath` + `FileName`. + +**Path:** `src/types/file/{name}.ts` or the appropriate domain folder. + +```ts +import { {PartA} } from "./{partA}.js"; +import { {PartB} } from "./{partB}.js"; + +export class {ClassName} { + private readonly {partA}: {PartA}; + private readonly {partB}: {PartB}; + + // Constructor takes rich types — callers must not pass raw strings + constructor({partA}: {PartA}, {partB}: {PartB}) { + this.{partA} = {partA}; + this.{partB} = {partB}; + } + + // Static factory for user-input: splits the raw string at the boundary (path.* calls), + // then wraps each part in its rich type + public static create(rawValue: string): {ClassName} | undefined { + if (!rawValue) { + return undefined; + } + try { + // path.* calls are a legitimate boundary — they produce the raw parts + const rawA = /* path.dirname / path.basename / etc. */; + const rawB = /* path.basename / path.extname / etc. */; + return new {ClassName}(new {PartA}(rawA), new {PartB}(rawB)); + } catch { + return undefined; + } + } + + // Transformation — swap one component, keep the other; returns a new rich type + public replace{PartA}(new{PartA}: {PartA}): {ClassName} { + return new {ClassName}(new{PartA}, this.{partB}); + } + + // toString() joins components at the infrastructure boundary. + // Template literal calls each component's toString() implicitly — that is correct here. + public toString(): string { + return `${this.{partA}}/${this.{partB}}`; + // Or: path.join(this.{partA}.toString(), this.{partB}.toString()) + // when OS-correct separators are required (path.* inside toString() is a boundary site) + } +} +``` + +**Notes:** +- Constructor signature uses the rich types — a caller with a raw string must run `static create()` first. +- `replace{PartA}()` receives and returns rich types — never accepts or returns strings. +- Component parts unwrap inside `toString()` via template literal; this is the one permitted place they call `.toString()`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..e1c17fc7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,17 @@ +# CLAUDE.md + +This file provides guidance to Claude Code when working in this repository. + +Read `.ai/instructions.md` for full project instructions (architecture, conventions, testing, commits). + +## Skills + +Reference these files as needed for scaffolding: + +- `.ai/skills/command.md` — Command + Action + Prompts conventions and scaffolding +- `.ai/skills/action.md` — Action class conventions and scaffolding +- `.ai/skills/context.md` — Context object conventions and scaffolding +- `.ai/skills/prompt.md` — Prompts class conventions and scaffolding +- `.ai/skills/service.md` — Infrastructure Service conventions and scaffolding +- `.ai/skills/value-object.md` — Value object (rich class) conventions: encapsulation, boundary unwrapping, composition +- `.ai/skills/event.md` — Domain event conventions: past-tense naming, variants, where to fire diff --git a/README.md b/README.md index f4edadaf..93e3a05b 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ USAGE * [`apimatic portal toc new`](#apimatic-portal-toc-new) * [`apimatic quickstart`](#apimatic-quickstart) * [`apimatic sdk generate`](#apimatic-sdk-generate) +* [`apimatic sdk save-changes`](#apimatic-sdk-save-changes) ## `apimatic api transform` @@ -411,18 +412,21 @@ Generate an SDK for your API ``` USAGE - $ apimatic sdk generate -l csharp|java|php|python|ruby|typescript|go [-i ] [-d ] [--api-version - ] [-f] [--zip] [-k ] + $ apimatic sdk generate -l csharp|java|php|python|ruby|typescript|go [-d ] [--skip-changes] + [--api-version ] [--zip] [--track-changes] [-i ] [-f] [-k ] FLAGS - -d, --destination= directory where the SDK will be generated + -d, --destination= [default: /sdk/ | /sdk//] path where the SDK + will be generated -f, --force overwrite changes without asking for user consent. -i, --input= [default: ./] path to the parent directory containing the 'src' directory, which includes API specifications and configuration files. -k, --auth-key= override current authentication state with an authentication key. - -l, --language=