diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 00000000..3e789769 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,225 @@ +# Testing the Seam CLI + +How to decide, for any module in this repo, what kind of test it gets and where +the fake goes. + +## Principles + +1. **Classical by default.** Assert on returned values and on data captured at + a process edge. A test that asserts "function A called function B" is + testing the implementation unless B is the outside world. +2. **Fake only where data leaves the process** — the terminal, the wire, the + disk location, the environment. Everything on our side of those edges stays + real in every test, including sibling modules in `src/lib`. +3. **Fakes are injected values, never module-path substitution.** + `vi.mock('./config/index.js')` couples the test to file layout and to the + accidental shape of the import; a rename or internal refactor breaks tests + while behavior is unchanged. A fake is a real implementation of a narrow + interface, handed to the code under test. +4. **`createMemoryOutput()` + `setOutput()` is the house pattern** + (`src/lib/output/`): a tiny interface, a real in-memory implementation, a + capture you assert on. Config (`createMemoryConfigStore()` + + `setConfigStore()`), the prompt layer (`createMemoryPrompt()` + + `setPromptClient()`), and the Seam API get the same treatment; nothing else + needs it. An interface with more than one implementation — the real edge + and its memory fake — is fulfilled by **classes** (`HttpSeamApi` / + `MemorySeamApi`, `TerminalPromptClient` / `MemoryPromptClient`, + `PersistentConfigStore` / `MemoryConfigStore`), with `createFoo` factories kept + as the convenient way to construct them. +5. **The e2e suite proves wiring once; module tests prove behavior + everywhere.** Don't re-prove auth headers in a unit test, and don't push + branching logic into `test/cli.test.ts`. + +## Where tests live + +- **Test fixtures live in `test/fixtures` and nowhere else.** Anything that + exists for a test — a hand-built blueprint, seeded config files — never + sits beside normal code. +- **A test that uses such a fixture is not a unit test.** It goes under + `test/`, mirroring the source layout (`test/commands/registry.test.ts` + tests `src/lib/commands/registry.ts`). +- **A test may sit beside its module in `src` only when it tests the module + of the same name and imports nothing beyond it** — external packages and + type-only imports excepted. The moment it needs another module's code (a + memory fake from elsewhere, a sibling's helpers, a fixture), it moves + under `test/`. + +## Taxonomy + +| Module kind | The tell | Default test | Gets faked | Never faked | +| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------- | +| **Pure transform** — `render/help`, `render/completion/render-*`, `output/select-response-payload`, `args/parse` | Value in → value out; no I/O imports | Classical unit, real values | Nothing | Anything | +| **Decision over injected data** — `interact-for-command-selection` (non-interactive), `blueprint/endpoint`, `context.ts` | Takes `CliContext` / blueprint / config store as a parameter | Classical with a literal ctx object (`command-selection.test.ts` is the model) | Nothing — a hand-built blueprint literal is a fixture, not a fake | The traversal/decision logic | +| **Prompt flow** — `interactions/*` importing `lib/prompt.js` | Imports `lib/prompt.js` | Classical on the returned value, memory output, scripted prompt fake; assert the choice list _offered_ where the prompt is the UX | The prompt layer (the whole `@clack/prompts` edge), output | The module's own branching and param assembly | +| **Config & state** — `config/config-store`, `config/migrate` | Touches `Configstore` / `env-paths` | Classical against a real store in a temp directory — it's a JSON file, and split/merge/migration _is_ the behavior | The directory; env vars (`vi.stubEnv`) | `Configstore` or fs behavior | +| **Network** — `http/request`, `auth/validate-token`, `blueprint/source-npm` | Constructs `SeamHttp` or calls `fetch` | Classical against a fake port (or a stubbed global `fetch` with captured requests, as `blueprint/source-npm.test.ts` does); assert the payload sent _and_ the value returned | The `SeamApi` port / global `fetch` | Status handling, payload selection, formatting — that's the unit | +| **Orchestration** — `bin/cli.ts` | Reads argv/env, wires everything | E2e: spawn via `execa`, `node:http` fake server, XDG temp dirs (`test/cli.test.ts`) | The far end of the wire; the home directories | Anything in-process | + +## The mocking boundary + +Legitimate fakes in this repo, exhaustively: the **terminal** (prompt layer + +output streams), the **wire** (fake `node:http` server for the spawned e2e; the +`SeamApi` port in-process; `fetch` stub for the npm registry), the **disk +location** (temp dirs — never a fake fs), and **env vars**. Everything else — +`Configstore`, blueprint traversal, response formatting, `command-spec`, any +sibling in `src/lib` — must stay real, because faking it removes exactly the +thing the test exists to prove. + +## London vs. classical: the rule + +A mock-verification assertion is legitimate **only when the interaction is +itself the user-observable contract** — when the message crosses a process +boundary. "We sent this request body to `/devices/list`" is behavior: the +request is the product. "The prompt offered these choices with these hints" is +behavior: the choices are what the user sees +(`blueprint-object.test.ts` asserting on the recorded `choices` +is the good in-repo example). "`resolveAuth` called `getConfigStore`" is +implementation: the contract is _what server comes back_, not how it was +looked up. + +Even at a real boundary, prefer **capture-then-assert** over +`toHaveBeenCalledWith`: have the fake record what it received (like the e2e +server's `requests` array, or `createMemoryPrompt()`'s `questions`) and make +classical assertions on the capture. A good London test asserts on the content +of one outbound message; a bad one asserts call counts and ordering of +internal helpers. + +## The real-HTTP line + +A test earns a real HTTP server only if it proves wiring that exists solely in +the real transport stack: `SeamHttp` auth-header construction, token-type +dispatch, endpoint resolution, `validateStatus`, and the exit code of the +actual spawned process. That is `test/cli.test.ts` and nothing else. Everything +in-process fakes at the port. Today the e2e file is ~20% of tests; hold it +there — each user-visible flow once end-to-end, while new module tests grow the +HTTP-free share. + +## The Seam SDK boundary + +**Wrap it behind our own narrow port.** Not `vi.mock('./http/client.js')`, and +not dependency-injecting `SeamHttp`: both force the fake to imitate the SDK's +whole surface, so tests end up re-verifying the SDK's shape instead of our +behavior. The CLI is blueprint-driven and has one chokepoint: preparing a +`SeamHttpRequest` for an endpoint path. The port mirrors that — prepare a +request, inspect it, send it: + +```ts +// src/lib/http/api.ts +export interface SeamApiRequest { + readonly url: URL + readonly method: string + readonly body: unknown + fetchResponse: () => Promise +} + +export interface SeamApi { + createRequest: (options: ApiRequestOptions) => SeamApiRequest +} + +export class HttpSeamApi implements SeamApi { + constructor(private readonly seam: SeamHttp) {} // the only place SeamHttp appears + + createRequest = ({ path, params, responseKey }: ApiRequestOptions) => + new SeamHttpRequest(this.seam, { + pathname: path, + method: 'POST', + body: params, + responseKey: responseKey ?? undefined, + }) +} +``` + +The real request object is the SDK's own `SeamHttpRequest`, so the URL is +inspectable before sending and an error status rejects with the SDK's typed +`SeamHttpApiError`. The fake is the in-process mirror of the e2e server — a +routes table plus a capture — and it rejects with those same SDK error +classes, never an imitation: + +```ts +// src/lib/http/memory-seam-api.ts +export class MemorySeamApi implements SeamApi { + readonly requests: Array<{ path: string; params: Record }> = + [] + + constructor(private readonly routes: Record) {} + + createRequest = ({ path, params }: ApiRequestOptions): SeamApiRequest => ({ + url: new URL(`https://memory.seam.example${path}`), + method: 'POST', + body: params, + fetchResponse: async () => { + this.requests.push({ path, params }) + const route = this.routes[path] + if (route == null || route.status >= 400) throw toSeamHttpError(route) + return route.data + }, + }) +} +``` + +This keeps transport separate from presentation: the error-status → exit-code +behavior is a classical test with zero HTTP: + +```ts +const api = createMemorySeamApi({ + '/devices/list': { status: 400, data: { error: { type: 'invalid_input' } } }, +}) +const memory = createMemoryOutput() + +await requestSeamApi( + { path: '/devices/list', params: { limit: 5 } }, + { api, output: memory.output }, +) + +// Boundary interaction: the outbound message IS the behavior. +expect(api.requests).toEqual([{ path: '/devices/list', params: { limit: 5 } }]) +expect(memory.stdout()).toContain('invalid_input') +expect(process.exitCode).toBe(1) +``` + +`auth/validate-token.ts` and the resource pickers (`interactions/device.ts` +and friends) use typed SDK methods and stay on the real SDK, covered by e2e — +don't invent a second port for them. + +## Singletons: `getConfigStore`, `getOutput`, the prompt client + +Target shape: `CliContext = { config, auth, output, blueprint, interactivity, api }` +threaded as a parameter, with port boundaries exactly the ones above — config +store interface, `Output`, the prompt client, `SeamApi`. That makes every fake +an ordinary argument. + +The rule: a singleton getter is tolerable only when it has (a) a setter + +reset and (b) an in-memory fake of the same narrow interface. In this repo +that is `getOutput`/`setOutput`/`resetOutput` + `createMemoryOutput()`, +`getConfigStore`/`setConfigStore`/`resetConfigStore` + +`createMemoryConfigStore()`, and `setPromptClient`/`resetPromptClient` + +`createMemoryPrompt()`. `vi.mock` on a module path is never the delivery +mechanism for a fake. Direct env reads (`env.ts`, `resolveAuth`) are a genuine +ambient edge — setting env vars in the test is fine. + +## Anti-pattern + +The shape the (since deleted) `get-server.test.ts` used: + +```ts +const storedConfig: Record = {} +vi.mock('./config/index.js', () => ({ + getConfigStore: vi.fn(() => ({ get: (key: string) => storedConfig[key] })), +})) +afterEach(() => { + vi.mocked(getConfigStore).mockClear() +}) +``` + +Three things wrong: the fake is delivered by file path, so renaming +`config/index.js` breaks the test; the fake's shape is whatever the test author +remembered (`{ get }`) rather than the store's interface, so it drifts +silently; and the `mockClear` bookkeeping exists only because the mock is +module-global state. The same tests written against an injected +`createMemoryConfigStore()` keep every assertion and lose all three problems. + +## Rule of thumb + +> **Assert on what leaves the process — stdout, the config file, the request +> payload, the choices offered, the exit code. Fake only the edge it leaves +> through, and keep everything on our side of that edge real.** diff --git a/eslint.config.ts b/eslint.config.ts index 5c765c9e..c99f0aeb 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -22,6 +22,23 @@ export default [ 'unused-imports': unusedImports, import: importPlugin, }, + settings: { + // no-cycle builds the import graph by parsing the imported files, and + // its default parser cannot read TypeScript: without this setting it + // sees no edges and silently reports nothing. + 'import/parsers': { + '@typescript-eslint/parser': ['.ts', '.tsx'], + }, + // Resolves the .js-suffixed TypeScript imports and the tsconfig path + // aliases. Without a resolver, every import in this ESM TypeScript + // codebase is unresolvable, which silently disables the import rules + // that resolve before reporting, e.g., no-relative-parent-imports. + 'import/resolver': { + typescript: { + project: './tsconfig.json', + }, + }, + }, rules: { '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-import-type-side-effects': 'error', @@ -31,9 +48,38 @@ export default [ fixStyle: 'inline-type-imports', }, ], - 'import/extensions': ['error', 'ignorePackages'], + // Not import/extensions: with the resolver active it resolves the + // .js-suffixed import to the .ts file and demands a .ts extension. + // TypeScript's nodenext resolution already fails the build on a + // missing or wrong extension, so the rule adds nothing here. + // + // Not import/no-relative-parent-imports: with a resolver it bans + // depending on anything in a parent directory however the import is + // written, path aliases included. The core rule below bans the ../ + // spelling, which is the actual mistake. 'import/no-duplicates': ['error', { 'prefer-inline': true }], - 'import/no-relative-parent-imports': 'error', + 'import/no-cycle': [ + 'error', + { + ignoreExternal: true, + // A cycle broken by a deferred import() is intentional, e.g., the + // command registry lists the completion command while the + // completion command builds a spec from the registry. + allowUnsafeDynamicCyclicDependency: true, + }, + ], + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['..', '../**'], + message: + 'Import by path alias instead, e.g., lib/foo/bar.js or test/fixtures/blueprint.js.', + }, + ], + }, + ], 'unused-imports/no-unused-imports': 'error', 'unused-imports/no-unused-vars': [ 'error', @@ -61,7 +107,7 @@ export default [ ['^node:'], ['^@?\\w'], ['@seamapi/cli'], - ['^lib/'], + ['^lib/', '^test/'], ['^'], ['^\\.'], ], diff --git a/package-lock.json b/package-lock.json index 7d168202..7a1bda83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "concurrently": "^10.0.4", "del-cli": "^7.0.0", "eslint": "^9.31.0", + "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-import": "^2.32.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.1.4", @@ -2028,6 +2029,353 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", @@ -3963,6 +4311,31 @@ "node": ">=10" } }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.10", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", @@ -3985,6 +4358,41 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.5.tgz", + "integrity": "sha512-nbE5XLph6TLtGYcu/U6e6ZVXyKBhbDWK5cLGk76eJ7NdZpwf1P9EFkpt1Z01mNZNrrilsAYWKH6zUkL4reoXbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "get-tsconfig": "^4.10.1", + "is-bun-module": "^2.0.0", + "stable-hash-x": "^0.2.0", + "tinyglobby": "^0.2.14", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, "node_modules/eslint-module-utils": { "version": "2.14.0", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", @@ -5609,6 +6017,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -6917,6 +7348,22 @@ "picocolors": "^1.1.1" } }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -8435,6 +8882,16 @@ "node": ">=0.10.0" } }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -9286,6 +9743,44 @@ "node": ">= 0.8" } }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 7ec8db3e..73366715 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "concurrently": "^10.0.4", "del-cli": "^7.0.0", "eslint": "^9.31.0", + "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-import": "^2.32.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.1.4", diff --git a/prepack.ts b/prepack.ts index b5a9bc84..71a9a5ca 100644 --- a/prepack.ts +++ b/prepack.ts @@ -8,7 +8,7 @@ import { completionFileNames, completionShells, renderCompletionStub, -} from './src/lib/completion/index.js' +} from './src/lib/render/completion/index.js' const versionFile = './src/lib/version.ts' const completionsDirectory = './completions' diff --git a/src/bin/cli.ts b/src/bin/cli.ts index dd890394..dffced76 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -1,65 +1,43 @@ #!/usr/bin/env node -import { randomBytes } from 'node:crypto' import { isDeepStrictEqual as isEqual } from 'node:util' import chalk from 'chalk' import type { ParsedArgs } from 'minimist' -import { findLocalCommand, getCommandSpec } from 'lib/command-spec.js' -import { - completionShells, - isCompletionShell, - renderCompletion, -} from 'lib/completion/index.js' -import { getConfigStore } from 'lib/config/index.js' -import { - assertEnvVarUnset, - endpointEnvVar, - EnvVarOverrideError, - getEndpointFromEnv, - getTokenFromEnv, - getWorkspaceIdFromEnv, - tokenEnvVar, - workspaceIdEnvVar, -} from 'lib/env.js' -import { getApiBlueprint } from 'lib/get-api-blueprint.js' -import { getCommandBlueprintDef } from 'lib/get-command-blueprint-def.js' -import { getToken } from 'lib/get-credentials.js' -import { getResponseKey } from 'lib/get-response-key.js' -import { getServer } from 'lib/get-server.js' -import { interactForActionAttemptPoll } from 'lib/interact-for-action-attempt-poll.js' -import { interactForCommandParams } from 'lib/interact-for-command-params.js' -import { interactForCommandSelection } from 'lib/interact-for-command-selection.js' -import { interactForLogin } from 'lib/interact-for-login.js' -import { interactForServerSelection } from 'lib/interact-for-server-selection.js' -import { interactForUseRemoteApiDefs } from 'lib/interact-for-use-remote-api-defs.js' -import { interactForWorkspaceId } from 'lib/interact-for-workspace-id.js' -import { createOutput } from 'lib/output/create-output.js' -import { getOutput, setOutput } from 'lib/output/get-output.js' -import { resolveOutputFormat } from 'lib/output/resolve-output-format.js' -import { renderHelp } from 'lib/render-help.js' -import type { ContextHelpers } from 'lib/types.js' import { cliFlags, getInteractivity, - type Interactivity, - NonInteractiveError, parseCliArgs, - toGivenArgName, toParameterName, - UsageError, -} from 'lib/util/cli-args.js' +} from 'lib/args/parse.js' +import { assertKnownArgs } from 'lib/args/validate.js' +import { getApiBlueprint } from 'lib/blueprint/index.js' +import { printCompletion } from 'lib/commands/local/completion.js' +import { runWizard } from 'lib/commands/local/wizard.js' +import { + acceptedParamsOf, + buildRegistry, + findLocalCommand, +} from 'lib/commands/registry.js' +import { getConfigStore } from 'lib/config/index.js' +import { type CliContext, resolveAuth } from 'lib/context.js' +import { tokenEnvVar } from 'lib/env.js' +import { reportErrorAndExit } from 'lib/errors.js' +import { createSeamApi, type SeamApi } from 'lib/http/api.js' +import { interactForCommandSelection } from 'lib/interactions/index.js' +import { getOutput, setOutput } from 'lib/output/get-output.js' +import { createOutput } from 'lib/output/output.js' +import { readStdinJson } from 'lib/output/read-stdin-json.js' +import { resolveOutputFormat } from 'lib/output/resolve-output-format.js' +import { canPrompt } from 'lib/prompt.js' import { - canPrompt, - PromptCancelledError, - promptConfirm, -} from 'lib/util/prompt.js' -import { readStdinJson } from 'lib/util/read-stdin-json.js' -import { RequestSeamApi } from 'lib/util/request-seam-api.js' -import { validateToken } from 'lib/validate-token.js' + completionShells, + isCompletionShell, +} from 'lib/render/completion/index.js' +import { renderHelp } from 'lib/render/help.js' import seamapiCliVersion from 'lib/version.js' -async function cli(args: ParsedArgs) { +async function cli(args: ParsedArgs, argv: string[]) { const config = getConfigStore() const output = getOutput() @@ -69,7 +47,8 @@ async function cli(args: ParsedArgs) { if (helpFlag != null) { // Help comes from the cached API definitions so that it works without // logging in, and offline once the cache is warm. - const spec = getCommandSpec(await getApiBlueprint(false, { update })) + const cachedBlueprint = await getApiBlueprint({ update }) + const { spec } = buildRegistry(cachedBlueprint) // minimist reads the word after --help as its value, so 'seam --help // devices' asks about devices just as 'seam devices --help' does. @@ -128,41 +107,28 @@ async function cli(args: ParsedArgs) { return } - assertKnownArgs(argParams, ['completion', shell]) + const command = findLocalCommand(['completion', shell]) + assertKnownArgs(argParams, ['completion', shell], { + accepted: + command == null ? new Set() : acceptedParamsOf(command.definition), + isLocal: true, + }) - // Completions always come from the cached API definitions so that they - // can be generated without logging in. They may lag the definitions - // served by Seam when config use-remote-api-defs is enabled. - output.text( - renderCompletion(shell, await getApiBlueprint(false, { update })), - ) + await printCompletion(shell, { update }) return } - if ( - args._[0] === 'config' && - args._[1] === 'set' && - args._[2] === 'fake-server' - ) { - assertEnvVarUnset(endpointEnvVar, getEndpointFromEnv(), 'select a server') - assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log in') + const localCommand = findLocalCommand(args._) - const randomstring = randomBytes(5).toString('hex') - const fakeApiUrl = `https://${randomstring}.fakeseamconnect.seam.vc` + // Commands declared not to need a token bypass the login gate. A partial + // path keeps the historical rule: only login and select server may be + // reached logged out. + const requiresAuth = + localCommand != null + ? localCommand.requiresAuth + : !(args._[0] === 'login' || isEqual(args._, ['select', 'server'])) - config.set('server', fakeApiUrl) - output.info(`Server URL set to ${fakeApiUrl}`) - - config.set(`${getServer()}.pat`, `seam_apikey1_token`) - output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) - return - } - - if ( - getToken() == null && - args._[0] !== 'login' && - !isEqual(args._, ['select', 'server']) - ) { + if (requiresAuth && resolveAuth(config).token == null) { output.error(`Not logged in. Please run "seam login" or set ${tokenEnvVar}`) process.exitCode = 1 return @@ -171,265 +137,78 @@ async function cli(args: ParsedArgs) { const useRemoteApiDefs = args['remote_api_defs'] ?? config.get('use_remote_api_defs') - const blueprint = await getApiBlueprint(useRemoteApiDefs ?? false, { + const blueprint = await getApiBlueprint({ + useRemoteDefinitions: useRemoteApiDefs ?? false, update, }) + const registry = buildRegistry(blueprint) + // Params piped or redirected in, e.g., `seam devices list < params.json`. - // Params given as arguments take precedence over these. - const commandParams: Record = { ...(await readStdinJson()) } + const pipedParams = await readStdinJson() + const stdinParams: Record = { ...pipedParams } + + const auth = resolveAuth(config) + let seamApi: Promise | null = null - const ctx: ContextHelpers = { + const ctx: CliContext = { + config, + auth, + output, blueprint, interactivity: getInteractivity(args, { canPrompt: canPrompt() }), + api: async () => await (seamApi ??= createSeamApi(auth)), } - const isNonInteractive = ctx.interactivity === 'non-interactive' - - Object.assign(commandParams, argParams) - - const selectedCommand = await interactForCommandSelection(args._, ctx) + const selectableCommands = registry.spec.commands.map(({ path }) => path) - // Hit 'back' on a top-level command path, so we start again - if (selectedCommand.slice(-1)[0] === '[Back]') { - return await cli({ - ...args, - _: [], + let commandPath = args._ + while (true) { + const selectedCommand = await interactForCommandSelection(commandPath, { + commands: selectableCommands, + interactivity: ctx.interactivity, }) - } - - // Check the arguments before the command acts on any of them, so a mistake - // is reported rather than half applied. - assertKnownArgs(argParams, selectedCommand, ctx) - if (isEqual(selectedCommand, ['login'])) { - // Nothing is stored while the environment overrides it, so refuse before - // storing anything rather than part way through. - assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log in') - if (args['server']) { - assertEnvVarUnset(endpointEnvVar, getEndpointFromEnv(), 'select a server') - } - if (args['workspace_id']) { - assertEnvVarUnset( - workspaceIdEnvVar, - getWorkspaceIdFromEnv(), - 'select a workspace', - ) - } - if (args['server']) { - config.set('server', args['server']) - config.delete('current_workspace_id') - } - if (args['token']) { - const token = String(args['token']).trim() - await validateToken(token, args['workspace_id']) - config.set(`${getServer()}.pat`, token) - config.delete('current_workspace_id') + // Hit 'back' on a top-level command path, so we start again + if (selectedCommand.at(-1) === '[Back]') { + commandPath = [] + continue } - if (args['workspace_id']) { - config.set(`current_workspace_id`, args['workspace_id']) - } - if (args['token'] || args['workspace_id'] || args['server']) { - return - } - if (isNonInteractive) { - throw new NonInteractiveError( - 'Missing required parameter for login: --token', - ) - } - await interactForLogin() - return - } else if (isEqual(selectedCommand, ['logout'])) { - assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log out') - config.delete(`${getServer()}.pat`) - config.delete('current_workspace_id') - output.info('Logged out!') - return - } else if (isEqual(selectedCommand, ['config', 'reveal-location'])) { - output.text(config.path) - return - } else if (isEqual(selectedCommand, ['config', 'use-remote-api-defs'])) { - if (isNonInteractive) { - throw new NonInteractiveError( - 'Cannot select whether to use remote API definitions in non-interactive mode', - ) - } - await interactForUseRemoteApiDefs() - return - } else if (isEqual(selectedCommand, ['select', 'workspace'])) { - assertEnvVarUnset( - workspaceIdEnvVar, - getWorkspaceIdFromEnv(), - 'select a workspace', - ) - if (isNonInteractive) { - throw new NonInteractiveError( - 'Cannot select a workspace in non-interactive mode: pass --workspace-id to "seam login"', - ) - } - await interactForWorkspaceId() - return - } else if (isEqual(selectedCommand, ['events', 'list'])) { - if (!commandParams['since']) { - const date = new Date() - date.setMonth(date.getMonth() - 1) - commandParams['since'] = date.toISOString() - } - } else if (isEqual(selectedCommand, ['select', 'server'])) { - assertEnvVarUnset(endpointEnvVar, getEndpointFromEnv(), 'select a server') - if (args['server']) { - config.set('server', args['server']) - config.delete('current_workspace_id') - return - } - if (isNonInteractive) { - throw new NonInteractiveError( - 'Missing required parameter for select server: --server', + + const command = registry.find(selectedCommand) + if (command == null) { + throw new Error( + `No definition for command seam ${selectedCommand.join(' ')}`, ) } - await interactForServerSelection() - return - } else if (isEqual(selectedCommand, ['health', 'get-health'])) { - await RequestSeamApi({ - path: '/health/get_health', - params: {}, - }) - - return - } - // TODO - do this using the OpenAPI spec for the command rather than - // explicitly encoding the property names - if (commandParams['accepted_providers']) { - commandParams['accepted_providers'] = - commandParams['accepted_providers'].split(',') - } - - const apiPath = `/${selectedCommand.join('/').replace(/-/g, '_')}` - const params = await interactForCommandParams( - { command: selectedCommand, params: commandParams }, - ctx, - ) - - if (params === '[Back]') { - const previousCommands = [...selectedCommand] - previousCommands.pop() - return await cli({ - ...args, - _: previousCommands, + // Check the arguments before the command acts on any of them, so a + // mistake is reported rather than half applied. + assertKnownArgs(argParams, selectedCommand, { + accepted: acceptedParamsOf(command.definition), + isLocal: findLocalCommand(selectedCommand) != null, }) - } - if (apiPath.includes('/events/list') && params.between) { - delete params.since - } - - const response = await RequestSeamApi({ - path: apiPath, - params, - responseKey: getResponseKey(selectedCommand, ctx), - }) - - if (response.data?.connect_webview) { - await handleConnectWebviewResponse( - response.data.connect_webview, - ctx.interactivity, + const result = await command.execute( + { path: selectedCommand, argParams, stdinParams, args, argv }, + ctx, ) - } - if (response.data?.action_attempt && !isNonInteractive) { - await interactForActionAttemptPoll(response.data.action_attempt) + if (result.kind === 'back') { + commandPath = result.toPath + continue + } + + return } } const toCommandWord = (arg: string): string => arg.toLowerCase().replace(/_/g, '-') -/** - * Report any argument the command does not accept, rather than acting on it. - * An unrecognized argument is a mistake: forwarded to the API it would fail - * somewhere less obvious or be quietly ignored, and on a command the CLI - * handles itself it would go nowhere at all. - * - * Only arguments are checked. Params read from stdin are passed through as - * given, so a caller may send whatever the API itself accepts. - * - * `ctx` is only needed to look up an endpoint's parameters, so commands the - * CLI declares itself can be checked before any blueprint is loaded. - */ -const assertKnownArgs = ( - argParams: Record, - command: string[], - ctx?: ContextHelpers, -): void => { - const local = findLocalCommand(command) - - let accepted: Set - if (local != null) { - accepted = new Set( - local.flags.flatMap(({ long }) => - long == null ? [] : [toParameterName(long)], - ), - ) - } else if (ctx != null) { - accepted = new Set( - getCommandBlueprintDef(command, ctx).request.parameters.map( - ({ name }) => name, - ), - ) - } else { - throw new Error(`No definition for command seam ${command.join(' ')}`) - } - - const unknown = Object.keys(argParams).filter((key) => !accepted.has(key)) - if (unknown.length === 0) return - - // Name an endpoint command by its path, as missing params are named, and a - // command the CLI handles itself by the words that run it. - const target = - local == null - ? `/${command.join('/').replace(/-/g, '_')}` - : command.join(' ') - - throw new UsageError( - `Unknown ${ - unknown.length === 1 ? 'parameter' : 'parameters' - } for ${target}: ${unknown.map(toGivenArgName).join(' ')}`, - { - hint: `Run 'seam ${command.join(' ')} --help' to see what it accepts.`, - }, - ) -} - -const handleConnectWebviewResponse = async ( - connectWebview: any, - interactivity: Interactivity, -) => { - const url = connectWebview.url - - if ( - interactivity !== 'non-interactive' && - process.env['INSIDE_WEB_BROWSER'] !== '1' - ) { - const action = await promptConfirm({ - message: 'Would you like to open the webview in your browser?', - initialValue: false, - }) - - if (action) { - const { default: open } = await import('open') - await open(url) - } - } -} - const run = async (argv: string[]) => { if (argv[0] === 'wizard') { - const { default: wizard } = await import('@seamapi/wizard') - await wizard({ - argv: argv.slice(1), - commandName: 'seam wizard', - }) + await runWizard(argv.slice(1)) return } @@ -444,30 +223,9 @@ const run = async (argv: string[]) => { }), ) - await cli(args) + await cli(args, argv) } run(process.argv.slice(2)).catch((e: unknown) => { - const output = getOutput() - process.exitCode = 1 - - if (e instanceof UsageError) { - output.error(chalk.red(e.message)) - if (e.hint !== '') output.error(e.hint) - return - } - - if (e instanceof NonInteractiveError || e instanceof EnvVarOverrideError) { - output.error(chalk.red(e.message)) - return - } - - if (e instanceof PromptCancelledError) { - output.error(chalk.gray(e.message)) - return - } - - const error = e instanceof Error ? e : new Error(String(e)) - output.error(chalk.red(`CLI Error: ${error.message}`)) - if (error.stack != null) output.error(chalk.gray(error.stack)) + reportErrorAndExit(e, getOutput()) }) diff --git a/src/lib/args/coerce.test.ts b/src/lib/args/coerce.test.ts new file mode 100644 index 00000000..4c9fe0b9 --- /dev/null +++ b/src/lib/args/coerce.test.ts @@ -0,0 +1,112 @@ +import type { Parameter } from '@seamapi/blueprint' +import { expect, test } from 'vitest' + +import { coerceArgParams, coerceParam } from './coerce.js' + +const parameter = (shape: Record): Parameter => + shape as unknown as Parameter + +const boolean = parameter({ name: 'is_managed', format: 'boolean' }) +const number = parameter({ name: 'limit', format: 'number' }) +const string = parameter({ name: 'code', format: 'string' }) +const id = parameter({ name: 'device_id', format: 'id' }) +const datetime = parameter({ name: 'since', format: 'datetime' }) +const enumParam = parameter({ + name: 'device_type', + format: 'enum', + values: [{ name: 'august_lock' }, { name: 'schlage_lock' }], +}) +const list = parameter({ + name: 'accepted_providers', + format: 'list', + itemFormat: 'string', +}) +const numberList = parameter({ + name: 'limits', + format: 'list', + itemFormat: 'number', +}) +const enumList = parameter({ + name: 'device_types', + format: 'list', + itemFormat: 'enum', + itemEnumValues: [{ name: 'august_lock' }, { name: 'schlage_lock' }], +}) +const object = parameter({ name: 'custom_metadata', format: 'object' }) + +test.each([ + [boolean, 'true', true], + [boolean, 'false', false], + [boolean, true, true], + [boolean, '1', true], + [boolean, 0, false], + [number, 5, 5], + [number, '5', 5], + [number, '0.5', 0.5], + [string, '0123', '0123'], + [string, 'a,b', 'a,b'], + [id, 'device1', 'device1'], + [datetime, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'], + [enumParam, 'august_lock', 'august_lock'], + [list, 'a,b', ['a', 'b']], + [list, 'a', ['a']], + [list, ['a', 'b'], ['a', 'b']], + [list, 5, ['5']], + [numberList, '1,2', [1, 2]], + [enumList, 'august_lock,schlage_lock', ['august_lock', 'schlage_lock']], + [object, '{"floor":2}', { floor: 2 }], +] as Array<[Parameter, unknown, unknown]>)( + 'coerceParam: %o given %o becomes %o', + (param, given, value) => { + expect(coerceParam(param, given)).toEqual({ value }) + }, +) + +test.each([ + [boolean, 'maybe', 'true or false'], + [boolean, 2, 'true or false'], + [number, 'five', 'a number'], + [number, '', 'a number'], + [number, true, 'a number'], + [enumParam, 'bogus', 'one of august_lock, schlage_lock'], + [numberList, '1,two', 'a list of numbers'], + [enumList, 'august_lock,bogus', 'a list of august_lock, schlage_lock'], + [object, 'not json', 'a JSON object'], + [object, '[1]', 'a JSON object'], + [string, ['a', 'b'], 'a single value'], +] as Array<[Parameter, unknown, string]>)( + 'coerceParam: %o rejects %o expecting %s', + (param, given, issue) => { + expect(coerceParam(param, given)).toEqual({ issue }) + }, +) + +test('coerceArgParams: coerces each argument by its own parameter', () => { + const { params, issues } = coerceArgParams([boolean, number, string], { + is_managed: 'true', + limit: '5', + code: '0123', + }) + + expect(issues).toEqual([]) + expect(params).toEqual({ is_managed: true, limit: 5, code: '0123' }) +}) + +test('coerceArgParams: passes unknown arguments through unchanged', () => { + const { params, issues } = coerceArgParams([number], { nope: 'x' }) + + expect(issues).toEqual([]) + expect(params).toEqual({ nope: 'x' }) +}) + +test('coerceArgParams: collects every issue at once', () => { + const { issues } = coerceArgParams([boolean, number], { + is_managed: 'maybe', + limit: 'five', + }) + + expect(issues).toEqual([ + { name: 'is_managed', given: 'maybe', expected: 'true or false' }, + { name: 'limit', given: 'five', expected: 'a number' }, + ]) +}) diff --git a/src/lib/args/coerce.ts b/src/lib/args/coerce.ts new file mode 100644 index 00000000..06e955eb --- /dev/null +++ b/src/lib/args/coerce.ts @@ -0,0 +1,158 @@ +import type { Parameter } from '@seamapi/blueprint' + +/** An argument whose value does not fit the parameter's documented format. */ +export interface CoercionIssue { + name: string + given: unknown + /** What the parameter takes, e.g., `a number`. */ + expected: string +} + +type Coerced = { value: unknown } | { issue: string } + +/** + * Read each argument as the JSON value its parameter documents: booleans and + * numbers become real booleans and numbers, lists split on commas, objects + * parse as JSON. The request body is then the same whether a value arrived + * as an argument, over stdin, or interactively. + * + * An argument naming no parameter passes through unchanged: unknown + * arguments are reported by `assertKnownArgs`, not silently dropped here. + */ +export const coerceArgParams = ( + parameters: Parameter[], + argParams: Record, +): { params: Record; issues: CoercionIssue[] } => { + const byName = new Map( + parameters.map((parameter) => [parameter.name, parameter]), + ) + + const params: Record = {} + const issues: CoercionIssue[] = [] + + for (const [name, given] of Object.entries(argParams)) { + const parameter = byName.get(name) + if (parameter == null) { + params[name] = given + continue + } + + const coerced = coerceParam(parameter, given) + if ('issue' in coerced) { + issues.push({ name, given, expected: coerced.issue }) + continue + } + params[name] = coerced.value + } + + return { params, issues } +} + +export const coerceParam = (parameter: Parameter, given: unknown): Coerced => { + if (parameter.format === 'list') return coerceList(parameter, given) + + // A repeated argument parses as an array, which only a list accepts. + if (Array.isArray(given)) return { issue: 'a single value' } + + switch (parameter.format) { + case 'boolean': + return coerceBoolean(given) + case 'number': + return coerceNumber(given) + case 'enum': + return coerceEnum(parameter, given) + case 'object': + return coerceObject(given) + default: + return { value: String(given) } + } +} + +const coerceBoolean = (given: unknown): Coerced => { + if (given === true || given === 'true' || given === '1' || given === 1) { + return { value: true } + } + if (given === false || given === 'false' || given === '0' || given === 0) { + return { value: false } + } + return { issue: 'true or false' } +} + +const coerceNumber = (given: unknown): Coerced => { + if (typeof given === 'number') return { value: given } + if (typeof given === 'string' && given.trim() !== '') { + const value = Number(given) + if (!Number.isNaN(value)) return { value } + } + return { issue: 'a number' } +} + +const coerceEnum = ( + parameter: Parameter & { format: 'enum' }, + given: unknown, +): Coerced => { + const value = String(given) + const names = parameter.values.map(({ name }) => name) + if (!names.includes(value)) return { issue: `one of ${names.join(', ')}` } + return { value } +} + +const coerceObject = (given: unknown): Coerced => { + if (typeof given !== 'string') return { issue: 'a JSON object' } + try { + const value: unknown = JSON.parse(given) + if (value == null || typeof value !== 'object' || Array.isArray(value)) { + return { issue: 'a JSON object' } + } + return { value } + } catch { + return { issue: 'a JSON object' } + } +} + +const coerceList = ( + parameter: Parameter & { format: 'list' }, + given: unknown, +): Coerced => { + const items = Array.isArray(given) + ? given + : typeof given === 'string' + ? given.split(',') + : [given] + + const values: unknown[] = [] + for (const item of items) { + const coerced = coerceListItem(parameter, item) + if ('issue' in coerced) return coerced + values.push(coerced.value) + } + return { value: values } +} + +const coerceListItem = ( + parameter: Parameter & { format: 'list' }, + item: unknown, +): Coerced => { + if (parameter.itemFormat === 'number') { + const coerced = coerceNumber(item) + if ('issue' in coerced) return { issue: 'a list of numbers' } + return coerced + } + + if (parameter.itemFormat === 'boolean') { + const coerced = coerceBoolean(item) + if ('issue' in coerced) return { issue: 'a list of true or false' } + return coerced + } + + if (parameter.itemFormat === 'enum') { + const value = String(item) + const names = parameter.itemEnumValues.map(({ name }) => name) + if (!names.includes(value)) { + return { issue: `a list of ${names.join(', ')}` } + } + return { value } + } + + return { value: String(item) } +} diff --git a/src/lib/util/cli-args.test.ts b/src/lib/args/parse.test.ts similarity index 99% rename from src/lib/util/cli-args.test.ts rename to src/lib/args/parse.test.ts index 8c6ca1a5..17007b5f 100644 --- a/src/lib/util/cli-args.test.ts +++ b/src/lib/args/parse.test.ts @@ -7,7 +7,7 @@ import { toArgName, toGivenArgName, toParameterName, -} from './cli-args.js' +} from './parse.js' // The CLI normalizes argument keys before checking them. const parse = (argv: string[]): ParsedArgs => { diff --git a/src/lib/util/cli-args.ts b/src/lib/args/parse.ts similarity index 71% rename from src/lib/util/cli-args.ts rename to src/lib/args/parse.ts index b5b54e47..5fc85dfa 100644 --- a/src/lib/util/cli-args.ts +++ b/src/lib/args/parse.ts @@ -37,40 +37,45 @@ export const cliFlags: string[] = [ 'version', ] -/** - * Thrown when the CLI needs input it cannot prompt for. - */ -export class NonInteractiveError extends Error { - override name = 'NonInteractiveError' -} - -/** - * Thrown when the arguments do not name something the CLI can run. - */ -export class UsageError extends Error { - override name = 'UsageError' - - /** What to run instead, reported after the message. */ - readonly hint: string - - constructor(message: string, { hint = '' }: { hint?: string } = {}) { - super(message) - this.hint = hint - } +export interface ParseCliArgsOptions { + /** + * Argument keys read exactly as given rather than by guessing at a type, + * e.g., every parameter of an endpoint that does not take a number or a + * boolean. Read as a number, an opaque value like an access code would + * lose leading zeroes or turn exponent notation into a digit string. + */ + stringKeys?: string[] } -export const parseCliArgs = (argv: string[]): ParsedArgs => +export const parseCliArgs = ( + argv: string[], + { stringKeys = [] }: ParseCliArgsOptions = {}, +): ParsedArgs => parseArgs(argv, { - // A page cursor is opaque, so keep it exactly as given: read as a number - // it would lose leading zeroes and turn exponent notation into a digit - // string, naming a page the API never issued. - string: ['code', 'page-cursor', 'page_cursor'], + // A page cursor and a code are opaque even before the endpoint's own + // parameter types are known, so always keep them exactly as given. + string: ['code', 'page-cursor', 'page_cursor', ...stringKeys], boolean: ['non-interactive', 'interactive', 'json'], // Deliberately not aliased to -n, which is reserved for a future // --dry-run flag. alias: { 'non-interactive': 'y', interactive: 'i' }, }) +/** + * The request params among the parsed arguments: every key normalized to + * the parameter it names, minus the flags that configure the CLI itself. + */ +export const toArgParams = (args: ParsedArgs): Record => { + const argParams: Record = {} + for (const [key, value] of Object.entries(args)) { + if (key === '_') continue + const name = toParameterName(key) + if (cliFlags.includes(name)) continue + argParams[name] = value + } + return argParams +} + export interface GetInteractivityOptions { /** * Whether there is a terminal to prompt on. diff --git a/src/lib/args/validate.test.ts b/src/lib/args/validate.test.ts new file mode 100644 index 00000000..21a11aa4 --- /dev/null +++ b/src/lib/args/validate.test.ts @@ -0,0 +1,78 @@ +import type { Parameter } from '@seamapi/blueprint' +import { expect, test } from 'vitest' + +import { assertKnownArgs, assertRequiredParams } from './validate.js' + +const parameters = [ + { name: 'device_id', isRequired: true, format: 'id' }, + { name: 'code', isRequired: true, format: 'string' }, + { name: 'name', isRequired: false, format: 'string' }, +] as unknown as Parameter[] + +test('assertRequiredParams: passes when every required parameter is given', () => { + expect(() => { + assertRequiredParams( + parameters, + { device_id: 'device1', code: '1234' }, + '/access_codes/create', + ) + }).not.toThrow() +}) + +test('assertRequiredParams: names one missing parameter as its argument', () => { + expect(() => { + assertRequiredParams(parameters, { code: '1234' }, '/access_codes/create') + }).toThrow('Missing required parameter for /access_codes/create: --device-id') +}) + +test('assertRequiredParams: names every missing parameter at once', () => { + expect(() => { + assertRequiredParams(parameters, {}, '/access_codes/create') + }).toThrow( + 'Missing required parameters for /access_codes/create: --device-id --code', + ) +}) + +test('assertKnownArgs: passes when every argument is accepted', () => { + expect(() => { + assertKnownArgs({ limit: 5 }, ['devices', 'list'], { + accepted: new Set(['limit']), + isLocal: false, + }) + }).not.toThrow() +}) + +test('assertKnownArgs: names an endpoint command by its path', () => { + expect(() => { + assertKnownArgs({ limitt: 5 }, ['devices', 'list'], { + accepted: new Set(['limit']), + isLocal: false, + }) + }).toThrow('Unknown parameter for /devices/list: --limitt') +}) + +test('assertKnownArgs: names a CLI command by its words', () => { + expect(() => { + assertKnownArgs({ serverr: 'https://example.com' }, ['select', 'server'], { + accepted: new Set(['server']), + isLocal: true, + }) + }).toThrow('Unknown parameter for select server: --serverr') +}) + +test('assertKnownArgs: names every unknown argument at once, with a hint', () => { + try { + assertKnownArgs({ limitt: 5, n: true }, ['devices', 'list'], { + accepted: new Set(['limit']), + isLocal: false, + }) + expect.unreachable() + } catch (error: any) { + expect(error.message).toBe( + 'Unknown parameters for /devices/list: --limitt -n', + ) + expect(error.hint).toBe( + "Run 'seam devices list --help' to see what it accepts.", + ) + } +}) diff --git a/src/lib/args/validate.ts b/src/lib/args/validate.ts new file mode 100644 index 00000000..9221d670 --- /dev/null +++ b/src/lib/args/validate.ts @@ -0,0 +1,73 @@ +import type { Parameter } from '@seamapi/blueprint' + +import { NonInteractiveError, UsageError } from 'lib/errors.js' + +import { toArgName, toGivenArgName } from './parse.js' + +/** + * Report every required parameter still missing from the params, rather + * than prompting for it. + * + * @param target What the params are for, e.g., `/devices/list`. + */ +export const assertRequiredParams = ( + parameters: Parameter[], + params: Record, + target: string, +): void => { + // A required parameter is satisfied by being present, not by being + // truthy: `false`, `0` and `''` are values a caller can supply. + const missing = parameters + .filter((parameter) => parameter.isRequired) + .map((parameter) => parameter.name) + .filter((name) => params[name] === undefined) + + if (missing.length === 0) return + + throw new NonInteractiveError( + `Missing required ${ + missing.length === 1 ? 'parameter' : 'parameters' + } for ${target}: ${missing.map(toArgName).join(' ')}`, + ) +} + +/** + * Report any argument the command does not accept, rather than acting on it. + * An unrecognized argument is a mistake: forwarded to the API it would fail + * somewhere less obvious or be quietly ignored, and on a command the CLI + * handles itself it would go nowhere at all. + * + * Only arguments are checked. Params read from stdin are passed through as + * given, so a caller may send whatever the API itself accepts. + */ +export const assertKnownArgs = ( + argParams: Record, + command: string[], + { + accepted, + isLocal, + }: { + /** Parameter names the command accepts. */ + accepted: Set + /** Whether the CLI handles the command itself. */ + isLocal: boolean + }, +): void => { + const unknown = Object.keys(argParams).filter((key) => !accepted.has(key)) + if (unknown.length === 0) return + + // Name an endpoint command by its path, as missing params are named, and a + // command the CLI handles itself by the words that run it. + const target = isLocal + ? command.join(' ') + : `/${command.join('/').replace(/-/g, '_')}` + + throw new UsageError( + `Unknown ${ + unknown.length === 1 ? 'parameter' : 'parameters' + } for ${target}: ${unknown.map(toGivenArgName).join(' ')}`, + { + hint: `Run 'seam ${command.join(' ')} --help' to see what it accepts.`, + }, + ) +} diff --git a/src/lib/auth/operations.ts b/src/lib/auth/operations.ts new file mode 100644 index 00000000..1b968fe2 --- /dev/null +++ b/src/lib/auth/operations.ts @@ -0,0 +1,169 @@ +import { randomBytes } from 'node:crypto' + +import { type ConfigStore, getConfigStore } from 'lib/config/index.js' +import { type AuthContext, resolveAuth } from 'lib/context.js' +import { + assertEnvVarUnset, + endpointEnvVar, + tokenEnvVar, + workspaceIdEnvVar, +} from 'lib/env.js' + +import { validateToken } from './validate-token.js' + +/** A stored auth setting an environment variable may override. */ +export type AuthSetting = 'server' | 'token' | 'workspaceId' + +/** + * Refuse to store a setting the environment overrides. + * + * The env-override policy lives here alone: every auth mutation asserts + * through this before writing, so a command that appears to succeed cannot + * leave the CLI using something else. + * + * @param action What the command does, e.g., `log in`. + */ +export const assertMutable = ( + auth: AuthContext, + setting: AuthSetting, + action: string, +): void => { + const { envVar, source, value } = { + server: { + envVar: endpointEnvVar, + source: auth.serverSource, + value: auth.server, + }, + token: { envVar: tokenEnvVar, source: auth.tokenSource, value: auth.token }, + workspaceId: { + envVar: workspaceIdEnvVar, + source: auth.workspaceIdSource, + value: auth.workspaceId, + }, + }[setting] + + if (source !== 'env') return + assertEnvVarUnset(envVar, value, action) +} + +export interface LoginOptions { + server?: string | undefined + token?: string | undefined + workspaceId?: string | undefined +} + +/** + * Store the given credentials, validating the token first. + * + * The token is stored under the server it will be used with, so a given + * server is stored and re-resolved before the token key is derived. + * + * Validation reaches the network, so a test may inject its own `validate`. + */ +export const login = async ( + { server, token, workspaceId }: LoginOptions, + config: ConfigStore = getConfigStore(), + validate: typeof validateToken = validateToken, +): Promise => { + let auth = resolveAuth(config) + + // Nothing is stored while the environment overrides it, so refuse before + // storing anything rather than part way through. + assertMutable(auth, 'token', 'log in') + if (server != null) assertMutable(auth, 'server', 'select a server') + if (workspaceId != null) { + assertMutable(auth, 'workspaceId', 'select a workspace') + } + + if (server != null) { + config.set('server', server) + config.delete('current_workspace_id') + auth = resolveAuth(config) + } + + if (token != null) { + await validate(token, workspaceId) + config.set(`${auth.server}.pat`, token) + config.delete('current_workspace_id') + } + + if (workspaceId != null) { + config.set('current_workspace_id', workspaceId) + } +} + +/** Store the token for the current server, e.g., one just prompted for. */ +export const storeToken = ( + token: string, + config: ConfigStore = getConfigStore(), +): void => { + const auth = resolveAuth(config) + assertMutable(auth, 'token', 'log in') + config.set(`${auth.server}.pat`, token) +} + +/** Remove the stored token and workspace selection. */ +export const logout = (config: ConfigStore = getConfigStore()): void => { + const auth = resolveAuth(config) + assertMutable(auth, 'token', 'log out') + config.delete(`${auth.server}.pat`) + // Configs written before tokens were stored per server may still hold an + // un-namespaced token, so drop that too. + config.delete('pat') + config.delete('current_workspace_id') +} + +/** + * Store the server to make requests against. + * + * The workspace selection belongs to the previous server, so it is cleared. + */ +export const selectServer = ( + server: string, + config: ConfigStore = getConfigStore(), +): void => { + assertMutable(resolveAuth(config), 'server', 'select a server') + config.set('server', server) + config.delete('current_workspace_id') +} + +/** Store the workspace requests are made against. */ +export const selectWorkspace = ( + workspaceId: string, + config: ConfigStore = getConfigStore(), +): void => { + assertMutable(resolveAuth(config), 'workspaceId', 'select a workspace') + config.set('current_workspace_id', workspaceId) +} + +/** + * Point the CLI at a fake Seam Connect server and store the well-known + * token it accepts. Returns the generated server URL for reporting. + */ +export const selectFakeServer = ({ + urlSeed = randomBytes(5).toString('hex'), + config = getConfigStore(), +}: { + urlSeed?: string + config?: ConfigStore +} = {}): { server: string; token: string } => { + const auth = resolveAuth(config) + assertMutable(auth, 'server', 'select a server') + assertMutable(auth, 'token', 'log in') + + const server = `https://${urlSeed}.fakeseamconnect.seam.vc` + const token = 'seam_apikey1_token' + config.set('server', server) + config.set(`${server}.pat`, token) + config.delete('current_workspace_id') + + return { server, token } +} + +/** Store whether API definitions come from the server instead of npm. */ +export const setUseRemoteApiDefs = ( + useRemoteApiDefs: boolean, + config: ConfigStore = getConfigStore(), +): void => { + config.set('use_remote_api_defs', useRemoteApiDefs) +} diff --git a/src/lib/validate-token.ts b/src/lib/auth/validate-token.ts similarity index 87% rename from src/lib/validate-token.ts rename to src/lib/auth/validate-token.ts index df8e4177..394af062 100644 --- a/src/lib/validate-token.ts +++ b/src/lib/auth/validate-token.ts @@ -5,10 +5,10 @@ import { SeamHttpWithoutWorkspace, } from '@seamapi/http/connect' -import { getServer } from './get-server.js' +import { resolveAuth } from 'lib/context.js' export const validateToken = async (token: string, workspaceId?: string) => { - const options = { endpoint: getServer() } + const options = { endpoint: resolveAuth().server } if (isPersonalAccessToken(token)) { const seam = workspaceId diff --git a/src/lib/blueprint/cache.ts b/src/lib/blueprint/cache.ts new file mode 100644 index 00000000..ec2d7b70 --- /dev/null +++ b/src/lib/blueprint/cache.ts @@ -0,0 +1,97 @@ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import type { Blueprint } from '@seamapi/blueprint' + +import { seamapiBlueprintVersion } from 'lib/version.js' + +const cacheFileName = 'blueprint.json' +const updateCheckInterval = 24 * 60 * 60 * 1000 + +export interface BlueprintCache { + blueprintVersion: string + typesVersion: string + checkedAt: string + blueprint: Blueprint +} + +export const getCacheFile = (cacheDirectory: string): string => + join(cacheDirectory, cacheFileName) + +export const readCache = async ( + file: string, +): Promise => { + try { + const contents = await readFile(file, 'utf8') + const cache = JSON.parse(contents) as unknown + if (!isBlueprintCache(cache)) return null + return cache + } catch { + return null + } +} + +export const writeCache = async ( + file: string, + cache: BlueprintCache, +): Promise => { + const temporaryFile = `${file}.tmp` + await mkdir(dirname(file), { recursive: true }) + await writeFile(temporaryFile, `${JSON.stringify(cache)}\n`, 'utf8') + await rename(temporaryFile, file) +} + +export const isUpdateCheckDue = (checkedAt: string): boolean => { + const checkedAtTime = Date.parse(checkedAt) + if (Number.isNaN(checkedAtTime)) return true + return Date.now() - checkedAtTime > updateCheckInterval +} + +/** + * The blueprint version the cache is keyed on: a cached blueprint built by a + * different @seamapi/blueprint version is stale even for the same types. + */ +export const getBlueprintVersion = async (): Promise => { + if (seamapiBlueprintVersion !== '0.0.0') return seamapiBlueprintVersion + + // The version is only injected when the package is packed, so a + // development checkout reads the pinned version from package.json + // to keep invalidating the cache on version changes as expected. + const pkg = await findOwnPackageJson() + return pkg?.dependencies?.['@seamapi/blueprint'] ?? seamapiBlueprintVersion +} + +const findOwnPackageJson = async (): Promise<{ + dependencies?: Record +} | null> => { + let directory = dirname(fileURLToPath(import.meta.url)) + while (true) { + try { + const contents = await readFile(join(directory, 'package.json'), 'utf8') + const pkg = JSON.parse(contents) as { + name?: string + dependencies?: Record + } + if (pkg.name === '@seamapi/cli') return pkg + } catch { + // Keep walking up until a package.json for this package is found. + } + const parent = dirname(directory) + if (parent === directory) return null + directory = parent + } +} + +const isBlueprintCache = (cache: unknown): cache is BlueprintCache => { + if (cache == null || typeof cache !== 'object') return false + const { blueprintVersion, typesVersion, checkedAt, blueprint } = + cache as Record + return ( + typeof blueprintVersion === 'string' && + typeof typesVersion === 'string' && + typeof checkedAt === 'string' && + blueprint != null && + typeof blueprint === 'object' + ) +} diff --git a/src/lib/get-response-key.ts b/src/lib/blueprint/endpoint.ts similarity index 52% rename from src/lib/get-response-key.ts rename to src/lib/blueprint/endpoint.ts index 7f0cb1ef..afb34d0a 100644 --- a/src/lib/get-response-key.ts +++ b/src/lib/blueprint/endpoint.ts @@ -1,5 +1,19 @@ -import { getCommandBlueprintDef } from './get-command-blueprint-def.js' -import type { ContextHelpers } from './types.js' +import type { ApiBlueprint } from './index.js' + +export const getCommandBlueprintDef = ( + cmd: string[], + helpers: { blueprint: ApiBlueprint }, +) => { + const path = `/${cmd.join('/').replace(/-/g, '_')}` + const def = helpers.blueprint.routes + .flatMap((route) => route.endpoints) + .find((endpoint) => endpoint.path === path) + if (!def) { + throw new Error(`No definition for path ${path}`) + } + + return def +} /** * The top level response key documented for a command, @@ -10,7 +24,7 @@ import type { ContextHelpers } from './types.js' */ export const getResponseKey = ( command: string[], - ctx: ContextHelpers, + ctx: { blueprint: ApiBlueprint }, ): string | null => { let endpoint try { diff --git a/src/lib/blueprint/index.ts b/src/lib/blueprint/index.ts new file mode 100644 index 00000000..7d050869 --- /dev/null +++ b/src/lib/blueprint/index.ts @@ -0,0 +1,27 @@ +import type { Blueprint } from '@seamapi/blueprint' + +import { getBlueprint } from './source-npm.js' +import { createRemoteBlueprint } from './source-remote.js' + +export type ApiBlueprint = Blueprint + +export interface GetApiBlueprintOptions { + /** + * Build from the OpenAPI document the configured server is currently + * running, instead of the published npm types. + */ + useRemoteDefinitions?: boolean + /** Force an update of the cached Seam API definitions. */ + update?: boolean +} + +export const getApiBlueprint = async ({ + useRemoteDefinitions = false, + update = false, +}: GetApiBlueprintOptions = {}): Promise => { + // Remote definitions describe whatever the server is currently running, so + // build them directly from the server's OpenAPI document. + if (useRemoteDefinitions) return await createRemoteBlueprint() + + return await getBlueprint({ update }) +} diff --git a/src/lib/blueprint.test.ts b/src/lib/blueprint/source-npm.test.ts similarity index 91% rename from src/lib/blueprint.test.ts rename to src/lib/blueprint/source-npm.test.ts index 1bda1454..0eec3421 100644 --- a/src/lib/blueprint.test.ts +++ b/src/lib/blueprint/source-npm.test.ts @@ -16,7 +16,7 @@ import { vi, } from 'vitest' -import getBlueprint from './blueprint.js' +import { getBlueprint } from './source-npm.js' const typesVersion = '1.985.0' const manifestUrl = 'https://registry.npmjs.org/@seamapi/types/latest' @@ -70,18 +70,25 @@ const seedCache = async ( ) } -const readCache = async (): Promise => - JSON.parse( - await readFile(join(cacheDirectory, 'blueprint.json'), 'utf8'), - ) as typeof seedCacheState +const readCache = async (): Promise => { + const contents = await readFile( + join(cacheDirectory, 'blueprint.json'), + 'utf8', + ) + return JSON.parse(contents) as typeof seedCacheState +} const hoursAgo = (hours: number): string => new Date(Date.now() - hours * 60 * 60 * 1000).toISOString() beforeAll(async () => { - const pkg = JSON.parse( - await readFile(new URL('../../package.json', import.meta.url), 'utf8'), - ) as { dependencies: Record } + const packageJson = await readFile( + new URL('../../../package.json', import.meta.url), + 'utf8', + ) + const pkg = JSON.parse(packageJson) as { + dependencies: Record + } pinnedBlueprintVersion = pkg.dependencies['@seamapi/blueprint'] ?? '' // Build a @seamapi/types package tarball from the locally installed @@ -106,9 +113,11 @@ beforeAll(async () => { try { stubRegistry() await getBlueprint({ cacheDirectory: seedDirectory }) - seedCacheState = JSON.parse( - await readFile(join(seedDirectory, 'blueprint.json'), 'utf8'), - ) as typeof seedCacheState + const seedContents = await readFile( + join(seedDirectory, 'blueprint.json'), + 'utf8', + ) + seedCacheState = JSON.parse(seedContents) as typeof seedCacheState } finally { vi.unstubAllGlobals() await rm(seedDirectory, { recursive: true, force: true }) diff --git a/src/lib/blueprint.ts b/src/lib/blueprint/source-npm.ts similarity index 59% rename from src/lib/blueprint.ts rename to src/lib/blueprint/source-npm.ts index cb68a8de..9ca623d3 100644 --- a/src/lib/blueprint.ts +++ b/src/lib/blueprint/source-npm.ts @@ -1,52 +1,46 @@ -import { - access, - mkdir, - readFile, - rename, - rm, - writeFile, -} from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' +import { access, mkdir, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' import type { Blueprint, TypesModuleInput } from '@seamapi/blueprint' import envPaths from 'env-paths' import { extract } from 'tar' -import { withLoading } from './util/with-loading.js' -import { seamapiBlueprintVersion } from './version.js' +import { withLoading } from 'lib/output/with-loading.js' + +import { + getBlueprintVersion, + getCacheFile, + isUpdateCheckDue, + readCache, + writeCache, +} from './cache.js' const typesPackageName = '@seamapi/types' const openapiTarEntryName = 'package/lib/seam/connect/openapi.js' const registryUrl = 'https://registry.npmjs.org' -const updateCheckInterval = 24 * 60 * 60 * 1000 - -const cacheFileName = 'blueprint.json' - -interface BlueprintCache { - blueprintVersion: string - typesVersion: string - checkedAt: string - blueprint: Blueprint -} interface TypesPackageManifest { version: string dist: { tarball: string } } -interface GetBlueprintOptions { +export interface GetBlueprintOptions { update?: boolean cacheDirectory?: string } -const getBlueprint = async ( +/** + * Build a blueprint from the latest published Seam API types on npm, + * using the on-disk cache unless it is stale or an update is forced. + */ +export const getBlueprint = async ( options: GetBlueprintOptions = {}, ): Promise => { const update = options.update ?? false const cacheDirectory = options.cacheDirectory ?? envPaths('seam', { suffix: '' }).cache - const cacheFile = join(cacheDirectory, cacheFileName) + const cacheFile = getCacheFile(cacheDirectory) const blueprintVersion = await getBlueprintVersion() const cache = await readCache(cacheFile) @@ -100,41 +94,6 @@ const getBlueprint = async ( return blueprint } -const getBlueprintVersion = async (): Promise => { - if (seamapiBlueprintVersion !== '0.0.0') return seamapiBlueprintVersion - - // The version is only injected when the package is packed, so a - // development checkout reads the pinned version from package.json - // to keep invalidating the cache on version changes as expected. - const pkg = await findOwnPackageJson() - return pkg?.dependencies?.['@seamapi/blueprint'] ?? seamapiBlueprintVersion -} - -const findOwnPackageJson = async (): Promise<{ - dependencies?: Record -} | null> => { - let directory = dirname(fileURLToPath(import.meta.url)) - while (true) { - try { - const pkg = JSON.parse( - await readFile(join(directory, 'package.json'), 'utf8'), - ) as { name?: string; dependencies?: Record } - if (pkg.name === '@seamapi/cli') return pkg - } catch { - // Keep walking up until a package.json for this package is found. - } - const parent = dirname(directory) - if (parent === directory) return null - directory = parent - } -} - -const isUpdateCheckDue = (checkedAt: string): boolean => { - const checkedAtTime = Date.parse(checkedAt) - if (Number.isNaN(checkedAtTime)) return true - return Date.now() - checkedAtTime > updateCheckInterval -} - const fetchLatestTypesPackageManifest = async (): Promise => { const res = await fetch(`${registryUrl}/${typesPackageName}/latest`, { @@ -144,7 +103,8 @@ const fetchLatestTypesPackageManifest = if (!res.ok) { throw new Error(`npm registry responded with status ${res.status}`) } - const manifest = (await res.json()) as Partial + const body = await res.json() + const manifest = body as Partial if ( typeof manifest.version !== 'string' || typeof manifest.dist?.tarball !== 'string' @@ -181,21 +141,24 @@ const downloadOpenapi = async ( await rm(extractDirectory, { recursive: true, force: true }) await mkdir(extractDirectory, { recursive: true }) try { - await writeFile(tarballFile, Buffer.from(await res.arrayBuffer())) + const tarball = await res.arrayBuffer() + await writeFile(tarballFile, Buffer.from(tarball)) await extract({ file: tarballFile, cwd: extractDirectory }, [ openapiTarEntryName, ]) const moduleFile = join(extractDirectory, openapiTarEntryName) - if (!(await exists(moduleFile))) { + const moduleFileExists = await exists(moduleFile) + if (!moduleFileExists) { throw new Error(`Missing ${openapiTarEntryName} in package tarball`) } // The OpenAPI document is published as a JavaScript module, so import it. const openapiModuleUrl = pathToFileURL(moduleFile).href - const { default: openapi } = (await import(openapiModuleUrl)) as { + const openapiModule = (await import(openapiModuleUrl)) as { default: unknown } + const { default: openapi } = openapiModule if (openapi == null) { throw new Error(`Missing default export in ${openapiTarEntryName}`) } @@ -214,40 +177,5 @@ const exists = async (file: string): Promise => { } } -const readCache = async (file: string): Promise => { - try { - const cache = JSON.parse(await readFile(file, 'utf8')) as unknown - if (!isBlueprintCache(cache)) return null - return cache - } catch { - return null - } -} - -const writeCache = async ( - file: string, - cache: BlueprintCache, -): Promise => { - const temporaryFile = `${file}.tmp` - await mkdir(dirname(file), { recursive: true }) - await writeFile(temporaryFile, `${JSON.stringify(cache)}\n`, 'utf8') - await rename(temporaryFile, file) -} - -const isBlueprintCache = (cache: unknown): cache is BlueprintCache => { - if (cache == null || typeof cache !== 'object') return false - const { blueprintVersion, typesVersion, checkedAt, blueprint } = - cache as Record - return ( - typeof blueprintVersion === 'string' && - typeof typesVersion === 'string' && - typeof checkedAt === 'string' && - blueprint != null && - typeof blueprint === 'object' - ) -} - const toErrorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error) - -export default getBlueprint diff --git a/src/lib/blueprint/source-remote.ts b/src/lib/blueprint/source-remote.ts new file mode 100644 index 00000000..6989a1cf --- /dev/null +++ b/src/lib/blueprint/source-remote.ts @@ -0,0 +1,17 @@ +import type { Blueprint } from '@seamapi/blueprint' + +import { resolveAuth } from 'lib/context.js' + +/** + * Build a blueprint from the OpenAPI document the current server is running, + * describing exactly what that server accepts rather than what is published. + */ +export const createRemoteBlueprint = async (): Promise => { + const [{ createBlueprint }, { getOpenapiSchema }] = await Promise.all([ + import('@seamapi/blueprint'), + import('@seamapi/http/connect'), + ]) + const openapi = await getOpenapiSchema(resolveAuth().server) + + return await createBlueprint({ openapi }, { omitUndocumented: true }) +} diff --git a/src/lib/commands/api-command.ts b/src/lib/commands/api-command.ts new file mode 100644 index 00000000..d87913e6 --- /dev/null +++ b/src/lib/commands/api-command.ts @@ -0,0 +1,109 @@ +import { isDeepStrictEqual as isEqual } from 'node:util' + +import { coerceArgParams } from 'lib/args/coerce.js' +import { parseCliArgs, toArgName, toArgParams } from 'lib/args/parse.js' +import { assertRequiredParams } from 'lib/args/validate.js' +import { + getCommandBlueprintDef, + getResponseKey, +} from 'lib/blueprint/endpoint.js' +import type { CliContext } from 'lib/context.js' +import { UsageError } from 'lib/errors.js' +import { runResponseFollowUps } from 'lib/http/follow-ups.js' +import { requestSeamApi } from 'lib/http/request.js' +import { interactForCommandParams } from 'lib/interactions/index.js' + +import type { CommandResult, Invocation } from './registry.js' + +/** + * Run a command that calls a Seam API endpoint: assemble the params, + * prompt for what is missing, make the request, and run any follow-ups + * the response calls for. + */ +export const executeApiCommand = async ( + invocation: Invocation, + ctx: CliContext, +): Promise => { + const { path } = invocation + const isNonInteractive = ctx.interactivity === 'non-interactive' + const apiPath = `/${path.join('/').replace(/-/g, '_')}` + + const parameters = getCommandBlueprintDef(path, ctx).request.parameters + + // Re-read the arguments knowing the endpoint's own parameter types — the + // generic first parse guessed, mangling opaque values like access codes — + // then coerce each value to the JSON type its parameter documents. + const stringKeys = parameters + .filter(({ format }) => format !== 'boolean' && format !== 'number') + .flatMap(({ name }) => [name, name.replace(/_/g, '-')]) + const { params: argParams, issues } = coerceArgParams( + parameters, + toArgParams(parseCliArgs(invocation.argv, { stringKeys })), + ) + + if (issues.length > 0) { + throw new UsageError( + `Invalid ${ + issues.length === 1 ? 'value' : 'values' + } for ${apiPath}: ${issues + .map(({ name, expected }) => `${toArgName(name)} expects ${expected}`) + .join('; ')}`, + { + hint: `Run 'seam ${path.join(' ')} --help' to see what it accepts.`, + }, + ) + } + + // Params given as arguments win over params piped in. + const commandParams: Record = { ...invocation.stdinParams } + Object.assign(commandParams, argParams) + + applyEndpointDefaults(path, commandParams) + + // Non-interactive runs never prompt: validate and send what was given. + let params: Record + if (isNonInteractive) { + assertRequiredParams(parameters, commandParams, apiPath) + params = commandParams + } else { + const edited = await interactForCommandParams( + { command: path, params: commandParams }, + ctx, + ) + + if (edited === '[Back]') { + return { kind: 'back', toPath: path.slice(0, -1) } + } + params = edited + } + + if (apiPath.includes('/events/list') && params['between']) { + delete params['since'] + } + + const api = await ctx.api() + const body = await requestSeamApi( + { path: apiPath, params, responseKey: getResponseKey(path, ctx) }, + { api, output: ctx.output }, + ) + + await runResponseFollowUps(body, ctx) + + return { kind: 'done' } +} + +/** + * Per-endpoint request policy that is not derivable from the API + * definitions. Keep this table small and explicit. + */ +const applyEndpointDefaults = ( + path: string[], + params: Record, +): void => { + // Unbounded event lists are never wanted, so default to the last month. + if (isEqual(path, ['events', 'list']) && !params['since']) { + const date = new Date() + date.setMonth(date.getMonth() - 1) + params['since'] = date.toISOString() + } +} diff --git a/src/lib/commands/local/completion.ts b/src/lib/commands/local/completion.ts new file mode 100644 index 00000000..729f3a28 --- /dev/null +++ b/src/lib/commands/local/completion.ts @@ -0,0 +1,50 @@ +import { getApiBlueprint } from 'lib/blueprint/index.js' +import type { Command } from 'lib/commands/registry.js' +import { getOutput } from 'lib/output/get-output.js' +import { + type CompletionShell, + renderCompletion, +} from 'lib/render/completion/index.js' + +/** + * Print the completion script for a shell. + * + * Completions always come from the cached API definitions so that they can + * be generated without logging in. They may lag the definitions served by + * Seam when config use-remote-api-defs is enabled. + * + * Called by the entry before any auth or blueprint context exists, and by + * the registered command's executor — one implementation for both. + */ +export const printCompletion = async ( + shell: CompletionShell, + { update = false }: { update?: boolean } = {}, +): Promise => { + // Deferred import: the registry lists this module's commands, so a static + // import back into it would be a cycle. + const { buildRegistry } = await import('lib/commands/registry.js') + const blueprint = await getApiBlueprint({ update }) + const { spec } = buildRegistry(blueprint) + getOutput().text(renderCompletion(shell, spec)) +} + +const completionCommand = (shell: CompletionShell): Command => ({ + definition: { + path: ['completion', shell], + kind: 'cli', + title: `Print the ${shell} completion script.`, + description: '', + flags: [], + }, + requiresAuth: false, + execute: async ({ args }) => { + await printCompletion(shell, { update: args['update'] === true }) + return { kind: 'done' } + }, +}) + +export const completionCommands: Command[] = [ + completionCommand('bash'), + completionCommand('fish'), + completionCommand('zsh'), +] diff --git a/src/lib/commands/local/config-reveal-location.ts b/src/lib/commands/local/config-reveal-location.ts new file mode 100644 index 00000000..f7ae4ebf --- /dev/null +++ b/src/lib/commands/local/config-reveal-location.ts @@ -0,0 +1,16 @@ +import type { Command } from 'lib/commands/registry.js' + +export const configRevealLocationCommand: Command = { + definition: { + path: ['config', 'reveal-location'], + kind: 'cli', + title: 'Print the path to the CLI configuration file.', + description: '', + flags: [], + }, + requiresAuth: true, + execute: async (_invocation, ctx) => { + ctx.output.text(ctx.config.path) + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/local/config-set-fake-server.ts b/src/lib/commands/local/config-set-fake-server.ts new file mode 100644 index 00000000..d91cdc09 --- /dev/null +++ b/src/lib/commands/local/config-set-fake-server.ts @@ -0,0 +1,21 @@ +import { selectFakeServer } from 'lib/auth/operations.js' +import type { Command } from 'lib/commands/registry.js' + +/** Hidden: a development shortcut, kept out of help and completion. */ +export const configSetFakeServerCommand: Command = { + definition: { + path: ['config', 'set', 'fake-server'], + kind: 'cli', + title: 'Point the CLI at a fake Seam Connect server.', + description: '', + flags: [], + }, + requiresAuth: false, + hidden: true, + execute: async (_invocation, ctx) => { + const { server } = selectFakeServer({ config: ctx.config }) + ctx.output.info(`Server URL set to ${server}`) + ctx.output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/local/config-use-remote-api-defs.ts b/src/lib/commands/local/config-use-remote-api-defs.ts new file mode 100644 index 00000000..1c401a5b --- /dev/null +++ b/src/lib/commands/local/config-use-remote-api-defs.ts @@ -0,0 +1,23 @@ +import type { Command } from 'lib/commands/registry.js' +import { NonInteractiveError } from 'lib/errors.js' +import { interactForUseRemoteApiDefs } from 'lib/interactions/index.js' + +export const configUseRemoteApiDefsCommand: Command = { + definition: { + path: ['config', 'use-remote-api-defs'], + kind: 'cli', + title: 'Choose whether to use the API definitions served by Seam.', + description: '', + flags: [], + }, + requiresAuth: true, + execute: async (_invocation, ctx) => { + if (ctx.interactivity === 'non-interactive') { + throw new NonInteractiveError( + 'Cannot select whether to use remote API definitions in non-interactive mode', + ) + } + await interactForUseRemoteApiDefs() + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/local/health.ts b/src/lib/commands/local/health.ts new file mode 100644 index 00000000..6ca942a6 --- /dev/null +++ b/src/lib/commands/local/health.ts @@ -0,0 +1,22 @@ +import type { Command } from 'lib/commands/registry.js' +import { requestSeamApi } from 'lib/http/request.js' + +export const healthCommand: Command = { + definition: { + path: ['health', 'get-health'], + // Handled by the CLI itself, but calls the Seam API. + kind: 'api', + title: 'Report the health of the Seam API.', + description: '', + flags: [], + }, + requiresAuth: true, + execute: async (_invocation, ctx) => { + const api = await ctx.api() + await requestSeamApi( + { path: '/health/get_health', params: {} }, + { api, output: ctx.output }, + ) + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/local/login.ts b/src/lib/commands/local/login.ts new file mode 100644 index 00000000..fda343f0 --- /dev/null +++ b/src/lib/commands/local/login.ts @@ -0,0 +1,42 @@ +import { assertMutable, login } from 'lib/auth/operations.js' +import type { Command } from 'lib/commands/registry.js' +import { stringFlag } from 'lib/commands/spec.js' +import { NonInteractiveError } from 'lib/errors.js' +import { interactForLogin } from 'lib/interactions/index.js' + +export const loginCommand: Command = { + definition: { + path: ['login'], + kind: 'cli', + title: 'Log in to Seam.', + description: + 'Prompts for a personal access token unless one is passed with --token.', + flags: [ + stringFlag('server', 'Seam API server to log in to.'), + stringFlag('token', 'Personal access token to log in with.'), + stringFlag('workspace-id', 'Workspace to select after logging in.'), + ], + }, + requiresAuth: false, + execute: async ({ args }, ctx) => { + if (args['token'] || args['workspace_id'] || args['server']) { + await login( + { + server: args['server'] ? args['server'] : undefined, + token: args['token'] ? String(args['token']).trim() : undefined, + workspaceId: args['workspace_id'] ? args['workspace_id'] : undefined, + }, + ctx.config, + ) + return { kind: 'done' } + } + assertMutable(ctx.auth, 'token', 'log in') + if (ctx.interactivity === 'non-interactive') { + throw new NonInteractiveError( + 'Missing required parameter for login: --token', + ) + } + await interactForLogin() + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/local/logout.ts b/src/lib/commands/local/logout.ts new file mode 100644 index 00000000..5a4cae45 --- /dev/null +++ b/src/lib/commands/local/logout.ts @@ -0,0 +1,18 @@ +import { logout } from 'lib/auth/operations.js' +import type { Command } from 'lib/commands/registry.js' + +export const logoutCommand: Command = { + definition: { + path: ['logout'], + kind: 'cli', + title: 'Log out of Seam.', + description: '', + flags: [], + }, + requiresAuth: true, + execute: async (_invocation, ctx) => { + logout(ctx.config) + ctx.output.info('Logged out!') + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/local/select-server.ts b/src/lib/commands/local/select-server.ts new file mode 100644 index 00000000..f98c9658 --- /dev/null +++ b/src/lib/commands/local/select-server.ts @@ -0,0 +1,30 @@ +import { assertMutable, selectServer } from 'lib/auth/operations.js' +import type { Command } from 'lib/commands/registry.js' +import { stringFlag } from 'lib/commands/spec.js' +import { NonInteractiveError } from 'lib/errors.js' +import { interactForServerSelection } from 'lib/interactions/index.js' + +export const selectServerCommand: Command = { + definition: { + path: ['select', 'server'], + kind: 'cli', + title: 'Select the Seam API server.', + description: '', + flags: [stringFlag('server', 'Seam API server to select.')], + }, + requiresAuth: false, + execute: async ({ args }, ctx) => { + assertMutable(ctx.auth, 'server', 'select a server') + if (args['server']) { + selectServer(args['server'], ctx.config) + return { kind: 'done' } + } + if (ctx.interactivity === 'non-interactive') { + throw new NonInteractiveError( + 'Missing required parameter for select server: --server', + ) + } + await interactForServerSelection() + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/local/select-workspace.ts b/src/lib/commands/local/select-workspace.ts new file mode 100644 index 00000000..237ceb64 --- /dev/null +++ b/src/lib/commands/local/select-workspace.ts @@ -0,0 +1,25 @@ +import { assertMutable } from 'lib/auth/operations.js' +import type { Command } from 'lib/commands/registry.js' +import { NonInteractiveError } from 'lib/errors.js' +import { interactForWorkspaceId } from 'lib/interactions/index.js' + +export const selectWorkspaceCommand: Command = { + definition: { + path: ['select', 'workspace'], + kind: 'cli', + title: 'Select the current workspace.', + description: '', + flags: [], + }, + requiresAuth: true, + execute: async (_invocation, ctx) => { + assertMutable(ctx.auth, 'workspaceId', 'select a workspace') + if (ctx.interactivity === 'non-interactive') { + throw new NonInteractiveError( + 'Cannot select a workspace in non-interactive mode: pass --workspace-id to "seam login"', + ) + } + await interactForWorkspaceId() + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/local/wizard.ts b/src/lib/commands/local/wizard.ts new file mode 100644 index 00000000..2c9caac0 --- /dev/null +++ b/src/lib/commands/local/wizard.ts @@ -0,0 +1,31 @@ +import type { Command } from 'lib/commands/registry.js' + +/** + * Run the Seam setup wizard. + * + * Intercepted by the entry before argument parsing so the wizard owns its + * own argv; the registered executor covers selecting it interactively. + */ +export const runWizard = async (argv: string[]): Promise => { + const { default: wizard } = await import('@seamapi/wizard') + await wizard({ + argv, + commandName: 'seam wizard', + }) +} + +export const wizardCommand: Command = { + definition: { + path: ['wizard'], + kind: 'cli', + title: 'Set up Seam in the current project.', + description: + 'Takes a project from zero to a working Seam integration. Run seam wizard --help for its own options.', + flags: [], + }, + requiresAuth: false, + execute: async () => { + await runWizard([]) + return { kind: 'done' } + }, +} diff --git a/src/lib/commands/registry.ts b/src/lib/commands/registry.ts new file mode 100644 index 00000000..19e77a6a --- /dev/null +++ b/src/lib/commands/registry.ts @@ -0,0 +1,123 @@ +import type { ParsedArgs } from 'minimist' + +import { toParameterName } from 'lib/args/parse.js' +import type { ApiBlueprint } from 'lib/blueprint/index.js' +import type { CliContext } from 'lib/context.js' + +import { executeApiCommand } from './api-command.js' +import { completionCommands } from './local/completion.js' +import { configRevealLocationCommand } from './local/config-reveal-location.js' +import { configSetFakeServerCommand } from './local/config-set-fake-server.js' +import { configUseRemoteApiDefsCommand } from './local/config-use-remote-api-defs.js' +import { healthCommand } from './local/health.js' +import { loginCommand } from './local/login.js' +import { logoutCommand } from './local/logout.js' +import { selectServerCommand } from './local/select-server.js' +import { selectWorkspaceCommand } from './local/select-workspace.js' +import { wizardCommand } from './local/wizard.js' +import { + type CommandDefinition, + type CommandSpec, + getCommandSpec, + isSamePath, +} from './spec.js' + +/** + * One invocable command: what it looks like to help, completion, and the + * interactive picker, whether it needs a login, and how to run it. Declaring + * the metadata and the executor together is what keeps them from drifting. + */ +export interface Command { + definition: CommandDefinition + /** Whether the command needs a token before it can do anything. */ + requiresAuth: boolean + /** Kept out of the spec, so out of help, completion, and the picker. */ + hidden?: boolean + execute: (invocation: Invocation, ctx: CliContext) => Promise +} + +/** Everything a single run of a command was given. */ +export interface Invocation { + path: string[] + /** Params given as arguments, held to what the command accepts. */ + argParams: Record + /** Params piped in as JSON, passed through as given. */ + stdinParams: Record + /** The full parsed arguments, for commands that read their own flags. */ + args: ParsedArgs + /** The raw argv, for commands that re-read arguments with their own types. */ + argv: string[] +} + +export type CommandResult = + | { kind: 'done' } + /** Navigate back to selecting a command under `toPath`. */ + | { kind: 'back'; toPath: string[] } + +export interface CommandRegistry { + /** The spec help, completion, and the interactive picker render. */ + spec: CommandSpec + find: (path: string[]) => Command | undefined +} + +/** + * Commands handled by the CLI itself, which have no endpoint in the + * blueprint. The single source of truth: the spec, the picker, and the + * dispatcher all consume this list. + */ +export const localCommands: Command[] = [ + ...completionCommands, + configRevealLocationCommand, + configSetFakeServerCommand, + configUseRemoteApiDefsCommand, + healthCommand, + loginCommand, + logoutCommand, + selectServerCommand, + selectWorkspaceCommand, + wizardCommand, +] + +/** Definitions shown in help, completion, and the picker. */ +export const localCommandDefinitions: CommandDefinition[] = localCommands + .filter((command) => command.hidden !== true) + .map((command) => command.definition) + +/** + * The local command going by a path, or `undefined` when the path is an + * endpoint or no command at all. Needs no blueprint, so the entry may check + * commands before any definitions are loaded. + */ +export const findLocalCommand = (path: string[]): Command | undefined => + localCommands.find((command) => isSamePath(command.definition.path, path)) + +/** Parameter names a command accepts as arguments. */ +export const acceptedParamsOf = (definition: CommandDefinition): Set => + new Set( + definition.flags.flatMap(({ long }) => + long == null ? [] : [toParameterName(long)], + ), + ) + +export const buildRegistry = (blueprint: ApiBlueprint): CommandRegistry => { + const spec = getCommandSpec(blueprint, localCommandDefinitions) + + const commands = new Map() + for (const definition of spec.commands) { + commands.set(definition.path.join(' '), { + definition, + requiresAuth: true, + execute: executeApiCommand, + }) + } + // Local commands win over a same-named endpoint, as the spec's dedupe does, + // and hidden ones are findable without being in the spec. + for (const command of localCommands) { + commands.set(command.definition.path.join(' '), command) + } + + return { + spec, + find: (path) => commands.get(path.join(' ')), + } +} diff --git a/src/lib/command-spec.ts b/src/lib/commands/spec.ts similarity index 71% rename from src/lib/command-spec.ts rename to src/lib/commands/spec.ts index f0ef0487..502a9215 100644 --- a/src/lib/command-spec.ts +++ b/src/lib/commands/spec.ts @@ -1,5 +1,7 @@ import type { Blueprint } from '@seamapi/blueprint' +import { firstSentence, toPlainText } from 'lib/render/text.js' + type Endpoint = Blueprint['routes'][number]['endpoints'][number] type Parameter = Endpoint['request']['parameters'][number] @@ -122,7 +124,15 @@ export const flagTokens = (flag: CommandFlag): string[] => { return tokens } -export const getCommandSpec = (blueprint: Blueprint): CommandSpec => { +/** + * Derive the command spec from the API definitions, merged with the commands + * the CLI declares itself (see `commands/registry.ts`, the single source of + * those declarations). + */ +export const getCommandSpec = ( + blueprint: Blueprint, + localCommands: CommandDefinition[] = [], +): CommandSpec => { const commands = sortByPath( dedupeByPath([ ...blueprint.routes @@ -151,22 +161,10 @@ export const findGroup = ( ): CommandGroup | undefined => spec.groups.find((group) => isSamePath(group.path, path)) -/** - * The definition of a command the CLI handles itself, or `undefined` when the - * path is an endpoint or no command at all. - * - * Unlike {@link findCommand} this needs no blueprint, since these commands are - * declared by the CLI rather than derived from the API definitions. - */ -export const findLocalCommand = ( - path: string[], -): CommandDefinition | undefined => - localCommands.find((command) => isSamePath(command.path, path)) - -const isSamePath = (a: string[], b: string[]): boolean => +export const isSamePath = (a: string[], b: string[]): boolean => a.length === b.length && a.every((word, index) => word === b[index]) -const stringFlag = (long: string, description: string): CommandFlag => ({ +export const stringFlag = (long: string, description: string): CommandFlag => ({ long, short: null, description, @@ -175,98 +173,6 @@ const stringFlag = (long: string, description: string): CommandFlag => ({ isRequired: false, }) -/** - * Commands handled by the CLI itself, which have no endpoint in the blueprint. - * - * Keep in sync with the command handling in `src/bin/cli.ts` and the extra - * commands offered by `interactForCommandSelection`. - */ -const localCommands: CommandDefinition[] = [ - { - path: ['completion', 'bash'], - kind: 'cli', - title: 'Print the bash completion script.', - description: '', - flags: [], - }, - { - path: ['completion', 'fish'], - kind: 'cli', - title: 'Print the fish completion script.', - description: '', - flags: [], - }, - { - path: ['completion', 'zsh'], - kind: 'cli', - title: 'Print the zsh completion script.', - description: '', - flags: [], - }, - { - path: ['config', 'reveal-location'], - kind: 'cli', - title: 'Print the path to the CLI configuration file.', - description: '', - flags: [], - }, - { - path: ['config', 'use-remote-api-defs'], - kind: 'cli', - title: 'Choose whether to use the API definitions served by Seam.', - description: '', - flags: [], - }, - { - path: ['health', 'get-health'], - kind: 'api', - title: 'Report the health of the Seam API.', - description: '', - flags: [], - }, - { - path: ['login'], - kind: 'cli', - title: 'Log in to Seam.', - description: - 'Prompts for a personal access token unless one is passed with --token.', - flags: [ - stringFlag('server', 'Seam API server to log in to.'), - stringFlag('token', 'Personal access token to log in with.'), - stringFlag('workspace-id', 'Workspace to select after logging in.'), - ], - }, - { - path: ['logout'], - kind: 'cli', - title: 'Log out of Seam.', - description: '', - flags: [], - }, - { - path: ['select', 'server'], - kind: 'cli', - title: 'Select the Seam API server.', - description: '', - flags: [stringFlag('server', 'Seam API server to select.')], - }, - { - path: ['select', 'workspace'], - kind: 'cli', - title: 'Select the current workspace.', - description: '', - flags: [], - }, - { - path: ['wizard'], - kind: 'cli', - title: 'Set up Seam in the current project.', - description: - 'Takes a project from zero to a working Seam integration. Run seam wizard --help for its own options.', - flags: [], - }, -] - const toCommandDefinition = (endpoint: Endpoint): CommandDefinition => { const description = toPlainText(endpoint.description) @@ -342,7 +248,11 @@ const toCommandGroups = (commands: CommandDefinition[]): CommandGroup[] => { // `seam devices` alongside `seam devices list`. Prefer the command // title, since it describes what running the name does. if (depth === command.path.length - 1) { - entries.set(name, { isCommand: true, kind, description: command.title }) + entries.set(name, { + isCommand: true, + kind, + description: command.title, + }) continue } @@ -397,16 +307,3 @@ const toCommandPath = (path: string): string[] => path.replace(/^\//, '').split('/').map(toFlagName) const toFlagName = (name: string): string => name.replace(/_/g, '-') - -/** Reduce documentation markdown to a single line of prose. */ -export const toPlainText = (markdown: string): string => - markdown - .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') - .replace(/[`*]/g, '') - .replace(/\s+/g, ' ') - .trim() - -export const firstSentence = (text: string): string => { - const [sentence] = text.split(/(?<=\.)\s/) - return sentence ?? text -} diff --git a/src/lib/config/config-store.ts b/src/lib/config/config-store.ts index 4223dc62..07c6fd7a 100644 --- a/src/lib/config/config-store.ts +++ b/src/lib/config/config-store.ts @@ -4,14 +4,48 @@ import Configstore from 'configstore' import envPaths from 'env-paths' import { migrateConfigStore } from './migrate.js' +import { isStateKey, mergeConfig, splitConfig } from './values.js' const configFileName = 'cli.json' const legacyConfigStoreId = 'seam-cli' -const currentWorkspaceIdKey = 'current_workspace_id' -const patKey = 'pat' const paths = envPaths('seam', { suffix: '' }) -export const getConfigStore = () => { +/** + * What a config store can do, regardless of where it keeps the values. + * + * The CLI reads and writes through this interface so a test may hand code an + * in-memory store (see `memory-config-store.ts`) instead of the real + * file-backed one. + */ +export interface ConfigStore { + readonly path: string + all: Record + readonly size: number + get: (key: string) => unknown + set: (key: string | Record, value?: unknown) => void + has: (key: string) => boolean + delete: (key: string) => void + clear: () => void +} + +let configStore: ConfigStore | null = null + +export const getConfigStore = (): ConfigStore => { + configStore ??= createConfigStore() + return configStore +} + +/** Replace the store, e.g., with an in-memory one for a test. */ +export const setConfigStore = (store: ConfigStore): void => { + configStore = store +} + +/** Drop the current store so the next read builds the real one. */ +export const resetConfigStore = (): void => { + configStore = null +} + +const createConfigStore = (): PersistentConfigStore => { const settingsStore = new Configstore(legacyConfigStoreId, undefined, { configPath: getConfigPath(), }) @@ -25,61 +59,10 @@ export const getConfigStore = () => { new Configstore(legacyConfigStoreId), ) - return new SeamConfigStore(settingsStore, stateStore) + return new PersistentConfigStore(settingsStore, stateStore) } -export const mergeConfig = ( - baseConfig: Record, - overrideConfig: Record, -): Record => { - const mergedConfig = { ...baseConfig } - - for (const [key, value] of Object.entries(overrideConfig)) { - const baseValue = mergedConfig[key] - mergedConfig[key] = - isRecord(baseValue) && isRecord(value) - ? mergeConfig(baseValue, value) - : value - } - - return mergedConfig -} - -export const splitConfig = ( - config: Record, -): { - settings: Record - state: Record -} => { - const settings: Record = {} - const state: Record = {} - - for (const [key, value] of Object.entries(config)) { - if (isStateKey(key)) { - state[key] = value - continue - } - - if (isRecord(value)) { - const splitValue = splitConfig(value) - if (Object.keys(splitValue.settings).length > 0) { - settings[key] = splitValue.settings - } - - if (Object.keys(splitValue.state).length > 0) { - state[key] = splitValue.state - } - - continue - } - - settings[key] = value - } - - return { settings, state } -} - -class SeamConfigStore { +export class PersistentConfigStore implements ConfigStore { readonly path: string constructor( @@ -145,14 +128,6 @@ const getStateConfigPath = (): string => { return join(paths.log, configFileName) } -const isStateKey = (key: string): boolean => { - return ( - key === currentWorkspaceIdKey || - key === patKey || - key.endsWith(`.${patKey}`) - ) -} - const isRecord = (value: unknown): value is Record => { return value != null && typeof value === 'object' && !Array.isArray(value) } diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts index b47a2819..380110c5 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -1 +1,8 @@ -export { getConfigStore } from './config-store.js' +export { + type ConfigStore, + getConfigStore, + type PersistentConfigStore, + resetConfigStore, + setConfigStore, +} from './config-store.js' +export { createMemoryConfigStore } from './memory-config-store.js' diff --git a/src/lib/config/memory-config-store.ts b/src/lib/config/memory-config-store.ts new file mode 100644 index 00000000..372d08df --- /dev/null +++ b/src/lib/config/memory-config-store.ts @@ -0,0 +1,62 @@ +import type { ConfigStore } from './config-store.js' + +/** + * A real {@link ConfigStore} held in memory, for tests. + * + * Keys are flat: the file-backed store nests dotted keys, but nothing reads + * a value back by a different spelling than it was written with. + */ +export class MemoryConfigStore implements ConfigStore { + readonly path = '/memory/cli.json' + + private readonly values: Map + + constructor(initialValues: Record = {}) { + this.values = new Map(Object.entries(initialValues)) + } + + get all(): Record { + return Object.fromEntries(this.values) + } + + set all(newValues: Record) { + this.values.clear() + for (const [key, value] of Object.entries(newValues)) { + this.values.set(key, value) + } + } + + get size(): number { + return this.values.size + } + + get(key: string): unknown { + return this.values.get(key) + } + + set(key: string | Record, value?: unknown): void { + if (typeof key === 'string') { + this.values.set(key, value) + return + } + for (const [configKey, configValue] of Object.entries(key)) { + this.values.set(configKey, configValue) + } + } + + has(key: string): boolean { + return this.values.has(key) + } + + delete(key: string): void { + this.values.delete(key) + } + + clear(): void { + this.values.clear() + } +} + +export const createMemoryConfigStore = ( + initialValues: Record = {}, +): ConfigStore => new MemoryConfigStore(initialValues) diff --git a/src/lib/config/migrate.ts b/src/lib/config/migrate.ts index d7c50ea1..09088fc3 100644 --- a/src/lib/config/migrate.ts +++ b/src/lib/config/migrate.ts @@ -2,7 +2,7 @@ import { existsSync, rmSync } from 'node:fs' import type Configstore from 'configstore' -import { mergeConfig, splitConfig } from './config-store.js' +import { mergeConfig, splitConfig } from './values.js' export const migrateConfigStore = ( settingsStore: Configstore, diff --git a/src/lib/config/values.ts b/src/lib/config/values.ts new file mode 100644 index 00000000..89931261 --- /dev/null +++ b/src/lib/config/values.ts @@ -0,0 +1,72 @@ +/** + * Config values as whole trees: merging two trees into one view, and + * splitting one tree into the settings file and the state file by key. + * Pure transforms shared by the persistent store and the legacy migration. + */ + +const currentWorkspaceIdKey = 'current_workspace_id' +const patKey = 'pat' + +/** Whether a key holds auth state rather than a setting. */ +export const isStateKey = (key: string): boolean => { + return ( + key === currentWorkspaceIdKey || + key === patKey || + key.endsWith(`.${patKey}`) + ) +} + +export const mergeConfig = ( + baseConfig: Record, + overrideConfig: Record, +): Record => { + const mergedConfig = { ...baseConfig } + + for (const [key, value] of Object.entries(overrideConfig)) { + const baseValue = mergedConfig[key] + mergedConfig[key] = + isRecord(baseValue) && isRecord(value) + ? mergeConfig(baseValue, value) + : value + } + + return mergedConfig +} + +export const splitConfig = ( + config: Record, +): { + settings: Record + state: Record +} => { + const settings: Record = {} + const state: Record = {} + + for (const [key, value] of Object.entries(config)) { + if (isStateKey(key)) { + state[key] = value + continue + } + + if (isRecord(value)) { + const splitValue = splitConfig(value) + if (Object.keys(splitValue.settings).length > 0) { + settings[key] = splitValue.settings + } + + if (Object.keys(splitValue.state).length > 0) { + state[key] = splitValue.state + } + + continue + } + + settings[key] = value + } + + return { settings, state } +} + +const isRecord = (value: unknown): value is Record => { + return value != null && typeof value === 'object' && !Array.isArray(value) +} diff --git a/src/lib/context.ts b/src/lib/context.ts new file mode 100644 index 00000000..5bef8eb6 --- /dev/null +++ b/src/lib/context.ts @@ -0,0 +1,84 @@ +import type { Interactivity } from './args/parse.js' +import type { ApiBlueprint } from './blueprint/index.js' +import { type ConfigStore, getConfigStore } from './config/index.js' +import { + getEndpointFromEnv, + getTokenFromEnv, + getWorkspaceIdFromEnv, +} from './env.js' +import type { SeamApi } from './http/api.js' +import type { Output } from './output/output.js' + +export const defaultServer = 'https://connect.getseam.com' + +/** Where a resolved value came from, e.g., to refuse writes the env shadows. */ +export type ValueSource = 'env' | 'config' | 'default' + +/** + * The server, token, and workspace requests are made with. + * + * Resolved in one place so the precedence rule exists once: an environment + * variable wins over the stored value, and the server falls back to Seam. + * The source tags say where each value came from. + */ +export interface AuthContext { + server: string + serverSource: ValueSource + token: string | null + tokenSource: Exclude | null + workspaceId: string | null + workspaceIdSource: Exclude | null +} + +export const resolveAuth = ( + config: ConfigStore = getConfigStore(), +): AuthContext => { + const envServer = getEndpointFromEnv() + const storedServer = config.get('server') + const server = + envServer ?? (typeof storedServer === 'string' ? storedServer : null) + + const envToken = getTokenFromEnv() + const storedToken = readString(config.get(`${server ?? defaultServer}.pat`)) + + const envWorkspaceId = getWorkspaceIdFromEnv() + const storedWorkspaceId = readString(config.get('current_workspace_id')) + + return { + server: server ?? defaultServer, + serverSource: + envServer != null ? 'env' : server != null ? 'config' : 'default', + token: envToken ?? storedToken, + tokenSource: + envToken != null ? 'env' : storedToken != null ? 'config' : null, + workspaceId: envWorkspaceId ?? storedWorkspaceId, + workspaceIdSource: + envWorkspaceId != null + ? 'env' + : storedWorkspaceId != null + ? 'config' + : null, + } +} + +/** + * Everything a command runs with: the stores and auth it reads, the API + * shape it acts on, and how it may interact with the user. + */ +export interface CliContext { + config: ConfigStore + auth: AuthContext + output: Output + blueprint: ApiBlueprint + interactivity: Interactivity + /** The Seam API, constructed on first use and shared for the run. */ + api: () => Promise +} + +const readString = (value: unknown): string | null => { + if (typeof value !== 'string') return null + + const trimmedValue = value.trim() + + return trimmedValue === '' ? null : trimmedValue +} diff --git a/src/lib/env.ts b/src/lib/env.ts index a109ec1b..a3e114aa 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -16,7 +16,7 @@ export const workspaceIdEnvVar = 'SEAM_CLI_WORKSPACE_ID' export const endpointEnvVar = 'SEAM_CLI_ENDPOINT' /** Every variable read here is declared on `ProcessEnv` in `env.d.ts`. */ -type SeamCliEnvVar = +type CliEnvVar = typeof endpointEnvVar | typeof tokenEnvVar | typeof workspaceIdEnvVar export const getTokenFromEnv = (): string | null => readEnvVar(tokenEnvVar) @@ -52,7 +52,14 @@ export const assertEnvVarUnset = ( ) } -const readEnvVar = (envVar: SeamCliEnvVar): string | null => { +/** + * Whether the CLI runs inside a hosted web terminal, where it cannot open + * anything in a browser of its own. + */ +export const isInsideWebBrowser = (): boolean => + process.env['INSIDE_WEB_BROWSER'] === '1' + +const readEnvVar = (envVar: CliEnvVar): string | null => { const value = process.env[envVar] if (value == null) return null diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 00000000..f9d930fc --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,67 @@ +import chalk from 'chalk' + +import { EnvVarOverrideError } from './env.js' +import type { Output } from './output/output.js' + +/** + * Thrown when the CLI needs input it cannot prompt for. + */ +export class NonInteractiveError extends Error { + override name = 'NonInteractiveError' +} + +/** + * Thrown when the user dismisses a prompt with ctrl-c or escape instead of + * answering it. + */ +export class PromptCancelledError extends Error { + constructor() { + super('Cancelled') + } +} + +/** + * Thrown when the arguments do not name something the CLI can run. + */ +export class UsageError extends Error { + override name = 'UsageError' + + /** What to run instead, reported after the message. */ + readonly hint: string + + constructor(message: string, { hint = '' }: { hint?: string } = {}) { + super(message) + this.hint = hint + } +} + +/** + * Report a failure and set the exit code: usage mistakes read as one line + * with a hint, environment overrides without a stack trace, and anything + * else as an unexpected CLI error. + */ +export const reportErrorAndExit = (e: unknown, output: Output): void => { + process.exitCode = 1 + + if (e instanceof UsageError) { + output.error(chalk.red(e.message)) + if (e.hint !== '') output.error(e.hint) + return + } + + if (e instanceof NonInteractiveError || e instanceof EnvVarOverrideError) { + output.error(chalk.red(e.message)) + return + } + + // Dismissing a prompt is the user stopping the CLI, not the CLI failing: + // note it quietly, without the alarm of an error. + if (e instanceof PromptCancelledError) { + output.error(chalk.gray(e.message)) + return + } + + const error = e instanceof Error ? e : new Error(String(e)) + output.error(chalk.red(`CLI Error: ${error.message}`)) + if (error.stack != null) output.error(chalk.gray(error.stack)) +} diff --git a/src/lib/get-api-blueprint.ts b/src/lib/get-api-blueprint.ts deleted file mode 100644 index 1568bf23..00000000 --- a/src/lib/get-api-blueprint.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Blueprint } from '@seamapi/blueprint' - -import getBlueprint from './blueprint.js' -import { getServer } from './get-server.js' - -export type ApiBlueprint = Blueprint - -export interface GetApiBlueprintOptions { - update?: boolean -} - -export const getApiBlueprint = async ( - useRemoteDefinitions: boolean, - options: GetApiBlueprintOptions = {}, -): Promise => { - // Remote definitions describe whatever the server is currently running, so - // build them directly from the server's OpenAPI document. - if (useRemoteDefinitions) return await createRemoteBlueprint() - - return await getBlueprint(options) -} - -const createRemoteBlueprint = async (): Promise => { - const [{ createBlueprint }, { getOpenapiSchema }] = await Promise.all([ - import('@seamapi/blueprint'), - import('@seamapi/http/connect'), - ]) - const openapi = await getOpenapiSchema(getServer()) - - return await createBlueprint({ openapi }, { omitUndocumented: true }) -} diff --git a/src/lib/get-command-blueprint-def.ts b/src/lib/get-command-blueprint-def.ts deleted file mode 100644 index 5d3178e4..00000000 --- a/src/lib/get-command-blueprint-def.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ContextHelpers } from './types.js' -export const getCommandBlueprintDef = ( - cmd: string[], - helpers: ContextHelpers, -) => { - const path = `/${cmd.join('/').replace(/-/g, '_')}` - const def = helpers.blueprint.routes - .flatMap((route) => route.endpoints) - .find((endpoint) => endpoint.path === path) - if (!def) { - throw new Error(`No definition for path ${path}`) - } - - return def -} diff --git a/src/lib/get-credentials.test.ts b/src/lib/get-credentials.test.ts deleted file mode 100644 index 9062fc03..00000000 --- a/src/lib/get-credentials.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { afterEach, beforeEach, expect, test, vi } from 'vitest' - -import { getConfigStore } from './config/index.js' -import { tokenEnvVar, workspaceIdEnvVar } from './env.js' -import { getToken, getWorkspaceId } from './get-credentials.js' - -const server = 'https://connect.example.com' - -const storedConfig: Record = {} - -vi.mock('./config/index.js', () => ({ - getConfigStore: vi.fn(() => ({ - get: (key: string) => storedConfig[key], - })), -})) - -vi.mock('./get-server.js', () => ({ - getServer: vi.fn(() => server), -})) - -const clearEnv = (): void => { - delete process.env[tokenEnvVar] - delete process.env[workspaceIdEnvVar] -} - -beforeEach(() => { - for (const key of Object.keys(storedConfig)) { - delete storedConfig[key] - } - clearEnv() -}) - -afterEach(() => { - clearEnv() - vi.mocked(getConfigStore).mockClear() -}) - -test('getToken: reads the token stored for the current server', () => { - storedConfig[`${server}.pat`] = 'seam_apikey1_stored' - - expect(getToken()).toBe('seam_apikey1_stored') -}) - -test(`getToken: ${tokenEnvVar} wins over the stored token`, () => { - storedConfig[`${server}.pat`] = 'seam_apikey1_stored' - process.env[tokenEnvVar] = 'seam_apikey1_env' - - expect(getToken()).toBe('seam_apikey1_env') -}) - -test(`getToken: ${tokenEnvVar} is used without a stored token`, () => { - process.env[tokenEnvVar] = 'seam_apikey1_env' - - expect(getToken()).toBe('seam_apikey1_env') -}) - -test(`getToken: ignores an empty ${tokenEnvVar}`, () => { - storedConfig[`${server}.pat`] = 'seam_apikey1_stored' - process.env[tokenEnvVar] = ' ' - - expect(getToken()).toBe('seam_apikey1_stored') -}) - -test('getToken: returns null when nothing is set', () => { - expect(getToken()).toBe(null) -}) - -test('getWorkspaceId: reads the stored workspace selection', () => { - storedConfig['current_workspace_id'] = 'workspace1' - - expect(getWorkspaceId()).toBe('workspace1') -}) - -test(`getWorkspaceId: ${workspaceIdEnvVar} wins over the stored selection`, () => { - storedConfig['current_workspace_id'] = 'workspace1' - process.env[workspaceIdEnvVar] = 'workspace2' - - expect(getWorkspaceId()).toBe('workspace2') -}) - -test(`getWorkspaceId: ${workspaceIdEnvVar} is used without a stored selection`, () => { - process.env[workspaceIdEnvVar] = 'workspace2' - - expect(getWorkspaceId()).toBe('workspace2') -}) - -test(`getWorkspaceId: ignores an empty ${workspaceIdEnvVar}`, () => { - storedConfig['current_workspace_id'] = 'workspace1' - process.env[workspaceIdEnvVar] = '' - - expect(getWorkspaceId()).toBe('workspace1') -}) - -test('getWorkspaceId: returns null when nothing is set', () => { - expect(getWorkspaceId()).toBe(null) -}) - -test('getToken and getWorkspaceId: either may be set on its own', () => { - storedConfig[`${server}.pat`] = 'seam_apikey1_stored' - storedConfig['current_workspace_id'] = 'workspace1' - process.env[workspaceIdEnvVar] = 'workspace2' - - expect(getToken()).toBe('seam_apikey1_stored') - expect(getWorkspaceId()).toBe('workspace2') -}) diff --git a/src/lib/get-credentials.ts b/src/lib/get-credentials.ts deleted file mode 100644 index 5e573654..00000000 --- a/src/lib/get-credentials.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { getConfigStore } from './config/index.js' -import { getTokenFromEnv, getWorkspaceIdFromEnv } from './env.js' -import { getServer } from './get-server.js' - -/** - * The token used to authenticate requests. - * - * `SEAM_CLI_TOKEN` wins over the token stored by `seam login`, - * so a token may be given per command or per shell without logging in. - */ -export const getToken = (): string | null => { - const token = getTokenFromEnv() - if (token != null) return token - - return readString(getConfigStore().get(`${getServer()}.pat`)) -} - -/** - * The workspace requests are made against. - * - * `SEAM_CLI_WORKSPACE_ID` wins over the workspace stored by - * `seam select workspace`. Returns `null` when neither is set: a token - * scoped to a single workspace does not need one. - */ -export const getWorkspaceId = (): string | null => { - const workspaceId = getWorkspaceIdFromEnv() - if (workspaceId != null) return workspaceId - - return readString(getConfigStore().get('current_workspace_id')) -} - -const readString = (value: unknown): string | null => { - if (typeof value !== 'string') return null - - const trimmedValue = value.trim() - - return trimmedValue === '' ? null : trimmedValue -} diff --git a/src/lib/get-current-workspace-id.ts b/src/lib/get-current-workspace-id.ts deleted file mode 100644 index 1ec2113d..00000000 --- a/src/lib/get-current-workspace-id.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { getWorkspaceId } from './get-credentials.js' -import { interactForWorkspaceId } from './interact-for-workspace-id.js' - -export const getCurrentWorkspaceId = async (): Promise => { - const currentWorkspaceId = getWorkspaceId() - if (currentWorkspaceId != null) return currentWorkspaceId - - return await interactForWorkspaceId() -} diff --git a/src/lib/get-server.test.ts b/src/lib/get-server.test.ts deleted file mode 100644 index f876bce3..00000000 --- a/src/lib/get-server.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { afterEach, beforeEach, expect, test, vi } from 'vitest' - -import { getConfigStore } from './config/index.js' -import { endpointEnvVar } from './env.js' -import { getServer } from './get-server.js' - -const storedConfig: Record = {} - -vi.mock('./config/index.js', () => ({ - getConfigStore: vi.fn(() => ({ - get: (key: string) => storedConfig[key], - })), -})) - -beforeEach(() => { - for (const key of Object.keys(storedConfig)) { - delete storedConfig[key] - } - delete process.env[endpointEnvVar] -}) - -afterEach(() => { - delete process.env[endpointEnvVar] - vi.mocked(getConfigStore).mockClear() -}) - -test('getServer: reads the stored server', () => { - storedConfig['server'] = 'https://connect.example.com' - - expect(getServer()).toBe('https://connect.example.com') -}) - -test('getServer: defaults to Seam', () => { - expect(getServer()).toBe('https://connect.getseam.com') -}) - -test(`getServer: ${endpointEnvVar} wins over the stored server`, () => { - storedConfig['server'] = 'https://connect.example.com' - process.env[endpointEnvVar] = 'http://localhost:3020' - - expect(getServer()).toBe('http://localhost:3020') -}) - -test(`getServer: ${endpointEnvVar} is used without a stored server`, () => { - process.env[endpointEnvVar] = 'http://localhost:3020' - - expect(getServer()).toBe('http://localhost:3020') -}) - -test(`getServer: ignores an empty ${endpointEnvVar}`, () => { - storedConfig['server'] = 'https://connect.example.com' - process.env[endpointEnvVar] = '' - - expect(getServer()).toBe('https://connect.example.com') -}) diff --git a/src/lib/get-server.ts b/src/lib/get-server.ts deleted file mode 100644 index 2c4521c7..00000000 --- a/src/lib/get-server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { getConfigStore } from './config/index.js' -import { getEndpointFromEnv } from './env.js' - -const defaultServer = 'https://connect.getseam.com' - -/** - * The Seam API server requests are made against. - * - * `SEAM_CLI_ENDPOINT` wins over the server stored by `seam select server`. - */ -export const getServer = (): string => { - const endpoint = getEndpointFromEnv() - if (endpoint != null) return endpoint - - const config = getConfigStore() - - const server = config.get('server') - - return typeof server === 'string' ? server : defaultServer -} diff --git a/src/lib/http/api.ts b/src/lib/http/api.ts new file mode 100644 index 00000000..7bfd5da4 --- /dev/null +++ b/src/lib/http/api.ts @@ -0,0 +1,61 @@ +import { type SeamHttp, SeamHttpRequest } from '@seamapi/http/connect' + +import type { AuthContext } from 'lib/context.js' + +import { getSeam } from './client.js' + +export interface ApiRequestOptions { + path: string + params: Record + /** Response key documented for the endpoint, e.g., `devices`. */ + responseKey?: string | null | undefined +} + +/** + * A prepared call to the Seam API: inspectable before it is sent, e.g., to + * report the URL, then sent with {@link SeamApiRequest.fetchResponse}. + * + * The real implementation is the SDK's own `SeamHttpRequest`; sending one + * rejects with a `SeamHttpApiError` when the API reports an error. + */ +export interface SeamApiRequest { + readonly url: URL + readonly method: string + readonly body: unknown + /** Send the request and return the full response body. */ + fetchResponse: () => Promise +} + +/** + * How the blueprint-driven CLI reaches the Seam API: prepare a request for + * an endpoint path. Tests fake at this port with `createMemorySeamApi()` — + * the in-process mirror of the e2e suite's HTTP server. + */ +export interface SeamApi { + createRequest: (options: ApiRequestOptions) => SeamApiRequest +} + +/** The only place `SeamHttp` appears for raw requests. */ +export class HttpSeamApi implements SeamApi { + constructor(private readonly seam: SeamHttp) {} + + createRequest = ({ + path, + params, + responseKey, + }: ApiRequestOptions): SeamApiRequest => + new SeamHttpRequest, string | undefined>( + this.seam, + { + pathname: path, + method: 'POST', + body: params, + responseKey: responseKey ?? undefined, + }, + ) +} + +export const createSeamApi = async (auth?: AuthContext): Promise => { + const seam = await getSeam(auth) + return new HttpSeamApi(seam) +} diff --git a/src/lib/get-seam.ts b/src/lib/http/client.ts similarity index 53% rename from src/lib/get-seam.ts rename to src/lib/http/client.ts index 7d252433..3e8d2efb 100644 --- a/src/lib/get-seam.ts +++ b/src/lib/http/client.ts @@ -5,19 +5,20 @@ import { SeamHttpWithoutWorkspace, } from '@seamapi/http/connect' -import { tokenEnvVar, workspaceIdEnvVar } from './env.js' -import { getToken, getWorkspaceId } from './get-credentials.js' -import { getServer } from './get-server.js' +import { type AuthContext, resolveAuth } from 'lib/context.js' +import { tokenEnvVar, workspaceIdEnvVar } from 'lib/env.js' -export const getSeam = async (): Promise => { - const token = getRequiredToken() +export const getSeam = async ( + auth: AuthContext = resolveAuth(), +): Promise => { + const token = getRequiredToken(auth) - const options = { endpoint: getServer() } + const options = { endpoint: auth.server } if (isPersonalAccessToken(token)) { return SeamHttp.fromPersonalAccessToken( token, - getRequiredWorkspaceId(), + getRequiredWorkspaceId(auth), options, ) } @@ -25,7 +26,7 @@ export const getSeam = async (): Promise => { if (isConsoleSessionToken(token)) { return SeamHttp.fromConsoleSessionToken( token, - getRequiredWorkspaceId(), + getRequiredWorkspaceId(auth), options, ) } @@ -33,21 +34,21 @@ export const getSeam = async (): Promise => { return SeamHttp.fromApiKey(token, options) } -export const getSeamMultiWorkspace = async (): Promise< - SeamHttpWithoutWorkspace | SeamHttp -> => { - const token = getRequiredToken() - const options = { endpoint: getServer() } +export const getSeamMultiWorkspace = async ( + auth: AuthContext = resolveAuth(), +): Promise => { + const token = getRequiredToken(auth) + const options = { endpoint: auth.server } if (isPersonalAccessToken(token)) { return SeamHttpWithoutWorkspace.fromPersonalAccessToken(token, options) } - return await getSeam() + return await getSeam(auth) } -const getRequiredToken = (): string => { - const token = getToken() +const getRequiredToken = (auth: AuthContext): string => { + const { token } = auth if (token == null) { throw new Error( @@ -58,8 +59,8 @@ const getRequiredToken = (): string => { return token } -const getRequiredWorkspaceId = (): string => { - const workspaceId = getWorkspaceId() +const getRequiredWorkspaceId = (auth: AuthContext): string => { + const { workspaceId } = auth if (workspaceId == null) { throw new Error( diff --git a/src/lib/http/follow-ups.ts b/src/lib/http/follow-ups.ts new file mode 100644 index 00000000..a262a98f --- /dev/null +++ b/src/lib/http/follow-ups.ts @@ -0,0 +1,42 @@ +import type { CliContext } from 'lib/context.js' +import { isInsideWebBrowser } from 'lib/env.js' +import { interactForActionAttemptPoll } from 'lib/interactions/index.js' +import { promptConfirm } from 'lib/prompt.js' + +/** + * Follow-ups a response may call for: opening a connect webview in the + * browser, and offering to poll a pending action attempt. + */ +export const runResponseFollowUps = async ( + data: any, + ctx: CliContext, +): Promise => { + const isNonInteractive = ctx.interactivity === 'non-interactive' + + if (data?.connect_webview) { + await handleConnectWebview(data.connect_webview, isNonInteractive) + } + + if (data?.action_attempt && !isNonInteractive) { + await interactForActionAttemptPoll(data.action_attempt) + } +} + +const handleConnectWebview = async ( + connectWebview: any, + isNonInteractive: boolean, +): Promise => { + const url = connectWebview.url + + if (!isNonInteractive && !isInsideWebBrowser()) { + const action = await promptConfirm({ + message: 'Would you like to open the webview in your browser?', + initialValue: false, + }) + + if (action) { + const { default: open } = await import('open') + await open(url) + } + } +} diff --git a/src/lib/http/memory-seam-api.ts b/src/lib/http/memory-seam-api.ts new file mode 100644 index 00000000..03cae5f9 --- /dev/null +++ b/src/lib/http/memory-seam-api.ts @@ -0,0 +1,57 @@ +import { + SeamHttpApiError, + SeamHttpInvalidInputError, +} from '@seamapi/http/connect' + +import type { ApiRequestOptions, SeamApi, SeamApiRequest } from './api.js' + +export interface MemorySeamApiResponse { + status: number + data: unknown +} + +/** + * A real {@link SeamApi} answering from a routes table and recording every + * request, for tests: the in-process mirror of the e2e suite's HTTP server. + * Error statuses reject with the SDK's own error classes, exactly as the + * real transport does. + */ +export class MemorySeamApi implements SeamApi { + /** Every request sent, in order — assert on the outbound messages. */ + readonly requests: Array<{ path: string; params: Record }> = + [] + + constructor(private readonly routes: Record) {} + + createRequest = ({ path, params }: ApiRequestOptions): SeamApiRequest => ({ + url: new URL(`https://memory.seam.example${path}`), + method: 'POST', + body: params, + fetchResponse: async () => { + this.requests.push({ path, params }) + + const route = this.routes[path] ?? { + status: 404, + data: { error: { type: 'not_found', message: 'Not Found' } }, + } + + if (route.status >= 400) { + throw toSeamHttpError(route) + } + return route.data + }, + }) +} + +const toSeamHttpError = (route: MemorySeamApiResponse): SeamHttpApiError => { + const error = (route.data as { error: { type: string; message: string } }) + .error + if (error.type === 'invalid_input') { + return new SeamHttpInvalidInputError(error, route.status, 'request_memory') + } + return new SeamHttpApiError(error, route.status, 'request_memory') +} + +export const createMemorySeamApi = ( + routes: Record, +): MemorySeamApi => new MemorySeamApi(routes) diff --git a/src/lib/http/request.ts b/src/lib/http/request.ts new file mode 100644 index 00000000..1b7a6496 --- /dev/null +++ b/src/lib/http/request.ts @@ -0,0 +1,69 @@ +import { + isSeamHttpApiError, + type SeamHttpApiError, +} from '@seamapi/http/connect' +import chalk from 'chalk' + +import type { Output } from 'lib/output/output.js' +import { selectResponsePayload } from 'lib/output/select-response-payload.js' +import { withLoading } from 'lib/output/with-loading.js' + +import type { SeamApi } from './api.js' + +export interface RequestSeamApiOptions { + path: string + params: Record + /** Response key for the endpoint, used to trim the reported payload. */ + responseKey?: string | null | undefined +} + +export interface RequestSeamApiDependencies { + api: SeamApi + output: Output +} + +/** + * Make a request and report the result: the request URL and params go to + * stderr, the trimmed payload to stdout. An API error reports its status + * and payload and sets the exit code. Returns the response body, or `null` + * when the API reported an error. + */ +export const requestSeamApi = async ( + options: RequestSeamApiOptions, + { api, output }: RequestSeamApiDependencies, +): Promise => { + const request = api.createRequest(options) + + output.info(`\n${chalk.green(request.url.toString())}`) + output.info(`Request Params:`) + output.info(formatParams(options.params)) + + let body: unknown + try { + body = await withLoading('Making request...', async () => { + return await request.fetchResponse() + }) + } catch (error) { + if (!isSeamHttpApiError(error)) throw error + + output.warn(chalk.red(`[${error.statusCode}]`)) + process.exitCode = 1 + output.data({ error: toErrorPayload(error) }) + return null + } + + output.data(selectResponsePayload(body, { responseKey: options.responseKey })) + + return body +} + +const toErrorPayload = ( + error: SeamHttpApiError, +): { type: string; message: string; data?: unknown } => ({ + type: error.code, + message: error.message, + ...(error.data === undefined ? {} : { data: error.data }), +}) + +const formatParams = (params: Record): string => + JSON.stringify(params, null, 2) diff --git a/src/lib/interact-for-command-selection.test.ts b/src/lib/interact-for-command-selection.test.ts deleted file mode 100644 index c6c40de9..00000000 --- a/src/lib/interact-for-command-selection.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { beforeEach, expect, test, vi } from 'vitest' - -import { interactForCommandSelection } from './interact-for-command-selection.js' -import type { ContextHelpers } from './types.js' -import type * as PromptModule from './util/prompt.js' -import { promptAutocomplete, withBackHint } from './util/prompt.js' - -vi.mock('./util/prompt.js', async (importOriginal) => ({ - ...(await importOriginal()), - promptAutocomplete: vi.fn(), -})) - -beforeEach(() => { - vi.mocked(promptAutocomplete).mockReset() -}) - -const ctx = { - interactivity: 'non-interactive', - blueprint: { - routes: [ - { - endpoints: [ - { path: '/devices/get' }, - { path: '/devices/list' }, - { path: '/devices/unmanaged/list' }, - ], - }, - ], - }, -} as unknown as ContextHelpers - -test('interactForCommandSelection: resolves a complete command', async () => { - await expect( - interactForCommandSelection(['devices', 'list'], ctx), - ).resolves.toEqual(['devices', 'list']) -}) - -test('interactForCommandSelection: rejects an incomplete command when non-interactive', async () => { - await expect( - interactForCommandSelection(['devices'], ctx), - ).rejects.toThrowError( - 'Incomplete command "seam devices": expected one of list, get, unmanaged', - ) -}) - -test('interactForCommandSelection: rejects a missing command when non-interactive', async () => { - await expect(interactForCommandSelection([], ctx)).rejects.toThrowError( - /^Missing command: expected one of /, - ) -}) - -const interactiveCtx = { - ...ctx, - interactivity: 'interactive', -} as unknown as ContextHelpers - -test('interactForCommandSelection: tells the user a sub-command menu can be left', async () => { - vi.mocked(promptAutocomplete).mockImplementationOnce(async () => 'list') - - await interactForCommandSelection(['devices'], interactiveCtx) - - expect(vi.mocked(promptAutocomplete).mock.calls[0]?.[0]).toMatchObject({ - message: withBackHint('Select a command: /devices'), - }) -}) - -// Escape stops the CLI at the top level, so promising a way back would lie. -test('interactForCommandSelection: says nothing about going back at the top level', async () => { - vi.mocked(promptAutocomplete) - .mockImplementationOnce(async () => 'devices') - .mockImplementationOnce(async () => 'list') - - await interactForCommandSelection([], interactiveCtx) - - expect(vi.mocked(promptAutocomplete).mock.calls[0]?.[0]).toMatchObject({ - message: 'Select a command: /', - }) -}) diff --git a/src/lib/interact-for-custom-metadata.test.ts b/src/lib/interact-for-custom-metadata.test.ts deleted file mode 100644 index ec8cbd52..00000000 --- a/src/lib/interact-for-custom-metadata.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { beforeEach, expect, test, vi } from 'vitest' - -import { interactForCustomMetadata } from './interact-for-custom-metadata.js' -import { createMemoryOutput } from './output/create-memory-output.js' -import { setOutput } from './output/get-output.js' -import type * as PromptModule from './util/prompt.js' -import { promptSelect, promptText } from './util/prompt.js' - -// Only the prompts themselves are replaced, so the real PromptCancelledError -// and withBackHint are used, as they are in production. -vi.mock('./util/prompt.js', async (importOriginal) => ({ - ...(await importOriginal()), - promptText: vi.fn(), - promptNumber: vi.fn(), - promptConfirm: vi.fn(), - promptSelect: vi.fn(), - promptAutocomplete: vi.fn(), - promptAutocompleteMultiselect: vi.fn(), -})) - -/** Queues answers in the order the editor asks for them. */ -const answerSelects = (...values: string[]): void => { - const queue = [...values] - vi.mocked(promptSelect).mockImplementation(async () => queue.shift() as never) -} - -const answerTexts = (...values: string[]): void => { - const queue = [...values] - vi.mocked(promptText).mockImplementation(async () => queue.shift() as never) -} - -beforeEach(() => { - vi.mocked(promptSelect).mockReset() - vi.mocked(promptText).mockReset() - setOutput(createMemoryOutput().output) -}) - -test('interactForCustomMetadata: adds a key and value', async () => { - answerSelects('add', 'done') - answerTexts('floor', '3') - - await expect(interactForCustomMetadata({})).resolves.toEqual({ floor: '3' }) -}) - -test('interactForCustomMetadata: removes a key from the result', async () => { - answerSelects('remove', 'floor', 'done') - - await expect( - interactForCustomMetadata({ floor: '3', wing: 'east' }), - ).resolves.toEqual({ wing: 'east' }) -}) - -test('interactForCustomMetadata: leaves the given metadata unmodified', async () => { - answerSelects('remove', 'floor', 'done') - const customMetadata = { floor: '3', wing: 'east' } - - await interactForCustomMetadata(customMetadata) - - expect(customMetadata).toEqual({ floor: '3', wing: 'east' }) -}) - -test.for([['true', true] as const, ['false', false] as const])( - 'interactForCustomMetadata: stores %s as a boolean', - async ([given, stored]) => { - answerSelects('add', 'done') - answerTexts('enabled', given) - - await expect(interactForCustomMetadata({})).resolves.toEqual({ - enabled: stored, - }) - }, -) - -test('interactForCustomMetadata: stores null for the null keyword', async () => { - answerSelects('add', 'done') - answerTexts('note', 'null') - - await expect(interactForCustomMetadata({})).resolves.toEqual({ note: null }) -}) diff --git a/src/lib/interact-for-access-code.ts b/src/lib/interactions/access-code.ts similarity index 80% rename from src/lib/interact-for-access-code.ts rename to src/lib/interactions/access-code.ts index caa707ba..198df95d 100644 --- a/src/lib/interact-for-access-code.ts +++ b/src/lib/interactions/access-code.ts @@ -1,6 +1,7 @@ -import { getSeam } from './get-seam.js' -import { interactForDevice } from './interact-for-device.js' -import { interactForResource } from './interact-for-resource.js' +import { getSeam } from 'lib/http/client.js' + +import { interactForDevice } from './device.js' +import { interactForResource } from './resource.js' export const interactForAccessCode = async ({ // The key is a Seam API parameter name: callers pass the blueprint params diff --git a/src/lib/interact-for-acs-entrance.ts b/src/lib/interactions/acs-entrance.ts similarity index 78% rename from src/lib/interact-for-acs-entrance.ts rename to src/lib/interactions/acs-entrance.ts index 9f22970a..0e08533b 100644 --- a/src/lib/interact-for-acs-entrance.ts +++ b/src/lib/interactions/acs-entrance.ts @@ -1,5 +1,6 @@ -import { getSeam } from './get-seam.js' -import { interactForResource } from './interact-for-resource.js' +import { getSeam } from 'lib/http/client.js' + +import { interactForResource } from './resource.js' export const interactForAcsEntrance = async () => { const seam = await getSeam() diff --git a/src/lib/interact-for-acs-system.ts b/src/lib/interactions/acs-system.ts similarity index 79% rename from src/lib/interact-for-acs-system.ts rename to src/lib/interactions/acs-system.ts index 87d25537..ef4cff7f 100644 --- a/src/lib/interact-for-acs-system.ts +++ b/src/lib/interactions/acs-system.ts @@ -1,5 +1,6 @@ -import { getSeam } from './get-seam.js' -import { interactForResource } from './interact-for-resource.js' +import { getSeam } from 'lib/http/client.js' + +import { interactForResource } from './resource.js' export const interactForAcsSystem = async (message?: string) => { const seam = await getSeam() diff --git a/src/lib/interact-for-acs-user.ts b/src/lib/interactions/acs-user.ts similarity index 74% rename from src/lib/interact-for-acs-user.ts rename to src/lib/interactions/acs-user.ts index bfb49d15..c74409db 100644 --- a/src/lib/interact-for-acs-user.ts +++ b/src/lib/interactions/acs-user.ts @@ -1,6 +1,7 @@ -import { getSeam } from './get-seam.js' -import { interactForAcsSystem } from './interact-for-acs-system.js' -import { interactForResource } from './interact-for-resource.js' +import { getSeam } from 'lib/http/client.js' + +import { interactForAcsSystem } from './acs-system.js' +import { interactForResource } from './resource.js' export const interactForAcsUser = async () => { const seam = await getSeam() diff --git a/src/lib/interact-for-action-attempt-poll.ts b/src/lib/interactions/action-attempt-poll.ts similarity index 82% rename from src/lib/interact-for-action-attempt-poll.ts rename to src/lib/interactions/action-attempt-poll.ts index 1903a6d0..c09d1997 100644 --- a/src/lib/interact-for-action-attempt-poll.ts +++ b/src/lib/interactions/action-attempt-poll.ts @@ -1,9 +1,9 @@ import type { ActionAttemptsGetResponse } from '@seamapi/http/connect' -import { getSeam } from './get-seam.js' -import { getOutput } from './output/get-output.js' -import { promptConfirm } from './util/prompt.js' -import { withLoading } from './util/with-loading.js' +import { getSeam } from 'lib/http/client.js' +import { getOutput } from 'lib/output/get-output.js' +import { withLoading } from 'lib/output/with-loading.js' +import { promptConfirm } from 'lib/prompt.js' export const interactForActionAttemptPoll = async ( actionAttempt: ActionAttemptsGetResponse['action_attempt'], diff --git a/src/lib/interact-for-array.ts b/src/lib/interactions/array.ts similarity index 93% rename from src/lib/interact-for-array.ts rename to src/lib/interactions/array.ts index 34a5b8f8..1711b3a4 100644 --- a/src/lib/interact-for-array.ts +++ b/src/lib/interactions/array.ts @@ -1,11 +1,11 @@ -import { getOutput } from './output/get-output.js' +import { PromptCancelledError } from 'lib/errors.js' +import { getOutput } from 'lib/output/get-output.js' import { - PromptCancelledError, promptNumber, promptSelect, promptText, withBackHint, -} from './util/prompt.js' +} from 'lib/prompt.js' export const interactForArray = async ( array: string[], diff --git a/src/lib/interact-for-blueprint-object.ts b/src/lib/interactions/blueprint-object.ts similarity index 86% rename from src/lib/interact-for-blueprint-object.ts rename to src/lib/interactions/blueprint-object.ts index 5f4e173f..19fe4911 100644 --- a/src/lib/interact-for-blueprint-object.ts +++ b/src/lib/interactions/blueprint-object.ts @@ -1,29 +1,30 @@ import type { Parameter } from '@seamapi/blueprint' -import { interactForAccessCode } from './interact-for-access-code.js' -import { interactForAcsEntrance } from './interact-for-acs-entrance.js' -import { interactForAcsSystem } from './interact-for-acs-system.js' -import { interactForAcsUser } from './interact-for-acs-user.js' -import { interactForArray } from './interact-for-array.js' -import { interactForConnectedAccount } from './interact-for-connected-account.js' -import { interactForCustomMetadata } from './interact-for-custom-metadata.js' -import { interactForDevice } from './interact-for-device.js' -import { interactForTimestamp } from './interact-for-timestamp.js' -import { interactForUserIdentity } from './interact-for-user-identity.js' -import { getOutput } from './output/get-output.js' -import type { ContextHelpers } from './types.js' -import { NonInteractiveError, toArgName } from './util/cli-args.js' -import { ellipsis } from './util/ellipsis.js' +import { assertRequiredParams } from 'lib/args/validate.js' +import type { CliContext } from 'lib/context.js' +import { NonInteractiveError, PromptCancelledError } from 'lib/errors.js' +import { getOutput } from 'lib/output/get-output.js' import { promptAutocomplete, promptAutocompleteMultiselect, - PromptCancelledError, promptConfirm, promptNumber, promptSelect, promptText, withBackHint, -} from './util/prompt.js' +} from 'lib/prompt.js' +import { ellipsis } from 'lib/render/text.js' + +import { interactForAccessCode } from './access-code.js' +import { interactForAcsEntrance } from './acs-entrance.js' +import { interactForAcsSystem } from './acs-system.js' +import { interactForAcsUser } from './acs-user.js' +import { interactForArray } from './array.js' +import { interactForConnectedAccount } from './connected-account.js' +import { interactForCustomMetadata } from './custom-metadata.js' +import { interactForDevice } from './device.js' +import { interactForTimestamp } from './timestamp.js' +import { interactForUserIdentity } from './user-identity.js' const ergonomicPropOrder = [ 'name', @@ -44,7 +45,7 @@ export const interactForBlueprintObject = async ( isSubProperty?: boolean subPropertyPath?: string }, - ctx: ContextHelpers, + ctx: CliContext, ): Promise => { // Clone args and args params so that we can mutate it args = { ...args, params: { ...args.params } } @@ -71,14 +72,10 @@ export const interactForBlueprintObject = async ( } if (ctx.interactivity === 'non-interactive') { - const missing = required.filter((k) => !isSupplied(k)) const target = args.isSubProperty ? `"${args.subPropertyPath}"` : cmdPath + assertRequiredParams(args.parameters, args.params, target) throw new NonInteractiveError( - missing.length > 0 - ? `Missing required ${ - missing.length === 1 ? 'parameter' : 'parameters' - } for ${target}: ${missing.map(toArgName).join(' ')}` - : `Cannot prompt for ${target} in non-interactive mode`, + `Cannot prompt for ${target} in non-interactive mode`, ) } diff --git a/src/lib/interact-for-command-params.ts b/src/lib/interactions/command-params.ts similarity index 62% rename from src/lib/interact-for-command-params.ts rename to src/lib/interactions/command-params.ts index 4923496e..ff978a62 100644 --- a/src/lib/interact-for-command-params.ts +++ b/src/lib/interactions/command-params.ts @@ -1,13 +1,14 @@ -import { getCommandBlueprintDef } from './get-command-blueprint-def.js' -import { interactForBlueprintObject } from './interact-for-blueprint-object.js' -import type { ContextHelpers } from './types.js' +import { getCommandBlueprintDef } from 'lib/blueprint/endpoint.js' +import type { CliContext } from 'lib/context.js' + +import { interactForBlueprintObject } from './blueprint-object.js' export const interactForCommandParams = async ( args: { command: string[] params: Record }, - ctx: ContextHelpers, + ctx: CliContext, ): Promise => { const endpoint = getCommandBlueprintDef(args.command, ctx) diff --git a/src/lib/interact-for-command-selection.ts b/src/lib/interactions/command-selection.ts similarity index 84% rename from src/lib/interact-for-command-selection.ts rename to src/lib/interactions/command-selection.ts index ed030b12..b74e81b1 100644 --- a/src/lib/interact-for-command-selection.ts +++ b/src/lib/interactions/command-selection.ts @@ -1,12 +1,8 @@ import { isDeepStrictEqual as isEqual } from 'node:util' -import type { ContextHelpers } from './types.js' -import { NonInteractiveError } from './util/cli-args.js' -import { - promptAutocomplete, - PromptCancelledError, - withBackHint, -} from './util/prompt.js' +import type { Interactivity } from 'lib/args/parse.js' +import { NonInteractiveError, PromptCancelledError } from 'lib/errors.js' +import { promptAutocomplete, withBackHint } from 'lib/prompt.js' const uniqBy = (items: T[], keyOf: (item: T) => unknown): T[] => { const seen = new Set() @@ -29,24 +25,16 @@ function ergonomicSort(aStr: string, bStr: string) { return a > b ? 1 : a < b ? -1 : 0 } +/** + * Resolve a command path to a full command, prompting to complete it when + * interactive. `commands` is every selectable command path, from the + * registry's spec. + */ export async function interactForCommandSelection( commandPath: string[], - helpers: ContextHelpers, -) { - const commands = helpers.blueprint.routes - .flatMap((route) => route.endpoints) - .map((endpoint) => - endpoint.path.replace(/_/g, '-').replace(/^\//, '').split('/'), - ) - .concat([ - ['login'], - ['logout'], - ['config', 'reveal-location'], - ['config', 'use-remote-api-defs'], - ['select', 'workspace'], - ['select', 'server'], - ['health', 'get-health'], - ]) + helpers: { commands: string[][]; interactivity: Interactivity }, +): Promise { + const commands = helpers.commands const possibleCommands = uniqBy( commandPath.length === 0 diff --git a/src/lib/interact-for-connected-account.ts b/src/lib/interactions/connected-account.ts similarity index 87% rename from src/lib/interact-for-connected-account.ts rename to src/lib/interactions/connected-account.ts index 7673be54..7892bfd1 100644 --- a/src/lib/interact-for-connected-account.ts +++ b/src/lib/interactions/connected-account.ts @@ -1,5 +1,6 @@ -import { getSeam } from './get-seam.js' -import { interactForResource } from './interact-for-resource.js' +import { getSeam } from 'lib/http/client.js' + +import { interactForResource } from './resource.js' export const interactForConnectedAccount = async () => { const seam = await getSeam() diff --git a/src/lib/interact-for-custom-metadata.ts b/src/lib/interactions/custom-metadata.ts similarity index 94% rename from src/lib/interact-for-custom-metadata.ts rename to src/lib/interactions/custom-metadata.ts index dde3181c..72ef462e 100644 --- a/src/lib/interact-for-custom-metadata.ts +++ b/src/lib/interactions/custom-metadata.ts @@ -1,10 +1,6 @@ -import { getOutput } from './output/get-output.js' -import { - PromptCancelledError, - promptSelect, - promptText, - withBackHint, -} from './util/prompt.js' +import { PromptCancelledError } from 'lib/errors.js' +import { getOutput } from 'lib/output/get-output.js' +import { promptSelect, promptText, withBackHint } from 'lib/prompt.js' // Structurally the CustomMetadata of @seamapi/types, spelled out here so the // published declarations do not depend on a development-only package. diff --git a/src/lib/interact-for-device.ts b/src/lib/interactions/device.ts similarity index 78% rename from src/lib/interact-for-device.ts rename to src/lib/interactions/device.ts index 33ed6cee..72043e70 100644 --- a/src/lib/interact-for-device.ts +++ b/src/lib/interactions/device.ts @@ -1,5 +1,6 @@ -import { getSeam } from './get-seam.js' -import { interactForResource } from './interact-for-resource.js' +import { getSeam } from 'lib/http/client.js' + +import { interactForResource } from './resource.js' export const interactForDevice = async () => { const seam = await getSeam() diff --git a/src/lib/interactions/index.ts b/src/lib/interactions/index.ts new file mode 100644 index 00000000..6fc0b67b --- /dev/null +++ b/src/lib/interactions/index.ts @@ -0,0 +1,19 @@ +export * from './access-code.js' +export * from './acs-entrance.js' +export * from './acs-system.js' +export * from './acs-user.js' +export * from './action-attempt-poll.js' +export * from './array.js' +export * from './blueprint-object.js' +export * from './command-params.js' +export * from './command-selection.js' +export * from './connected-account.js' +export * from './custom-metadata.js' +export * from './device.js' +export * from './login.js' +export * from './resource.js' +export * from './server-selection.js' +export * from './timestamp.js' +export * from './use-remote-api-defs.js' +export * from './user-identity.js' +export * from './workspace-id.js' diff --git a/src/lib/interact-for-login.ts b/src/lib/interactions/login.ts similarity index 61% rename from src/lib/interact-for-login.ts rename to src/lib/interactions/login.ts index 90775a7d..683b8533 100644 --- a/src/lib/interact-for-login.ts +++ b/src/lib/interactions/login.ts @@ -1,24 +1,27 @@ import { isApiKey, isPersonalAccessToken } from '@seamapi/http/connect' import chalk from 'chalk' -import { getConfigStore } from './config/index.js' -import { assertEnvVarUnset, getTokenFromEnv, tokenEnvVar } from './env.js' -import { getServer } from './get-server.js' -import { interactForWorkspaceId } from './interact-for-workspace-id.js' -import { getOutput } from './output/get-output.js' -import { promptText } from './util/prompt.js' -import { withLoading } from './util/with-loading.js' -import { validateToken } from './validate-token.js' +import { assertMutable, storeToken } from 'lib/auth/operations.js' +import { validateToken } from 'lib/auth/validate-token.js' +import { getConfigStore } from 'lib/config/index.js' +import { resolveAuth } from 'lib/context.js' +import { getOutput } from 'lib/output/get-output.js' +import { withLoading } from 'lib/output/with-loading.js' +import { promptText } from 'lib/prompt.js' + +import { interactForWorkspaceId } from './workspace-id.js' export const interactForLogin = async () => { - const config = await getConfigStore() + const config = getConfigStore() const output = getOutput() + const auth = resolveAuth(config) - assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log in') + // Refuse before prompting: nothing typed here could be stored. + assertMutable(auth, 'token', 'log in') - if (getServer().includes('localhost')) { + if (auth.server.includes('localhost')) { output.info( - `You're using a local Seam Connect instance, you can enter the API Key to your local user, you can create a new user from:\n\n${getServer()}/admin/create_user_with_api_key`, + `You're using a local Seam Connect instance, you can enter the API Key to your local user, you can create a new user from:\n\n${auth.server}/admin/create_user_with_api_key`, ) } else { output.info( @@ -51,6 +54,6 @@ export const interactForLogin = async () => { ) } - config.set(`${getServer()}.pat`, token) + storeToken(token, config) output.info(`Token saved! You may begin using the CLI!`) } diff --git a/src/lib/interact-for-resource.ts b/src/lib/interactions/resource.ts similarity index 87% rename from src/lib/interact-for-resource.ts rename to src/lib/interactions/resource.ts index 7438c11e..10b2cfa0 100644 --- a/src/lib/interact-for-resource.ts +++ b/src/lib/interactions/resource.ts @@ -1,5 +1,5 @@ -import { promptAutocomplete, withBackHint } from './util/prompt.js' -import { withLoading } from './util/with-loading.js' +import { withLoading } from 'lib/output/with-loading.js' +import { promptAutocomplete, withBackHint } from 'lib/prompt.js' export interface ResourceChoice { title: string diff --git a/src/lib/interact-for-server-selection.ts b/src/lib/interactions/server-selection.ts similarity index 58% rename from src/lib/interact-for-server-selection.ts rename to src/lib/interactions/server-selection.ts index 3859fcb1..26aba76b 100644 --- a/src/lib/interact-for-server-selection.ts +++ b/src/lib/interactions/server-selection.ts @@ -1,19 +1,18 @@ import { randomBytes } from 'node:crypto' -import { getConfigStore } from './config/index.js' import { - assertEnvVarUnset, - endpointEnvVar, - getEndpointFromEnv, - getTokenFromEnv, - tokenEnvVar, -} from './env.js' -import { getServer } from './get-server.js' -import { getOutput } from './output/get-output.js' -import { promptAutocomplete, promptText } from './util/prompt.js' + assertMutable, + selectFakeServer, + selectServer, +} from 'lib/auth/operations.js' +import { getConfigStore } from 'lib/config/index.js' +import { resolveAuth } from 'lib/context.js' +import { getOutput } from 'lib/output/get-output.js' +import { promptAutocomplete, promptText } from 'lib/prompt.js' export async function interactForServerSelection() { - assertEnvVarUnset(endpointEnvVar, getEndpointFromEnv(), 'select a server') + const config = getConfigStore() + assertMutable(resolveAuth(config), 'server', 'select a server') const servers = [ 'http://localhost:3020', @@ -27,7 +26,6 @@ export async function interactForServerSelection() { choices: servers.map((server) => ({ label: server, value: server })), }) - const config = getConfigStore() const output = getOutput() if (server === servers[2]) { let userUrlSeed = await promptText({ @@ -38,13 +36,10 @@ export async function interactForServerSelection() { if (userUrlSeed.trim().length === 0) { userUrlSeed = randomBytes(5).toString('hex') } - assertEnvVarUnset(tokenEnvVar, getTokenFromEnv(), 'log in') - config.set('server', `https://${userUrlSeed}.fakeseamconnect.seam.vc`) - config.set(`${getServer()}.pat`, `seam_apikey1_token`) + selectFakeServer({ urlSeed: userUrlSeed, config }) output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`) } else { - config.set('server', server) + selectServer(server, config) } - config.delete('current_workspace_id') output.info(`Server set to ${server}`) } diff --git a/src/lib/interact-for-timestamp.ts b/src/lib/interactions/timestamp.ts similarity index 89% rename from src/lib/interact-for-timestamp.ts rename to src/lib/interactions/timestamp.ts index 6ecf1164..dda6467e 100644 --- a/src/lib/interact-for-timestamp.ts +++ b/src/lib/interactions/timestamp.ts @@ -1,4 +1,4 @@ -import { promptText, withBackHint } from './util/prompt.js' +import { promptText, withBackHint } from 'lib/prompt.js' export const interactForTimestamp = async () => { const now = new Date().toISOString() diff --git a/src/lib/interact-for-use-remote-api-defs.ts b/src/lib/interactions/use-remote-api-defs.ts similarity index 61% rename from src/lib/interact-for-use-remote-api-defs.ts rename to src/lib/interactions/use-remote-api-defs.ts index cd51a69e..59f006b6 100644 --- a/src/lib/interact-for-use-remote-api-defs.ts +++ b/src/lib/interactions/use-remote-api-defs.ts @@ -1,6 +1,6 @@ -import { getConfigStore } from './config/index.js' -import { getOutput } from './output/get-output.js' -import { promptSelect } from './util/prompt.js' +import { setUseRemoteApiDefs } from 'lib/auth/operations.js' +import { getOutput } from 'lib/output/get-output.js' +import { promptSelect } from 'lib/prompt.js' export async function interactForUseRemoteApiDefs() { const useRemoteApiDefs = await promptSelect({ @@ -17,7 +17,6 @@ export async function interactForUseRemoteApiDefs() { ], }) - const config = getConfigStore() - config.set('use_remote_api_defs', useRemoteApiDefs) + setUseRemoteApiDefs(useRemoteApiDefs) getOutput().info(`Use remote API Definitions: ${useRemoteApiDefs}`) } diff --git a/src/lib/interact-for-user-identity.ts b/src/lib/interactions/user-identity.ts similarity index 81% rename from src/lib/interact-for-user-identity.ts rename to src/lib/interactions/user-identity.ts index 2403ffba..be153306 100644 --- a/src/lib/interact-for-user-identity.ts +++ b/src/lib/interactions/user-identity.ts @@ -1,5 +1,6 @@ -import { getSeam } from './get-seam.js' -import { interactForResource } from './interact-for-resource.js' +import { getSeam } from 'lib/http/client.js' + +import { interactForResource } from './resource.js' export const interactForUserIdentity = async () => { const seam = await getSeam() diff --git a/src/lib/interact-for-workspace-id.ts b/src/lib/interactions/workspace-id.ts similarity index 60% rename from src/lib/interact-for-workspace-id.ts rename to src/lib/interactions/workspace-id.ts index aeb42181..4380878c 100644 --- a/src/lib/interact-for-workspace-id.ts +++ b/src/lib/interactions/workspace-id.ts @@ -1,28 +1,21 @@ import { SeamHttpWithoutWorkspace } from '@seamapi/http/connect' -import { getConfigStore } from './config/index.js' -import { - assertEnvVarUnset, - getWorkspaceIdFromEnv, - workspaceIdEnvVar, -} from './env.js' -import { getSeamMultiWorkspace } from './get-seam.js' -import { getServer } from './get-server.js' -import { promptAutocomplete } from './util/prompt.js' -import { withLoading } from './util/with-loading.js' +import { assertMutable, selectWorkspace } from 'lib/auth/operations.js' +import { getConfigStore } from 'lib/config/index.js' +import { resolveAuth } from 'lib/context.js' +import { getSeamMultiWorkspace } from 'lib/http/client.js' +import { withLoading } from 'lib/output/with-loading.js' +import { promptAutocomplete } from 'lib/prompt.js' export const interactForWorkspaceId = async (personalAccessToken?: string) => { const config = getConfigStore() - assertEnvVarUnset( - workspaceIdEnvVar, - getWorkspaceIdFromEnv(), - 'select a workspace', - ) + // Refuse before prompting: nothing selected here could be stored. + assertMutable(resolveAuth(config), 'workspaceId', 'select a workspace') const seam = personalAccessToken ? SeamHttpWithoutWorkspace.fromPersonalAccessToken(personalAccessToken, { - endpoint: getServer(), + endpoint: resolveAuth(config).server, }) : await getSeamMultiWorkspace() @@ -40,6 +33,6 @@ export const interactForWorkspaceId = async (personalAccessToken?: string) => { })), }) - config.set('current_workspace_id', workspaceId) + selectWorkspace(workspaceId, config) return workspaceId } diff --git a/src/lib/memory-prompt.ts b/src/lib/memory-prompt.ts new file mode 100644 index 00000000..448c2482 --- /dev/null +++ b/src/lib/memory-prompt.ts @@ -0,0 +1,89 @@ +import { PromptCancelledError } from 'lib/errors.js' +import type { + PromptChoice, + PromptClient, + PromptConfirmOptions, + PromptNumberOptions, + PromptSelectOptions, + PromptTextOptions, +} from 'lib/prompt.js' + +/** A question a {@link PromptClient} was asked, as a test sees it. */ +export interface PromptQuestion { + kind: + | 'text' + | 'number' + | 'confirm' + | 'select' + | 'autocomplete' + | 'autocompleteMultiselect' + message: string + choices?: Array> +} + +/** Scripted in place of an answer to dismiss that prompt. */ +export const cancelPrompt = Symbol('cancel-prompt') + +/** + * A real {@link PromptClient} that answers from a script instead of a + * terminal, and records every question it was asked. + * + * Each ask consumes the next scripted answer in turn. Scripting + * {@link cancelPrompt} dismisses that prompt, and an exhausted script + * dismisses every prompt after it, exactly as a user cancelling would. + */ +export class MemoryPromptClient implements PromptClient { + /** Every question asked, in order — assert on what the user was offered. */ + readonly questions: PromptQuestion[] = [] + + private readonly answers: unknown[] + + constructor(script: unknown[] = []) { + this.answers = [...script] + } + + canPrompt = (): boolean => true + + text = async ({ message }: PromptTextOptions): Promise => + this.answer({ kind: 'text', message }) as string + + number = async ({ message }: PromptNumberOptions): Promise => + this.answer({ kind: 'number', message }) as number + + confirm = async ({ message }: PromptConfirmOptions): Promise => + this.answer({ kind: 'confirm', message }) as boolean + + select = async ({ + message, + choices, + }: PromptSelectOptions): Promise => + this.answer({ kind: 'select', message, choices }) as Value + + autocomplete = async ({ + message, + choices, + }: PromptSelectOptions): Promise => + this.answer({ kind: 'autocomplete', message, choices }) as Value + + autocompleteMultiselect = async ({ + message, + choices, + }: PromptSelectOptions): Promise => + this.answer({ + kind: 'autocompleteMultiselect', + message, + choices, + }) as Value[] + + private answer(question: PromptQuestion): unknown { + this.questions.push(question) + if (this.answers.length === 0) throw new PromptCancelledError() + const value = this.answers.shift() + if (value === cancelPrompt) throw new PromptCancelledError() + return value + } +} + +export const createMemoryPrompt = ( + script: unknown[] = [], +): MemoryPromptClient => new MemoryPromptClient(script) diff --git a/src/lib/output/get-output.ts b/src/lib/output/get-output.ts index 93ee6fac..5be5c45e 100644 --- a/src/lib/output/get-output.ts +++ b/src/lib/output/get-output.ts @@ -1,4 +1,4 @@ -import { createOutput, type Output } from './create-output.js' +import { createOutput, type Output } from './output.js' let output: Output | null = null diff --git a/src/lib/output/create-memory-output.ts b/src/lib/output/memory-output.ts similarity index 97% rename from src/lib/output/create-memory-output.ts rename to src/lib/output/memory-output.ts index 91e4ef29..33822a90 100644 --- a/src/lib/output/create-memory-output.ts +++ b/src/lib/output/memory-output.ts @@ -3,7 +3,7 @@ import { type CreateOutputOptions, type Output, type OutputStream, -} from './create-output.js' +} from './output.js' export interface MemoryOutput { output: Output diff --git a/src/lib/output/create-output.test.ts b/src/lib/output/output.test.ts similarity index 96% rename from src/lib/output/create-output.test.ts rename to src/lib/output/output.test.ts index ce39d3f5..14c65052 100644 --- a/src/lib/output/create-output.test.ts +++ b/src/lib/output/output.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest' -import { createMemoryOutput } from './create-memory-output.js' +import { createMemoryOutput } from './memory-output.js' test('createOutput: writes data to stdout as json', () => { const { output, stdout, stderr } = createMemoryOutput({ format: 'json' }) diff --git a/src/lib/output/create-output.ts b/src/lib/output/output.ts similarity index 60% rename from src/lib/output/create-output.ts rename to src/lib/output/output.ts index 21239485..064ea9af 100644 --- a/src/lib/output/create-output.ts +++ b/src/lib/output/output.ts @@ -58,41 +58,56 @@ export interface CreateOutputOptions { colors?: boolean } -export const createOutput = ({ - format = 'text', - stdout = process.stdout, - stderr = process.stderr, - colors = false, -}: CreateOutputOptions = {}): Output => { - const isJson = format === 'json' - - return { - format, - - data: (value: unknown): void => { - if (value === undefined) return - stdout.write(`${formatData(value, format, colors)}\n`) - }, - - text: (value: string): void => { - stdout.write(`${value}\n`) - }, - - info: (message = ''): void => { - if (isJson) return - stderr.write(`${message}\n`) - }, - - warn: (message: string): void => { - stderr.write(`${message}\n`) - }, - - error: (message: string): void => { - stderr.write(`${message}\n`) - }, +/** + * The one {@link Output} implementation: writes to a pair of streams. The + * process streams make it the real output; in-memory streams make it the + * test capture (see `create-memory-output.ts`). + */ +export class StreamOutput implements Output { + readonly format: OutputFormat + + private readonly stdout: OutputStream + private readonly stderr: OutputStream + private readonly colors: boolean + + constructor({ + format = 'text', + stdout = process.stdout, + stderr = process.stderr, + colors = false, + }: CreateOutputOptions = {}) { + this.format = format + this.stdout = stdout + this.stderr = stderr + this.colors = colors + } + + data = (value: unknown): void => { + if (value === undefined) return + this.stdout.write(`${formatData(value, this.format, this.colors)}\n`) + } + + text = (value: string): void => { + this.stdout.write(`${value}\n`) + } + + info = (message = ''): void => { + if (this.format === 'json') return + this.stderr.write(`${message}\n`) + } + + warn = (message: string): void => { + this.stderr.write(`${message}\n`) + } + + error = (message: string): void => { + this.stderr.write(`${message}\n`) } } +export const createOutput = (options: CreateOutputOptions = {}): Output => + new StreamOutput(options) + const formatData = ( value: unknown, format: OutputFormat, diff --git a/src/lib/util/read-stdin-json.test.ts b/src/lib/output/read-stdin-json.test.ts similarity index 100% rename from src/lib/util/read-stdin-json.test.ts rename to src/lib/output/read-stdin-json.test.ts diff --git a/src/lib/util/read-stdin-json.ts b/src/lib/output/read-stdin-json.ts similarity index 100% rename from src/lib/util/read-stdin-json.ts rename to src/lib/output/read-stdin-json.ts diff --git a/src/lib/output/resolve-output-format.ts b/src/lib/output/resolve-output-format.ts index de451de7..be8b7f95 100644 --- a/src/lib/output/resolve-output-format.ts +++ b/src/lib/output/resolve-output-format.ts @@ -1,4 +1,4 @@ -import type { OutputFormat } from './create-output.js' +import type { OutputFormat } from './output.js' export interface ResolveOutputFormatOptions { /** Whether stdout is a terminal. */ diff --git a/src/lib/util/with-loading.ts b/src/lib/output/with-loading.ts similarity index 91% rename from src/lib/util/with-loading.ts rename to src/lib/output/with-loading.ts index 515fbb9e..09d9d7bd 100644 --- a/src/lib/util/with-loading.ts +++ b/src/lib/output/with-loading.ts @@ -1,6 +1,6 @@ import { createSpinner } from 'nanospinner' -import { getOutput } from 'lib/output/get-output.js' +import { getOutput } from './get-output.js' export const withLoading = async ( message: string, diff --git a/src/lib/util/prompt.test.ts b/src/lib/prompt.test.ts similarity index 99% rename from src/lib/util/prompt.test.ts rename to src/lib/prompt.test.ts index 2a73dbcb..ce2715de 100644 --- a/src/lib/util/prompt.test.ts +++ b/src/lib/prompt.test.ts @@ -8,7 +8,7 @@ import { emitArrowKeyAliases, type SearchableChoice, searchChoices, -} from './prompt.js' +} from 'lib/prompt.js' const workspaces = [ { label: 'Sandbox', hint: 'ws_1' }, diff --git a/src/lib/prompt.ts b/src/lib/prompt.ts new file mode 100644 index 00000000..45528e0d --- /dev/null +++ b/src/lib/prompt.ts @@ -0,0 +1,306 @@ +import type { EventEmitter } from 'node:events' +import type { Key } from 'node:readline' + +import { + autocomplete, + autocompleteMultiselect, + confirm, + isCancel, + type Option, + select, + text, +} from '@clack/prompts' +import chalk from 'chalk' + +import { NonInteractiveError, PromptCancelledError } from 'lib/errors.js' + +export interface PromptChoice { + label: string + value: Value + hint?: string | undefined +} + +export interface PromptTextOptions { + message: string + placeholder?: string + defaultValue?: string + validate?: (value: string | undefined) => string | undefined +} + +export interface PromptNumberOptions { + message: string + validate?: (value: number) => string | undefined +} + +export interface PromptConfirmOptions { + message: string + initialValue?: boolean + active?: string + inactive?: string +} + +export interface PromptSelectOptions { + message: string + choices: Array> +} + +/** + * The terminal edge behind the prompt functions: whether questions can be + * asked, and how to ask each kind. + * + * A test replaces this with an in-memory client (see + * `memory-prompt.ts`) via {@link setPromptClient} — the code under + * test keeps calling `promptText` and friends as usual. + */ +export interface PromptClient { + canPrompt: () => boolean + text: (options: PromptTextOptions) => Promise + number: (options: PromptNumberOptions) => Promise + confirm: (options: PromptConfirmOptions) => Promise + select: (options: PromptSelectOptions) => Promise + autocomplete: (options: PromptSelectOptions) => Promise + autocompleteMultiselect: ( + options: PromptSelectOptions, + ) => Promise +} + +/** + * Note on a prompt message that dismissing it returns to the previous step. + * + * Only for prompts whose caller catches the dismissal: elsewhere it still + * stops the CLI, and saying otherwise would mislead. The note goes in the + * message because clack renders its own keyboard hints from a hardcoded list + * that a caller cannot add to. + */ +export const withBackHint = (message: string): string => + `${message} ${chalk.dim('· Esc: go back')}` + +/** + * The arrow keypress an Emacs-style control keypress stands for, or + * undefined for any other key: ctrl-p is up and ctrl-n is down. + */ +export const arrowKeyFor = (key: Key | undefined): Key | undefined => { + if (key?.ctrl !== true || key.meta === true || key.shift === true) { + return undefined + } + const base = { ctrl: false, meta: false, shift: false } + if (key.name === 'p') return { ...base, name: 'up', sequence: '\x1B[A' } + if (key.name === 'n') return { ...base, name: 'down', sequence: '\x1B[B' } + return undefined +} + +/** + * Re-emit Emacs-style control keypresses as the arrow keys they stand for. + * + * Clack navigates on the readline key name, so a synthetic arrow keypress + * moves the cursor in every prompt kind. Its own alias table cannot express + * this: aliases match bare key names, unaware of ctrl, and are ignored by + * prompts that track typed input, such as autocomplete. + */ +export const emitArrowKeyAliases = (input: EventEmitter): void => { + input.on('keypress', (_char, key: Key | undefined) => { + const arrowKey = arrowKeyFor(key) + if (arrowKey !== undefined) input.emit('keypress', undefined, arrowKey) + }) +} + +let arrowKeyAliasesInstalled = false + +// Keypress events only flow while a prompt has stdin in raw mode, so the +// listener is inert the rest of the time and never holds the process open. +const installArrowKeyAliases = (): void => { + if (arrowKeyAliasesInstalled) return + arrowKeyAliasesInstalled = true + emitArrowKeyAliases(process.stdin) +} + +const unwrap = (value: Value | symbol): Value => { + if (isCancel(value)) throw new PromptCancelledError() + return value as Value +} + +// Prompts are rendered to stderr: a selection is not a command result, +// so it must not end up in stdout when the CLI is piped. +const output = process.stderr + +const toOptions = ( + choices: Array>, +): Array> => + choices.map( + ({ label, value, hint }) => + (hint === undefined + ? { label, value } + : { label, value, hint }) as Option, + ) + +export class TerminalPromptClient implements PromptClient { + /** + * Prompts read raw keypresses and render an interface, so they need a + * terminal on both ends: when stdin is a pipe or a file it holds request + * params, not answers, and when stderr is redirected nobody sees the + * question. + */ + canPrompt = (): boolean => + process.stdin.isTTY === true && process.stderr.isTTY === true + + text = async (options: PromptTextOptions): Promise => { + installArrowKeyAliases() + return unwrap(await text({ ...options, output })) + } + + number = async (options: PromptNumberOptions): Promise => { + installArrowKeyAliases() + const value = unwrap( + await text({ + message: options.message, + validate: (value) => { + if (value == null || value.trim() === '') return 'Enter a number' + const parsed = Number(value) + if (Number.isNaN(parsed)) return 'Enter a number' + return options.validate?.(parsed) + }, + output, + }), + ) + return Number(value) + } + + confirm = async (options: PromptConfirmOptions): Promise => { + installArrowKeyAliases() + return unwrap(await confirm({ ...options, output })) + } + + select = async ( + options: PromptSelectOptions, + ): Promise => { + installArrowKeyAliases() + return unwrap( + await select({ + message: options.message, + options: toOptions(options.choices), + output, + }), + ) + } + + autocomplete = async ( + options: PromptSelectOptions, + ): Promise => { + installArrowKeyAliases() + return unwrap( + await autocomplete({ + message: options.message, + options: toOptions(options.choices), + // Search a list by any part of a name or hint, rather than only by + // the label, which is all clack matches for itself. + filter: searchChoices, + output, + }), + ) + } + + autocompleteMultiselect = async ( + options: PromptSelectOptions, + ): Promise => { + installArrowKeyAliases() + return unwrap( + await autocompleteMultiselect({ + message: options.message, + options: toOptions(options.choices), + filter: searchChoices, + output, + }), + ) + } +} + +const terminalPromptClient = new TerminalPromptClient() + +let client: PromptClient = terminalPromptClient + +export const setPromptClient = (promptClient: PromptClient): void => { + client = promptClient +} + +export const resetPromptClient = (): void => { + client = terminalPromptClient +} + +/** Whether the CLI can ask the user a question. */ +export const canPrompt = (): boolean => client.canPrompt() + +const ensureInteractive = (): void => { + if (!client.canPrompt()) { + throw new NonInteractiveError( + 'Cannot prompt without a terminal: pass the missing arguments, or pipe them in as JSON', + ) + } +} + +export const promptText = async ( + options: PromptTextOptions, +): Promise => { + ensureInteractive() + return await client.text(options) +} + +export const promptNumber = async ( + options: PromptNumberOptions, +): Promise => { + ensureInteractive() + return await client.number(options) +} + +export const promptConfirm = async ( + options: PromptConfirmOptions, +): Promise => { + ensureInteractive() + return await client.confirm(options) +} + +export const promptSelect = async ( + options: PromptSelectOptions, +): Promise => { + ensureInteractive() + return await client.select(options) +} + +export const promptAutocomplete = async ( + options: PromptSelectOptions, +): Promise => { + ensureInteractive() + return await client.autocomplete(options) +} + +export const promptAutocompleteMultiselect = async ( + options: PromptSelectOptions, +): Promise => { + ensureInteractive() + return await client.autocompleteMultiselect(options) +} + +export interface SearchableChoice { + label?: string | undefined + hint?: string | undefined +} + +/** + * Match a choice by every whitespace separated term of the input, matched + * case insensitively against the label and the hint. + */ +export const searchChoices = ( + input: string, + choice: SearchableChoice, +): boolean => { + const terms = input + .toLowerCase() + .split(/\s+/) + .filter((term) => term.length > 0) + + if (terms.length === 0) return true + + const searchable = `${choice.label ?? ''} ${choice.hint ?? ''}` + .toLowerCase() + .trim() + return terms.every((term) => searchable.includes(term)) +} diff --git a/src/lib/completion/describe.ts b/src/lib/render/completion/describe.ts similarity index 86% rename from src/lib/completion/describe.ts rename to src/lib/render/completion/describe.ts index 5eb038cb..f66482d7 100644 --- a/src/lib/completion/describe.ts +++ b/src/lib/render/completion/describe.ts @@ -1,5 +1,4 @@ -import { firstSentence } from '../command-spec.js' -import { ellipsis } from '../util/ellipsis.js' +import { ellipsis, firstSentence } from 'lib/render/text.js' const maxDescriptionLength = 72 diff --git a/src/lib/completion/index.ts b/src/lib/render/completion/index.ts similarity index 95% rename from src/lib/completion/index.ts rename to src/lib/render/completion/index.ts index 171a49fd..c032f6d7 100644 --- a/src/lib/completion/index.ts +++ b/src/lib/render/completion/index.ts @@ -1,6 +1,5 @@ -import type { Blueprint } from '@seamapi/blueprint' +import type { CommandSpec } from 'lib/commands/spec.js' -import { type CommandSpec, getCommandSpec } from '../command-spec.js' import { renderBashCompletion } from './render-bash.js' import { renderFishCompletion } from './render-fish.js' import { renderZshCompletion } from './render-zsh.js' @@ -27,8 +26,8 @@ const renderers: Record string> = { export const renderCompletion = ( shell: CompletionShell, - blueprint: Blueprint, -): string => renderers[shell](getCommandSpec(blueprint)) + spec: CommandSpec, +): string => renderers[shell](spec) /** * Render the completion loader installed by system packages. diff --git a/src/lib/completion/render-bash.ts b/src/lib/render/completion/render-bash.ts similarity index 99% rename from src/lib/completion/render-bash.ts rename to src/lib/render/completion/render-bash.ts index 13278c0f..fcabd6e7 100644 --- a/src/lib/completion/render-bash.ts +++ b/src/lib/render/completion/render-bash.ts @@ -2,7 +2,7 @@ import { type CommandFlag, type CommandSpec, flagTokens, -} from '../command-spec.js' +} from 'lib/commands/spec.js' export const renderBashCompletion = (spec: CommandSpec): string => { const globalTokens = spec.globalFlags.flatMap(flagTokens).sort() diff --git a/src/lib/completion/render-fish.ts b/src/lib/render/completion/render-fish.ts similarity index 97% rename from src/lib/completion/render-fish.ts rename to src/lib/render/completion/render-fish.ts index 937651ec..a76ab079 100644 --- a/src/lib/completion/render-fish.ts +++ b/src/lib/render/completion/render-fish.ts @@ -1,4 +1,5 @@ -import type { CommandFlag, CommandSpec } from '../command-spec.js' +import type { CommandFlag, CommandSpec } from 'lib/commands/spec.js' + import { describeForShell } from './describe.js' export const renderFishCompletion = (spec: CommandSpec): string => diff --git a/src/lib/completion/render-zsh.ts b/src/lib/render/completion/render-zsh.ts similarity index 99% rename from src/lib/completion/render-zsh.ts rename to src/lib/render/completion/render-zsh.ts index e057c629..f7474ff9 100644 --- a/src/lib/completion/render-zsh.ts +++ b/src/lib/render/completion/render-zsh.ts @@ -2,7 +2,8 @@ import { type CommandFlag, type CommandSpec, flagTokens, -} from '../command-spec.js' +} from 'lib/commands/spec.js' + import { describeForShell } from './describe.js' export const renderZshCompletion = (spec: CommandSpec): string => { diff --git a/src/lib/render-help.ts b/src/lib/render/help.ts similarity index 99% rename from src/lib/render-help.ts rename to src/lib/render/help.ts index 5a54b01c..7228f58d 100644 --- a/src/lib/render-help.ts +++ b/src/lib/render/help.ts @@ -7,7 +7,7 @@ import { type CommandSpec, findCommand, findGroup, -} from './command-spec.js' +} from 'lib/commands/spec.js' /** * Render the help guide for a command path, or `null` when no command or diff --git a/src/lib/render/text.test.ts b/src/lib/render/text.test.ts new file mode 100644 index 00000000..f59fe3b8 --- /dev/null +++ b/src/lib/render/text.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from 'vitest' + +import { ellipsis, firstSentence, toPlainText } from './text.js' + +test('ellipsis: truncates only when over the limit', () => { + expect(ellipsis('seam', 10)).toBe('seam') + expect(ellipsis('seam-cli', 6)).toBe('sea...') +}) + +test('toPlainText: reduces markdown to one line', () => { + expect(toPlainText('Returns all [devices](https://docs.seam.co).')).toBe( + 'Returns all devices.', + ) + expect(toPlainText('Uses `code`\nand **bold**.')).toBe('Uses code and bold.') + expect(toPlainText("Keeps the device's colon: intact.")).toBe( + "Keeps the device's colon: intact.", + ) +}) + +test('firstSentence: stops at the first sentence break', () => { + expect(firstSentence('First sentence. Second sentence.')).toBe( + 'First sentence.', + ) + expect(firstSentence('No break here')).toBe('No break here') +}) diff --git a/src/lib/render/text.ts b/src/lib/render/text.ts new file mode 100644 index 00000000..59c56422 --- /dev/null +++ b/src/lib/render/text.ts @@ -0,0 +1,17 @@ +export const ellipsis = (str: string, len: number) => { + if (str.length <= len) return str + return str.slice(0, len - 3) + '...' +} + +/** Reduce documentation markdown to a single line of prose. */ +export const toPlainText = (markdown: string): string => + markdown + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[`*]/g, '') + .replace(/\s+/g, ' ') + .trim() + +export const firstSentence = (text: string): string => { + const [sentence] = text.split(/(?<=\.)\s/) + return sentence ?? text +} diff --git a/src/lib/types.ts b/src/lib/types.ts deleted file mode 100644 index 64a8d95c..00000000 --- a/src/lib/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { ApiBlueprint } from './get-api-blueprint.js' -import type { Interactivity } from './util/cli-args.js' - -export interface ContextHelpers { - blueprint: ApiBlueprint - interactivity: Interactivity -} diff --git a/src/lib/util/ellipsis.test.ts b/src/lib/util/ellipsis.test.ts deleted file mode 100644 index 7a9661b9..00000000 --- a/src/lib/util/ellipsis.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { expect, test } from 'vitest' - -import { ellipsis } from './ellipsis.js' - -test('ellipsis: truncates only when over the limit', () => { - expect(ellipsis('seam', 10)).toBe('seam') - expect(ellipsis('seam-cli', 6)).toBe('sea...') -}) diff --git a/src/lib/util/ellipsis.ts b/src/lib/util/ellipsis.ts deleted file mode 100644 index 7b10f971..00000000 --- a/src/lib/util/ellipsis.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const ellipsis = (str: string, len: number) => { - if (str.length <= len) return str - return str.slice(0, len - 3) + '...' -} diff --git a/src/lib/util/prompt.ts b/src/lib/util/prompt.ts deleted file mode 100644 index 1c84691e..00000000 --- a/src/lib/util/prompt.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { EventEmitter } from 'node:events' -import type { Key } from 'node:readline' - -import { - autocomplete, - autocompleteMultiselect, - confirm, - isCancel, - type Option, - select, - text, -} from '@clack/prompts' -import chalk from 'chalk' - -import { NonInteractiveError } from './cli-args.js' - -/** - * Whether the CLI can ask the user a question. - * - * Prompts read raw keypresses and render an interface, so they need a - * terminal on both ends: when stdin is a pipe or a file it holds request - * params, not answers, and when stderr is redirected nobody sees the - * question. - */ -export const canPrompt = (): boolean => - process.stdin.isTTY === true && process.stderr.isTTY === true - -/** The user dismissed a prompt with ctrl-c or escape instead of answering. */ -export class PromptCancelledError extends Error { - constructor() { - super('Cancelled') - } -} - -export interface PromptChoice { - label: string - value: Value - hint?: string | undefined -} - -/** - * Note on a prompt message that dismissing it returns to the previous step. - * - * Only for prompts whose caller catches the dismissal: elsewhere it still - * stops the CLI, and saying otherwise would mislead. The note goes in the - * message because clack renders its own keyboard hints from a hardcoded list - * that a caller cannot add to. - */ -export const withBackHint = (message: string): string => - `${message} ${chalk.dim('· Esc: go back')}` - -const ensureInteractive = (): void => { - if (!canPrompt()) { - throw new NonInteractiveError( - 'Cannot prompt without a terminal: pass the missing arguments, or pipe them in as JSON', - ) - } - installArrowKeyAliases() -} - -/** - * The arrow keypress an Emacs-style control keypress stands for, or - * undefined for any other key: ctrl-p is up and ctrl-n is down. - */ -export const arrowKeyFor = (key: Key | undefined): Key | undefined => { - if (key?.ctrl !== true || key.meta === true || key.shift === true) { - return undefined - } - const base = { ctrl: false, meta: false, shift: false } - if (key.name === 'p') return { ...base, name: 'up', sequence: '\x1B[A' } - if (key.name === 'n') return { ...base, name: 'down', sequence: '\x1B[B' } - return undefined -} - -/** - * Re-emit Emacs-style control keypresses as the arrow keys they stand for. - * - * Clack navigates on the readline key name, so a synthetic arrow keypress - * moves the cursor in every prompt kind. Its own alias table cannot express - * this: aliases match bare key names, unaware of ctrl, and are ignored by - * prompts that track typed input, such as autocomplete. - */ -export const emitArrowKeyAliases = (input: EventEmitter): void => { - input.on('keypress', (_char, key: Key | undefined) => { - const arrowKey = arrowKeyFor(key) - if (arrowKey !== undefined) input.emit('keypress', undefined, arrowKey) - }) -} - -let arrowKeyAliasesInstalled = false - -// Keypress events only flow while a prompt has stdin in raw mode, so the -// listener is inert the rest of the time and never holds the process open. -const installArrowKeyAliases = (): void => { - if (arrowKeyAliasesInstalled) return - arrowKeyAliasesInstalled = true - emitArrowKeyAliases(process.stdin) -} - -const unwrap = (value: Value | symbol): Value => { - if (isCancel(value)) throw new PromptCancelledError() - return value as Value -} - -// Prompts are rendered to stderr: a selection is not a command result, -// so it must not end up in stdout when the CLI is piped. -const output = process.stderr - -const toOptions = ( - choices: Array>, -): Array> => - choices.map( - ({ label, value, hint }) => - (hint === undefined - ? { label, value } - : { label, value, hint }) as Option, - ) - -export const promptText = async (options: { - message: string - placeholder?: string - defaultValue?: string - validate?: (value: string | undefined) => string | undefined -}): Promise => { - ensureInteractive() - return unwrap(await text({ ...options, output })) -} - -export const promptNumber = async (options: { - message: string - validate?: (value: number) => string | undefined -}): Promise => { - ensureInteractive() - const value = unwrap( - await text({ - message: options.message, - validate: (value) => { - if (value == null || value.trim() === '') return 'Enter a number' - const parsed = Number(value) - if (Number.isNaN(parsed)) return 'Enter a number' - return options.validate?.(parsed) - }, - output, - }), - ) - return Number(value) -} - -export const promptConfirm = async (options: { - message: string - initialValue?: boolean - active?: string - inactive?: string -}): Promise => { - ensureInteractive() - return unwrap(await confirm({ ...options, output })) -} - -export const promptSelect = async (options: { - message: string - choices: Array> -}): Promise => { - ensureInteractive() - return unwrap( - await select({ - message: options.message, - options: toOptions(options.choices), - output, - }), - ) -} - -export const promptAutocomplete = async (options: { - message: string - choices: Array> -}): Promise => { - ensureInteractive() - return unwrap( - await autocomplete({ - message: options.message, - options: toOptions(options.choices), - // Search a list by any part of a name or hint, rather than only by - // the label, which is all clack matches for itself. - filter: searchChoices, - output, - }), - ) -} - -export const promptAutocompleteMultiselect = async (options: { - message: string - choices: Array> -}): Promise => { - ensureInteractive() - return unwrap( - await autocompleteMultiselect({ - message: options.message, - options: toOptions(options.choices), - filter: searchChoices, - output, - }), - ) -} - -export interface SearchableChoice { - label?: string | undefined - hint?: string | undefined -} - -/** - * Match a choice by every whitespace separated term of the input, matched - * case insensitively against the label and the hint. - */ -export const searchChoices = ( - input: string, - choice: SearchableChoice, -): boolean => { - const terms = input - .toLowerCase() - .split(/\s+/) - .filter((term) => term.length > 0) - - if (terms.length === 0) return true - - const searchable = `${choice.label ?? ''} ${choice.hint ?? ''}` - .toLowerCase() - .trim() - return terms.every((term) => searchable.includes(term)) -} diff --git a/src/lib/util/request-seam-api.ts b/src/lib/util/request-seam-api.ts deleted file mode 100644 index 54a73409..00000000 --- a/src/lib/util/request-seam-api.ts +++ /dev/null @@ -1,47 +0,0 @@ -import chalk from 'chalk' - -import { getSeam } from 'lib/get-seam.js' -import { getOutput } from 'lib/output/get-output.js' -import { selectResponsePayload } from 'lib/output/select-response-payload.js' - -import { withLoading } from './with-loading.js' - -export interface RequestSeamApiOptions { - path: string - params: Record - /** Response key for the endpoint, used to trim the reported payload. */ - responseKey?: string | null | undefined -} - -export const RequestSeamApi = async ({ - path, - params, - responseKey, -}: RequestSeamApiOptions) => { - const seam = await getSeam() - const output = getOutput() - - output.info(`\n${chalk.green(path)}`) - output.info(`Request Params:`) - output.info(formatParams(params)) - - const response = await withLoading('Making request...', () => - seam.client.post(path, params, { - validateStatus: () => true, - }), - ) - - if (response.status >= 400) { - output.warn(chalk.red(`[${response.status}]`)) - process.exitCode = 1 - } else { - output.info(chalk.green(`[${response.status}]`)) - } - - output.data(selectResponsePayload(response.data, { responseKey })) - - return response -} - -const formatParams = (params: Record): string => - JSON.stringify(params, null, 2) diff --git a/test/auth/operations.test.ts b/test/auth/operations.test.ts new file mode 100644 index 00000000..20c35811 --- /dev/null +++ b/test/auth/operations.test.ts @@ -0,0 +1,228 @@ +import { afterEach, beforeEach, expect, test } from 'vitest' + +import { + login, + logout, + selectFakeServer, + selectServer, + selectWorkspace, + storeToken, +} from 'lib/auth/operations.js' +import { createMemoryConfigStore } from 'lib/config/memory-config-store.js' +import { endpointEnvVar, tokenEnvVar, workspaceIdEnvVar } from 'lib/env.js' + +const server = 'https://connect.example.com' + +/** + * Validation is a network call, so it is faked at that edge: a capture of + * what would have been validated, asserted on like any outbound message. + */ +const createValidate = (): { + validate: (token: string, workspaceId?: string) => Promise + validated: Array<{ token: string; workspaceId: string | undefined }> +} => { + const validated: Array<{ token: string; workspaceId: string | undefined }> = + [] + return { + validated, + validate: async (token, workspaceId) => { + validated.push({ token, workspaceId }) + }, + } +} + +const clearEnv = (): void => { + delete process.env[endpointEnvVar] + delete process.env[tokenEnvVar] + delete process.env[workspaceIdEnvVar] +} + +beforeEach(clearEnv) +afterEach(clearEnv) + +test('login: stores a validated token under the current server', async () => { + const store = createMemoryConfigStore({ server }) + const { validate, validated } = createValidate() + + await login({ token: 'seam_apikey1_stored' }, store, validate) + + expect(validated).toEqual([ + { token: 'seam_apikey1_stored', workspaceId: undefined }, + ]) + expect(store.get(`${server}.pat`)).toBe('seam_apikey1_stored') +}) + +test('login: stores the token under a server given alongside it', async () => { + const store = createMemoryConfigStore({ server }) + const { validate } = createValidate() + + await login( + { server: 'https://other.example.com', token: 'seam_apikey1_stored' }, + store, + validate, + ) + + expect(store.get('server')).toBe('https://other.example.com') + expect(store.get('https://other.example.com.pat')).toBe('seam_apikey1_stored') + expect(store.has(`${server}.pat`)).toBe(false) +}) + +test('login: a new login clears the previous workspace selection', async () => { + const store = createMemoryConfigStore({ + server, + current_workspace_id: 'workspace1', + }) + const { validate } = createValidate() + + await login({ token: 'seam_apikey1_stored' }, store, validate) + + expect(store.has('current_workspace_id')).toBe(false) +}) + +test('login: stores a workspace given with the token', async () => { + const store = createMemoryConfigStore({ server }) + const { validate, validated } = createValidate() + + await login( + { token: 'seam_at1_stored', workspaceId: 'workspace1' }, + store, + validate, + ) + + expect(validated).toEqual([ + { token: 'seam_at1_stored', workspaceId: 'workspace1' }, + ]) + expect(store.get('current_workspace_id')).toBe('workspace1') +}) + +test(`login: refuses while ${tokenEnvVar} is set, before storing anything`, async () => { + process.env[tokenEnvVar] = 'seam_apikey1_env' + const store = createMemoryConfigStore({ server }) + const { validate, validated } = createValidate() + + await expect( + login({ token: 'seam_apikey1_stored' }, store, validate), + ).rejects.toThrow(`Cannot log in while ${tokenEnvVar} is set`) + expect(store.has(`${server}.pat`)).toBe(false) + expect(validated).toEqual([]) +}) + +test(`login: refuses a server while ${endpointEnvVar} is set`, async () => { + process.env[endpointEnvVar] = server + const store = createMemoryConfigStore() + const { validate } = createValidate() + + await expect( + login({ server: 'https://other.example.com' }, store, validate), + ).rejects.toThrow(`Cannot select a server while ${endpointEnvVar} is set`) +}) + +test(`login: refuses a workspace while ${workspaceIdEnvVar} is set`, async () => { + process.env[workspaceIdEnvVar] = 'workspace_env' + const store = createMemoryConfigStore({ server }) + const { validate } = createValidate() + + await expect( + login( + { token: 'seam_at1_stored', workspaceId: 'workspace1' }, + store, + validate, + ), + ).rejects.toThrow( + `Cannot select a workspace while ${workspaceIdEnvVar} is set`, + ) +}) + +test('storeToken: stores under the current server without validating', () => { + const store = createMemoryConfigStore({ server }) + + storeToken('seam_apikey1_stored', store) + + expect(store.get(`${server}.pat`)).toBe('seam_apikey1_stored') +}) + +test('logout: removes the stored token, legacy token, and workspace', () => { + const store = createMemoryConfigStore({ + server, + [`${server}.pat`]: 'seam_apikey1_stored', + pat: 'seam_apikey1_legacy', + current_workspace_id: 'workspace1', + }) + + logout(store) + + expect(store.has(`${server}.pat`)).toBe(false) + expect(store.has('pat')).toBe(false) + expect(store.has('current_workspace_id')).toBe(false) +}) + +test(`logout: refuses while ${tokenEnvVar} is set`, () => { + process.env[tokenEnvVar] = 'seam_apikey1_env' + const store = createMemoryConfigStore({ + server, + [`${server}.pat`]: 'seam_apikey1_stored', + }) + + expect(() => { + logout(store) + }).toThrow(`Cannot log out while ${tokenEnvVar} is set`) + expect(store.get(`${server}.pat`)).toBe('seam_apikey1_stored') +}) + +test('selectServer: stores the server and clears the workspace', () => { + const store = createMemoryConfigStore({ current_workspace_id: 'workspace1' }) + + selectServer(server, store) + + expect(store.get('server')).toBe(server) + expect(store.has('current_workspace_id')).toBe(false) +}) + +test(`selectServer: refuses while ${endpointEnvVar} is set`, () => { + process.env[endpointEnvVar] = 'http://localhost:3020' + const store = createMemoryConfigStore() + + expect(() => { + selectServer(server, store) + }).toThrow(`Cannot select a server while ${endpointEnvVar} is set`) +}) + +test('selectWorkspace: stores the workspace selection', () => { + const store = createMemoryConfigStore() + + selectWorkspace('workspace1', store) + + expect(store.get('current_workspace_id')).toBe('workspace1') +}) + +test(`selectWorkspace: refuses while ${workspaceIdEnvVar} is set`, () => { + process.env[workspaceIdEnvVar] = 'workspace_env' + const store = createMemoryConfigStore() + + expect(() => { + selectWorkspace('workspace1', store) + }).toThrow(`Cannot select a workspace while ${workspaceIdEnvVar} is set`) +}) + +test('selectFakeServer: stores the server and its well-known token', () => { + const store = createMemoryConfigStore({ current_workspace_id: 'workspace1' }) + + const { server: fakeServer } = selectFakeServer({ + urlSeed: 'abc123', + config: store, + }) + + expect(fakeServer).toBe('https://abc123.fakeseamconnect.seam.vc') + expect(store.get('server')).toBe(fakeServer) + expect(store.get(`${fakeServer}.pat`)).toBe('seam_apikey1_token') + expect(store.has('current_workspace_id')).toBe(false) +}) + +test(`selectFakeServer: refuses while ${endpointEnvVar} is set`, () => { + process.env[endpointEnvVar] = server + const store = createMemoryConfigStore() + + expect(() => selectFakeServer({ urlSeed: 'abc123', config: store })).toThrow( + `Cannot select a server while ${endpointEnvVar} is set`, + ) +}) diff --git a/test/cli.test.ts b/test/cli.test.ts index 2702aa8f..5142338c 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -7,6 +7,8 @@ import { fileURLToPath } from 'node:url' import { execa } from 'execa' import { afterAll, beforeAll, expect, test } from 'vitest' +import { testBlueprint } from './fixtures/blueprint.js' + const projectRoot = fileURLToPath(new URL('..', import.meta.url)) const entrypoint = join(projectRoot, 'src', 'bin', 'cli.ts') @@ -25,6 +27,7 @@ let server: Server let endpoint: string let stateHome: string let configHome: string +let cacheHome: string let loggedOutStateHome: string let otherServerConfigHome: string let requests: Array<{ @@ -92,6 +95,25 @@ beforeAll(async () => { join(otherServerConfigHome, 'seam', 'cli.json'), JSON.stringify({ server: 'http://localhost:1' }), ) + + // A pre-seeded blueprint cache holding the fixture blueprint, so tests + // that pin parameter handling run against known API definitions and + // never touch the npm registry. + const packageJson = await readFile(join(projectRoot, 'package.json'), 'utf8') + const pkg = JSON.parse(packageJson) as { + dependencies: Record + } + cacheHome = join(home, 'cache') + await mkdir(join(cacheHome, 'seam'), { recursive: true }) + await writeFile( + join(cacheHome, 'seam', 'blueprint.json'), + JSON.stringify({ + blueprintVersion: pkg.dependencies['@seamapi/blueprint'], + typesVersion: '0.0.0-e2e', + checkedAt: new Date().toISOString(), + blueprint: testBlueprint, + }), + ) }) afterAll(async () => { @@ -111,11 +133,13 @@ const runCli = async ( env, configHome: configHomeOverride, stateHome: stateHomeOverride, + cacheHome: cacheHomeOverride, }: { input?: string env?: Record configHome?: string stateHome?: string + cacheHome?: string } = {}, ): Promise => { const { stdout, stderr, exitCode } = await execa( @@ -126,6 +150,9 @@ const runCli = async ( env: { XDG_CONFIG_HOME: configHomeOverride ?? configHome, XDG_STATE_HOME: stateHomeOverride ?? stateHome, + ...(cacheHomeOverride == null + ? {} + : { XDG_CACHE_HOME: cacheHomeOverride }), FORCE_COLOR: '0', // Never inherit credentials from the environment running the tests. SEAM_CLI_TOKEN: undefined, @@ -379,6 +406,101 @@ test('cli: SEAM_CLI_TOKEN authenticates without logging in', async () => { ) }) +test('cli: sends a boolean parameter as a JSON boolean', async () => { + requests = [] + const { exitCode } = await runCli( + ['devices', 'list', '--is-managed', 'true'], + { cacheHome }, + ) + + expect(exitCode).toBe(0) + expect(requests[0]?.body).toEqual({ is_managed: true }) + + await runCli(['devices', 'list', '--is-managed', 'false'], { cacheHome }) + expect(requests[1]?.body).toEqual({ is_managed: false }) +}) + +test('cli: sends a number parameter as a JSON number', async () => { + requests = [] + const { exitCode } = await runCli(['devices', 'list', '--limit', '5'], { + cacheHome, + }) + + expect(exitCode).toBe(0) + expect(requests[0]?.body).toEqual({ limit: 5 }) +}) + +test('cli: keeps an opaque string parameter exactly as given', async () => { + requests = [] + const { exitCode } = await runCli( + ['access-codes', 'create', '--device-id', 'device1', '--code', '0123'], + { cacheHome }, + ) + + expect(exitCode).toBe(0) + expect(requests[0]?.body).toEqual({ device_id: 'device1', code: '0123' }) +}) + +test('cli: splits a list parameter on commas', async () => { + requests = [] + const { exitCode } = await runCli( + [ + 'access-codes', + 'create', + '--device-id', + 'device1', + '--accepted-providers', + 'august,schlage', + ], + { cacheHome }, + ) + + expect(exitCode).toBe(0) + expect(requests[0]?.body).toEqual({ + device_id: 'device1', + accepted_providers: ['august', 'schlage'], + }) +}) + +test('cli: rejects a value outside the documented enum', async () => { + requests = [] + const { stdout, stderr, exitCode } = await runCli( + ['devices', 'list', '--device-type', 'bogus'], + { cacheHome }, + ) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toContain( + '--device-type expects one of august_lock, schlage_lock', + ) + expect(requests).toHaveLength(0) +}) + +test('cli: rejects a value that is not the documented boolean', async () => { + requests = [] + const { stderr, exitCode } = await runCli( + ['devices', 'list', '--is-managed', 'maybe'], + { cacheHome }, + ) + + expect(exitCode).toBe(1) + expect(stderr).toContain('--is-managed expects true or false') + expect(requests).toHaveLength(0) +}) + +test('cli: help and completion work without being logged in', async () => { + const help = await runCli(['--help'], { stateHome: loggedOutStateHome }) + expect(help.exitCode).toBe(0) + expect(help.stdout).toContain('Seam CLI') + + const completion = await runCli(['completion', 'bash'], { + stateHome: loggedOutStateHome, + }) + expect(completion.exitCode).toBe(0) + expect(completion.stdout).toContain('complete -F _seam_completion seam') +}) + test('cli: reports not being logged in without SEAM_CLI_TOKEN', async () => { const { stdout, stderr, exitCode } = await runCli(['devices', 'list'], { stateHome: loggedOutStateHome, @@ -474,6 +596,43 @@ test('cli: refuses to select a server while SEAM_CLI_ENDPOINT is set', async () ) }) +test('cli: logout removes the stored token and workspace', async () => { + // A dedicated state home: logging out of the shared one would break + // every test that runs after this one. + const logoutStateHome = join(await mkdtemp(join(tmpdir(), 'seam-cli-test-'))) + await mkdir(join(logoutStateHome, 'seam'), { recursive: true }) + const stateFile = join(logoutStateHome, 'seam', 'cli.json') + await writeFile( + stateFile, + JSON.stringify({ + [endpoint]: { pat: 'seam_apikey1_token' }, + // A token stored before tokens were kept per server. + pat: 'seam_apikey1_legacy', + current_workspace_id: 'workspace1', + }), + ) + + // Info messages only print in text format, so ask for it explicitly. + const { stderr, exitCode } = await runCli(['logout', '--no-json'], { + stateHome: logoutStateHome, + }) + + expect(exitCode).toBe(0) + expect(stderr).toContain('Logged out!') + + const stateJson = await readFile(stateFile, 'utf8') + const state = JSON.parse(stateJson) + expect(state[endpoint]?.pat).toBeUndefined() + expect(state.pat).toBeUndefined() + expect(state.current_workspace_id).toBeUndefined() + + const next = await runCli(['devices', 'list'], { + stateHome: logoutStateHome, + }) + expect(next.exitCode).toBe(1) + expect(next.stderr).toContain('Not logged in') +}) + test('cli: refuses to log out while SEAM_CLI_TOKEN is set', async () => { const { stderr, exitCode } = await runCli(['logout'], { env: { SEAM_CLI_TOKEN: 'seam_apikey1_from_env' }, diff --git a/test/commands/registry.test.ts b/test/commands/registry.test.ts new file mode 100644 index 00000000..2d5e84c4 --- /dev/null +++ b/test/commands/registry.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from 'vitest' + +import { + acceptedParamsOf, + buildRegistry, + findLocalCommand, + localCommands, +} from 'lib/commands/registry.js' +import { testBlueprint } from 'test/fixtures/blueprint.js' + +const registry = buildRegistry(testBlueprint) + +test('registry: every spec command resolves to an executable command', () => { + for (const { path } of registry.spec.commands) { + const command = registry.find(path) + expect(command, `no executor for seam ${path.join(' ')}`).toBeDefined() + expect(command?.execute).toBeTypeOf('function') + } +}) + +test('registry: every visible local command is in the spec', () => { + for (const { definition, hidden } of localCommands) { + const inSpec = registry.spec.commands.some( + ({ path }) => path.join(' ') === definition.path.join(' '), + ) + expect( + inSpec, + `seam ${definition.path.join(' ')} should${hidden === true ? ' not' : ''} be in the spec`, + ).toBe(hidden !== true) + } +}) + +test('registry: hidden commands are findable without being offered', () => { + const fakeServer = registry.find(['config', 'set', 'fake-server']) + expect(fakeServer?.hidden).toBe(true) + expect(fakeServer?.requiresAuth).toBe(false) +}) + +test('registry: only commands for logging in and selecting a server skip auth', () => { + const noAuth = localCommands + .filter(({ requiresAuth }) => !requiresAuth) + .map(({ definition }) => definition.path.join(' ')) + .sort() + expect(noAuth).toEqual([ + 'completion bash', + 'completion fish', + 'completion zsh', + 'config set fake-server', + 'login', + 'select server', + 'wizard', + ]) +}) + +test('registry: api commands come from the blueprint and require auth', () => { + const devicesList = registry.find(['devices', 'list']) + expect(devicesList?.requiresAuth).toBe(true) + expect(devicesList?.definition.kind).toBe('api') +}) + +test('findLocalCommand: knows nothing of blueprint endpoints', () => { + expect(findLocalCommand(['login'])?.definition.path).toEqual(['login']) + expect(findLocalCommand(['devices', 'list'])).toBeUndefined() +}) + +test('acceptedParamsOf: names the parameters behind the flags', () => { + const login = findLocalCommand(['login']) + expect(login).toBeDefined() + if (login == null) return + expect(acceptedParamsOf(login.definition)).toEqual( + new Set(['server', 'token', 'workspace_id']), + ) +}) diff --git a/src/lib/command-spec.test.ts b/test/commands/spec.test.ts similarity index 85% rename from src/lib/command-spec.test.ts rename to test/commands/spec.test.ts index 2a8e0af1..35cb802f 100644 --- a/src/lib/command-spec.test.ts +++ b/test/commands/spec.test.ts @@ -1,15 +1,10 @@ import { expect, test } from 'vitest' -import { testBlueprint } from '../../test/fixtures/blueprint.js' -import { - findCommand, - findGroup, - firstSentence, - getCommandSpec, - toPlainText, -} from './command-spec.js' +import { localCommandDefinitions } from 'lib/commands/registry.js' +import { findCommand, findGroup, getCommandSpec } from 'lib/commands/spec.js' +import { testBlueprint } from 'test/fixtures/blueprint.js' -const spec = getCommandSpec(testBlueprint) +const spec = getCommandSpec(testBlueprint, localCommandDefinitions) test('command spec: derives commands from endpoint paths', () => { expect(findCommand(spec, ['devices', 'list'])?.title).toBe('List Devices') @@ -155,20 +150,3 @@ test('command spec: a command path is either a command or a group', () => { expect(findCommand(spec, ['nope'])).toBeUndefined() expect(findGroup(spec, ['nope'])).toBeUndefined() }) - -test('toPlainText: reduces markdown to one line', () => { - expect(toPlainText('Returns all [devices](https://docs.seam.co).')).toBe( - 'Returns all devices.', - ) - expect(toPlainText('Uses `code`\nand **bold**.')).toBe('Uses code and bold.') - expect(toPlainText("Keeps the device's colon: intact.")).toBe( - "Keeps the device's colon: intact.", - ) -}) - -test('firstSentence: stops at the first sentence break', () => { - expect(firstSentence('First sentence. Second sentence.')).toBe( - 'First sentence.', - ) - expect(firstSentence('No break here')).toBe('No break here') -}) diff --git a/test/context.test.ts b/test/context.test.ts new file mode 100644 index 00000000..33a9e86a --- /dev/null +++ b/test/context.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, expect, test } from 'vitest' + +import { createMemoryConfigStore } from 'lib/config/memory-config-store.js' +import { resolveAuth } from 'lib/context.js' +import { endpointEnvVar, tokenEnvVar, workspaceIdEnvVar } from 'lib/env.js' + +const server = 'https://connect.example.com' + +const store = createMemoryConfigStore + +const clearEnv = (): void => { + delete process.env[endpointEnvVar] + delete process.env[tokenEnvVar] + delete process.env[workspaceIdEnvVar] +} + +beforeEach(clearEnv) +afterEach(clearEnv) + +test('resolveAuth: reads the stored server', () => { + const auth = resolveAuth(store({ server })) + + expect(auth.server).toBe(server) + expect(auth.serverSource).toBe('config') +}) + +test('resolveAuth: defaults the server to Seam', () => { + const auth = resolveAuth(store()) + + expect(auth.server).toBe('https://connect.getseam.com') + expect(auth.serverSource).toBe('default') +}) + +test(`resolveAuth: ${endpointEnvVar} wins over the stored server`, () => { + process.env[endpointEnvVar] = 'http://localhost:3020' + + const auth = resolveAuth(store({ server })) + + expect(auth.server).toBe('http://localhost:3020') + expect(auth.serverSource).toBe('env') +}) + +test(`resolveAuth: ${endpointEnvVar} is used without a stored server`, () => { + process.env[endpointEnvVar] = 'http://localhost:3020' + + expect(resolveAuth(store()).server).toBe('http://localhost:3020') +}) + +test(`resolveAuth: ignores an empty ${endpointEnvVar}`, () => { + process.env[endpointEnvVar] = '' + + const auth = resolveAuth(store({ server })) + + expect(auth.server).toBe(server) + expect(auth.serverSource).toBe('config') +}) + +test('resolveAuth: reads the token stored for the current server', () => { + const auth = resolveAuth( + store({ server, [`${server}.pat`]: 'seam_apikey1_stored' }), + ) + + expect(auth.token).toBe('seam_apikey1_stored') + expect(auth.tokenSource).toBe('config') +}) + +test(`resolveAuth: the token stored for ${endpointEnvVar} wins over the stored server's`, () => { + process.env[endpointEnvVar] = 'http://localhost:3020' + + const auth = resolveAuth( + store({ + server, + [`${server}.pat`]: 'seam_apikey1_stored', + 'http://localhost:3020.pat': 'seam_apikey1_local', + }), + ) + + expect(auth.token).toBe('seam_apikey1_local') +}) + +test(`resolveAuth: ${tokenEnvVar} wins over the stored token`, () => { + process.env[tokenEnvVar] = 'seam_apikey1_env' + + const auth = resolveAuth( + store({ server, [`${server}.pat`]: 'seam_apikey1_stored' }), + ) + + expect(auth.token).toBe('seam_apikey1_env') + expect(auth.tokenSource).toBe('env') +}) + +test(`resolveAuth: ${tokenEnvVar} is used without a stored token`, () => { + process.env[tokenEnvVar] = 'seam_apikey1_env' + + expect(resolveAuth(store()).token).toBe('seam_apikey1_env') +}) + +test(`resolveAuth: ignores an empty ${tokenEnvVar}`, () => { + process.env[tokenEnvVar] = ' ' + + const auth = resolveAuth( + store({ server, [`${server}.pat`]: 'seam_apikey1_stored' }), + ) + + expect(auth.token).toBe('seam_apikey1_stored') +}) + +test('resolveAuth: token is null when nothing is set', () => { + const auth = resolveAuth(store()) + + expect(auth.token).toBe(null) + expect(auth.tokenSource).toBe(null) +}) + +test('resolveAuth: reads the stored workspace selection', () => { + const auth = resolveAuth(store({ current_workspace_id: 'workspace1' })) + + expect(auth.workspaceId).toBe('workspace1') + expect(auth.workspaceIdSource).toBe('config') +}) + +test(`resolveAuth: ${workspaceIdEnvVar} wins over the stored selection`, () => { + process.env[workspaceIdEnvVar] = 'workspace2' + + const auth = resolveAuth(store({ current_workspace_id: 'workspace1' })) + + expect(auth.workspaceId).toBe('workspace2') + expect(auth.workspaceIdSource).toBe('env') +}) + +test(`resolveAuth: ${workspaceIdEnvVar} is used without a stored selection`, () => { + process.env[workspaceIdEnvVar] = 'workspace2' + + expect(resolveAuth(store()).workspaceId).toBe('workspace2') +}) + +test(`resolveAuth: ignores an empty ${workspaceIdEnvVar}`, () => { + process.env[workspaceIdEnvVar] = '' + + expect( + resolveAuth(store({ current_workspace_id: 'workspace1' })).workspaceId, + ).toBe('workspace1') +}) + +test('resolveAuth: workspace is null when nothing is set', () => { + const auth = resolveAuth(store()) + + expect(auth.workspaceId).toBe(null) + expect(auth.workspaceIdSource).toBe(null) +}) + +test('resolveAuth: each value resolves on its own', () => { + process.env[workspaceIdEnvVar] = 'workspace2' + + const auth = resolveAuth( + store({ + server, + [`${server}.pat`]: 'seam_apikey1_stored', + current_workspace_id: 'workspace1', + }), + ) + + expect(auth.token).toBe('seam_apikey1_stored') + expect(auth.workspaceId).toBe('workspace2') +}) diff --git a/test/fixtures/blueprint.ts b/test/fixtures/blueprint.ts index 802c6095..0439520e 100644 --- a/test/fixtures/blueprint.ts +++ b/test/fixtures/blueprint.ts @@ -36,6 +36,7 @@ export const testBlueprint = { }, ], }, + response: { responseType: 'resource_list', responseKey: 'devices' }, }, { path: '/devices/unmanaged/get', @@ -51,6 +52,36 @@ export const testBlueprint = { }, ], }, + response: { responseType: 'resource', responseKey: 'device' }, + }, + { + path: '/access_codes/create', + title: 'Create an Access Code', + description: 'Creates an access code on a device.', + request: { + parameters: [ + { + name: 'device_id', + description: 'ID of the device.', + format: 'id', + isRequired: true, + }, + { + name: 'code', + description: 'Code to program, e.g., with leading zeroes.', + format: 'string', + isRequired: false, + }, + { + name: 'accepted_providers', + description: 'Providers to accept.', + format: 'list', + itemFormat: 'string', + isRequired: false, + }, + ], + }, + response: { responseType: 'resource', responseKey: 'access_code' }, }, ], }, diff --git a/test/http/request.test.ts b/test/http/request.test.ts new file mode 100644 index 00000000..e74ac2bd --- /dev/null +++ b/test/http/request.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, expect, test } from 'vitest' + +import { createMemorySeamApi } from 'lib/http/memory-seam-api.js' +import { requestSeamApi } from 'lib/http/request.js' +import { createMemoryOutput } from 'lib/output/memory-output.js' + +let exitCodeBefore: number | string | undefined + +beforeEach(() => { + exitCodeBefore = process.exitCode ?? undefined +}) + +afterEach(() => { + process.exitCode = exitCodeBefore +}) + +test('requestSeamApi: sends the params and reports the trimmed payload', async () => { + const api = createMemorySeamApi({ + '/devices/list': { + status: 200, + data: { + devices: [{ device_id: 'device1' }], + pagination: { has_next_page: false }, + ok: true, + }, + }, + }) + const memory = createMemoryOutput({ format: 'json' }) + + const body = await requestSeamApi( + { path: '/devices/list', params: { limit: 5 }, responseKey: 'devices' }, + { api, output: memory.output }, + ) + + // Boundary interaction: the outbound message IS the behavior. + expect(api.requests).toEqual([ + { path: '/devices/list', params: { limit: 5 } }, + ]) + expect(body).toMatchObject({ devices: [{ device_id: 'device1' }] }) + expect(JSON.parse(memory.stdout())).toEqual({ + devices: [{ device_id: 'device1' }], + pagination: { has_next_page: false }, + }) + expect(process.exitCode).toBe(exitCodeBefore) +}) + +test('requestSeamApi: reports an API error and sets the exit code', async () => { + const api = createMemorySeamApi({ + '/devices/list': { + status: 400, + data: { + error: { type: 'invalid_input', message: 'Bad request' }, + ok: false, + }, + }, + }) + const memory = createMemoryOutput({ format: 'json' }) + + const body = await requestSeamApi( + { path: '/devices/list', params: { limit: 5 } }, + { api, output: memory.output }, + ) + + expect(api.requests).toEqual([ + { path: '/devices/list', params: { limit: 5 } }, + ]) + expect(body).toBe(null) + expect(JSON.parse(memory.stdout())).toEqual({ + error: { type: 'invalid_input', message: 'Bad request' }, + }) + expect(memory.stderr()).toContain('[400]') + expect(process.exitCode).toBe(1) +}) + +test('requestSeamApi: reports the request URL on stderr, never stdout', async () => { + const api = createMemorySeamApi({ + '/devices/list': { status: 200, data: { devices: [], ok: true } }, + }) + const memory = createMemoryOutput({ format: 'text' }) + + await requestSeamApi( + { path: '/devices/list', params: {}, responseKey: 'devices' }, + { api, output: memory.output }, + ) + + expect(memory.stderr()).toContain('/devices/list') + expect(memory.stderr()).toContain('Request Params:') + expect(memory.stdout()).not.toContain('Request Params:') +}) diff --git a/src/lib/interact-for-blueprint-object.test.ts b/test/interactions/blueprint-object.test.ts similarity index 65% rename from src/lib/interact-for-blueprint-object.test.ts rename to test/interactions/blueprint-object.test.ts index baa949d7..4a3065af 100644 --- a/src/lib/interact-for-blueprint-object.test.ts +++ b/test/interactions/blueprint-object.test.ts @@ -1,46 +1,42 @@ import type { Parameter } from '@seamapi/blueprint' -import { beforeEach, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, expect, test } from 'vitest' -import { interactForBlueprintObject } from './interact-for-blueprint-object.js' -import { createMemoryOutput } from './output/create-memory-output.js' -import { setOutput } from './output/get-output.js' -import type { ContextHelpers } from './types.js' -import type * as PromptModule from './util/prompt.js' +import type { CliContext } from 'lib/context.js' +import { interactForBlueprintObject } from 'lib/interactions/index.js' import { - promptAutocomplete, - PromptCancelledError, - promptSelect, - promptText, - withBackHint, -} from './util/prompt.js' - -// Only the prompts themselves are replaced, so the real PromptCancelledError -// and withBackHint are used, as they are in production. -vi.mock('./util/prompt.js', async (importOriginal) => ({ - ...(await importOriginal()), - promptText: vi.fn(), - promptNumber: vi.fn(), - promptConfirm: vi.fn(), - promptSelect: vi.fn(), - promptAutocomplete: vi.fn(async () => 'done'), - promptAutocompleteMultiselect: vi.fn(), -})) + cancelPrompt, + createMemoryPrompt, + type MemoryPromptClient, +} from 'lib/memory-prompt.js' +import { setOutput } from 'lib/output/get-output.js' +import { createMemoryOutput } from 'lib/output/memory-output.js' +import { resetPromptClient, setPromptClient, withBackHint } from 'lib/prompt.js' + +let memoryPrompt: MemoryPromptClient + +/** Replace the prompt client, scripting an answer for each ask in turn. */ +const scriptPrompt = (script: unknown[]): MemoryPromptClient => { + memoryPrompt = createMemoryPrompt(script) + setPromptClient(memoryPrompt) + return memoryPrompt +} beforeEach(() => { - vi.mocked(promptAutocomplete).mockClear() - vi.mocked(promptAutocomplete).mockImplementation(async () => 'done') - vi.mocked(promptText).mockReset() + // Any unscripted review prompt submits immediately. + scriptPrompt(['done']) // Keep the interactive chrome out of the test output. setOutput(createMemoryOutput().output) }) +afterEach(resetPromptClient) + const parameters = [ { name: 'device_id', isRequired: true, format: 'id' }, { name: 'name', isRequired: false, format: 'string' }, ] as unknown as Parameter[] -const ctx = (interactivity: ContextHelpers['interactivity']): ContextHelpers => - ({ interactivity, blueprint: {} }) as unknown as ContextHelpers +const ctx = (interactivity: CliContext['interactivity']): CliContext => + ({ interactivity, blueprint: {} }) as unknown as CliContext const args = (params: Record) => ({ command: ['devices', 'get'], @@ -52,7 +48,7 @@ test('interactForBlueprintObject: submits without prompting once every required await expect( interactForBlueprintObject(args({ device_id: 'device1' }), ctx('auto')), ).resolves.toEqual({ device_id: 'device1' }) - expect(promptAutocomplete).not.toHaveBeenCalled() + expect(memoryPrompt.questions).toHaveLength(0) }) test('interactForBlueprintObject: prompts to review given parameters when interactive', async () => { @@ -62,7 +58,7 @@ test('interactForBlueprintObject: prompts to review given parameters when intera ctx('interactive'), ), ).resolves.toEqual({ device_id: 'device1' }) - expect(promptAutocomplete).toHaveBeenCalledTimes(1) + expect(memoryPrompt.questions).toHaveLength(1) }) test('interactForBlueprintObject: prefills the prompt with the given parameters', async () => { @@ -71,7 +67,7 @@ test('interactForBlueprintObject: prefills the prompt with the given parameters' ctx('interactive'), ) - const { choices } = vi.mocked(promptAutocomplete).mock.calls[0]?.[0] as { + const { choices } = memoryPrompt.questions[0] as unknown as { choices: Array<{ value: string; hint?: string }> } expect(choices.find(({ value }) => value === 'device_id')).toMatchObject({ @@ -86,7 +82,7 @@ test('interactForBlueprintObject: submits without prompting when non-interactive ctx('non-interactive'), ), ).resolves.toEqual({ device_id: 'device1' }) - expect(promptAutocomplete).not.toHaveBeenCalled() + expect(memoryPrompt.questions).toHaveLength(0) }) test('interactForBlueprintObject: rejects missing required parameters when non-interactive', async () => { @@ -98,7 +94,7 @@ test('interactForBlueprintObject: rejects missing required parameters when non-i ).rejects.toThrowError( 'Missing required parameter for /devices/get: --device-id', ) - expect(promptAutocomplete).not.toHaveBeenCalled() + expect(memoryPrompt.questions).toHaveLength(0) }) const falsyParameters = [ @@ -123,7 +119,7 @@ test.for([ await expect( interactForBlueprintObject(falsyArgs({ enabled: value }), ctx('auto')), ).resolves.toEqual({ enabled: value }) - expect(promptAutocomplete).not.toHaveBeenCalled() + expect(memoryPrompt.questions).toHaveLength(0) }, ) @@ -154,21 +150,19 @@ test('interactForBlueprintObject: offers the submit choice when a required value ctx('interactive'), ) - const { choices } = vi.mocked(promptAutocomplete).mock.calls[0]?.[0] as { + const { choices } = memoryPrompt.questions[0] as unknown as { choices: Array<{ value: string; label: string }> } expect(choices.map(({ value }) => value)).toContain('done') }) -// `custom_metadata` and `custom_metadata_has` are both records, a format with no -// branch of its own, so each has to be routed by name. +// `custom_metadata` and `custom_metadata_has` are both records, a format with +// no branch of its own, so each has to be routed by name. test.for(['custom_metadata', 'custom_metadata_has'] as const)( 'interactForBlueprintObject: edits %s with the metadata editor', async (name) => { - vi.mocked(promptAutocomplete) - .mockImplementationOnce(async () => name as never) - .mockImplementationOnce(async () => 'done' as never) - vi.mocked(promptSelect).mockImplementation(async () => 'done' as never) + // Pick the parameter, finish the metadata editor, then submit. + scriptPrompt([name, 'done', 'done']) await expect( interactForBlueprintObject( @@ -186,9 +180,7 @@ test.for(['custom_metadata', 'custom_metadata_has'] as const)( ) test('interactForBlueprintObject: dismissing the parameter menu leaves the command', async () => { - vi.mocked(promptAutocomplete).mockRejectedValueOnce( - new PromptCancelledError(), - ) + scriptPrompt([cancelPrompt]) await expect( interactForBlueprintObject( @@ -199,10 +191,7 @@ test('interactForBlueprintObject: dismissing the parameter menu leaves the comma }) test('interactForBlueprintObject: dismissing a value prompt returns to the menu', async () => { - vi.mocked(promptAutocomplete) - .mockImplementationOnce(async () => 'name') - .mockImplementationOnce(async () => 'done') - vi.mocked(promptText).mockRejectedValueOnce(new PromptCancelledError()) + scriptPrompt(['name', cancelPrompt, 'done']) // The parameter is left unset and the command still runs, rather than the // dismissal ending the whole command. @@ -212,14 +201,13 @@ test('interactForBlueprintObject: dismissing a value prompt returns to the menu' ctx('interactive'), ), ).resolves.toEqual({ device_id: 'device1' }) - expect(promptAutocomplete).toHaveBeenCalledTimes(2) + expect( + memoryPrompt.questions.filter(({ kind }) => kind === 'autocomplete'), + ).toHaveLength(2) }) test('interactForBlueprintObject: dismissing a value prompt keeps an earlier value', async () => { - vi.mocked(promptAutocomplete) - .mockImplementationOnce(async () => 'name') - .mockImplementationOnce(async () => 'done') - vi.mocked(promptText).mockRejectedValueOnce(new PromptCancelledError()) + scriptPrompt(['name', cancelPrompt, 'done']) await expect( interactForBlueprintObject( @@ -235,24 +223,21 @@ test('interactForBlueprintObject: tells the user the parameter menu can be left' ctx('interactive'), ) - const { message } = vi.mocked(promptAutocomplete).mock.calls[0]?.[0] as { - message: string - } - expect(message).toBe(withBackHint('[/devices/get] Parameters')) + expect(memoryPrompt.questions[0]).toMatchObject({ + message: withBackHint('[/devices/get] Parameters'), + }) }) test('interactForBlueprintObject: tells the user a value prompt can be left', async () => { - vi.mocked(promptAutocomplete) - .mockImplementationOnce(async () => 'name') - .mockImplementationOnce(async () => 'done') - vi.mocked(promptText).mockImplementationOnce(async () => 'Front Door') + scriptPrompt(['name', 'Front Door', 'done']) await interactForBlueprintObject( args({ device_id: 'device1' }), ctx('interactive'), ) - expect(vi.mocked(promptText).mock.calls[0]?.[0]).toMatchObject({ + expect(memoryPrompt.questions[1]).toMatchObject({ + kind: 'text', message: withBackHint('name:'), }) }) diff --git a/test/interactions/command-selection.test.ts b/test/interactions/command-selection.test.ts new file mode 100644 index 00000000..f8fd1dc0 --- /dev/null +++ b/test/interactions/command-selection.test.ts @@ -0,0 +1,74 @@ +import { afterEach, expect, test } from 'vitest' + +import { interactForCommandSelection } from 'lib/interactions/index.js' +import { createMemoryPrompt } from 'lib/memory-prompt.js' +import { resetPromptClient, setPromptClient, withBackHint } from 'lib/prompt.js' + +afterEach(resetPromptClient) + +const helpers = { + interactivity: 'non-interactive', + commands: [ + ['devices', 'get'], + ['devices', 'list'], + ['devices', 'unmanaged', 'list'], + ], +} as const + +test('interactForCommandSelection: resolves a complete command', async () => { + await expect( + interactForCommandSelection(['devices', 'list'], { + ...helpers, + commands: [...helpers.commands.map((path) => [...path])], + }), + ).resolves.toEqual(['devices', 'list']) +}) + +test('interactForCommandSelection: rejects an incomplete command when non-interactive', async () => { + await expect( + interactForCommandSelection(['devices'], { + ...helpers, + commands: [...helpers.commands.map((path) => [...path])], + }), + ).rejects.toThrowError( + 'Incomplete command "seam devices": expected one of list, get, unmanaged', + ) +}) + +test('interactForCommandSelection: rejects a missing command when non-interactive', async () => { + await expect( + interactForCommandSelection([], { + ...helpers, + commands: [...helpers.commands.map((path) => [...path])], + }), + ).rejects.toThrowError(/^Missing command: expected one of /) +}) + +const interactiveHelpers = () => ({ + ...helpers, + interactivity: 'interactive' as const, + commands: [...helpers.commands.map((path) => [...path])], +}) + +test('interactForCommandSelection: tells the user a sub-command menu can be left', async () => { + const memoryPrompt = createMemoryPrompt(['list']) + setPromptClient(memoryPrompt) + + await interactForCommandSelection(['devices'], interactiveHelpers()) + + expect(memoryPrompt.questions[0]).toMatchObject({ + message: withBackHint('Select a command: /devices'), + }) +}) + +// Escape stops the CLI at the top level, so promising a way back would lie. +test('interactForCommandSelection: says nothing about going back at the top level', async () => { + const memoryPrompt = createMemoryPrompt(['devices', 'list']) + setPromptClient(memoryPrompt) + + await interactForCommandSelection([], interactiveHelpers()) + + expect(memoryPrompt.questions[0]).toMatchObject({ + message: 'Select a command: /', + }) +}) diff --git a/test/interactions/custom-metadata.test.ts b/test/interactions/custom-metadata.test.ts new file mode 100644 index 00000000..8339925f --- /dev/null +++ b/test/interactions/custom-metadata.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, expect, test } from 'vitest' + +import { interactForCustomMetadata } from 'lib/interactions/index.js' +import { createMemoryPrompt } from 'lib/memory-prompt.js' +import { setOutput } from 'lib/output/get-output.js' +import { createMemoryOutput } from 'lib/output/memory-output.js' +import { resetPromptClient, setPromptClient } from 'lib/prompt.js' + +/** Scripts an answer for each ask, in the order the editor asks. */ +const scriptPrompt = (script: unknown[]): void => { + setPromptClient(createMemoryPrompt(script)) +} + +beforeEach(() => { + // Keep the interactive chrome out of the test output. + setOutput(createMemoryOutput().output) +}) + +afterEach(resetPromptClient) + +test('interactForCustomMetadata: adds a key and value', async () => { + scriptPrompt(['add', 'floor', '3', 'done']) + + await expect(interactForCustomMetadata({})).resolves.toEqual({ floor: '3' }) +}) + +test('interactForCustomMetadata: removes a key from the result', async () => { + scriptPrompt(['remove', 'floor', 'done']) + + await expect( + interactForCustomMetadata({ floor: '3', wing: 'east' }), + ).resolves.toEqual({ wing: 'east' }) +}) + +test('interactForCustomMetadata: leaves the given metadata unmodified', async () => { + scriptPrompt(['remove', 'floor', 'done']) + const customMetadata = { floor: '3', wing: 'east' } + + await interactForCustomMetadata(customMetadata) + + expect(customMetadata).toEqual({ floor: '3', wing: 'east' }) +}) + +test.for([['true', true] as const, ['false', false] as const])( + 'interactForCustomMetadata: stores %s as a boolean', + async ([given, stored]) => { + scriptPrompt(['add', 'enabled', given, 'done']) + + await expect(interactForCustomMetadata({})).resolves.toEqual({ + enabled: stored, + }) + }, +) + +test('interactForCustomMetadata: stores null for the null keyword', async () => { + scriptPrompt(['add', 'note', 'null', 'done']) + + await expect(interactForCustomMetadata({})).resolves.toEqual({ note: null }) +}) diff --git a/src/lib/completion/completion.test.ts b/test/render/completion.test.ts similarity index 85% rename from src/lib/completion/completion.test.ts rename to test/render/completion.test.ts index f35cd478..3b494fb6 100644 --- a/src/lib/completion/completion.test.ts +++ b/test/render/completion.test.ts @@ -1,14 +1,17 @@ import { expect, test } from 'vitest' -import { testBlueprint } from '../../../test/fixtures/blueprint.js' -import { describeForShell } from './describe.js' +import { buildRegistry } from 'lib/commands/registry.js' +import { describeForShell } from 'lib/render/completion/describe.js' import { completionScriptSentinels, completionShells, isCompletionShell, renderCompletion, renderCompletionStub, -} from './index.js' +} from 'lib/render/completion/index.js' +import { testBlueprint } from 'test/fixtures/blueprint.js' + +const { spec } = buildRegistry(testBlueprint) test('isCompletionShell: accepts only supported shells', () => { expect(completionShells.every(isCompletionShell)).toBe(true) @@ -28,7 +31,7 @@ test('describeForShell: drops characters that would end a quoted string', () => }) test('bash completion: dispatches on the command path', () => { - const script = renderCompletion('bash', testBlueprint) + const script = renderCompletion('bash', spec) expect(script).toContain('complete -F _seam_completion seam') expect(script).toContain("'devices') echo 'list unmanaged' ;;") expect(script).toContain( @@ -40,7 +43,7 @@ test('bash completion: dispatches on the command path', () => { }) test('zsh completion: describes every candidate', () => { - const script = renderCompletion('zsh', testBlueprint) + const script = renderCompletion('zsh', spec) expect(script.startsWith('#compdef seam\n')).toBe(true) expect(script).toContain("('devices') _seam_reply+=('list:List Devices'") expect(script).toContain("'--limit:Number of devices to return.'") @@ -50,7 +53,7 @@ test('zsh completion: describes every candidate', () => { }) test('fish completion: guards each candidate with its command path', () => { - const script = renderCompletion('fish', testBlueprint) + const script = renderCompletion('fish', spec) expect(script).toContain('complete -c seam -f') expect(script).toContain( `complete -c seam -n '__seam_using "devices"' -a 'list' -d 'List Devices'`, @@ -62,7 +65,7 @@ test('fish completion: guards each candidate with its command path', () => { }) test.each(completionShells)('%s completion: quotes safely', (shell) => { - const script = renderCompletion(shell, testBlueprint) + const script = renderCompletion(shell, spec) // Descriptions are embedded in single-quoted shell strings. expect(script).not.toContain("device's") expect(script.endsWith('\n')).toBe(true) @@ -84,9 +87,7 @@ test.each(completionShells)( // The stub requires the sentinel, and the generated script provides it // as its exact first line, so the two cannot drift apart. expect(renderCompletionStub(shell)).toContain(sentinel) - expect( - renderCompletion(shell, testBlueprint).startsWith(`${sentinel}\n`), - ).toBe(true) + expect(renderCompletion(shell, spec).startsWith(`${sentinel}\n`)).toBe(true) }, ) @@ -97,7 +98,7 @@ test('zsh completion stub: is an autoloadable completion function', () => { test('zsh completion: completes the in-flight request when evaluated by the stub', () => { // eval pushes '(eval)' onto funcstack, so the dispatch must search the // whole stack for _seam, not only the top. - expect(renderCompletion('zsh', testBlueprint)).toContain( + expect(renderCompletion('zsh', spec)).toContain( // eslint-disable-next-line no-template-curly-in-string 'if (( ${funcstack[(I)_seam]} )); then', ) diff --git a/src/lib/render-help.test.ts b/test/render/help.test.ts similarity index 94% rename from src/lib/render-help.test.ts rename to test/render/help.test.ts index 12142731..3525ec5d 100644 --- a/src/lib/render-help.test.ts +++ b/test/render/help.test.ts @@ -1,10 +1,10 @@ import { expect, test } from 'vitest' -import { testBlueprint } from '../../test/fixtures/blueprint.js' -import { getCommandSpec } from './command-spec.js' -import { renderHelp } from './render-help.js' +import { buildRegistry } from 'lib/commands/registry.js' +import { renderHelp } from 'lib/render/help.js' +import { testBlueprint } from 'test/fixtures/blueprint.js' -const spec = getCommandSpec(testBlueprint) +const { spec } = buildRegistry(testBlueprint) const help = (...path: string[]): string => { const rendered = renderHelp(path, spec) diff --git a/tsconfig.json b/tsconfig.json index ac990992..695324a3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,8 @@ "types": ["node"], "paths": { "@seamapi/cli": ["./src/index.ts"], - "lib/*": ["./src/lib/*"] + "lib/*": ["./src/lib/*"], + "test/*": ["./test/*"] } }, "files": ["src/index.ts", "src/bin/cli.ts"], diff --git a/vitest.config.ts b/vitest.config.ts index 06a056bc..1b7bfd5e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ alias: { '@seamapi/cli': new URL('./src/index.ts', import.meta.url).pathname, lib: new URL('./src/lib', import.meta.url).pathname, + test: new URL('./test', import.meta.url).pathname, }, }, test: {