From 483a9ff9ec5a59c24c0c07ddad0c58ab63579f8a Mon Sep 17 00:00:00 2001 From: Parth Shah Date: Tue, 13 Oct 2026 12:06:43 -0400 Subject: [PATCH] LTRAC-2163: feat(cli) - Ask for feedback after catalyst deploy --- .changeset/ltrac-2163-cli-feedback.md | 1 + packages/catalyst/AGENTS.md | 6 +- .../catalyst/src/cli/hooks/survey.spec.ts | 104 ++++++++++ packages/catalyst/src/cli/hooks/survey.ts | 39 ++++ .../src/cli/lib/prompt-context.spec.ts | 86 +++++++++ .../catalyst/src/cli/lib/prompt-context.ts | 60 ++++++ packages/catalyst/src/cli/lib/survey.spec.ts | 182 ++++++++++++++++++ packages/catalyst/src/cli/lib/survey.ts | 96 +++++++++ packages/catalyst/src/cli/lib/user-config.ts | 10 + packages/catalyst/src/cli/program.ts | 8 +- 10 files changed, 590 insertions(+), 2 deletions(-) create mode 100644 packages/catalyst/src/cli/hooks/survey.spec.ts create mode 100644 packages/catalyst/src/cli/hooks/survey.ts create mode 100644 packages/catalyst/src/cli/lib/prompt-context.spec.ts create mode 100644 packages/catalyst/src/cli/lib/prompt-context.ts create mode 100644 packages/catalyst/src/cli/lib/survey.spec.ts create mode 100644 packages/catalyst/src/cli/lib/survey.ts diff --git a/.changeset/ltrac-2163-cli-feedback.md b/.changeset/ltrac-2163-cli-feedback.md index ec05c78096..e9ece8b09a 100644 --- a/.changeset/ltrac-2163-cli-feedback.md +++ b/.changeset/ltrac-2163-cli-feedback.md @@ -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. diff --git a/packages/catalyst/AGENTS.md b/packages/catalyst/AGENTS.md index e97af0c71c..1b47a8780c 100644 --- a/packages/catalyst/AGENTS.md +++ b/packages/catalyst/AGENTS.md @@ -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 @@ -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 diff --git a/packages/catalyst/src/cli/hooks/survey.spec.ts b/packages/catalyst/src/cli/hooks/survey.spec.ts new file mode 100644 index 0000000000..2f436f9045 --- /dev/null +++ b/packages/catalyst/src/cli/hooks/survey.spec.ts @@ -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 ') + .option('--access-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(); +}); diff --git a/packages/catalyst/src/cli/hooks/survey.ts b/packages/catalyst/src/cli/hooks/survey.ts new file mode 100644 index 0000000000..9eafd8f5f3 --- /dev/null +++ b/packages/catalyst/src/cli/hooks/survey.ts @@ -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); + } +}; diff --git a/packages/catalyst/src/cli/lib/prompt-context.spec.ts b/packages/catalyst/src/cli/lib/prompt-context.spec.ts new file mode 100644 index 0000000000..595c3a1782 --- /dev/null +++ b/packages/catalyst/src/cli/lib/prompt-context.spec.ts @@ -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(); + +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 ') + .option('--access-token ') + .option('--api-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(); +}); diff --git a/packages/catalyst/src/cli/lib/prompt-context.ts b/packages/catalyst/src/cli/lib/prompt-context.ts new file mode 100644 index 0000000000..c588c447cb --- /dev/null +++ b/packages/catalyst/src/cli/lib/prompt-context.ts @@ -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 = 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 = 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), + }; +} diff --git a/packages/catalyst/src/cli/lib/survey.spec.ts b/packages/catalyst/src/cli/lib/survey.spec.ts new file mode 100644 index 0000000000..49d95e1ca3 --- /dev/null +++ b/packages/catalyst/src/cli/lib/survey.spec.ts @@ -0,0 +1,182 @@ +import { input } from '@inquirer/prompts'; +import { afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest'; + +import PACKAGE_INFO from '../../../package.json'; + +import { submitFeedback } from './feedback'; +import { consola } from './logger'; +import { isSurveyDue, runSurvey } from './survey'; + +vi.mock('@inquirer/prompts', () => ({ input: vi.fn() })); +vi.mock('./feedback', async (importOriginal) => ({ + ...(await importOriginal()), + submitFeedback: vi.fn(), +})); + +// In-memory user config, so tests never write to the real OS config dir. +const store = new Map(); +const userConfig = { + get: (key: string) => store.get(key), + set: (key: string, value: unknown) => store.set(key, value), +}; + +vi.mock('./user-config', () => ({ getUserConfig: () => userConfig })); + +const inputMock = vi.mocked(input); +const submitFeedbackMock = vi.mocked(submitFeedback); + +const context = { + commandName: 'deploy', + storeHash: 'test-store', + accessToken: 'test-token', + apiHost: 'api.bigcommerce.com', +}; + +const DAY_MS = 24 * 60 * 60 * 1000; +const now = Date.parse('2026-10-01T00:00:00.000Z'); + +const promptError = (name: string) => Object.assign(new Error('prompt'), { name }); + +beforeAll(() => { + consola.mockTypes(() => vi.fn()); +}); + +beforeEach(() => { + vi.useFakeTimers({ now, toFake: ['Date'] }); + store.clear(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +test('the survey is due on a machine that has never shown it', () => { + expect(isSurveyDue()).toBe(true); +}); + +test('after the survey is shown, it is not due again for 90 days', async () => { + inputMock.mockResolvedValueOnce(''); + + await runSurvey(context); + + vi.setSystemTime(now + 90 * DAY_MS - 1); + + expect(isSurveyDue()).toBe(false); + + vi.setSystemTime(now + 90 * DAY_MS); + + expect(isSurveyDue()).toBe(true); +}); + +test('sends the score and the comment', async () => { + inputMock.mockResolvedValueOnce(' 8 ').mockResolvedValueOnce(' Deploys are fast. '); + + await runSurvey(context); + + // Each prompt closes on its own if nobody answers. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect(inputMock).toHaveBeenCalledWith(expect.anything(), { signal: expect.any(AbortSignal) }); + expect(submitFeedbackMock).toHaveBeenCalledWith( + { + title: 'CLI survey: 8/10', + description: 'Deploys are fast.', + diagnostics: [ + { + title: 'Survey', + body: `Score: 8/10\nCLI version: ${PACKAGE_INFO.version}\nCommand: deploy`, + }, + ], + }, + 'test-store', + 'test-token', + 'api.bigcommerce.com', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + { source: 'catalyst-cli-survey', signal: expect.any(AbortSignal) }, + ); + expect(consola.success).toHaveBeenCalledWith('Thank you for your feedback!'); +}); + +test('sends a placeholder description when there is no comment', async () => { + inputMock.mockResolvedValueOnce('0').mockResolvedValueOnce(''); + + await runSurvey(context); + + expect(submitFeedbackMock.mock.calls[0]?.[0]).toMatchObject({ + title: 'CLI survey: 0/10', + description: 'No comment.', + }); +}); + +test('Enter without a score skips the comment and sends nothing', async () => { + inputMock.mockResolvedValueOnce(''); + + await runSurvey(context); + + expect(inputMock).toHaveBeenCalledTimes(1); + expect(submitFeedbackMock).not.toHaveBeenCalled(); + expect(consola.info).toHaveBeenCalledWith( + 'Skipped. We will not ask again for at least three months.', + ); +}); + +test('the score must be a whole number from 0 to 10, or empty', async () => { + inputMock.mockResolvedValueOnce(''); + + await runSurvey(context); + + const validate = inputMock.mock.calls[0]?.[0].validate; + const check = (answers: string[]) => Promise.all(answers.map(async (a) => validate?.(a))); + + expect(new Set(await check(['', ' ', '0', ' 7 ', '10']))).toEqual(new Set([true])); + expect(new Set(await check(['11', '-1', '7.5', '08', 'great']))).toEqual( + new Set(['Enter a whole number from 0 to 10, or press Enter to skip.']), + ); +}); + +test('the comment is optional but limited to 5000 characters', async () => { + inputMock.mockResolvedValueOnce('9').mockResolvedValueOnce('ok'); + + await runSurvey(context); + + const validate = inputMock.mock.calls[1]?.[0].validate; + + expect(await validate?.('')).toBe(true); + expect(await validate?.('a'.repeat(5000))).toBe(true); + expect(await validate?.('a'.repeat(5001))).toBe( + 'Description must be at most 5000 characters (got 5001).', + ); +}); + +test.each(['ExitPromptError', 'AbortPromptError'])( + 'a %s (Ctrl+C or the prompt timeout) ends the survey quietly', + async (name) => { + inputMock.mockRejectedValueOnce(promptError(name)); + + await expect(runSurvey(context)).resolves.toBeUndefined(); + + expect(consola.warn).not.toHaveBeenCalled(); + expect(isSurveyDue()).toBe(false); + }, +); + +test('a failed request warns and does not throw', async () => { + inputMock.mockResolvedValueOnce('5').mockResolvedValueOnce(''); + submitFeedbackMock.mockRejectedValueOnce(new Error('network down')); + + await expect(runSurvey(context)).resolves.toBeUndefined(); + + expect(consola.warn).toHaveBeenCalledWith( + 'Could not send your survey answer. Use `catalyst feedback` to send feedback.', + ); +}); + +test('a user config that cannot be written skips the survey', async () => { + vi.spyOn(userConfig, 'set').mockImplementationOnce(() => { + throw new Error('EACCES'); + }); + + await expect(runSurvey(context)).resolves.toBeUndefined(); + + expect(inputMock).not.toHaveBeenCalled(); +}); diff --git a/packages/catalyst/src/cli/lib/survey.ts b/packages/catalyst/src/cli/lib/survey.ts new file mode 100644 index 0000000000..cf1526e486 --- /dev/null +++ b/packages/catalyst/src/cli/lib/survey.ts @@ -0,0 +1,96 @@ +import { input } from '@inquirer/prompts'; +import { colorize } from 'consola/utils'; + +import PACKAGE_INFO from '../../../package.json'; + +import { submitFeedback, validateFeedbackField } from './feedback'; +import { consola } from './logger'; +import { + type Credentials, + isQuietPromptError, + PROMPT_TIMEOUT_MS, + SUBMIT_TIMEOUT_MS, +} from './prompt-context'; +import { getUserConfig } from './user-config'; + +// After the survey is shown (answered or skipped), do not show it again on +// this machine for this long. +const SURVEY_INTERVAL_MS = 90 * 24 * 60 * 60 * 1000; + +// A whole number from 0 to 10, or nothing (skip). +const SCORE_ANSWER = /^\s*(?:10|\d)?\s*$/; + +export interface SurveyContext extends Credentials { + commandName: string; +} + +export function isSurveyDue(): boolean { + const lastShownAt = getUserConfig().get('survey')?.lastShownAt; + + return lastShownAt === undefined || Date.now() - lastShownAt >= SURVEY_INTERVAL_MS; +} + +const ask = async (message: string, validate: (value: string) => boolean | string) => + (await input({ message, validate }, { signal: AbortSignal.timeout(PROMPT_TIMEOUT_MS) })).trim(); + +// Asks for a 0-10 score and an optional comment, then sends them as feedback. +// The survey must never fail the command that triggered it, so all errors +// (including Ctrl+C at the prompt) are caught here. +export async function runSurvey({ + commandName, + storeHash, + accessToken, + apiHost, +}: SurveyContext): Promise { + try { + // Mark first, so the survey is not shown again soon even if the user + // presses Ctrl+C or the request fails. + getUserConfig().set('survey', { lastShownAt: Date.now() }); + + consola.log(''); + consola.log(colorize('cyan', 'Help us improve Catalyst. One question, and you can skip it.')); + + const score = await ask( + 'How was your experience using the Catalyst CLI with Storefront Hosting? (0 = very poor, 10 = excellent, Enter to skip)', + (value) => + SCORE_ANSWER.test(value) || 'Enter a whole number from 0 to 10, or press Enter to skip.', + ); + + if (!score) { + consola.info('Skipped. We will not ask again for at least three months.'); + + return; + } + + const comment = await ask( + 'What went well, or what could be better? (optional, Enter to skip)', + (value) => !value.trim() || (validateFeedbackField('Description', value) ?? true), + ); + + await submitFeedback( + { + title: `CLI survey: ${score}/10`, + description: comment || 'No comment.', + diagnostics: [ + { + title: 'Survey', + body: `Score: ${score}/10\nCLI version: ${PACKAGE_INFO.version}\nCommand: ${commandName}`, + }, + ], + }, + storeHash, + accessToken, + apiHost, + { source: 'catalyst-cli-survey', signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS) }, + ); + + consola.success('Thank you for your feedback!'); + } catch (error) { + if (isQuietPromptError(error)) { + return; + } + + consola.debug(error); + consola.warn('Could not send your survey answer. Use `catalyst feedback` to send feedback.'); + } +} diff --git a/packages/catalyst/src/cli/lib/user-config.ts b/packages/catalyst/src/cli/lib/user-config.ts index 2fb7f48ebe..559c4653f6 100644 --- a/packages/catalyst/src/cli/lib/user-config.ts +++ b/packages/catalyst/src/cli/lib/user-config.ts @@ -5,6 +5,10 @@ export interface UserConfigSchema { enabled: boolean; anonymousId: string; }; + survey?: { + // When the feedback survey was last shown, in ms since the epoch. + lastShownAt?: number; + }; } let userConfigInstance: Conf | undefined; @@ -25,6 +29,12 @@ export function getUserConfig(): Conf { anonymousId: { type: 'string' }, }, }, + survey: { + type: 'object', + properties: { + lastShownAt: { type: 'number' }, + }, + }, }, }); diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 2230c0f7d0..c20990b0b0 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -18,6 +18,7 @@ import { start } from './commands/start'; import { telemetry } from './commands/telemetry'; import { upgrade } from './commands/upgrade'; import { version } from './commands/version'; +import { surveyPostHook } from './hooks/survey'; import { telemetryPostHook, telemetryPreHook } from './hooks/telemetry'; import { consola } from './lib/logger'; @@ -45,6 +46,10 @@ program 'CLI tool for Catalyst development.\n\nConfiguration priority: flags > process.env > .bigcommerce/project.json. `build` and `deploy` additionally load env files (--env-path, or an auto-loaded .env.local/.env) for the build.\n\nCATALYST_STORE_HASH falls back to BIGCOMMERCE_STORE_HASH if unset.\n\nRun `catalyst --help` for details on a specific command.', ) .configureHelp({ showGlobalOptions: true }) + .option( + '--no-hints', + 'Do not show the feedback survey. Also off in CI, without a TTY, or when CATALYST_NO_HINTS is set.', + ) .addCommand(version) .addCommand(create) .addCommand(start) @@ -61,4 +66,5 @@ program .addCommand(debug) .addCommand(feedback) .hook('preAction', telemetryPreHook) - .hook('postAction', telemetryPostHook); + .hook('postAction', telemetryPostHook) + .hook('postAction', surveyPostHook);