Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changeset/ltrac-2163-cli-feedback.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@

Add ways to send feedback to the BigCommerce team from the CLI:

- After a successful `catalyst deploy`, the CLI can ask one question: how was your experience using the Catalyst CLI with Storefront Hosting (0–10), with an optional comment. It asks at most once every three months on each machine. Press Enter without a number to skip it. It never asks in CI or without a TTY. To turn it off, pass `--no-hints` or set `CATALYST_NO_HINTS`. Only the score, your comment, the CLI version, and the command name are sent.
- `catalyst feedback` sends feedback, a bug report, or a feature request at any time. Pass `--title` and `--description`, or answer the prompts. The `catalyst debug` report is attached (with no secret values, and the home directory shown as `~`). Use `--no-diagnostics` to send without it.
6 changes: 5 additions & 1 deletion packages/catalyst/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ src/cli/
├── index.ts # Entry point (#!/usr/bin/env node)
├── program.ts # Commander program setup, registers all commands
├── commands/ # CLI command implementations (auth, build, channels, create, debug, deploy, domains, env, feedback, logs, projects, start, telemetry, upgrade, version)
├── hooks/ # Pre/post action hooks (telemetry)
├── hooks/ # Pre/post action hooks (telemetry, feedback survey)
└── lib/ # Utilities (auth, logger, project config, credentials, wrangler config, telemetry, deployment errors)
templates/ # OpenNext config and public_headers template
tests/mocks/ # MSW handlers and test mocks
Expand Down Expand Up @@ -38,6 +38,10 @@ Resource-based commands are named in the plural (`projects`, `channels`, `domain
| `upgrade` | Upgrade the project to a newer Catalyst release |
| `version` | Display version and platform info |

## Feedback Survey

`hooks/survey.ts` runs after `deploy` succeeds (see `SURVEY_COMMANDS`). It asks for a 0-10 score and an optional comment, and sends them with `lib/feedback.ts` (source `catalyst-cli-survey`). It shows at most once every 90 days per machine (`survey.lastShownAt` in the user config), and never when `--no-hints`, `CATALYST_NO_HINTS`, CI, or no TTY. It must never fail the command, so `runSurvey` catches all errors. Shared prompt helpers are in `lib/prompt-context.ts`.

## Development

```bash
Expand Down
104 changes: 104 additions & 0 deletions packages/catalyst/src/cli/hooks/survey.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Command } from '@commander-js/extra-typings';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';

import { canPrompt } from '../lib/can-prompt';
import { isSurveyDue, runSurvey } from '../lib/survey';

import { surveyPostHook } from './survey';

vi.mock('../lib/can-prompt', () => ({ canPrompt: vi.fn() }));
vi.mock('../lib/survey', () => ({ isSurveyDue: vi.fn(), runSurvey: vi.fn() }));
// No linked project: credentials come only from the flags.
vi.mock('../lib/project-config', () => ({ getProjectConfig: () => ({ get: () => undefined }) }));

const runSurveyMock = vi.mocked(runSurvey);

// A small program with the same option shapes as the real CLI.
function makeProgram() {
const action = () => undefined;

return new Command()
.exitOverride()
.option('--no-hints')
.addCommand(
new Command('deploy')
.option('--store-hash <hash>')
.option('--access-token <token>')
.action(action),
)
.addCommand(new Command('projects').addCommand(new Command('list').action(action)))
.hook('postAction', surveyPostHook);
}

const credentials = ['--store-hash', 'test-store', '--access-token', 'test-token'];

const run = (...args: string[]) => makeProgram().parseAsync(args, { from: 'user' });

beforeEach(() => {
vi.mocked(canPrompt).mockReturnValue(true);
vi.mocked(isSurveyDue).mockReturnValue(true);
});

afterEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
});

