From 817b64e970d690c99e1b265ecb02d4dbff97d8d8 Mon Sep 17 00:00:00 2001 From: Dishant Langayan Date: Sun, 25 Oct 2026 22:27:08 -0400 Subject: [PATCH 1/2] Add new-sc-command skill for scaffolding Oclif commands Documents the conventions established by the platform env commands so new commands are consistent without re-deriving them each time. The workflow starts with `oclif generate command ::` to create the command and test files, then replaces their contents with the templates in templates/ (one per command shape, plus a test template). References cover: * flags.md - kebab-case flags mapped to camelCase API fields, the shared alias/org flags, exactlyOne id-or-name groups, and the reserved short chars * testing.md - the Mocha/Chai/Sinon harness, and keeping suites to a limited set of tests covering core functionality rather than every permutation * linting.md - verifying via npm test (posttest runs lint), and the perfectionist sort-objects/sort-union-types rules that fail most often * openapi.md - spec URLs and mapping an operation to a command Commands in this project follow the Solace Cloud OpenAPI specs at https://api.solace.dev/cloud/page/openapi-specifications Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/new-sc-command/SKILL.md | 188 ++++++++++++++++++ .../skills/new-sc-command/references/flags.md | 136 +++++++++++++ .../new-sc-command/references/linting.md | 130 ++++++++++++ .../new-sc-command/references/openapi.md | 71 +++++++ .../new-sc-command/references/testing.md | 184 +++++++++++++++++ .../templates/create.ts.template | 74 +++++++ .../templates/delete.ts.template | 109 ++++++++++ .../templates/display.ts.template | 75 +++++++ .../new-sc-command/templates/list.ts.template | 103 ++++++++++ .../new-sc-command/templates/test.ts.template | 126 ++++++++++++ .../templates/update.ts.template | 93 +++++++++ 11 files changed, 1289 insertions(+) create mode 100644 .claude/skills/new-sc-command/SKILL.md create mode 100644 .claude/skills/new-sc-command/references/flags.md create mode 100644 .claude/skills/new-sc-command/references/linting.md create mode 100644 .claude/skills/new-sc-command/references/openapi.md create mode 100644 .claude/skills/new-sc-command/references/testing.md create mode 100644 .claude/skills/new-sc-command/templates/create.ts.template create mode 100644 .claude/skills/new-sc-command/templates/delete.ts.template create mode 100644 .claude/skills/new-sc-command/templates/display.ts.template create mode 100644 .claude/skills/new-sc-command/templates/list.ts.template create mode 100644 .claude/skills/new-sc-command/templates/test.ts.template create mode 100644 .claude/skills/new-sc-command/templates/update.ts.template diff --git a/.claude/skills/new-sc-command/SKILL.md b/.claude/skills/new-sc-command/SKILL.md new file mode 100644 index 0000000..fc97a9a --- /dev/null +++ b/.claude/skills/new-sc-command/SKILL.md @@ -0,0 +1,188 @@ +--- +name: new-sc-command +description: Scaffold a new Oclif command (and its tests) for the Solace Cloud CLI, following the conventions established by `sc platform env create|delete|update|list|display`. Use when adding, modifying, or reviewing any command under src/commands/, wiring a new Solace Cloud API endpoint into the CLI, adding flags to an existing command, or writing tests for a command. Triggers on "new command", "add a command", "add a topic", "wire up endpoint", "add flag to", or naming any Solace Cloud API (Platform, Mission Control, Event Portal, Billing, Micro-Integration). +--- + +# Adding a command to the Solace Cloud CLI + +Commands are thin, declarative wrappers over one Solace Cloud REST endpoint. The job is +almost always: parse flags → resolve an org connection → build a URL and body → call the +API → print a table → return the raw response for `--json`. + +Read the five reference commands before writing anything. They are the spec: + +| Shape | Command | Test | +|---|---|---| +| create (POST) | `src/commands/platform/env/create.ts` | `test/commands/platform/env/create.test.ts` | +| display (GET one) | `src/commands/platform/env/display.ts` | `.../display.test.ts` | +| list (GET paginated) | `src/commands/platform/env/list.ts` | `.../list.test.ts` | +| update (PUT) | `src/commands/platform/env/update.ts` | `.../update.test.ts` | +| delete (DELETE) | `src/commands/platform/env/delete.ts` | `.../delete.test.ts` | + +Scaffold the files with `oclif generate command` (step 3 below), then replace their +contents with the matching template from `templates/` — `create.ts.template`, +`display.ts.template`, `list.ts.template`, `update.ts.template`, `delete.ts.template`, +`test.ts.template`. They are written against a fictional `platform thing` resource and +have been verified to lint, typecheck, and pass; rename `Thing`/`thing`/`things` +throughout. Deeper rules live in `references/`: + +- `references/flags.md` — flag naming, kebab→camel mapping, shared flags, `char` registry +- `references/testing.md` — the Mocha/Chai/Sinon harness and what to assert +- `references/linting.md` — how to verify, and the rules that fail most often +- `references/openapi.md` — spec URLs and how to map an operation to a command + +## Workflow + +1. **Find the endpoint in the OpenAPI spec.** Fetch the relevant spec and read the + operation's parameters, request body, and response schema. Do not guess field names. + See `references/openapi.md` for spec URLs and how to extract an operation. +2. **Pick the command id.** `src/commands///.ts` maps to + `sc ` (`topicSeparator` is a space). Topic mirrors the API + family: `platform/`, `missionctrl/`, `account/`. Verbs are `create`, `delete`, + `update`, `list`, `display` — use `display` (not `get`/`show`) for single-resource + reads. Class name is PascalCase of the path: `PlatformEnvCreate`. +3. **Generate the scaffolding with oclif.** Always start here rather than hand-creating + files — it creates both the command and its test file in the right places with the + correct class name: + ``` + ./node_modules/.bin/oclif generate command :: + ``` + e.g. `oclif generate command platform:users:list` creates + `src/commands/platform/users/list.ts` and `test/commands/platform/users/list.test.ts`. + See "Generating the scaffolding" below for what it emits and what to strip. +4. **Add/extend the response types** in `src/types/.ts`: a resource interface, + `ApiResponse` (`{data: T}`), and `ListApiResponse` + (`{data: T[]; meta: {pagination: {...}}}`). Mirror the spec's schema; mark everything + optional except the fields the API always returns. +5. **Replace the generated command body** with the matching template from `templates/`. + Flag naming rules are non-negotiable — see `references/flags.md`. +6. **Replace the generated test** with a small test file — see `references/testing.md`. + Cover core functionality only: the happy path, one meaningful flag variation, and + `--alias`/`--org` resolution. Do not write exhaustive tests. +7. **Run the tests, which also lint:** + ``` + npm test + ``` + `posttest` runs `npm run lint` (eslint over the whole repo), so a green run means no + lint errors. The whole suite takes about a second — just run all of it. Both must be + clean before you report done. + + Most lint failures here are mechanical ordering rules, so run + `./node_modules/.bin/eslint --fix src/commands/ test/commands/` on your new + files first and let it sort things out. The two that bite are + `perfectionist/sort-objects` and `perfectionist/sort-union-types` (the latter fires or + not depending on your resource name) — see `references/linting.md`. +8. **Regenerate README docs** with `./node_modules/.bin/oclif readme` only if the user + asks; otherwise note that `npm run prepack` / `npm version` does it on release. + + Check the diff before keeping it. `oclif readme` rewrites the whole command reference + from the *currently installed* plugins and the *current machine*, so it routinely + sweeps in changes unrelated to your command: + - the usage line embeds platform and node version + (`…/0.7.0 linux-x64 node-v24.21.0` → `darwin-arm64 node-v24.1.0`) + - `@dishantlangayan/sc-plugin-broker` command docs and their `See code:` version links + are re-emitted at the installed version, which may be ahead of what the committed + README was generated from + + If `git diff --stat README.md` is hundreds of lines for one new command, that is + pre-existing drift, not your change. Either revert it (`git checkout README.md`) and + leave the README to the release flow, or tell the user what else moved so they can + decide — don't quietly commit unrelated churn. + +## Generating the scaffolding + +`oclif generate command ::` is the required first step. It is +purely additive — it creates two files and does not touch `package.json`, the README, or +the oclif manifest (commands are discovered from `./dist/commands`, so no registration is +needed). + +``` +./node_modules/.bin/oclif generate command platform:users:list +# Adding platform users list to @dishantlangayan/solace-cloud-cli! +# Creating src/commands/platform/users/list.ts +# Creating test/commands/platform/users/list.test.ts +``` + +Useful flags: `--force` overwrites existing files (needed when regenerating over a +command you have already started), `--commands-dir` changes the target directory (leave +it at the default `src/commands`). + +What it emits is generic oclif boilerplate — a `hello world` command extending `Command`, +with placeholder `force`/`name` flags, a `file` arg, and a two-case test asserting +`'hello world'`. **Keep only the file paths and the class name**; everything inside both +files gets replaced. Specifically, you must: + +- change `extends Command` to `extends ScCommand` and swap the + import from `@oclif/core` to `{ScCommand}` from `@dishantlangayan/sc-cli-core` (keep + `{Flags}` from `@oclif/core`) +- delete the `Args` import and the `file` arg, leaving `static override args = {}` — + commands in this CLI take flags, not positional args +- delete the placeholder `force`/`name` flags and the `hello ${name}` log, which contains + an absolute path from the generating machine +- change the `run()` return type from `Promise` to the response type + +The generated class name is PascalCase of the command id (`platform:users:list` → +`PlatformUsersList`), which already matches the convention — don't rename it. Note the +generated code passes eslint as-is, so a clean lint run is not evidence you finished the +replacement. + +## The anatomy every command shares + +```ts +import {printObjectAsKeyValueTable, ScCommand} from '@dishantlangayan/sc-cli-core' +import {Flags} from '@oclif/core' + +import {resolveOrgConnection} from '../../../lib/org-utils.js' // .js extension required +import {ThingApiResponse} from '../../../types/thing.js' + +export default class PlatformThingCreate extends ScCommand { + static override args = {} + static override description = `One-line summary. + + Token Permissions: [ things:edit ]` + static override examples = [/* '<%= config.bin %> <%= command.id %> --name=X' */] + static override flags = {/* alphabetically sorted */} + + public async run(): Promise { + const {flags} = await this.parse(PlatformThingCreate) + const conn = await resolveOrgConnection(this, flags.org ?? flags.alias) + const resp = await conn.post('/platform/things', body) + this.log(printObjectAsKeyValueTable(resp.data as unknown as Record)) + return resp // oclif prints this as JSON when --json is set + } +} +``` + +Non-obvious invariants: + +- **Extend `ScCommand`, never `Command`.** It supplies `--json`, `--log-level`, and + `getOrgManager()`. +- **Always resolve the connection with `resolveOrgConnection(this, flags.org ?? flags.alias)`.** + Never construct `ScConnection` directly in a command; the helper owns all the + "no default org" / "org not found" error messages. +- **API URLs omit `/api/v2`.** `ScConnection` prepends it. A spec path of + `/api/v2/platform/environments` becomes `/platform/environments`. +- **`run()` returns the response object** and separately logs a human table. The return + value is what `--json` emits — never `JSON.stringify` to stdout yourself. +- **Build request bodies with conditional spreads** so unset optional flags are omitted + rather than sent as `""`/`false`: + `{...(desc && {description: desc}), isDefault, name}`. +- **Resolve name → id when the API only accepts an id.** `update`/`delete` take + `--name` or `---id` via `exactlyOne`, and when given a name they GET + `?name=` first. If more than one matches, `this.error(...)` for update and + `this.warn(...)` + exit for delete (see the two reference files — the handling + deliberately differs). +- **Destructive commands get `--no-prompt`** and an `@inquirer/prompts` `confirm()` + guarded by try/catch (Ctrl+C must exit 0 with "cancelled", not a stack trace). +- **List commands never hand-roll pagination.** Spread `paginationFlags()` and iterate + `fetchPages()` from `src/lib/pagination.ts`, writing rows through `createTableWriter()` + so `--all` streams. See `templates/list.ts.template`. + +## Output helpers + +From `@dishantlangayan/sc-cli-core`: + +- `printObjectAsKeyValueTable(obj)` — single resource (create/update/display). +- `createTableWriter(header, {...})` from `src/lib/pagination.ts` — list output. +- `renderTable(rows, columnConfig)` — the underlying renderer; tests use it to build + expected output. diff --git a/.claude/skills/new-sc-command/references/flags.md b/.claude/skills/new-sc-command/references/flags.md new file mode 100644 index 0000000..e7362b0 --- /dev/null +++ b/.claude/skills/new-sc-command/references/flags.md @@ -0,0 +1,136 @@ +# Flag naming and definition rules + +## kebab-case flags → camelCase API fields + +The Solace Cloud API uses camelCase JSON field names. The CLI exposes **kebab-case** +flags. The command does the mapping explicitly — there is no automatic conversion. + +| API field (spec) | CLI flag | Local variable | +|---|---|---| +| `datacenterId` | `--datacenter-id` | `const datacenterId = flags['datacenter-id'] ?? ''` | +| `msgVpnName` | `--msg-vpn-name` | `const msgVpnName = flags['msg-vpn-name'] ?? ''` | +| `maxSpoolUsage` | `--max-spool-usage` | `const maxSpoolUsage = flags['max-spool-usage'] ?? ''` | +| `redundancyGroupSslEnabled` | `--redundancy-group-ssl-enabled` | `flags['redundancy-group-ssl-enabled']` | +| `pageSize` | `--page-size` | `flags['page-size']` | + +Pattern: + +```ts +static override flags = { + 'datacenter-id': Flags.string({char: 'd', description: '...', required: true}), +} + +public async run() { + const {flags} = await this.parse(MyCommand) + const datacenterId = flags['datacenter-id'] ?? '' // kebab flag → camel local + const body = {...(datacenterId && {datacenterId})} // camel key in the body +} +``` + +Always read multi-word flags with bracket notation and immediately assign to a camelCase +local; then build the body from the locals so the body literal matches the spec's field +names exactly. + +### The grandfathered exceptions + +`--isDefault`, `--isProduction`, and `--locked` are already camelCase/single-word in +shipped commands (`platform env create`, `platform env update`). Leave them as-is — +renaming is a breaking change. **Do not use them as precedent.** Any new multi-word flag +is kebab-case. `--page-size`/`--page-number` replaced `--pageSize`/`--pageNumber` in +commit 2d05593 precisely to establish this convention. + +## The four flags nearly every command has + +```ts +alias: Flags.string({ + char: 'a', + description: 'Organization alias to use. If not specified, uses the default organization.', + exclusive: ['org'], +}), +org: Flags.string({ + char: 'o', + description: 'Organization ID to use. If not specified, uses the default organization or alias if specified.', + exclusive: ['alias'], +}), +``` + +Copy these descriptions verbatim — they are identical across all commands and the README +is generated from them. + +For commands addressing one existing resource, offer both an id and a name and require +exactly one: + +```ts +'env-id': Flags.string({char: 'e', description: 'Id of the environment.', exactlyOne: ['env-id', 'name']}), +name: Flags.string({char: 'n', description: 'Name of the environment.', exactlyOne: ['env-id', 'name']}), +``` + +Note `exactlyOne` is repeated on **every** member of the group, listing all members. + +Destructive commands add: + +```ts +'no-prompt': Flags.boolean({default: false, description: 'Skip confirmation prompt and assume Yes'}), +``` + +`update` commands that rename a resource use `--name` for the *current* name and +`--new-name` for the new one (description: `'Current name of the environment.'` / +`'New name of the environment.'`). + +## Short characters (`char`) + +Reserved across the CLI, do not reassign: + +| char | flag | +|---|---| +| `a` | `--alias` | +| `o` | `--org` | +| `n` | `--name` | +| `d` | `--description` (or `--datacenter-id` on broker create) | +| `e` | `--env-id` / `--env-name` | +| `p` | `--page-number` (list only) | +| `s` | `--page-size` (list only) | + +`char` is optional — only add one for flags users type often. `--new-name`, `--sort`, +`--no-prompt`, `--all`, `--isDefault`, `--isProduction` deliberately have none. Never +reuse a char within one command; oclif fails at runtime. + +## Ordering and types + +- **Flag keys must be alphabetically sorted** inside `static override flags = {...}`; + `perfectionist/sort-objects` is an eslint error otherwise. Quoted kebab keys sort by + their text (`'env-id'` before `isDefault` before `name` before `'new-name'` before + `org`). The `...paginationFlags(...)` spread sorts by position of its first key + (`all`) — in `platform env list` it sits between `org` and `sort`, which eslint accepts. +- Use `Flags.integer({min, max})` for numeric parameters so oclif validates bounds + (see `paginationFlags`), not `Flags.string`. +- Use `Flags.boolean({default: false})` for API booleans that have a default; omit + `default` when "unset" must be distinguishable from `false` (as in `platform env update`, + where `...(flags.isDefault && {isDefault: flags.isDefault})` only sends the field when + the user passed it). +- Mark `required: true` only for fields the API itself requires. +- Descriptions are sentences ending in a period. Document defaults in the description + ("The default value is false."), since users see this in `--help` and the README. + +## Description and examples + +```ts +static override description = `Create a new environment. + + Token Permissions: [ environments:edit ]` +``` + +Always include the endpoint's required token permissions, copied from the OpenAPI +operation's security/description. Multi-line descriptions use a template literal with +two-space-indented continuation lines. + +Examples use oclif's template tokens and should cover: minimal invocation, `--org`, +`--alias`, and one fully-loaded call: + +```ts +static override examples = [ + '<%= config.bin %> <%= command.id %> --name=MyEnvironment', + '<%= config.bin %> <%= command.id %> --org=my-org --name=MyEnvironment --description="My environment description"', + '<%= config.bin %> <%= command.id %> --alias=my-alias --name=MyEnvironment', +] +``` diff --git a/.claude/skills/new-sc-command/references/linting.md b/.claude/skills/new-sc-command/references/linting.md new file mode 100644 index 0000000..484b856 --- /dev/null +++ b/.claude/skills/new-sc-command/references/linting.md @@ -0,0 +1,130 @@ +# Linting — verify by running the tests + +There is no separate "check" step to remember: `npm test` runs mocha and then `posttest` +runs `npm run lint` (`eslint` over the repo, `eslint-config-oclif` + prettier). So: + +``` +npm test # tests + lint over everything, ~1s +``` + +A clean `npm test` is the definition of done. If you ran only a direct mocha invocation +(`./node_modules/.bin/mocha "test/..."`), lint did **not** run — finish with +`npm run lint` or a full `npm test`. (`npm test -- ` does not scope the run; the +path is appended to the script's own glob.) + +Lint a single file while iterating (use the local binary; `npx eslint` may fetch a +different major version): + +``` +./node_modules/.bin/eslint src/commands/platform/thing/create.ts +./node_modules/.bin/eslint --fix src/commands/platform/thing/create.ts +``` + +## The rules that actually bite + +### `perfectionist/sort-objects` — alphabetical keys, everywhere + +This is an **error**, and it applies to every object literal: `flags`, request bodies, +`columnConfig`, test fixtures, stub return values. + +```ts +// error: Expected "isDefault" to come before "name" +const body = {name, isDefault, isProduction} +// ok +const body = {isDefault, isProduction, name} +``` + +With conditional spreads, the sort is judged on the keys as written, so keep the spreads +in alphabetical order of their inner key: + +```ts +const body = { + ...(datacenterId && {datacenterId}), + ...(envId && {environmentId: envId}), + ...(eventBrokerVersion && {eventBrokerVersion}), + locked, + ...(maxSpoolUsage && {maxSpoolUsage}), + ...(msgVpnName && {msgVpnName}), + name, + redundancyGroupSslEnabled, + serviceClassId, +} +``` + +Quoted kebab keys sort by their literal text: `'env-id'` < `isDefault` < `name` < +`'new-name'` < `org`. `--fix` resolves most of these automatically. + +### `perfectionist/sort-union-types` — alphabetical union members + +Also an **error**, and easy to miss because whether it fires depends on your resource +name. Union members sort **case-insensitively**, so `undefined` lands between `T…` and +`V…`: + +```ts +let resp: EnvironmentListApiResponse | undefined // ok — e < u +let resp: ThingListApiResponse | undefined // ok — t < u +let resp: UserListApiResponse | undefined // ERROR — "undefined" must come first +let resp: undefined | UserListApiResponse // ok +let resp: undefined | ThingListApiResponse // ERROR — "Thing…" must come first +``` + +There is **no order that is safe for every resource name** — the rule enforces exact +alphabetical position in both directions. So don't try to memorise one: after renaming +`Thing` to your resource in `templates/list.ts.template`, run + +``` +./node_modules/.bin/eslint --fix src/commands///list.ts +``` + +and let it place `undefined` correctly. The template ships with +`ThingListApiResponse | undefined`, which is correct for `Thing` and wrong for anything +sorting after `undefined`. + +The same rule covers `Promise` in display +commands (two type names — alphabetical) and `string | undefined` in update/delete +(`s` < `u`, fine as written). + +### `no-await-in-loop` + +Sequential paging needs an inline disable, as in `src/lib/pagination.ts`: + +```ts +// eslint-disable-next-line no-await-in-loop +const resp = await conn.get(buildUrl(pageNumber, options.pageSize)) +``` + +Prefer using `fetchPages()` so you never write the loop yourself. + +### ESM import extensions + +Runtime imports inside `src/` must use the `.js` extension even though the source is +TypeScript (`module: NodeNext`): + +```ts +import {resolveOrgConnection} from '../../../lib/org-utils.js' +import {EnvironmentApiResponse} from '../../../types/environment.js' +``` + +Missing extensions compile under `tsc` in some configs but fail at runtime. Test files +import the command with `.js` too (`../../../../src/commands/platform/env/create.js`). + +### Import ordering + +`eslint-config-oclif` sorts imports: external packages first (alphabetical), blank line, +then relative imports (alphabetical). `--fix` handles it. + +### Formatting + +Prettier via `@oclif/prettier-config`: no semicolons, single quotes, 2-space indent, no +bracket spacing (`{flags}` not `{ flags }`), trailing commas in multiline literals. +Match the surrounding files and `--fix` will leave you alone. + +## Type-level gotchas that surface as errors + +- `printObjectAsKeyValueTable` needs `Record`; the established cast is + `resp.data as unknown as Record`. +- `static override` is required on `args`, `description`, `examples`, `flags` — they + override `ScCommand` members and `strict` mode rejects the plain form. +- `this.error()` returns `never`, so TypeScript narrows correctly after it; `this.warn()` + does not — code after a `warn` still runs (this is why `platform env delete` uses a + `foundMultiple` flag rather than falling through). diff --git a/.claude/skills/new-sc-command/references/openapi.md b/.claude/skills/new-sc-command/references/openapi.md new file mode 100644 index 0000000..5d64c41 --- /dev/null +++ b/.claude/skills/new-sc-command/references/openapi.md @@ -0,0 +1,71 @@ +# Working from the Solace Cloud OpenAPI specs + +Index page: https://api.solace.dev/cloud/page/openapi-specifications + +| API family | Spec URL | CLI topic | +|---|---|---| +| Platform | https://api.solace.dev/cloud/openapi/platform.json | `platform/` | +| Mission Control | https://api.solace.dev/cloud/openapi/mission-control.json | `missionctrl/` | +| Event Portal – Designer | https://api.solace.dev/cloud/openapi/event-portal-designer.json | (new topic) | +| Event Portal – Runtime | https://api.solace.dev/cloud/openapi/event-portal-runtime.json | (new topic) | +| Billing | https://api.solace.dev/cloud/openapi/billing.json | (new topic) | +| Micro-Integration (beta) | https://api.solace.dev/cloud/openapi/micro-integrations.json | (new topic) | + +All are OpenAPI v2.0-era JSON (no YAML). Mission Control is ~440 KB — never read it whole. + +## Extracting one operation + +Download and query with node rather than fetching the whole document into context: + +```bash +curl -s -o /tmp/platform.json https://api.solace.dev/cloud/openapi/platform.json + +# find the path +node -e "console.log(Object.keys(require('/tmp/platform.json').paths).filter(p=>/environments/.test(p)))" + +# read one operation: parameters, body schema, response schema +node -e " +const s = require('/tmp/platform.json') +const op = s.paths['/api/v2/platform/environments'].post +console.log(JSON.stringify({params: op.parameters, body: op.requestBody, responses: Object.keys(op.responses)}, null, 1)) +" + +# resolve a \$ref +node -e "console.log(JSON.stringify(require('/tmp/platform.json').components.schemas.Environment, null, 1))" +``` + +## Mapping a spec operation to a command + +- **Path → URL.** Strip the `/api/v2` prefix; `ScConnection` adds it (it builds + `baseURL + /api/v2` at construction). Spec `/api/v2/platform/environments/{id}` → + command URL `` `/platform/environments/${id}` ``. +- **Servers.** The spec lists four regional hosts (`api.solace.cloud`, + `api.solacecloud.com.au`, `.eu`, `.sg`). Commands never hardcode a host — + `OrgManager.createConnection()` supplies it from the stored org config. +- **Method → verb.** `POST` → `create`, `GET` collection → `list`, `GET` by id → + `display`, `PUT`/`PATCH` → `update`, `DELETE` → `delete`. +- **Query parameters → flags**, kebab-cased (`pageSize` → `--page-size`). Standard + `pageSize`/`pageNumber` parameters are already covered by `paginationFlags()` — don't + redefine them. +- **Request body properties → flags**, kebab-cased, with the spec's `required` list + driving `required: true` and the spec's `default` mentioned in the flag description. +- **Enum parameters**: pass the allowed values through `Flags.string({options: [...]})` + so oclif validates, and list them in the description. (`--sort` on existing list + commands predates this and is a plain string; new enum flags should use `options`.) +- **Response schema → `src/types/.ts`.** Model the resource interface on the + component schema, then wrap: `{data: T}` for single, `{data: T[]; meta: {pagination}}` + for collections. +- **Token permissions.** Each operation documents required permissions (e.g. + `environments:edit`, `services:post`). Put them in the command description as + `Token Permissions: [ environments:edit ]` — existing commands all do, and users rely + on it to diagnose 403s. + +## Deviating from the spec + +The CLI is deliberately friendlier than the raw API in two places; follow suit: + +1. **Names instead of ids.** Where the API only accepts an id, the command also accepts + `--name` and resolves it with a `GET ?name=` first (see `platform env update`, + `platform env delete`, and `--env-name` on `missionctrl broker create`). Error when + the name matches more than one resource. +2. **Pagination.** `--all` follows every page client-side; the API has no such parameter. diff --git a/.claude/skills/new-sc-command/references/testing.md b/.claude/skills/new-sc-command/references/testing.md new file mode 100644 index 0000000..d5756e7 --- /dev/null +++ b/.claude/skills/new-sc-command/references/testing.md @@ -0,0 +1,184 @@ +# Test patterns — Mocha + Chai + Sinon + +Tests live at `test/commands///.test.ts`, mirroring `src/`. The +file is created for you by `oclif generate command` alongside the command — replace its +contents wholesale. The generated version extends nothing, stubs nothing, and asserts on +`'hello world'`; none of it survives. + +Run the tests with: + +``` +npm test # everything + lint (~1s) +./node_modules/.bin/mocha --forbid-only "test/commands/platform/env/create.test.ts" # one file, no lint +``` + +Note that `npm test -- ` does **not** scope the run — the argument is appended to +the script's existing `"test/**/*.test.ts"` glob, so the full suite runs anyway. Use the +direct mocha invocation to iterate on one file, and `npm test` to finish. + +## Keep the suite small + +**Write a limited set of tests that validate core functionality — not a comprehensive +suite.** For a new command, 3–5 `it()` blocks is the target: + +1. The bare command runs (smoke test — `it('runs platform:env:create', ...)` asserting + `expect(stdout).to.contain('')`). This catches flag-definition and import errors. +2. The happy path with required flags: assert the URL, the request body, and that the + rendered table appears in stdout. +3. One meaningful variation — an optional flag that changes the body, or the + name→id resolution path. +4. `--alias=test-alias` (or `--org`) to prove org resolution is wired up. + +Skip: every flag permutation, every error branch, output formatting minutiae. The +`platform env list` test file is larger than this because pagination has genuinely +distinct behaviours (`--all` multi-page fetch, `--all --json` aggregation, rejection of +the legacy `--pageSize`); a list command may justify 2–3 extra cases, nothing else does. + +## The standard harness + +Every test file uses the same `beforeEach`. Copy it from +`templates/test.ts.template` or an existing file and change only the imported command class. + +```ts +import {OrgManager, printObjectAsKeyValueTable, ScConnection} from '@dishantlangayan/sc-cli-core' +import {runCommand} from '@oclif/test' +import {expect} from 'chai' +import * as sinon from 'sinon' + +import PlatformEnvCreate from '../../../../src/commands/platform/env/create.js' +import {anEnv} from '../../../util/test-utils' + +describe('platform:env:create', () => { + let orgManagerStub: sinon.SinonStubbedInstance + let getOrgManagerStub: sinon.SinonStub + let scConnStub: sinon.SinonStub + + beforeEach(() => { + orgManagerStub = sinon.createStubInstance(OrgManager) + getOrgManagerStub = sinon.stub( + PlatformEnvCreate.prototype as unknown as Record, + 'getOrgManager', + ).resolves(orgManagerStub) + + orgManagerStub.getDefaultOrg.resolves({accessToken: 'test-token', orgId: 'default-org'}) + + orgManagerStub.getOrg.callsFake(async (identifier: string) => { + if (identifier === 'test-org' || identifier === 'test-alias') { + return { + accessToken: 'test-token', + alias: identifier === 'test-alias' ? 'test-alias' : undefined, + orgId: 'test-org-id', + } + } + return null + }) + + const mockConnection = new ScConnection('https://api.solace.cloud', 'test-token') + orgManagerStub.createConnection.resolves(mockConnection) + + scConnStub = sinon.stub(ScConnection.prototype, 'post') // match the verb under test + }) + + afterEach(() => { + getOrgManagerStub.restore() + scConnStub.restore() + }) +}) +``` + +Why it looks like this: + +- **Stub the command's `getOrgManager` on the prototype**, not `resolveOrgConnection`. + `getOrgManager` is `protected`, hence the `as unknown as Record` cast. + The stub must target the exact command class under test. +- **Stub `ScConnection.prototype.`**, one stub per HTTP verb the command uses. A + command that resolves a name to an id needs two (`scConnGetStub` for the lookup, + `scConnDeleteStub`/`scConnUpdateStub` for the action) — name them after the verb. +- **`createConnection` resolves a real `ScConnection`** whose methods are stubbed; the + constructor rejects empty `baseURL`/`accessToken`, so pass the literals above. +- **Restore every stub in `afterEach`.** Leaked prototype stubs break unrelated files. + +## Driving the command + +`runCommand` from `@oclif/test` returns `{stdout, stderr, error, result}`: + +```ts +const {stdout} = await runCommand(`platform:env:create --name=${envName}`) +const result = await runCommand('platform:env:list --pageSize=5') // when expecting failure +expect(result.error).to.exist +``` + +Use the colon form of the command id (`platform:env:create`), not spaces. + +## Assertions + +Follow the Arrange / Act / Assert comment structure used throughout the suite. + +```ts +// request URL + body +expect(scConnStub.getCall(0).calledWith('/platform/environments', expectBody)).to.be.true +// URL only (query strings) +expect(scConnGetStub.getCall(0).args[0]).to.contain(`?name=${envName}`) +// org resolution +expect(orgManagerStub.getDefaultOrg.calledOnce).to.be.true +expect(orgManagerStub.createConnection.calledWith('default-org')).to.be.true +expect(orgManagerStub.getOrg.calledWith('test-alias')).to.be.true +// single-resource output +expect(stdout).to.contain(printObjectAsKeyValueTable(expectResponse.data as unknown as Record)) +// table output — rebuild the expected rows and render them the same way the command does +expect(stdout).to.contain(renderTable(envArray, {4: {width: 50, wrapWord: true}})) +// JSON output +const json = JSON.parse(stdout) +expect(json.data.map((e: Environment) => e.name)).to.deep.equal(['Default', 'Prod']) +``` + +`expectBody` must be the exact object the command builds — including which optional keys +are omitted. This is the assertion that catches a kebab→camel mapping mistake. + +## Fixtures + +Add a factory to `test/util/test-utils.ts` for each new resource, following `anEnv`: + +```ts +export function aThing(name: string) { + return {createdTime: '2024-09-05T19:54:42.766', id: `id${name}`, name, /* ... */} +} +``` + +Deriving the id as `` `id${name}` `` is the established trick — it lets a test assert +`/platform/things/idMyThing` without threading a separate constant through. + +List tests also need a pagination meta helper; declare it at the top of the test file: + +```ts +const aPagination = (pageNumber: number, pageSize: number, count: number, totalPages: number) => ({ + pagination: {count, nextPage: null, pageNumber, pageSize, totalPages}, +}) +``` + +## Testing list output + +Table rendering depends on whether stdout is a TTY, which varies by environment. Pin it: + +```ts +import {restoreStdoutIsTTY, setStdoutIsTTY} from '../../../util/test-utils' + +let stdoutIsTTY: PropertyDescriptor | undefined + +beforeEach(() => { + stdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY') + setStdoutIsTTY(false) // non-TTY → buffered single table, deterministic + // ...rest of harness +}) + +afterEach(() => { + restoreStdoutIsTTY(stdoutIsTTY) + // ...restores +}) +``` + +Multi-page fetches are staged per call: `scConnStub.onCall(0).resolves({data: page1, meta: aPagination(1, 2, 2, 2)})`. + +## Testing commands that prompt + +Pass `--no-prompt` so `confirm()` is never reached. Do not stub `@inquirer/prompts`. diff --git a/.claude/skills/new-sc-command/templates/create.ts.template b/.claude/skills/new-sc-command/templates/create.ts.template new file mode 100644 index 0000000..c91e831 --- /dev/null +++ b/.claude/skills/new-sc-command/templates/create.ts.template @@ -0,0 +1,74 @@ +// Template: POST a new resource. +// Reference implementation: src/commands/platform/env/create.ts +// Replaces the body of the file created by: +// ./node_modules/.bin/oclif generate command platform:thing:create +// Replace Thing/thing/things and the flags with the real resource. +import {printObjectAsKeyValueTable, ScCommand} from '@dishantlangayan/sc-cli-core' +import {Flags} from '@oclif/core' + +import {resolveOrgConnection} from '../../../lib/org-utils.js' +import {ThingApiResponse} from '../../../types/thing.js' + +export default class PlatformThingCreate extends ScCommand { + static override args = {} + static override description = `Create a new thing. + + Token Permissions: [ things:edit ]` + static override examples = [ + '<%= config.bin %> <%= command.id %> --name=MyThing', + '<%= config.bin %> <%= command.id %> --org=my-org --name=MyThing --description="My thing description"', + '<%= config.bin %> <%= command.id %> --alias=my-alias --name=MyThing', + ] + // Keys must be alphabetically sorted (perfectionist/sort-objects). + static override flags = { + alias: Flags.string({ + char: 'a', + description: 'Organization alias to use. If not specified, uses the default organization.', + exclusive: ['org'], + }), + description: Flags.string({ + char: 'd', + description: 'Description of the thing to create.', + }), + name: Flags.string({ + char: 'n', + description: 'Name of the thing to create.', + required: true, + }), + org: Flags.string({ + char: 'o', + description: 'Organization ID to use. If not specified, uses the default organization or alias if specified.', + exclusive: ['alias'], + }), + // kebab-case flag, camelCase API field: + // 'some-field': Flags.string({description: 'Maps to the API field someField.'}), + } + + public async run(): Promise { + const {flags} = await this.parse(PlatformThingCreate) + + const name = flags.name ?? '' + const desc = flags.description ?? '' + // const someField = flags['some-field'] ?? '' + + const conn = await resolveOrgConnection(this, flags.org ?? flags.alias) + + // API url — no /api/v2 prefix, ScConnection adds it + const apiUrl: string = `/platform/things` + // API body — conditional spreads omit unset optional fields + const body = { + ...(desc && {description: desc}), + name, + // ...(someField && {someField}), + } + + // API call + const resp = await conn.post(apiUrl, body) + + // Display results + this.log(printObjectAsKeyValueTable(resp.data as unknown as Record)) + + // Return raw json if --json flag is set + return resp + } +} diff --git a/.claude/skills/new-sc-command/templates/delete.ts.template b/.claude/skills/new-sc-command/templates/delete.ts.template new file mode 100644 index 0000000..70095e3 --- /dev/null +++ b/.claude/skills/new-sc-command/templates/delete.ts.template @@ -0,0 +1,109 @@ +// Template: DELETE a resource, addressed by id or name, with a confirmation prompt. +// Reference implementation: src/commands/platform/env/delete.ts +// Replaces the body of the file created by: +// ./node_modules/.bin/oclif generate command platform:thing:delete +import {ScCommand} from '@dishantlangayan/sc-cli-core' +import {confirm} from '@inquirer/prompts' +import {Flags} from '@oclif/core' + +import {resolveOrgConnection} from '../../../lib/org-utils.js' +import {ThingListApiResponse} from '../../../types/thing.js' + +export default class PlatformThingDelete extends ScCommand { + static override args = {} + static override description = `Delete a thing using either its name or unique identifier. + + Token Permissions: [ things:edit ]` + static override examples = [ + '<%= config.bin %> <%= command.id %> --name=MyThingName', + '<%= config.bin %> <%= command.id %> --thing-id=MyThingId', + '<%= config.bin %> <%= command.id %> --org=my-org --name=MyThingName', + '<%= config.bin %> <%= command.id %> --thing-id=MyThingId --no-prompt', + ] + static override flags = { + alias: Flags.string({ + char: 'a', + description: 'Organization alias to use. If not specified, uses the default organization.', + exclusive: ['org'], + }), + name: Flags.string({ + char: 'n', + description: 'Name of the thing.', + exactlyOne: ['thing-id', 'name'], + }), + 'no-prompt': Flags.boolean({ + default: false, + description: 'Skip confirmation prompt and assume Yes', + }), + org: Flags.string({ + char: 'o', + description: 'Organization ID to use. If not specified, uses the default organization or alias if specified.', + exclusive: ['alias'], + }), + 'thing-id': Flags.string({ + char: 't', + description: 'Id of the thing.', + exactlyOne: ['thing-id', 'name'], + }), + } + + public async run(): Promise<{message: string}> { + const {flags} = await this.parse(PlatformThingDelete) + + const name = flags.name ?? '' + const thingId = flags['thing-id'] ?? '' + + const conn = await resolveOrgConnection(this, flags.org ?? flags.alias) + + // API url + let apiUrl: string = `/platform/things` + let thingIdToDelete: string | undefined = thingId + + // If a name was provided, resolve it to exactly one id first + let foundMultiple: boolean = false + if (name) { + const getThingApiUrl = `${apiUrl}?name=${name}` + const resp = await conn.get(getThingApiUrl) + if (resp.data.length > 1) { + foundMultiple = true + } else { + thingIdToDelete = resp.data[0]?.id + } + } + + let message = '' + if (foundMultiple) { + // this.warn does not return never, so the else branch is required + message = `Multiple things found with: ${name}. Exactly one thing must match the provided name.` + this.warn(message) + } else { + // Confirm deletion unless --no-prompt flag is set + if (!flags['no-prompt']) { + const identifier = name || thingId + try { + const shouldProceed = await confirm({ + default: false, + message: `Are you sure you want to delete the thing '${identifier}'?`, + }) + + if (!shouldProceed) { + this.log('Deletion cancelled.') + this.exit(0) + } + } catch { + // User cancelled the confirmation (Ctrl+C) + this.log('Deletion cancelled.') + this.exit(0) + } + } + + apiUrl += `/${thingIdToDelete}` + await conn.delete(apiUrl) + message = `Thing with id '${thingIdToDelete}' has been deleted successfully.` + this.log(message) + } + + // Return raw json if --json flag is set + return {message} + } +} diff --git a/.claude/skills/new-sc-command/templates/display.ts.template b/.claude/skills/new-sc-command/templates/display.ts.template new file mode 100644 index 0000000..955b90e --- /dev/null +++ b/.claude/skills/new-sc-command/templates/display.ts.template @@ -0,0 +1,75 @@ +// Template: GET a single resource, by id or by name. +// Reference implementation: src/commands/platform/env/display.ts +// Replaces the body of the file created by: +// ./node_modules/.bin/oclif generate command platform:thing:display +import {printObjectAsKeyValueTable, ScCommand} from '@dishantlangayan/sc-cli-core' +import {Flags} from '@oclif/core' + +import {resolveOrgConnection} from '../../../lib/org-utils.js' +import {ThingApiResponse, ThingListApiResponse} from '../../../types/thing.js' + +export default class PlatformThingDisplay extends ScCommand { + static override args = {} + static override description = `Display information about a Thing. + + Use either the Thing's ID (--thing-id) or name of the Thing (--name). + + Required token permissions: [ things:view ]` + static override examples = [ + '<%= config.bin %> <%= command.id %> --name=MyThingName', + '<%= config.bin %> <%= command.id %> --thing-id=MyThingId', + '<%= config.bin %> <%= command.id %> --org=my-org --thing-id=MyThingId', + '<%= config.bin %> <%= command.id %> --alias=my-alias --name=MyThingName', + ] + static override flags = { + alias: Flags.string({ + char: 'a', + description: 'Organization alias to use. If not specified, uses the default organization.', + exclusive: ['org'], + }), + name: Flags.string({ + char: 'n', + description: 'Name of the thing.', + exactlyOne: ['thing-id', 'name'], + }), + org: Flags.string({ + char: 'o', + description: 'Organization ID to use. If not specified, uses the default organization or alias if specified.', + exclusive: ['alias'], + }), + 'thing-id': Flags.string({ + char: 't', + description: 'Id of the thing.', + exactlyOne: ['thing-id', 'name'], + }), + } + + public async run(): Promise { + const {flags} = await this.parse(PlatformThingDisplay) + + const name = flags.name ?? '' + const thingId = flags['thing-id'] ?? '' + + const conn = await resolveOrgConnection(this, flags.org ?? flags.alias) + + // API url + // If name provided, get all things matching provided name + // If id provided, get the thing with that id + let apiUrl: string = `/platform/things` + let resp: ThingApiResponse | ThingListApiResponse + if (thingId) { + apiUrl += `/${thingId}` + resp = await conn.get(apiUrl) + this.log(printObjectAsKeyValueTable(resp.data as unknown as Record)) + } else { + apiUrl += `?name=${name}` + resp = await conn.get(apiUrl) + for (const thing of resp.data) { + this.log(printObjectAsKeyValueTable(thing as unknown as Record)) + } + } + + // Return raw json if --json flag is set + return resp + } +} diff --git a/.claude/skills/new-sc-command/templates/list.ts.template b/.claude/skills/new-sc-command/templates/list.ts.template new file mode 100644 index 0000000..be81de5 --- /dev/null +++ b/.claude/skills/new-sc-command/templates/list.ts.template @@ -0,0 +1,103 @@ +// Template: GET a paginated collection, rendered as a table. +// Reference implementation: src/commands/platform/env/list.ts +// Replaces the body of the file created by: +// ./node_modules/.bin/oclif generate command platform:thing:list +import {ScCommand} from '@dishantlangayan/sc-cli-core' +import {Flags} from '@oclif/core' + +import {resolveOrgConnection} from '../../../lib/org-utils.js' +import { + createTableWriter, + DEFAULT_PAGE_SIZE, + fetchPages, + MAX_PAGE_SIZE, + paginationFlags, +} from '../../../lib/pagination.js' +import {Thing, ThingListApiResponse} from '../../../types/thing.js' + +export default class PlatformThingList extends ScCommand { + static override args = {} + static override description = `Get a list of all Things. + + Required token permissions: [ things:view ]` + static override examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --org=my-org', + '<%= config.bin %> <%= command.id %> --alias=my-alias --page-number=1 --page-size=10 --sort=name:ASC', + '<%= config.bin %> <%= command.id %> --all', + ] + static override flags = { + alias: Flags.string({ + char: 'a', + description: 'Organization alias to use. If not specified, uses the default organization.', + exclusive: ['org'], + }), + name: Flags.string({ + char: 'n', + description: 'Name of the thing to match on.', + }), + org: Flags.string({ + char: 'o', + description: 'Organization ID to use. If not specified, uses the default organization or alias if specified.', + exclusive: ['alias'], + }), + // Supplies --all, --page-number, --page-size. Never hand-roll these. + ...paginationFlags('things', {pageNumber: 'p', pageSize: 's'}), + sort: Flags.string({ + description: 'The query (fieldName:) used to sort the thing list in the response.', + }), + } + + public async run(): Promise { + const {flags} = await this.parse(PlatformThingList) + + const conn = await resolveOrgConnection(this, flags.org ?? flags.alias) + + const pageSize = flags['page-size'] ?? (flags.all ? MAX_PAGE_SIZE : DEFAULT_PAGE_SIZE) + const pageNumber = flags['page-number'] ?? 1 + + // API url + const buildUrl = (page: number, size: number): string => { + let apiUrl: string = `/platform/things?pageSize=${size}&pageNumber=${page}` + if (flags.sort) { + apiUrl += `&sort=${flags.sort}` + } + + return apiUrl + } + + // Output as table, streaming rows as each page is received when fetching all pages + const table = createTableWriter(['Name', 'Id', 'Description'], { + columnConfig: {2: {width: 50, wrapWord: true}}, + log: (msg) => this.log(msg), + stream: flags.all && !this.jsonEnabled(), + streamColumnConfig: { + 0: {width: 20, wrapWord: true}, + 1: {width: 26}, + 2: {width: 50, wrapWord: true}, + }, + }) + + // API calls + // perfectionist/sort-union-types sorts union members case-insensitively, so this + // order depends on your resource name: `Thing` < `undefined`, but a name sorting + // after `undefined` (e.g. UserListApiResponse) must be written the other way round. + // Run `eslint --fix` on this file after renaming and it will sort it for you. + let resp: ThingListApiResponse | undefined + const allData: Thing[] = [] + for await (const page of fetchPages(conn, buildUrl, { + all: flags.all, + pageNumber, + pageSize, + })) { + resp = page + allData.push(...page.data) + table.write(page.data.map((item: Thing) => [item.name, item.id, item.description])) + } + + table.end() + + // Return raw json if --json flag is set + return {...(resp as ThingListApiResponse), data: allData} + } +} diff --git a/.claude/skills/new-sc-command/templates/test.ts.template b/.claude/skills/new-sc-command/templates/test.ts.template new file mode 100644 index 0000000..01663bb --- /dev/null +++ b/.claude/skills/new-sc-command/templates/test.ts.template @@ -0,0 +1,126 @@ +// Template: a limited test file for one command — happy path, one variation, org resolution. +// Reference implementations: test/commands/platform/env/{create,delete,update,display,list}.test.ts +// +// Replaces the whole file created alongside the command by: +// ./node_modules/.bin/oclif generate command platform:thing:create +// Nothing from the generated 'hello world' test survives. +// +// Swap `post` for the verb(s) the command under test actually calls. Commands that +// resolve a name to an id need two stubs (a `get` for the lookup plus the action verb). +import {OrgManager, printObjectAsKeyValueTable, ScConnection} from '@dishantlangayan/sc-cli-core' +import {runCommand} from '@oclif/test' +import {expect} from 'chai' +import * as sinon from 'sinon' + +import PlatformThingCreate from '../../../../src/commands/platform/thing/create.js' +import {ThingApiResponse} from '../../../../src/types/thing.js' +import {aThing} from '../../../util/test-utils' + +describe('platform:thing:create', () => { + let orgManagerStub: sinon.SinonStubbedInstance + let getOrgManagerStub: sinon.SinonStub + let scConnStub: sinon.SinonStub + const thingName: string = 'MyTestThing' + + beforeEach(() => { + // Stub OrgManager + orgManagerStub = sinon.createStubInstance(OrgManager) + getOrgManagerStub = sinon.stub( + PlatformThingCreate.prototype as unknown as Record, + 'getOrgManager', + ).resolves(orgManagerStub) + + // Set up default org behavior + orgManagerStub.getDefaultOrg.resolves({ + accessToken: 'test-token', + orgId: 'default-org', + }) + + // Set up getOrg behavior for specific org/alias lookups + orgManagerStub.getOrg.callsFake(async (identifier: string) => { + if (identifier === 'test-org' || identifier === 'test-alias') { + return { + accessToken: 'test-token', + alias: identifier === 'test-alias' ? 'test-alias' : undefined, + orgId: 'test-org-id', + } + } + + return null + }) + + // Stub createConnection + const mockConnection = new ScConnection('https://api.solace.cloud', 'test-token') + orgManagerStub.createConnection.resolves(mockConnection) + + // Stub ScConnection methods + scConnStub = sinon.stub(ScConnection.prototype, 'post') + }) + + afterEach(() => { + getOrgManagerStub.restore() + scConnStub.restore() + }) + + // Smoke test: catches flag-definition and import errors + it('runs platform:thing:create', async () => { + const {stdout} = await runCommand('platform:thing:create') + expect(stdout).to.contain('') + }) + + it(`runs platform:thing:create --name=${thingName}`, async () => { + // Arrange + const expectBody = { + name: thingName, + } + const expectResponse: ThingApiResponse = { + data: aThing(thingName), + } + scConnStub.returns(expectResponse) + + // Act + const {stdout} = await runCommand(`platform:thing:create --name=${thingName}`) + + // Assert + expect(orgManagerStub.getDefaultOrg.calledOnce).to.be.true + expect(orgManagerStub.createConnection.calledWith('default-org')).to.be.true + expect(scConnStub.getCall(0).calledWith('/platform/things', expectBody)).to.be.true + expect(stdout).to.contain(printObjectAsKeyValueTable(expectResponse.data as unknown as Record)) + }) + + it(`runs platform:thing:create --name=${thingName} --description="A description."`, async () => { + // Arrange — asserts the optional flag lands in the body under its camelCase API name + const expectBody = { + description: 'A description.', + name: thingName, + } + const expectResponse: ThingApiResponse = { + data: aThing(thingName), + } + expectResponse.data.description = expectBody.description + scConnStub.returns(expectResponse) + + // Act + const {stdout} = await runCommand(`platform:thing:create --name=${thingName} --description="A description."`) + + // Assert + expect(scConnStub.getCall(0).calledWith('/platform/things', expectBody)).to.be.true + expect(stdout).to.contain(printObjectAsKeyValueTable(expectResponse.data as unknown as Record)) + }) + + it(`runs platform:thing:create --alias=test-alias --name=${thingName}`, async () => { + // Arrange + const expectResponse: ThingApiResponse = { + data: aThing(thingName), + } + scConnStub.returns(expectResponse) + + // Act + await runCommand(`platform:thing:create --alias=test-alias --name=${thingName}`) + + // Assert + expect(orgManagerStub.getOrg.calledWith('test-alias')).to.be.true + expect(orgManagerStub.createConnection.calledWith('test-alias')).to.be.true + expect(orgManagerStub.getDefaultOrg.called).to.be.false + }) +}) diff --git a/.claude/skills/new-sc-command/templates/update.ts.template b/.claude/skills/new-sc-command/templates/update.ts.template new file mode 100644 index 0000000..141d24f --- /dev/null +++ b/.claude/skills/new-sc-command/templates/update.ts.template @@ -0,0 +1,93 @@ +// Template: PUT/PATCH an existing resource, addressed by id or name. +// Reference implementation: src/commands/platform/env/update.ts +// Replaces the body of the file created by: +// ./node_modules/.bin/oclif generate command platform:thing:update +import {printObjectAsKeyValueTable, ScCommand} from '@dishantlangayan/sc-cli-core' +import {Flags} from '@oclif/core' + +import {resolveOrgConnection} from '../../../lib/org-utils.js' +import {ThingApiResponse, ThingListApiResponse} from '../../../types/thing.js' + +export default class PlatformThingUpdate extends ScCommand { + static override args = {} + static override description = `Modify a thing's attributes + + Use either the Thing's ID (--thing-id) or name of the Thing (--name). + + Token Permissions: [ things:edit ] + ` + static override examples = [ + '<%= config.bin %> <%= command.id %> --name=MyThingName --new-name=MyNewThingName', + '<%= config.bin %> <%= command.id %> --thing-id=MyThingId --new-name=MyNewThingName --description="Updated description"', + '<%= config.bin %> <%= command.id %> --alias=my-alias --name=MyThingName --new-name=MyNewThingName', + ] + static override flags = { + alias: Flags.string({ + char: 'a', + description: 'Organization alias to use. If not specified, uses the default organization.', + exclusive: ['org'], + }), + description: Flags.string({ + char: 'd', + description: 'Description of the thing to update.', + }), + name: Flags.string({ + char: 'n', + description: 'Current name of the thing.', + exactlyOne: ['thing-id', 'name'], + }), + 'new-name': Flags.string({ + description: 'New name of the thing.', + }), + org: Flags.string({ + char: 'o', + description: 'Organization ID to use. If not specified, uses the default organization or alias if specified.', + exclusive: ['alias'], + }), + 'thing-id': Flags.string({ + char: 't', + description: 'Id of the thing.', + exactlyOne: ['thing-id', 'name'], + }), + } + + public async run(): Promise { + const {flags} = await this.parse(PlatformThingUpdate) + + const desc = flags.description ?? '' + const name = flags.name ?? '' + const newName = flags['new-name'] ?? '' + const thingId = flags['thing-id'] ?? '' + + // API body — only send what the user actually passed + const body = { + ...(desc && {description: desc}), + ...(newName && {name: newName}), + } + + const conn = await resolveOrgConnection(this, flags.org ?? flags.alias) + + // API url + let apiUrl: string = `/platform/things` + let thingIdToUpdate: string | undefined = thingId + + // If a name was provided, resolve it to exactly one id first + if (name) { + const getThingApiUrl = `${apiUrl}?name=${name}` + const resp = await conn.get(getThingApiUrl) + if (resp.data.length > 1) { + this.error(`Multiple things found with: ${name}. Exactly one thing must match the provided name.`) + } else { + thingIdToUpdate = resp.data[0]?.id + } + } + + // API call to update by id + apiUrl += `/${thingIdToUpdate}` + const resp = await conn.put(apiUrl, body) + + this.log(printObjectAsKeyValueTable(resp.data as unknown as Record)) + + return resp + } +} From b710e34037971a96a4ac0bd09665894265b842a8 Mon Sep 17 00:00:00 2001 From: Dishant Langayan Date: Sun, 25 Oct 2026 22:27:25 -0400 Subject: [PATCH 2/2] Add platform users list command Wraps GET /api/v2/platform/users (Beta), which requires the iam_users:get token permission. Supports the endpoint's filters: --name and --email match on partial strings, and --id, --role, and --group are repeatable flags that map to the ids, roles, and groups array query parameters. The query string is built with URLSearchParams so repeated keys and encoding are handled correctly. Paging uses the shared paginationFlags/fetchPages/createTableWriter helpers, so --all streams rows as each page arrives. The README is left to the release flow; regenerating it now would also sweep in unrelated broker plugin version drift. Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/platform/users/list.ts | 123 +++++++++++++++++ src/types/user.ts | 37 ++++++ test/commands/platform/users/list.test.ts | 152 ++++++++++++++++++++++ test/util/test-utils.ts | 14 ++ 4 files changed, 326 insertions(+) create mode 100644 src/commands/platform/users/list.ts create mode 100644 src/types/user.ts create mode 100644 test/commands/platform/users/list.test.ts diff --git a/src/commands/platform/users/list.ts b/src/commands/platform/users/list.ts new file mode 100644 index 0000000..ca0b8d3 --- /dev/null +++ b/src/commands/platform/users/list.ts @@ -0,0 +1,123 @@ +import {ScCommand} from '@dishantlangayan/sc-cli-core' +import {Flags} from '@oclif/core' + +import {resolveOrgConnection} from '../../../lib/org-utils.js' +import { + createTableWriter, + DEFAULT_PAGE_SIZE, + fetchPages, + MAX_PAGE_SIZE, + paginationFlags, +} from '../../../lib/pagination.js' +import {User, UserListApiResponse} from '../../../types/user.js' + +export default class PlatformUsersList extends ScCommand { + static override args = {} + static override description = `(Beta) Get a list of users in your account. + + You can retrieve a list of users matching on email, name, identifiers, roles, or groups. + The --name and --email filters match on partial strings. + + Required token permissions: [ iam_users:get ]` + static override examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --org=my-org', + '<%= config.bin %> <%= command.id %> --alias=my-alias --name=Jane --page-number=1 --page-size=10', + '<%= config.bin %> <%= command.id %> --email=jane@example.com', + '<%= config.bin %> <%= command.id %> --role=role-id-1 --role=role-id-2', + '<%= config.bin %> <%= command.id %> --all', + ] + static override flags = { + alias: Flags.string({ + char: 'a', + description: 'Organization alias to use. If not specified, uses the default organization.', + exclusive: ['org'], + }), + email: Flags.string({ + description: + 'The email address of the user. You can enter a partial string into the field to match multiple users.', + }), + group: Flags.string({ + description: + 'The unique identifier of a group that is assigned to the user. Repeat the flag to match on multiple groups.', + multiple: true, + }), + id: Flags.string({ + description: 'The unique identifier of a user. Repeat the flag to match on multiple users.', + multiple: true, + }), + name: Flags.string({ + char: 'n', + description: 'The name of the user. You can enter a partial string into the field to match multiple users.', + }), + org: Flags.string({ + char: 'o', + description: 'Organization ID to use. If not specified, uses the default organization or alias if specified.', + exclusive: ['alias'], + }), + ...paginationFlags('users', {pageNumber: 'p', pageSize: 's'}), + role: Flags.string({ + description: + 'The unique identifier of a role that is assigned to the user. Repeat the flag to match on multiple roles.', + multiple: true, + }), + } + + public async run(): Promise { + const {flags} = await this.parse(PlatformUsersList) + + const conn = await resolveOrgConnection(this, flags.org ?? flags.alias) + + const pageSize = flags['page-size'] ?? (flags.all ? MAX_PAGE_SIZE : DEFAULT_PAGE_SIZE) + const pageNumber = flags['page-number'] ?? 1 + + // API url + // The ids, roles, and groups query parameters are arrays, so each value is repeated + const buildUrl = (page: number, size: number): string => { + const params = new URLSearchParams() + params.append('pageSize', String(size)) + params.append('pageNumber', String(page)) + if (flags.name) params.append('name', flags.name) + if (flags.email) params.append('email', flags.email) + for (const id of flags.id ?? []) params.append('ids', id) + for (const role of flags.role ?? []) params.append('roles', role) + for (const group of flags.group ?? []) params.append('groups', group) + + return `/platform/users?${params.toString()}` + } + + // Output as table, streaming rows as each page is received when fetching all pages + const table = createTableWriter(['Email', 'First Name', 'Last Name', 'Id', 'State'], { + columnConfig: {0: {width: 30, wrapWord: true}}, + log: (msg) => this.log(msg), + stream: flags.all && !this.jsonEnabled(), + streamColumnConfig: { + 0: {width: 30, wrapWord: true}, + 1: {width: 15, wrapWord: true}, + 2: {width: 15, wrapWord: true}, + 3: {width: 26}, + 4: {width: 16}, + }, + }) + + // API calls + let resp: undefined | UserListApiResponse + const allData: User[] = [] + for await (const page of fetchPages(conn, buildUrl, { + all: flags.all, + pageNumber, + pageSize, + })) { + resp = page + allData.push(...page.data) + table.write( + page.data.map((item: User) => [item.email, item.firstName, item.lastName, item.id, item.state]), + ) + } + + table.end() + + // Return raw json if --json flag is set + return {...(resp as UserListApiResponse), data: allData} + } +} diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 0000000..79282a4 --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,37 @@ +// This file contains the TypeScript interfaces for the user data structure. +// It is used to define the structure of the user data returned by the API. +// The interfaces are used to ensure that the data returned by the API matches the expected structure. +export interface GroupInfo { + id?: string + name?: string +} + +export interface User { + email?: string + firstName?: string + groups?: GroupInfo[] + id?: string + lastName?: string + organizationId?: string + roles?: string[] + state?: string + type?: string + userAttributes?: Record +} + +export interface UserListApiResponse { + data: User[] + meta: { + pagination: { + count: number + nextPage: null | string + pageNumber: number + pageSize: number + totalPages: number + } + } +} + +export interface UserApiResponse { + data: User +} diff --git a/test/commands/platform/users/list.test.ts b/test/commands/platform/users/list.test.ts new file mode 100644 index 0000000..8d43278 --- /dev/null +++ b/test/commands/platform/users/list.test.ts @@ -0,0 +1,152 @@ +import {OrgManager, renderTable, ScConnection} from '@dishantlangayan/sc-cli-core' +import {runCommand} from '@oclif/test' +import {expect} from 'chai' +import * as sinon from 'sinon' + +import PlatformUsersList from '../../../../src/commands/platform/users/list.js' +import {User} from '../../../../src/types/user' +import {aUser, restoreStdoutIsTTY, setStdoutIsTTY} from '../../../util/test-utils' + +const aPagination = (pageNumber: number, pageSize: number, count: number, totalPages: number) => ({ + pagination: {count, nextPage: null, pageNumber, pageSize, totalPages}, +}) + +const expectedTable = (users: User[]) => + renderTable( + [ + ['Email', 'First Name', 'Last Name', 'Id', 'State'], + ...users.map((item: User) => [item.email, item.firstName, item.lastName, item.id, item.state]), + ], + {0: {width: 30, wrapWord: true}}, + ) + +describe('platform:users:list', () => { + const defaultPageSize = 10 + const defaultPageNumber = 1 + + let orgManagerStub: sinon.SinonStubbedInstance + let getOrgManagerStub: sinon.SinonStub + let scConnStub: sinon.SinonStub + + let stdoutIsTTY: PropertyDescriptor | undefined + + beforeEach(() => { + // Default to non-TTY output so results don't depend on where the tests are run + stdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY') + setStdoutIsTTY(false) + + // Stub OrgManager + orgManagerStub = sinon.createStubInstance(OrgManager) + getOrgManagerStub = sinon.stub( + PlatformUsersList.prototype as unknown as Record, + 'getOrgManager', + ).resolves(orgManagerStub) + + // Set up default org behavior + orgManagerStub.getDefaultOrg.resolves({ + accessToken: 'test-token', + orgId: 'default-org', + }) + + // Set up getOrg behavior for specific org/alias lookups + orgManagerStub.getOrg.callsFake(async (identifier: string) => { + if (identifier === 'test-org' || identifier === 'test-alias') { + return { + accessToken: 'test-token', + alias: identifier === 'test-alias' ? 'test-alias' : undefined, + orgId: 'test-org-id', + } + } + + return null + }) + + // Stub createConnection + const mockConnection = new ScConnection('https://api.solace.cloud', 'test-token') + orgManagerStub.createConnection.resolves(mockConnection) + + // Stub ScConnection.get + scConnStub = sinon.stub(ScConnection.prototype, 'get') + }) + + afterEach(() => { + getOrgManagerStub.restore() + scConnStub.restore() + restoreStdoutIsTTY(stdoutIsTTY) + }) + + it('runs platform:users:list', async () => { + // Arrange + const users = [aUser('Jane', 'Doe'), aUser('John', 'Smith')] + scConnStub.resolves({data: users, meta: aPagination(1, 10, 2, 1)}) + + // Act + const {stdout} = await runCommand('platform:users:list') + + // Assert + expect(orgManagerStub.getDefaultOrg.calledOnce).to.be.true + expect(orgManagerStub.createConnection.calledWith('default-org')).to.be.true + expect(scConnStub.getCall(0).args[0]).to.contain(`pageSize=${defaultPageSize}&pageNumber=${defaultPageNumber}`) + expect(stdout).to.contain(expectedTable(users)) + }) + + it('runs platform:users:list --name=Jane --email=jane.doe@example.com', async () => { + // Arrange + const users = [aUser('Jane', 'Doe')] + scConnStub.resolves({data: users, meta: aPagination(1, 10, 1, 1)}) + + // Act + const {stdout} = await runCommand('platform:users:list --name=Jane --email=jane.doe@example.com') + + // Assert + const url = scConnStub.getCall(0).args[0] + expect(url).to.contain('name=Jane') + expect(url).to.contain('email=jane.doe%40example.com') + expect(stdout).to.contain(expectedTable(users)) + }) + + it('repeats the ids, roles, and groups query parameters for array filters', async () => { + // Arrange + scConnStub.resolves({data: [aUser('Jane', 'Doe')], meta: aPagination(1, 10, 1, 1)}) + + // Act + await runCommand('platform:users:list --id=id-1 --id=id-2 --role=role-1 --group=group-1') + + // Assert + const url = scConnStub.getCall(0).args[0] + expect(url).to.contain('ids=id-1') + expect(url).to.contain('ids=id-2') + expect(url).to.contain('roles=role-1') + expect(url).to.contain('groups=group-1') + }) + + it('runs platform:users:list --all and fetches every page', async () => { + // Arrange + const page1 = [aUser('Jane', 'Doe')] + const page2 = [aUser('John', 'Smith')] + scConnStub.onCall(0).resolves({data: page1, meta: aPagination(1, 1, 1, 2)}) + scConnStub.onCall(1).resolves({data: page2, meta: aPagination(2, 1, 1, 2)}) + + // Act + const {stdout} = await runCommand('platform:users:list --all -s 1') + + // Assert + expect(scConnStub.callCount).to.equal(2) + expect(scConnStub.getCall(0).args[0]).to.contain('pageSize=1&pageNumber=1') + expect(scConnStub.getCall(1).args[0]).to.contain('pageSize=1&pageNumber=2') + expect(stdout).to.contain(expectedTable([...page1, ...page2])) + }) + + it('uses specified alias when --alias provided', async () => { + // Arrange + scConnStub.resolves({data: [aUser('Jane', 'Doe')], meta: aPagination(1, 10, 1, 1)}) + + // Act + await runCommand('platform:users:list --alias=test-alias') + + // Assert + expect(orgManagerStub.getOrg.calledWith('test-alias')).to.be.true + expect(orgManagerStub.createConnection.calledWith('test-alias')).to.be.true + expect(orgManagerStub.getDefaultOrg.called).to.be.false + }) +}) diff --git a/test/util/test-utils.ts b/test/util/test-utils.ts index 1a8c1e2..4503aeb 100644 --- a/test/util/test-utils.ts +++ b/test/util/test-utils.ts @@ -36,6 +36,20 @@ export function anEnv(name: string, isDefault: boolean, isProd: boolean) { } } +export function aUser(firstName: string, lastName: string) { + return { + email: `${firstName.toLowerCase()}.${lastName.toLowerCase()}@example.com`, + firstName, + groups: [], + id: `id${firstName}${lastName}`, + lastName, + organizationId: 'some-org-id', + roles: ['org-admin'], + state: 'ACTIVE', + type: 'user', + } +} + export function aBroker(brokerId: string, brokerName: string): EventBrokerServiceDetail { return { adminState: 'START',