Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/feedback-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/cli-kit': minor
'@shopify/cli': minor
---

Add `shopify feedback` command for sending feedback about the CLI to Shopify
10 changes: 8 additions & 2 deletions packages/cli-kit/src/public/node/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,12 @@ export function createRuntimeMetadataContainer<
}

// We want to track anything that ends up getting sent to monorail as `cmd_all_*`,
// `cmd_app_*`, `cmd_theme_*`, `store_*`, and `env_auto_upgrade_*`
// `cmd_app_*`, `cmd_theme_*`, `store_*`, `env_auto_upgrade_*`, and `cmd_feedback_*`
type CmdFieldsFromMonorail = PickByPrefix<MonorailEventPublic, 'cmd_all_'> &
PickByPrefix<MonorailEventPublic, 'cmd_app_'> &
PickByPrefix<MonorailEventPublic, 'cmd_create_app_'> &
PickByPrefix<MonorailEventPublic, 'cmd_theme_'> &
PickByPrefix<MonorailEventPublic, 'cmd_feedback_'> &
PickByPrefix<MonorailEventPublic, 'store_'> &
PickByPrefix<MonorailEventPublic, 'env_auto_upgrade_'>

Expand All @@ -195,7 +196,12 @@ const coreData = createRuntimeMetadataContainer<
startArgs: string[]
requiresSyncAnalytics?: boolean
}
} & {environmentFlags: string} & PickByPrefix<MonorailEventSensitive, 'store_'>
} & {
environmentFlags: string
// The `shopify feedback` message is user-authored free text that may contain PII, so it travels
// inside the sensitive `metadata` field of the Monorail event rather than as a public field.
cmd_feedback_message: string
} & PickByPrefix<MonorailEventSensitive, 'store_'>
>({cmd_all_timing_network_ms: 0, cmd_all_timing_prompts_ms: 0})

export const {getAllPublicMetadata, getAllSensitiveMetadata, addPublicMetadata, addSensitiveMetadata, runWithTimer} =
Expand Down
10 changes: 9 additions & 1 deletion packages/cli-kit/src/public/node/monorail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const url = 'https://monorail-edge.shopifysvc.com/v1/produce'
type Optional<T> = T | null

// This is the topic name of the main event we log to Monorail, the command tracker
export const MONORAIL_COMMAND_TOPIC = 'app_cli3_command/1.28'
export const MONORAIL_COMMAND_TOPIC = 'app_cli3_command/1.29'