test('shows the survey after deploy, with the credentials deploy used', async () => {
await run('deploy', ...credentials);

expect(runSurveyMock).toHaveBeenCalledWith({
commandName: 'deploy',
storeHash: 'test-store',
accessToken: 'test-token',
apiHost: 'api.bigcommerce.com',
});
});

test('does not show the survey after other commands', async () => {
await run('projects', 'list');

expect(runSurveyMock).not.toHaveBeenCalled();
});

test.each([
['--no-hints before the command', () => run('--no-hints', 'deploy', ...credentials)],
['--no-hints after the command', () => run('deploy', ...credentials, '--no-hints')],
[
'CATALYST_NO_HINTS',
() => {
vi.stubEnv('CATALYST_NO_HINTS', '1');

return run('deploy', ...credentials);
},
],
[
'no TTY or CI',
() => {
vi.mocked(canPrompt).mockReturnValue(false);

return run('deploy', ...credentials);
},
],
[
'shown in the last 90 days',
() => {
vi.mocked(isSurveyDue).mockReturnValue(false);

return run('deploy', ...credentials);
},
],
['no credentials', () => run('deploy')],
])('does not show the survey with %s', async (_name, runCase) => {
await runCase();

expect(runSurveyMock).not.toHaveBeenCalled();
});

test('an error in the hook does not fail the command', async () => {
vi.mocked(isSurveyDue).mockImplementation(() => {
throw new Error('corrupt user config');
});

await expect(run('deploy', ...credentials)).resolves.toBeDefined();
});
39 changes: 39 additions & 0 deletions packages/catalyst/src/cli/hooks/survey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { CommandUnknownOpts } from '@commander-js/extra-typings';

import { canPrompt } from '../lib/can-prompt';
import { getCommandPath } from '../lib/command-path';
import { consola } from '../lib/logger';
import { hintsDisabled, resolveCommandCredentials } from '../lib/prompt-context';
import { isSurveyDue, runSurvey } from '../lib/survey';

// The survey is shown only after these commands succeed: the moment the user
// has just used Storefront Hosting.
const SURVEY_COMMANDS = new Set(['deploy']);

export const surveyPostHook = async (
program: CommandUnknownOpts,
actionCommand: CommandUnknownOpts,
) => {
// The survey must never fail a command that has already succeeded.
try {
const commandName = getCommandPath(actionCommand);

if (
!SURVEY_COMMANDS.has(commandName) ||
hintsDisabled(program) ||
!canPrompt() ||
!isSurveyDue()
) {
return;
}

// The answer is sent with the user's token. Without one, there is nowhere to send it.
const credentials = resolveCommandCredentials(actionCommand);

if (credentials) {
await runSurvey({ commandName, ...credentials });
}
} catch (error) {
consola.debug(error);
}
};
86 changes: 86 additions & 0 deletions packages/catalyst/src/cli/lib/prompt-context.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Command } from '@commander-js/extra-typings';
import { afterEach, expect, test, vi } from 'vitest';

import { hintsDisabled, isQuietPromptError, resolveCommandCredentials } from './prompt-context';

const projectConfig = new Map<string, string>();

vi.mock('./project-config', () => ({
getProjectConfig: () => ({ get: (key: string) => projectConfig.get(key) }),
}));

afterEach(() => {
projectConfig.clear();
vi.unstubAllEnvs();
});

function parsedCommand(...args: string[]) {
const command = new Command('deploy')
.exitOverride()
.option('--store-hash <hash>')
.option('--access-token <token>')
.option('--api-host <host>')
.option('--no-hints');

return command.parse(args, { from: 'user' });
}

test.each(['ExitPromptError', 'AbortPromptError'])('%s is a quiet prompt error', (name) => {
const error = new Error('prompt');

error.name = name;

expect(isQuietPromptError(error)).toBe(true);
});

test('other errors are not quiet prompt errors', () => {
expect(isQuietPromptError(new Error('ExitPromptError'))).toBe(false);
expect(isQuietPromptError('ExitPromptError')).toBe(false);
});

