diff --git a/AGENTS.md b/AGENTS.md index 2fb3290..bb5f39d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ This file provides guidance to AI agents when working with code in this reposito ```bash pnpm build # Build CLI with tsup (output: dist/) +pnpm dev # Run CLI from source via tsx (e.g. pnpm dev -- members list) pnpm test # Run all tests with vitest pnpm check # Lint and format check (Biome via ultracite) pnpm fix # Auto-fix lint and format issues @@ -41,4 +42,5 @@ Write code that is **accessible, performant, type-safe, and maintainable**. Focu ## Resources [ARCHITECTURE.md](./ARCHITECTURE.md): Detailed Project Architecture -[CONTRIBUTING.md](.github/CONTRIBUTING.md): Project Contribution Guidelines \ No newline at end of file +[CONTRIBUTING.md](.github/CONTRIBUTING.md): Project Contribution Guidelines +[Memberstack CLI Documentation](https://memberstack-cli.flashbrew.digital/llms.txt): Memberstack CLI Documentation \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7e45dcc..db63900 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -50,7 +50,10 @@ memberstack-cli/ │ └── core/ # Core library tests │ ├── auth.test.ts │ ├── graphql-client.test.ts +│ ├── no-color.test.ts │ ├── oauth.test.ts +│ ├── program-options.test.ts +│ ├── quiet.test.ts │ └── utils.test.ts │ ├── dist/ # Compiled output (ESM) @@ -64,14 +67,17 @@ memberstack-cli/ ### Entry Point (`src/index.ts`) -Prints the ASCII banner to stderr, registers all command groups on the shared `program` instance, and calls `parseAsync()`. +Propagates `--no-color` / `NO_COLOR` to all color libraries before imports, conditionally prints the ASCII banner (suppressed by `--quiet`), registers all command groups on the shared `program` instance, and calls `parseAsync()`. ### Program (`src/lib/program.ts`) -A shared Commander instance with two global options: +A shared Commander instance with global options: -- `--json` — output raw JSON instead of formatted tables -- `--live` — use live environment instead of sandbox (appended as `?mode=live` or `?mode=sandbox` to the GraphQL URL) +- `-j, --json` — output raw JSON instead of formatted tables (env: `MEMBERSTACK_JSON`) +- `-q, --quiet` — suppress banner and non-essential output +- `--no-color` — disable color output (respects the `NO_COLOR` standard) +- `--mode ` — set environment mode: `sandbox` (default) or `live` (env: `MEMBERSTACK_MODE`) +- `--live` / `--sandbox` — shorthands for `--mode live` and `--mode sandbox` ### Commands (`src/commands/`) @@ -118,7 +124,7 @@ Tokens are stored in `~/.memberstack/auth.json` with restrictive file permission - `printTable()` — renders data as a `cli-table3` table to stderr (or JSON to stdout with `--json`) - `printRecord()` — renders a single object as a vertical key-value table - `printJson()` — writes raw JSON to stdout -- `printSuccess()` / `printError()` — colored status messages to stderr +- `printSuccess()` / `printError()` — colored status messages to stderr (`printSuccess` is suppressed by `--quiet`) - `parseKeyValuePairs()` — parses `key=value` strings for `--data` options - `parseWhereClause()` — parses `field operator value` filter syntax for `--where` - `parseJsonString()` — parses raw JSON strings for `--query` @@ -154,7 +160,7 @@ All user-facing output (tables, spinners, messages) goes to **stderr**. JSON out | `open` | Opens browser for OAuth login | | `papaparse` | CSV parsing and generation | -Dev: `tsup` (bundler), `typescript`, `vitest` (tests), `biome` via `ultracite` (lint/format). +Dev: `tsup` (bundler), `tsx` (dev runner), `typescript`, `vitest` (tests), `biome` via `ultracite` (lint/format). ## Build & CI diff --git a/README.md b/README.md index 70195ee..ed1ef0d 100644 --- a/README.md +++ b/README.md @@ -42,106 +42,30 @@ memberstack skills add memberstack-cli ### Global Options -| Option | Description | -|---|---| -| `--json` | Output raw JSON instead of formatted tables | -| `--live` | Use live environment instead of sandbox | +| Option | Env Var | Description | +|---|---|---| +| `-j, --json` | `MEMBERSTACK_JSON` | Output raw JSON instead of formatted tables | +| `-q, --quiet` | | Suppress banner and non-essential output | +| `--no-color` | `NO_COLOR` | Disable color output (respects the [NO_COLOR standard](https://no-color.org)) | +| `--mode ` | `MEMBERSTACK_MODE` | Set environment mode (`sandbox` or `live`, default: `sandbox`) | +| `--live` | | Shorthand for `--mode live` | +| `--sandbox` | | Shorthand for `--mode sandbox` | ### Commands -#### `auth` — Authentication - -| Subcommand | Description | -|---|---| -| `login` | Authenticate with Memberstack via OAuth | -| `logout` | Remove stored authentication tokens | -| `status` | Show current authentication status | - -#### `whoami` — Identity - -Show the current authenticated app and user. - -#### `apps` — App Management - -| Subcommand | Description | -|---|---| -| `current` | Show the current app | -| `create` | Create a new app | -| `update` | Update the current app | -| `delete` | Delete an app | -| `restore` | Restore a deleted app | - -#### `members` — Member Management - -| Subcommand | Description | -|---|---| -| `list` | List members (with pagination) | -| `get ` | Get a member by ID or email | -| `create` | Create a new member | -| `update ` | Update a member | -| `delete ` | Delete a member | -| `add-plan ` | Add a free plan to a member | -| `remove-plan ` | Remove a free plan from a member | -| `count` | Show total member count | -| `find` | Find members by field values or plan | -| `stats` | Show member statistics | -| `export` | Export all members to CSV or JSON | -| `import` | Import members from a CSV or JSON file | -| `bulk-update` | Bulk update members from a file | -| `bulk-add-plan` | Add a plan to multiple members | - -#### `plans` — Plan Management - -| Subcommand | Description | -|---|---| -| `list` | List all plans | -| `get ` | Get a plan by ID | -| `create` | Create a new plan | -| `update ` | Update a plan (name, status, redirects, permissions, etc.) | -| `delete ` | Delete a plan | -| `order` | Reorder plans by priority | - -#### `tables` — Data Table Management - -| Subcommand | Description | +| Command | Functionality | |---|---| -| `list` | List all data tables | -| `get ` | Get a data table by key or ID | -| `describe ` | Show table schema and access rules | -| `create` | Create a new data table | -| `update ` | Update a data table | -| `delete ` | Delete a data table | - -#### `records` — Record Management - -| Subcommand | Description | -|---|---| -| `create ` | Create a new record | -| `update ` | Update a record | -| `delete ` | Delete a record | -| `query ` | Query records with a JSON filter | -| `count ` | Count records in a table | -| `find ` | Find records with friendly filter syntax | -| `export ` | Export all records to CSV or JSON | -| `import ` | Import records from a CSV or JSON file | -| `bulk-update` | Bulk update records from a file | -| `bulk-delete ` | Bulk delete records matching a filter | - -#### `custom-fields` — Custom Field Management - -| Subcommand | Description | -|---|---| -| `list` | List all custom fields | -| `create` | Create a custom field | -| `update ` | Update a custom field | -| `delete ` | Delete a custom field | - -#### `skills` — Agent Skill Management - -| Subcommand | Description | -|---|---| -| `add ` | Add a Memberstack agent skill | -| `remove ` | Remove a Memberstack agent skill | +| `auth` | Login, logout, and check authentication status | +| `whoami` | Show current authenticated app and user | +| `apps` | View, create, update, delete, and restore apps | +| `members` | List, create, update, delete, import/export, bulk ops | +| `plans` | List, create, update, delete, and reorder plans | +| `tables` | List, create, update, delete, and describe schema | +| `records` | CRUD, query, import/export, bulk ops | +| `custom-fields` | List, create, update, and delete custom fields | +| `skills` | Add/remove agent skills for Claude Code and Codex | + +For full command details and usage, see the [Command Reference](https://memberstack-cli.flashbrew.digital/docs/commands). ## Examples @@ -162,6 +86,7 @@ memberstack members export --format csv --output members.csv memberstack records import my_table --file data.json # Use live environment +memberstack members list --mode live memberstack members list --live ``` @@ -171,6 +96,9 @@ memberstack members list --live # Install dependencies pnpm install +# Run locally (via tsx, no build needed) +pnpm dev -- members list --json + # Build pnpm build diff --git a/package.json b/package.json index a06061a..69725c0 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "packageManager": "pnpm@10.29.3", "scripts": { "build": "tsup", + "dev": "tsx src/index.ts", "test": "vitest run", "type-check": "tsc --noEmit", "check": "ultracite check", @@ -70,6 +71,7 @@ "@types/papaparse": "^5.5.2", "lint-staged": "^16.2.7", "tsup": "^8.5.1", + "tsx": "^4.21.0", "typescript": "^5.9.3", "ultracite": "7.2.3", "vitest": "^4.0.18" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 856efd4..38f0cff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,7 +41,10 @@ importers: version: 16.2.7 tsup: specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -50,7 +53,7 @@ importers: version: 7.2.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@25.2.3)(yaml@2.8.2) + version: 4.0.18(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -652,6 +655,9 @@ packages: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + glob@13.0.3: resolution: {integrity: sha512-/g3B0mC+4x724v1TgtBlBtt2hPi/EWptsIAmXUx9Z2rvBYleQcsrmaOzd5LyL50jf/Soi83ZDJmw2+XqvH/EeA==} engines: {node: 20 || >=22} @@ -849,6 +855,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -977,6 +986,11 @@ packages: typescript: optional: true + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1345,13 +1359,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.3)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.2.3)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@4.0.18': dependencies: @@ -1526,6 +1540,10 @@ snapshots: get-east-asian-width@1.4.0: {} + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + glob@13.0.3: dependencies: minimatch: 10.2.0 @@ -1679,11 +1697,12 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 - postcss-load-config@6.0.1(postcss@8.5.6)(yaml@2.8.2): + postcss-load-config@6.0.1(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 optionalDependencies: postcss: 8.5.6 + tsx: 4.21.0 yaml: 2.8.2 postcss@8.5.6: @@ -1698,6 +1717,8 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -1823,7 +1844,7 @@ snapshots: ts-interface-checker@0.1.13: {} - tsup@8.5.1(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.2): + tsup@8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): dependencies: bundle-require: 5.1.0(esbuild@0.27.3) cac: 6.7.14 @@ -1834,7 +1855,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.6)(yaml@2.8.2) + postcss-load-config: 6.0.1(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) resolve-from: 5.0.0 rollup: 4.57.1 source-map: 0.7.6 @@ -1851,6 +1872,13 @@ snapshots: - tsx - yaml + tsx@4.21.0: + dependencies: + esbuild: 0.27.3 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + typescript@5.9.3: {} ufo@1.6.3: {} @@ -1866,7 +1894,7 @@ snapshots: undici-types@7.16.0: {} - vite@7.3.1(@types/node@25.2.3)(yaml@2.8.2): + vite@7.3.1(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -1877,12 +1905,13 @@ snapshots: optionalDependencies: '@types/node': 25.2.3 fsevents: 2.3.3 + tsx: 4.21.0 yaml: 2.8.2 - vitest@4.0.18(@types/node@25.2.3)(yaml@2.8.2): + vitest@4.0.18(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.3)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -1899,7 +1928,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.2.3)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.2.3 diff --git a/src/index.ts b/src/index.ts index 9e13276..8011396 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,7 @@ +if (process.argv.includes("--no-color") || process.env.NO_COLOR) { + process.env.NO_COLOR = "1"; +} + import pc from "picocolors"; import { appsCommand } from "./commands/apps.js"; import { authCommand } from "./commands/auth.js"; @@ -45,8 +49,11 @@ const banner = [ "", ].join("\n"); -process.stderr.write(`${banner}\n`); +if (!(process.argv.includes("--quiet") || process.argv.includes("-q"))) { + process.stderr.write(`${banner}\n`); +} +program.action(() => program.help()); program.addCommand(appsCommand); program.addCommand(authCommand); program.addCommand(whoamiCommand); diff --git a/src/lib/graphql-client.ts b/src/lib/graphql-client.ts index 64266fa..af3133e 100644 --- a/src/lib/graphql-client.ts +++ b/src/lib/graphql-client.ts @@ -35,7 +35,7 @@ export const graphqlRequest = async ( ); } - const mode = program.opts().live ? "live" : "sandbox"; + const mode: string = program.opts().mode; const endpoint = `${GRAPHQL_BASE_URL}?mode=${mode}`; const headers: Record = { diff --git a/src/lib/program.ts b/src/lib/program.ts index 7d5d0b1..595c90d 100644 --- a/src/lib/program.ts +++ b/src/lib/program.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, Help, Option } from "commander"; declare const __VERSION__: string | undefined; const version = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev"; @@ -10,8 +10,41 @@ program .usage(" [subcommand] [params] [options]") .description("Manage your Memberstack account from the terminal.") .version(version) - .option("-j, --json", "Output raw JSON instead of formatted tables") - .option("--live", "Use live environment instead of sandbox") + .configureHelp({ + visibleOptions(cmd: Command) { + const opts = Help.prototype.visibleOptions.call(this, cmd); + const help = opts.find((o) => o.long === "--help"); + const rest = opts.filter((o) => o.long !== "--help"); + return help ? [help, ...rest] : opts; + }, + }) + .addOption( + new Option("-j, --json", "Output raw JSON instead of formatted tables").env( + "MEMBERSTACK_JSON" + ) + ) + .option("-q, --quiet", "Suppress banner and non-essential output") + .option("--no-color", "Disable color output") + .addOption( + new Option("--mode ", "Set environment mode") + .choices(["sandbox", "live"]) + .default("sandbox") + .env("MEMBERSTACK_MODE") + ) + .addOption( + new Option("--live", "Shorthand for --mode live").conflicts("sandbox") + ) + .addOption( + new Option("--sandbox", "Shorthand for --mode sandbox").conflicts("live") + ) + .hook("preAction", (thisCommand) => { + const opts = thisCommand.opts(); + if (opts.live) { + thisCommand.setOptionValueWithSource("mode", "live", "cli"); + } else if (opts.sandbox) { + thisCommand.setOptionValueWithSource("mode", "sandbox", "cli"); + } + }) .addHelpText( "after", ` diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a863699..5fa7e1f 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -2,6 +2,9 @@ import Table from "cli-table3"; import pc from "picocolors"; import { program } from "./program.js"; +const plainStyle = { head: [], border: [] }; +const tableOptions = pc.isColorSupported ? {} : { style: plainStyle }; + export const printJson = (data: unknown): void => { const json = JSON.stringify(data, null, 2); process.stdout.write(`${json}\n`); @@ -32,7 +35,10 @@ export const printTable = (rows: object[]): void => { } const entries = rows as Record[]; const headers = [...new Set(entries.flatMap(Object.keys))]; - const table = new Table({ head: headers.map((h) => pc.cyan(h)) }); + const table = new Table({ + ...tableOptions, + head: headers.map((h) => pc.cyan(h)), + }); for (const row of entries) { table.push(headers.map((h) => formatCellValue(row[h]))); } @@ -44,7 +50,7 @@ export const printRecord = (obj: object): void => { printJson(obj); return; } - const table = new Table(); + const table = new Table(tableOptions); for (const [key, value] of Object.entries(obj)) { table.push({ [pc.cyan(key)]: formatCellValue(value) }); } @@ -56,6 +62,9 @@ export const printError = (message: string): void => { }; export const printSuccess = (message: string): void => { + if (program.opts().quiet) { + return; + } process.stderr.write(`${pc.green(message)}\n`); }; diff --git a/tests/commands/helpers.ts b/tests/commands/helpers.ts index f6d1700..d3e1bfa 100644 --- a/tests/commands/helpers.ts +++ b/tests/commands/helpers.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import { vi } from "vitest"; /** - * Creates a fresh parent program with --json and --live flags, + * Creates a fresh parent program with --json and --mode flags, * adds the given command, and parses the provided args. */ export const runCommand = async ( @@ -11,7 +11,7 @@ export const runCommand = async ( ): Promise => { const program = new Command(); program.option("--json", "Output as JSON"); - program.option("--live", "Use live mode"); + program.option("--mode ", "Set environment mode", "sandbox"); program.exitOverride(); program.addCommand(command); await program.parseAsync(["node", "test", command.name(), ...args]); diff --git a/tests/core/graphql-client.test.ts b/tests/core/graphql-client.test.ts index 0e8de86..eb0d850 100644 --- a/tests/core/graphql-client.test.ts +++ b/tests/core/graphql-client.test.ts @@ -5,7 +5,7 @@ vi.mock("../../src/lib/constants.js", () => ({ })); vi.mock("../../src/lib/program.js", () => ({ - program: { opts: () => ({}) }, + program: { opts: () => ({ mode: "sandbox" }) }, })); const getValidAccessToken = vi.fn(); diff --git a/tests/core/no-color.test.ts b/tests/core/no-color.test.ts new file mode 100644 index 0000000..9e1f2ff --- /dev/null +++ b/tests/core/no-color.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; + +// Set NO_COLOR before any color library is imported +process.env.NO_COLOR = "1"; + +// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape detection +const ANSI_REGEX = /\x1b\[[\d;]*m/; + +vi.mock("../../src/lib/program.js", () => ({ + program: { opts: () => ({}) }, +})); + +const { printSuccess, printError, printTable, printRecord } = await import( + "../../src/lib/utils.js" +); + +describe("no-color output", () => { + it("printSuccess outputs without ANSI codes", () => { + const write = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + printSuccess("done"); + expect(write).toHaveBeenCalledOnce(); + expect(write.mock.calls[0][0]).not.toMatch(ANSI_REGEX); + expect(write.mock.calls[0][0]).toContain("done"); + write.mockRestore(); + }); + + it("printError outputs without ANSI codes", () => { + const write = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + printError("fail"); + expect(write).toHaveBeenCalledOnce(); + expect(write.mock.calls[0][0]).not.toMatch(ANSI_REGEX); + expect(write.mock.calls[0][0]).toContain("fail"); + write.mockRestore(); + }); + + it("printTable outputs without ANSI codes", () => { + const write = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + printTable([{ id: "1", name: "Test" }]); + expect(write).toHaveBeenCalledOnce(); + const output = write.mock.calls[0][0] as string; + expect(output).not.toMatch(ANSI_REGEX); + expect(output).toContain("id"); + expect(output).toContain("name"); + write.mockRestore(); + }); + + it("printRecord outputs without ANSI codes", () => { + const write = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + printRecord({ id: "1", name: "Test" }); + expect(write).toHaveBeenCalledOnce(); + const output = write.mock.calls[0][0] as string; + expect(output).not.toMatch(ANSI_REGEX); + expect(output).toContain("id"); + write.mockRestore(); + }); +}); diff --git a/tests/core/program-options.test.ts b/tests/core/program-options.test.ts new file mode 100644 index 0000000..4595a9e --- /dev/null +++ b/tests/core/program-options.test.ts @@ -0,0 +1,147 @@ +import { Command, Option } from "commander"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const createProgram = () => { + const cmd = new Command(); + cmd + .exitOverride() + .addOption( + new Option("-j, --json", "Output raw JSON").env("MEMBERSTACK_JSON") + ) + .addOption( + new Option("--mode ", "Set environment mode") + .choices(["sandbox", "live"]) + .default("sandbox") + .env("MEMBERSTACK_MODE") + ) + .addOption( + new Option("--live", "Shorthand for --mode live").conflicts("sandbox") + ) + .addOption( + new Option("--sandbox", "Shorthand for --mode sandbox").conflicts("live") + ) + .hook("preAction", (thisCommand) => { + const opts = thisCommand.opts(); + if (opts.live) { + thisCommand.setOptionValueWithSource("mode", "live", "cli"); + } else if (opts.sandbox) { + thisCommand.setOptionValueWithSource("mode", "sandbox", "cli"); + } + }) + .option("-q, --quiet", "Suppress banner and non-essential output") + .action(() => undefined); + return cmd; +}; + +const parse = async (args: string[]) => { + const cmd = createProgram(); + await cmd.parseAsync(["node", "test", ...args]); + return cmd.opts(); +}; + +describe("program mode options", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("defaults to sandbox mode", async () => { + const opts = await parse([]); + expect(opts.mode).toBe("sandbox"); + }); + + it("--mode live sets mode to live", async () => { + const opts = await parse(["--mode", "live"]); + expect(opts.mode).toBe("live"); + }); + + it("--mode sandbox explicitly sets sandbox", async () => { + const opts = await parse(["--mode", "sandbox"]); + expect(opts.mode).toBe("sandbox"); + }); + + it("rejects invalid mode choices", async () => { + await expect(parse(["--mode", "staging"])).rejects.toThrow(); + }); + + it("--live sets mode to live", async () => { + const opts = await parse(["--live"]); + expect(opts.mode).toBe("live"); + }); + + it("--sandbox sets mode to sandbox", async () => { + const opts = await parse(["--sandbox"]); + expect(opts.mode).toBe("sandbox"); + }); + + it("--live and --sandbox conflict", async () => { + await expect(parse(["--live", "--sandbox"])).rejects.toThrow(); + }); + + it("MEMBERSTACK_MODE env var sets mode", async () => { + vi.stubEnv("MEMBERSTACK_MODE", "live"); + const opts = await parse([]); + expect(opts.mode).toBe("live"); + }); + + it("--mode flag overrides MEMBERSTACK_MODE env var", async () => { + vi.stubEnv("MEMBERSTACK_MODE", "live"); + const opts = await parse(["--mode", "sandbox"]); + expect(opts.mode).toBe("sandbox"); + }); + + it("--live flag overrides MEMBERSTACK_MODE env var", async () => { + vi.stubEnv("MEMBERSTACK_MODE", "sandbox"); + const opts = await parse(["--live"]); + expect(opts.mode).toBe("live"); + }); +}); + +describe("program json option", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("defaults to no json output", async () => { + const opts = await parse([]); + expect(opts.json).toBeUndefined(); + }); + + it("--json flag enables json output", async () => { + const opts = await parse(["--json"]); + expect(opts.json).toBe(true); + }); + + it("-j shorthand enables json output", async () => { + const opts = await parse(["-j"]); + expect(opts.json).toBe(true); + }); + + it("MEMBERSTACK_JSON env var enables json output", async () => { + vi.stubEnv("MEMBERSTACK_JSON", "1"); + const opts = await parse([]); + expect(opts.json).toBe(true); + }); + + it("--json flag overrides MEMBERSTACK_JSON env var", async () => { + vi.stubEnv("MEMBERSTACK_JSON", "0"); + const opts = await parse(["--json"]); + expect(opts.json).toBe(true); + }); +}); + +describe("program quiet option", () => { + it("defaults to no quiet", async () => { + const opts = await parse([]); + expect(opts.quiet).toBeUndefined(); + }); + + it("--quiet flag enables quiet mode", async () => { + const opts = await parse(["--quiet"]); + expect(opts.quiet).toBe(true); + }); + + it("-q shorthand enables quiet mode", async () => { + const opts = await parse(["-q"]); + expect(opts.quiet).toBe(true); + }); +}); diff --git a/tests/core/quiet.test.ts b/tests/core/quiet.test.ts new file mode 100644 index 0000000..14ad946 --- /dev/null +++ b/tests/core/quiet.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/lib/program.js", () => ({ + program: { opts: () => ({ quiet: true }) }, +})); + +const { printSuccess, printError } = await import("../../src/lib/utils.js"); + +describe("quiet mode", () => { + it("printSuccess is suppressed", () => { + const write = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + printSuccess("done"); + expect(write).not.toHaveBeenCalled(); + write.mockRestore(); + }); + + it("printError still outputs", () => { + const write = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + printError("fail"); + expect(write).toHaveBeenCalledOnce(); + expect(write.mock.calls[0][0]).toContain("fail"); + write.mockRestore(); + }); +});