export interface Schemas {
[MONORAIL_COMMAND_TOPIC]: {
Expand Down Expand Up @@ -135,6 +135,14 @@ export interface Schemas {
// Release related commands
cmd_release_confirm_cancelled?: Optional<boolean>

// Feedback command. The message itself is user-authored free text, so it travels in the
// sensitive `metadata` field rather than as a public field.
cmd_feedback_sentiment?: Optional<string>
cmd_feedback_category?: Optional<string>
cmd_feedback_message_length?: Optional<number>
cmd_feedback_message_truncated?: Optional<boolean>
cmd_feedback_source?: Optional<string>

// App setup
app_extensions_any?: Optional<boolean>
app_extensions_breakdown?: Optional<string>
Expand Down
54 changes: 54 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
* [`shopify config autoupgrade status`](#shopify-config-autoupgrade-status)
* [`shopify doc fetch`](#shopify-doc-fetch)
* [`shopify doc search`](#shopify-doc-search)
* [`shopify feedback`](#shopify-feedback)
* [`shopify help [command] [flags]`](#shopify-help-command-flags)
* [`shopify hydrogen build`](#shopify-hydrogen-build)
* [`shopify hydrogen check RESOURCE`](#shopify-hydrogen-check-resource)
Expand Down Expand Up @@ -2212,6 +2213,59 @@ EXAMPLES
shopify doc search --query "create a product" --api-name admin --api-version latest
```

## `shopify feedback`

Send feedback about Shopify CLI.

```
USAGE
$ shopify feedback -m <value> [--category
wrong_guidance|missing_capability|confusing_docs|tool_failure|slow|other] [-j] [--no-color] [--sentiment
frustrated|blocked|confused|praise] [--verbose]

FLAGS
-j, --json
Output the result as JSON. Automatically disables color output.
[env: SHOPIFY_FLAG_JSON]

-m, --message=<value>
(required) The feedback message. Pass - to read the message from stdin. Messages longer than 2000 characters are
truncated.
[env: SHOPIFY_FLAG_MESSAGE]

--category=<option>
What the feedback is about.
[env: SHOPIFY_FLAG_CATEGORY]
<options: wrong_guidance|missing_capability|confusing_docs|tool_failure|slow|other>

--no-color
Disable color output.
[env: SHOPIFY_FLAG_NO_COLOR]

--sentiment=<option>
How the experience felt.
[env: SHOPIFY_FLAG_SENTIMENT]
<options: frustrated|blocked|confused|praise>

--verbose
Increase the verbosity of the output. May include sensitive data.
[env: SHOPIFY_FLAG_VERBOSE]

DESCRIPTION
Send feedback about Shopify CLI.

Sends feedback about Shopify CLI to the team that builds it. The feedback travels on the usage analytics the CLI
already reports, so it makes no separate network request and respects the analytics opt-out. It never prompts, so both
humans and AI agents can run it.

EXAMPLES
$ shopify feedback --message "The deploy command told me to use a flag that does not exist"

$ shopify feedback --message "dev keeps disconnecting from my store" --sentiment frustrated --category tool_failure

echo "Docs and CLI disagree about theme push" | shopify feedback --message -
```

## `shopify help [command] [flags]`

Display help for Shopify CLI
Expand Down
90 changes: 90 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4086,6 +4086,96 @@
"pluginType": "core",
"strict": true
},
"feedback": {
"aliases": [
],
"args": {
},
"description": "Sends feedback about Shopify CLI to the team that builds it. The feedback travels on the usage analytics the CLI already reports, so it makes no separate network request and respects the analytics opt-out. It never prompts, so both humans and AI agents can run it.",
"descriptionWithMarkdown": "Sends feedback about Shopify CLI to the team that builds it. The feedback travels on the usage analytics the CLI already reports, so it makes no separate network request and respects the analytics opt-out. It never prompts, so both humans and AI agents can run it.",
"enableJsonFlag": false,
"examples": [
"<%= config.bin %> <%= command.id %> --message \"The deploy command told me to use a flag that does not exist\"",
"<%= config.bin %> <%= command.id %> --message \"dev keeps disconnecting from my store\" --sentiment frustrated --category tool_failure",
"echo \"Docs and CLI disagree about theme push\" | <%= config.bin %> <%= command.id %> --message -"
],
"flags": {
"category": {
"description": "What the feedback is about.",
"env": "SHOPIFY_FLAG_CATEGORY",
"hasDynamicHelp": false,
"multiple": false,
"name": "category",
"options": [
"wrong_guidance",
"missing_capability",
"confusing_docs",
"tool_failure",
"slow",
"other"
],
"type": "option"
},
"json": {
"allowNo": false,
"char": "j",
"description": "Output the result as JSON. Automatically disables color output.",
"env": "SHOPIFY_FLAG_JSON",
"hidden": false,
"name": "json",
"type": "boolean"
},
"message": {
"char": "m",
"description": "The feedback message. Pass - to read the message from stdin. Messages longer than 2000 characters are truncated.",
"env": "SHOPIFY_FLAG_MESSAGE",
"hasDynamicHelp": false,
"multiple": false,
"name": "message",
"required": true,
"type": "option"
},
"no-color": {
"allowNo": false,
"description": "Disable color output.",
"env": "SHOPIFY_FLAG_NO_COLOR",
"hidden": false,
"name": "no-color",
"type": "boolean"
},
"sentiment": {
"description": "How the experience felt.",
"env": "SHOPIFY_FLAG_SENTIMENT",
"hasDynamicHelp": false,
"multiple": false,
"name": "sentiment",
"options": [
"frustrated",
"blocked",
"confused",
"praise"
],
"type": "option"
},
"verbose": {
"allowNo": false,
"description": "Increase the verbosity of the output. May include sensitive data.",
"env": "SHOPIFY_FLAG_VERBOSE",
"hidden": false,
"name": "verbose",
"type": "boolean"
}
},
"hasDynamicHelp": false,
"hiddenAliases": [
],
"id": "feedback",
"pluginAlias": "@shopify/cli",
"pluginName": "@shopify/cli",
"pluginType": "core",
"strict": true,
"summary": "Send feedback about Shopify CLI."
},
"help": {
"aliases": [
],
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/src/cli/commands/feedback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Feedback from './feedback.js'
import {feedbackService} from '../services/commands/feedback.js'
import {describe, expect, test, vi} from 'vitest'

// Only mock the service function itself: the command imports MAX_FEEDBACK_MESSAGE_LENGTH from the
// same module, and that must stay real for the flag descriptions.
vi.mock('../services/commands/feedback.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../services/commands/feedback.js')>()
return {...original, feedbackService: vi.fn()}
})

describe('feedback command', () => {
test('delegates to the feedback service with the parsed flags', async () => {
await Feedback.run(
['--message', 'The docs were wrong', '--sentiment', 'confused', '--category', 'confusing_docs'],
import.meta.url,
)

expect(feedbackService).toHaveBeenCalledWith({
message: 'The docs were wrong',
sentiment: 'confused',
category: 'confusing_docs',
json: false,
})
})

test('passes --json through to the service', async () => {
await Feedback.run(['--message', 'It worked!', '--json'], import.meta.url)

expect(feedbackService).toHaveBeenCalledWith({
message: 'It worked!',
sentiment: undefined,
category: undefined,
json: true,
})
})

test('fails when --message is missing', async () => {
await expect(Feedback.run([], import.meta.url)).rejects.toThrow()

expect(feedbackService).not.toHaveBeenCalled()
})

test('rejects a sentiment outside the closed set', async () => {
await expect(Feedback.run(['--message', 'hi', '--sentiment', 'angry'], import.meta.url)).rejects.toThrow()

expect(feedbackService).not.toHaveBeenCalled()
})

test('rejects a category outside the closed set', async () => {
await expect(Feedback.run(['--message', 'hi', '--category', 'bad_vibes'], import.meta.url)).rejects.toThrow()

expect(feedbackService).not.toHaveBeenCalled()
})
})
55 changes: 55 additions & 0 deletions packages/cli/src/cli/commands/feedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {feedbackService, MAX_FEEDBACK_MESSAGE_LENGTH} from '../services/commands/feedback.js'
import Command from '@shopify/cli-kit/node/base-command'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {Flags} from '@oclif/core'

export default class Feedback extends Command {
// The analytics event carrying the feedback is the whole point of this command, so wait for the
// delivery attempt instead of detaching it to a background process that might not outlive us.
public static get requiresSyncAnalytics(): boolean {
return true
}

static summary = 'Send feedback about Shopify CLI.'

static descriptionWithMarkdown = `Sends feedback about Shopify CLI to the team that builds it. The feedback travels on the usage analytics the CLI already reports, so it makes no separate network request and respects the analytics opt-out. It never prompts, so both humans and AI agents can run it.`

static description = this.descriptionWithoutMarkdown()

static examples = [
'<%= config.bin %> <%= command.id %> --message "The deploy command told me to use a flag that does not exist"',
'<%= config.bin %> <%= command.id %> --message "dev keeps disconnecting from my store" --sentiment frustrated --category tool_failure',
'echo "Docs and CLI disagree about theme push" | <%= config.bin %> <%= command.id %> --message -',
]

static flags = {
...globalFlags,
...jsonFlag,
message: Flags.string({
char: 'm',
description: `The feedback message. Pass - to read the message from stdin. Messages longer than ${MAX_FEEDBACK_MESSAGE_LENGTH} characters are truncated.`,
required: true,
env: 'SHOPIFY_FLAG_MESSAGE',
}),
sentiment: Flags.string({
description: 'How the experience felt.',
options: ['frustrated', 'blocked', 'confused', 'praise'],
env: 'SHOPIFY_FLAG_SENTIMENT',
}),
category: Flags.string({
description: 'What the feedback is about.',
options: ['wrong_guidance', 'missing_capability', 'confusing_docs', 'tool_failure', 'slow', 'other'],
env: 'SHOPIFY_FLAG_CATEGORY',
}),
}

async run(): Promise<void> {
const {flags} = await this.parse(Feedback)
await feedbackService({
message: flags.message,
sentiment: flags.sentiment,
category: flags.category,
json: flags.json,
})
}
}
Loading
Loading