test('hintsDisabled with --no-hints or CATALYST_NO_HINTS', () => {
expect(hintsDisabled(parsedCommand())).toBe(false);
expect(hintsDisabled(parsedCommand('--no-hints'))).toBe(true);

vi.stubEnv('CATALYST_NO_HINTS', '1');

expect(hintsDisabled(parsedCommand())).toBe(true);
});

test('resolveCommandCredentials prefers the command options over the project config', () => {
projectConfig.set('storeHash', 'config-store');
projectConfig.set('accessToken', 'config-token');
projectConfig.set('apiHost', 'api.config.com');

expect(
resolveCommandCredentials(
parsedCommand(
...['--store-hash', 'flag-store', '--access-token', 'flag-token'],
...['--api-host', 'api.flag.com'],
),
),
).toEqual({ storeHash: 'flag-store', accessToken: 'flag-token', apiHost: 'api.flag.com' });

expect(resolveCommandCredentials(parsedCommand())).toEqual({
storeHash: 'config-store',
accessToken: 'config-token',
apiHost: 'api.config.com',
});
});

test('resolveCommandCredentials uses the project config when no command ran', () => {
projectConfig.set('storeHash', 'config-store');
projectConfig.set('accessToken', 'config-token');

expect(resolveCommandCredentials(undefined)).toEqual({
storeHash: 'config-store',
accessToken: 'config-token',
apiHost: 'api.bigcommerce.com',
});
});

test('resolveCommandCredentials returns undefined without a token', () => {
projectConfig.set('storeHash', 'config-store');

expect(resolveCommandCredentials(parsedCommand())).toBeUndefined();
});
60 changes: 60 additions & 0 deletions packages/catalyst/src/cli/lib/prompt-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { CommandUnknownOpts } from '@commander-js/extra-typings';

import { getProjectConfig } from './project-config';
import { resolveApiHost } from './shared-options';

// Helpers for prompts that run outside a command action (the survey after a
// command, the error report after a crash).

// Close an unanswered prompt, in case the CLI runs with a TTY but nobody is
// there (some runners allocate a pseudo-terminal without setting CI).
export const PROMPT_TIMEOUT_MS = 2 * 60 * 1000;

// Do not hold up the terminal for a slow endpoint.
export const SUBMIT_TIMEOUT_MS = 5000;

// Prompt errors from @inquirer/prompts that mean "the user did not answer":
// Ctrl+C (Exit) and a prompt timeout (Abort).
const QUIET_PROMPT_ERRORS = new Set(['ExitPromptError', 'AbortPromptError']);

export const isQuietPromptError = (error: unknown): boolean =>
error instanceof Error && QUIET_PROMPT_ERRORS.has(error.name);

// The global `--no-hints` flag or CATALYST_NO_HINTS.
export function hintsDisabled(program: CommandUnknownOpts): boolean {
const options: Record<string, unknown> = program.opts();

return options.hints === false || Boolean(process.env.CATALYST_NO_HINTS);
}

export interface Credentials {
storeHash: string;
accessToken: string;
apiHost: string;
}

// Commander option values are untyped outside an action.
const asString = (value: unknown): string | undefined =>
typeof value === 'string' ? value : undefined;

// Resolves credentials the same way commands do: the command's options (flags
// and their env bindings), then .bigcommerce/project.json. Returns undefined
// when there is no store hash or token.
export function resolveCommandCredentials(
command: CommandUnknownOpts | undefined,
): Credentials | undefined {
const options: Record<string, unknown> = command?.opts() ?? {};
const config = getProjectConfig();
const storeHash = asString(options.storeHash) ?? config.get('storeHash');
const accessToken = asString(options.accessToken) ?? config.get('accessToken');

if (!storeHash || !accessToken) {
return undefined;
}

return {
storeHash,
accessToken,
apiHost: resolveApiHost({ apiHost: asString(options.apiHost) }, config),
};
}
Loading
Loading