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 + } +} 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',