diff --git a/.changeset/doc-search-json-output.md b/.changeset/doc-search-json-output.md new file mode 100644 index 00000000000..dfb6525d650 --- /dev/null +++ b/.changeset/doc-search-json-output.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add typed JSON output and schema discovery to `doc search`. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index ff195fb7ec2..d0c8de00f05 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -5256,9 +5256,18 @@ "description": "Increase the verbosity of the output. May include sensitive data.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/doc-search.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" } ], - "value": "export interface docsearch {\n /**\n * Limit results to a specific API (for example: admin, storefront, hydrogen, functions). Unrecognized values are ignored.\n * @environment SHOPIFY_FLAG_API_NAME\n */\n '--api-name '?: string\n\n /**\n * Limit results to a specific API version (for example: 2025-10, latest, current).\n * @environment SHOPIFY_FLAG_API_VERSION\n */\n '--api-version '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The search query.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '--query ': string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface docsearch {\n /**\n * Limit results to a specific API (for example: admin, storefront, hydrogen, functions). Unrecognized values are ignored.\n * @environment SHOPIFY_FLAG_API_NAME\n */\n '--api-name '?: string\n\n /**\n * Limit results to a specific API version (for example: 2025-10, latest, current).\n * @environment SHOPIFY_FLAG_API_VERSION\n */\n '--api-version '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The search query.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '--query ': string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "help": { diff --git a/packages/cli/README.md b/packages/cli/README.md index cbddf9f639f..33e628bdde0 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4615,10 +4615,14 @@ Query the shopify.dev vector store and print the most relevant documentation chu ``` USAGE - $ shopify doc search --query [--api-name ] [--api-version ] [--json-schema] [--no-color] - [--no-input] [--verbose] + $ shopify doc search --query [--api-name ] [--api-version ] [-j] [--json-schema] + [--no-color] [--no-input] [--verbose] FLAGS + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + --api-name= Limit results to a specific API (for example: admin, storefront, hydrogen, functions). Unrecognized values are ignored. @@ -4653,11 +4657,99 @@ DESCRIPTION discovery — surfacing the relevant pieces of documentation for a topic, rather than retrieving a whole document. To download a full document verbatim, use `doc fetch`. + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `DocSearchResult` schema. + + ```json + { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/DocumentationSearchEntry" + }, + "description": "The top matching chunks from one search request." + }, + "pageInfo": { + "$ref": "#/definitions/PageInfo" + } + }, + "required": [ + "results", + "pageInfo" + ], + "additionalProperties": false, + "title": "DocSearchResult", + "definitions": { + "DocumentationSearchEntry": { + "type": "object", + "properties": { + "score": { + "type": "number", + "description": "The relevance score returned by shopify.dev." + }, + "content": { + "type": "string", + "description": "The matching documentation chunk." + }, + "url": { + "type": "string", + "format": "uri", + "description": "The URL of the matching document." + }, + "title": { + "type": "string", + "description": "The title of the matching document." + }, + "domain": { + "type": [ + "string", + "null" + ], + "description": "The documentation domain, or null when unavailable." + } + }, + "required": [ + "score", + "content", + "url", + "title", + "domain" + ], + "additionalProperties": false + }, + "PageInfo": { + "type": "object", + "properties": { + "hasNextPage": { + "type": [ + "boolean", + "null" + ], + "description": "Whether more results are available, or null when unknown." + } + }, + "required": [ + "hasNextPage" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` + EXAMPLES # search shopify.dev for a topic shopify doc search --query "subscribe to webhooks" # narrow the search to a specific API and version shopify doc search --query "create a product" --api-name admin --api-version latest + + # return typed documentation results as a JSON object + + $ shopify doc search --query "subscribe to webhooks" --json ``` ## `shopify help [command] [flags]` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 2a705203ba1..bbacb9436c6 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -6239,10 +6239,12 @@ ], "args": { }, - "description": "Query the shopify.dev vector store and print the most relevant documentation chunks as JSON. Best for programmatic discovery — surfacing the relevant pieces of documentation for a topic, rather than retrieving a whole document. To download a full document verbatim, use `doc fetch`.", + "description": "Query the shopify.dev vector store and print the most relevant documentation chunks as JSON. Best for programmatic discovery — surfacing the relevant pieces of documentation for a topic, rather than retrieving a whole document. To download a full document verbatim, use `doc fetch`.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `DocSearchResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"results\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/DocumentationSearchEntry\"\n },\n \"description\": \"The top matching chunks from one search request.\"\n },\n \"pageInfo\": {\n \"$ref\": \"#/definitions/PageInfo\"\n }\n },\n \"required\": [\n \"results\",\n \"pageInfo\"\n ],\n \"additionalProperties\": false,\n \"title\": \"DocSearchResult\",\n \"definitions\": {\n \"DocumentationSearchEntry\": {\n \"type\": \"object\",\n \"properties\": {\n \"score\": {\n \"type\": \"number\",\n \"description\": \"The relevance score returned by shopify.dev.\"\n },\n \"content\": {\n \"type\": \"string\",\n \"description\": \"The matching documentation chunk.\"\n },\n \"url\": {\n \"type\": \"string\",\n \"format\": \"uri\",\n \"description\": \"The URL of the matching document.\"\n },\n \"title\": {\n \"type\": \"string\",\n \"description\": \"The title of the matching document.\"\n },\n \"domain\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"The documentation domain, or null when unavailable.\"\n }\n },\n \"required\": [\n \"score\",\n \"content\",\n \"url\",\n \"title\",\n \"domain\"\n ],\n \"additionalProperties\": false\n },\n \"PageInfo\": {\n \"type\": \"object\",\n \"properties\": {\n \"hasNextPage\": {\n \"type\": [\n \"boolean\",\n \"null\"\n ],\n \"description\": \"Whether more results are available, or null when unknown.\"\n }\n },\n \"required\": [\n \"hasNextPage\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "descriptionWithMarkdown": "Query the shopify.dev vector store and print the most relevant documentation chunks as JSON. Best for programmatic discovery — surfacing the relevant pieces of documentation for a topic, rather than retrieving a whole document. To download a full document verbatim, use `doc fetch`.", "enableJsonFlag": false, "examples": [ - "# search shopify.dev for a topic\n shopify doc search --query \"subscribe to webhooks\"\n\n # narrow the search to a specific API and version\n shopify doc search --query \"create a product\" --api-name admin --api-version latest\n " + "# search shopify.dev for a topic\n shopify doc search --query \"subscribe to webhooks\"\n\n # narrow the search to a specific API and version\n shopify doc search --query \"create a product\" --api-name admin --api-version latest", + "# return typed documentation results as a JSON object\nshopify doc search --query \"subscribe to webhooks\" --json" ], "flags": { "api-name": { @@ -6261,6 +6263,15 @@ "name": "api-version", "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" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", diff --git a/packages/cli/src/cli/commands/doc/search.test.ts b/packages/cli/src/cli/commands/doc/search.test.ts new file mode 100644 index 00000000000..3916c514059 --- /dev/null +++ b/packages/cli/src/cli/commands/doc/search.test.ts @@ -0,0 +1,186 @@ +import DocSearch from './search.js' +import {docSearchJsonOutputSchema} from '../../services/commands/doc/types.js' +import {shopifyFetch, Response} from '@shopify/cli-kit/node/http' +import {launchCLI} from '@shopify/cli-kit/node/cli-launcher' +import {ShopifyConfig} from '@shopify/cli-kit/node/custom-oclif-loader' +import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/http', async (importOriginal) => ({ + ...(await importOriginal()), + shopifyFetch: vi.fn(), +})) + +const entry = {score: 0.99, content: 'About webhooks', url: 'https://shopify.dev/x', title: 'Webhooks', domain: null} +const body = `[\n {"title":"Webhooks","url":"https://shopify.dev/x","content":"About webhooks","score":0.99,"domain":null}\n]` + +beforeEach(() => { + vi.stubEnv('CI', '1') + vi.stubEnv('SHOPIFY_CLI_NO_ANALYTICS', '1') + vi.mocked(shopifyFetch).mockResolvedValue(new Response(body)) + vi.spyOn(process, 'exit').mockReturnValue(undefined as never) +}) + +afterEach(() => { + vi.unstubAllEnvs() + mockAndCaptureOutput().clear() +}) + +describe('doc search command', () => { + test('preserves the exact response bytes on stdout without JSON selection', async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await DocSearch.run(['--query', 'webhooks', '--no-input'], import.meta.url) + expect(stdout()).toBe(`${body}\n`) + expect(stderr()).toBe('') + }) + }) + + test.each([ + 'not JSON', + JSON.stringify([{...entry, title: null}]), + JSON.stringify([{score: entry.score, content: entry.content, url: entry.url, domain: entry.domain}]), + ])('preserves raw successful responses outside the JSON schema: %s', async (response) => { + vi.mocked(shopifyFetch).mockResolvedValue(new Response(response)) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await DocSearch.run(['--query', 'webhooks', '--no-input'], import.meta.url) + expect(stdout()).toBe(`${response}\n`) + expect(stderr()).toBe('') + expect(process.exit).not.toHaveBeenCalled() + }) + }) + + test.each([ + JSON.stringify([{...entry, title: null}]), + JSON.stringify([{score: entry.score, content: entry.content, url: entry.url, domain: entry.domain}]), + ])('reports incompatible search data as one fatal JSON document: %s', async (response) => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + vi.mocked(shopifyFetch).mockResolvedValue(new Response(response)) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await DocSearch.run(['--query', 'webhooks', '--json'], import.meta.url) + expect(JSON.parse(stdout())).toEqual({ + error: {type: 'abort', message: 'Search returned an invalid documentation response.'}, + }) + expect(stderr()).toBe('') + expect(process.exit).toHaveBeenCalledWith(1) + }) + }) + + test.each([{flags: []}, {flags: ['--no-input']}])( + 'writes one result object with JSON and flags $flags', + async ({flags}) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await DocSearch.run( + ['--query', 'webhooks', '--api-name', 'admin', '--api-version', 'latest', '--json', ...flags], + import.meta.url, + ) + expect(JSON.parse(stdout())).toEqual({results: [entry], pageInfo: {hasNextPage: null}}) + expect(stderr()).toBe('') + expect(shopifyFetch).toHaveBeenCalledWith( + 'https://shopify.dev/assistant/search?query=webhooks&api_name=admin&api_version=latest', + { + headers: {Accept: 'application/json', 'X-Shopify-Surface': 'cli'}, + }, + ) + }) + }, + ) + + test('supports the shared JSON environment flag', async () => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await DocSearch.run(['--query', 'webhooks'], import.meta.url) + expect(JSON.parse(stdout())).toEqual({results: [entry], pageInfo: {hasNextPage: null}}) + expect(stderr()).toBe('') + }) + }) + + test('reports missing required input before making a request', async () => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await DocSearch.run(['--json', '--no-input'], import.meta.url) + expect(JSON.parse(stdout())).toHaveProperty('error') + expect(process.exit).toHaveBeenCalledWith(2) + expect(stderr()).toBe('') + expect(shopifyFetch).not.toHaveBeenCalled() + }) + }) + + test.each([{results: []}, {results: [{...entry, score: 0, domain: 'admin'}]}])( + 'encodes empty results and nullable metadata: $results', + async ({results}) => { + vi.mocked(shopifyFetch).mockResolvedValue(new Response(JSON.stringify(results))) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await DocSearch.run(['--query', 'webhooks', '--json'], import.meta.url) + expect(JSON.parse(stdout())).toEqual({results, pageInfo: {hasNextPage: null}}) + expect(stderr()).toBe('') + }) + }, + ) + + test.each([ + { + status: 400, + statusText: 'Bad Request', + response: '{"error":"Invalid api_version"}', + message: 'Search failed: Invalid api_version', + }, + { + status: 500, + statusText: 'Internal Server Error', + response: 'nope', + message: 'Search failed: 500 Internal Server Error', + }, + { + status: 200, + statusText: 'OK', + response: 'not JSON', + message: 'Search returned an invalid documentation response.', + }, + ])('reports $status failures through the shared error document', async ({status, statusText, response, message}) => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + vi.mocked(shopifyFetch).mockResolvedValue(new Response(response, {status, statusText})) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect(DocSearch.run(['--query', 'webhooks', '--json'], import.meta.url)).resolves.toBeUndefined() + expect(process.exit).toHaveBeenCalledWith(1) + expect(JSON.parse(stdout())).toEqual({error: {type: 'abort', message}}) + expect(stderr()).toBe('') + }) + }) + + test('keeps the transport failure guidance and nonzero exit', async () => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + vi.mocked(shopifyFetch).mockRejectedValue(new Error('getaddrinfo ENOTFOUND shopify.dev')) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect(DocSearch.run(['--query', 'webhooks', '--json'], import.meta.url)).resolves.toBeUndefined() + expect(process.exit).toHaveBeenCalledWith(1) + expect(JSON.parse(stdout())).toEqual({ + error: { + type: 'abort', + message: 'Could not reach shopify.dev to run the search.', + tryMessage: 'Check your network connection and try again.', + }, + }) + expect(stderr()).toBe('') + }) + }) + + test('exposes the schema in help and through the launcher without a search request', async () => { + expect(DocSearch.jsonOutputSchema).toBe(docSearchJsonOutputSchema) + expect(DocSearch.flags.json).toBeDefined() + expect(DocSearch.description).toContain('Output from `--json` conforms to the `DocSearchResult` schema.') + vi.spyOn(ShopifyConfig.prototype, 'runHook').mockResolvedValue({successes: [], failures: []}) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await launchCLI({ + moduleURL: import.meta.url, + argv: ['doc', 'search', '--json-schema'], + lazyCommandLoader: async () => DocSearch, + }) + const schema = JSON.parse(stdout()) + expect(schema.definitions.Result.required).toEqual(['results', 'pageInfo']) + expect(schema.definitions.Result.properties.results.items.properties.domain.type).toEqual(['string', 'null']) + expect(schema.definitions.Result.additionalProperties).toBe(false) + expect(stderr()).toBe('') + expect(shopifyFetch).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/cli/src/cli/commands/doc/search.ts b/packages/cli/src/cli/commands/doc/search.ts index c9911fea502..0e5be753ac2 100644 --- a/packages/cli/src/cli/commands/doc/search.ts +++ b/packages/cli/src/cli/commands/doc/search.ts @@ -1,23 +1,29 @@ import {docSearchService} from '../../services/commands/doc/search.js' +import {presentDocSearchResult} from '../../services/commands/doc/search-result.js' +import {docSearchJsonOutputSchema} from '../../services/commands/doc/types.js' import Command from '@shopify/cli-kit/node/base-command' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {Flags} from '@oclif/core' export default class DocSearch extends Command { - static description = + static descriptionWithMarkdown = 'Query the shopify.dev vector store and print the most relevant documentation chunks as JSON. Best for programmatic discovery — surfacing the relevant pieces of documentation for a topic, rather than retrieving a whole document. To download a full document verbatim, use `doc fetch`.' + static description = this.descriptionForHelp() + static examples = [ `# search shopify.dev for a topic shopify doc search --query "subscribe to webhooks" # narrow the search to a specific API and version - shopify doc search --query "create a product" --api-name admin --api-version latest - `, + shopify doc search --query "create a product" --api-name admin --api-version latest`, + `# return typed documentation results as a JSON object +shopify doc search --query "subscribe to webhooks" --json`, ] static flags = { ...globalFlags, + ...jsonFlag, query: Flags.string({ description: 'The search query.', env: 'SHOPIFY_FLAG_QUERY', @@ -34,8 +40,13 @@ export default class DocSearch extends Command { }), } + static get jsonOutputSchema() { + return docSearchJsonOutputSchema + } + async run(): Promise { const {flags} = await this.parse(DocSearch) - await docSearchService(flags.query, flags['api-name'], flags['api-version']) + const result = await docSearchService(flags.query, flags['api-name'], flags['api-version']) + presentDocSearchResult(result, flags.json ? 'json' : 'text') } } diff --git a/packages/cli/src/cli/services/commands/doc/search-result.ts b/packages/cli/src/cli/services/commands/doc/search-result.ts new file mode 100644 index 00000000000..30703aa8ec1 --- /dev/null +++ b/packages/cli/src/cli/services/commands/doc/search-result.ts @@ -0,0 +1,16 @@ +import {docSearchJsonOutputSchema} from './types.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {AbortError} from '@shopify/cli-kit/node/error' +import type {DocSearchServiceResult} from './search.js' + +export function presentDocSearchResult(result: DocSearchServiceResult, format: 'json' | 'text'): void { + if (format === 'text') { + outputResult(result.body) + return + } + + if (result.status === 'invalid-response') { + throw new AbortError('Search returned an invalid documentation response.') + } + outputResult(docSearchJsonOutputSchema.encode({results: result.results, pageInfo: result.pageInfo})) +} diff --git a/packages/cli/src/cli/services/commands/doc/search.test.ts b/packages/cli/src/cli/services/commands/doc/search.test.ts index 0a6433e30ff..f8b73450170 100644 --- a/packages/cli/src/cli/services/commands/doc/search.test.ts +++ b/packages/cli/src/cli/services/commands/doc/search.test.ts @@ -1,23 +1,16 @@ import {docSearchService} from './search.js' import {describe, expect, test, vi, beforeEach} from 'vitest' -import {shopifyFetch} from '@shopify/cli-kit/node/http' -import {outputResult} from '@shopify/cli-kit/node/output' +import {shopifyFetch, Response} from '@shopify/cli-kit/node/http' import {AbortError} from '@shopify/cli-kit/node/error' -vi.mock('@shopify/cli-kit/node/http') -// Only stub `outputResult`; keep the rest of the module real. Blanket-mocking it -// would also mock `stringifyMessage`, which `AbortError`'s constructor relies on — -// that would silently empty out every thrown error message. -vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => ({ - ...(await importOriginal()), - outputResult: vi.fn(), +vi.mock('@shopify/cli-kit/node/http', async (importOriginal) => ({ + ...(await importOriginal()), + shopifyFetch: vi.fn(), })) -const okResponse = (body: string) => - ({ok: true, status: 200, statusText: 'OK', text: () => Promise.resolve(body)}) as any +const okResponse = (body: string) => new Response(body) -const errorResponse = (status: number, statusText: string, body: string) => - ({ok: false, status, statusText, text: () => Promise.resolve(body)}) as any +const errorResponse = (status: number, statusText: string, body: string) => new Response(body, {status, statusText}) const resultsBody = '[{"score":0.99,"content":"About webhooks","url":"https://shopify.dev/x","title":"Webhooks","domain":null}]' @@ -27,13 +20,20 @@ beforeEach(() => { }) describe('docSearchService', () => { - test('requests the search endpoint with the query and prints the raw JSON body', async () => { - await docSearchService('webhooks') + test('requests the search endpoint and returns typed chunks and the original body', async () => { + const result = await docSearchService('webhooks') expect(shopifyFetch).toHaveBeenCalledWith('https://shopify.dev/assistant/search?query=webhooks', { headers: {Accept: 'application/json', 'X-Shopify-Surface': 'cli'}, }) - expect(outputResult).toHaveBeenCalledWith(resultsBody) + expect(result).toEqual({ + status: 'success', + results: [ + {score: 0.99, content: 'About webhooks', url: 'https://shopify.dev/x', title: 'Webhooks', domain: null}, + ], + pageInfo: {hasNextPage: null}, + body: resultsBody, + }) }) test('includes api_name and api_version params when provided', async () => { @@ -65,15 +65,14 @@ describe('docSearchService', () => { await expect(docSearchService('products', 'admin', '2025-01')).rejects.toThrowError( /Invalid api_version '2025-01' for api_name 'admin'\. Available versions: 2026-07/, ) - expect(outputResult).not.toHaveBeenCalled() }) test('falls back to the status line when a non-ok response is not JSON', async () => { vi.mocked(shopifyFetch).mockResolvedValue(errorResponse(500, 'Internal Server Error', 'nope')) - await expect(docSearchService('products')).rejects.toThrowError(AbortError) - await expect(docSearchService('products')).rejects.toThrowError(/500 Internal Server Error/) - expect(outputResult).not.toHaveBeenCalled() + const result = docSearchService('products') + await expect(result).rejects.toThrowError(AbortError) + await expect(result).rejects.toThrowError(/500 Internal Server Error/) }) test('reports a friendly error when the request cannot reach shopify.dev', async () => { @@ -81,6 +80,34 @@ describe('docSearchService', () => { await expect(docSearchService('products')).rejects.toThrowError(AbortError) await expect(docSearchService('products')).rejects.toThrowError(/Could not reach shopify\.dev/) - expect(outputResult).not.toHaveBeenCalled() }) + + test('returns an empty collection', async () => { + vi.mocked(shopifyFetch).mockResolvedValue(okResponse('[]')) + await expect(docSearchService('nothing')).resolves.toEqual({ + status: 'success', + results: [], + pageInfo: {hasNextPage: null}, + body: '[]', + }) + }) + + test('projects public fields and normalizes missing domain metadata', async () => { + const body = '[{"score":0,"content":"","url":"https://shopify.dev/x","title":"Example","internal":"hidden"}]' + vi.mocked(shopifyFetch).mockResolvedValue(okResponse(body)) + await expect(docSearchService('example')).resolves.toEqual({ + status: 'success', + results: [{score: 0, content: '', url: 'https://shopify.dev/x', title: 'Example', domain: null}], + pageInfo: {hasNextPage: null}, + body, + }) + }) + + test.each(['not JSON', 'null', '{}', '[{"url":"https://shopify.dev/x"}]'])( + 'returns an invalid response condition with the original body: %s', + async (body) => { + vi.mocked(shopifyFetch).mockResolvedValue(okResponse(body)) + await expect(docSearchService('example')).resolves.toEqual({status: 'invalid-response', body}) + }, + ) }) diff --git a/packages/cli/src/cli/services/commands/doc/search.ts b/packages/cli/src/cli/services/commands/doc/search.ts index a663ffe17e1..37ceb8fc140 100644 --- a/packages/cli/src/cli/services/commands/doc/search.ts +++ b/packages/cli/src/cli/services/commands/doc/search.ts @@ -1,6 +1,7 @@ +import {documentationSearchEntrySchema, type DocSearchResult} from './types.js' import {shopifyFetch, type Response} from '@shopify/cli-kit/node/http' -import {outputResult} from '@shopify/cli-kit/node/output' import {AbortError} from '@shopify/cli-kit/node/error' +import {zod} from '@shopify/cli-kit/node/schema' // The dev-assistant search endpoint queries the shopify.dev vector store and // returns an array of matching documentation chunks as JSON. @@ -11,7 +12,17 @@ const SEARCH_URL = 'https://shopify.dev/assistant/search' const SURFACE_HEADER = 'X-Shopify-Surface' const SURFACE = 'cli' -export async function docSearchService(query: string, apiName?: string, apiVersion?: string) { +export type DocSearchServiceResult = + | (DocSearchResult & {status: 'success'; body: string}) + | {status: 'invalid-response'; body: string} + +const SearchResponseSchema = zod.array(documentationSearchEntrySchema.strip().extend({domain: zod.string().nullish()})) + +export async function docSearchService( + query: string, + apiName?: string, + apiVersion?: string, +): Promise { const params = new URLSearchParams({query}) if (apiName) params.append('api_name', apiName) if (apiVersion) params.append('api_version', apiVersion) @@ -46,5 +57,26 @@ export async function docSearchService(query: string, apiName?: string, apiVersi throw new AbortError(`Search failed: ${message}`) } - outputResult(body) + let responseData: unknown + try { + responseData = JSON.parse(body) + } catch (error) { + if (!(error instanceof SyntaxError)) throw error + return {status: 'invalid-response', body} + } + const parsed = SearchResponseSchema.safeParse(responseData) + if (!parsed.success) return {status: 'invalid-response', body} + + return { + status: 'success', + results: parsed.data.map(({score, content, url, title, domain}) => ({ + score, + content, + url, + title, + domain: domain ?? null, + })), + pageInfo: {hasNextPage: null}, + body, + } } diff --git a/packages/cli/src/cli/services/commands/doc/types.test.ts b/packages/cli/src/cli/services/commands/doc/types.test.ts index 6d030a73fbf..bf83f8141fb 100644 --- a/packages/cli/src/cli/services/commands/doc/types.test.ts +++ b/packages/cli/src/cli/services/commands/doc/types.test.ts @@ -1,7 +1,8 @@ -import {docFetchJsonOutputSchema} from './types.js' +import {docFetchJsonOutputSchema, docSearchJsonOutputSchema} from './types.js' import {describe, expect, test} from 'vitest' const document = {url: 'https://shopify.dev/docs', content: ''} +const entry = {score: 0, content: '', url: 'https://shopify.dev/docs', title: 'Docs', domain: null} describe('documentation JSON schemas', () => { test('encodes a document, including empty Markdown', () => { @@ -29,4 +30,39 @@ describe('documentation JSON schemas', () => { ])('rejects an invalid fetch result: %j', (result) => { expect(() => docFetchJsonOutputSchema.validate(result)).toThrow() }) + + test.each([true, false, null])('encodes public search data with hasNextPage=%s', (hasNextPage) => { + const result = {results: [entry, {...entry, score: 0.99, domain: 'admin'}], pageInfo: {hasNextPage}} + expect(JSON.parse(docSearchJsonOutputSchema.encode(result))).toEqual(result) + expect(JSON.parse(docSearchJsonOutputSchema.encode({results: [], pageInfo: {hasNextPage}}))).toEqual({ + results: [], + pageInfo: {hasNextPage}, + }) + }) + + test('rejects a bare collection result', () => { + expect(() => docSearchJsonOutputSchema.validate([])).toThrow() + }) + + test.each([ + {...entry, score: '0'}, + {...entry, score: Infinity}, + {...entry, url: 'invalid'}, + {...entry, content: null}, + {...entry, title: false}, + {...entry, domain: undefined}, + {...entry, internal: true}, + ])('rejects an invalid search entry: %j', (result) => { + expect(() => docSearchJsonOutputSchema.validate({results: [result], pageInfo: {hasNextPage: null}})).toThrow() + }) + + test.each([ + {results: [], pageInfo: {hasNextPage: 'unknown'}}, + {results: [], pageInfo: {hasNextPage: null, cursor: 'invented'}}, + {results: [], pageInfo: {hasNextPage: null}, body: '[]'}, + {invalidArray: []}, + null, + ])('rejects an invalid search wrapper: %j', (result) => { + expect(() => docSearchJsonOutputSchema.validate(result)).toThrow() + }) }) diff --git a/packages/cli/src/cli/services/commands/doc/types.ts b/packages/cli/src/cli/services/commands/doc/types.ts index ea624ba2d46..e1b7cf0f2dc 100644 --- a/packages/cli/src/cli/services/commands/doc/types.ts +++ b/packages/cli/src/cli/services/commands/doc/types.ts @@ -26,3 +26,32 @@ export const docFetchJsonOutputSchema = defineJsonOutputSchema({ export type DocFetchResult = InferJsonOutputSchema export type DocFetchDocument = Extract + +export const documentationSearchEntrySchema = zod + .object({ + score: zod.number().finite().describe('The relevance score returned by shopify.dev.'), + content: zod.string().describe('The matching documentation chunk.'), + url: zod.string().url().describe('The URL of the matching document.'), + title: zod.string().describe('The title of the matching document.'), + domain: zod.string().nullable().describe('The documentation domain, or null when unavailable.'), + }) + .strict() + +const PageInfoSchema = zod + .object({ + hasNextPage: zod.boolean().nullable().describe('Whether more results are available, or null when unknown.'), + }) + .strict() + +export const docSearchJsonOutputSchema = defineJsonOutputSchema({ + name: 'DocSearchResult', + schema: zod + .object({ + results: zod.array(documentationSearchEntrySchema).describe('The top matching chunks from one search request.'), + pageInfo: PageInfoSchema, + }) + .strict(), + definitions: {DocumentationSearchEntry: documentationSearchEntrySchema, PageInfo: PageInfoSchema}, +}) + +export type DocSearchResult = InferJsonOutputSchema diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index f80d04d0b10..db9d1e6d4cf 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -32,7 +32,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/subscription-migrations/status.ts', 'packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts', 'packages/app/src/cli/commands/app/webhook/trigger.ts', - 'packages/cli/src/cli/commands/doc/search.ts', 'packages/cli/src/cli/commands/upgrade.ts', 'packages/plugin-did-you-mean/src/commands/config/autocorrect/off.ts', 'packages/plugin-did-you-mean/src/commands/config/autocorrect/on.ts',