From fdebfc545937f9236a4bd8ab3c2a7acb49b45062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 6 Oct 2026 15:11:18 +0200 Subject: [PATCH] Add typed JSON output to doc fetch --- .changeset/doc-fetch-json-output.md | 5 + .../generated/generated_docs_data_v2.json | 13 +- packages/cli/README.md | 82 ++++++++- packages/cli/oclif.manifest.json | 17 +- .../cli/src/cli/commands/doc/fetch.test.ts | 165 ++++++++++++++++++ packages/cli/src/cli/commands/doc/fetch.ts | 21 ++- .../cli/services/commands/doc/fetch-result.ts | 24 +++ .../cli/services/commands/doc/fetch.test.ts | 47 ++--- .../src/cli/services/commands/doc/fetch.ts | 20 +-- .../cli/services/commands/doc/types.test.ts | 32 ++++ .../src/cli/services/commands/doc/types.ts | 28 +++ .../rules/json-output-command-exceptions.js | 1 - 12 files changed, 397 insertions(+), 58 deletions(-) create mode 100644 .changeset/doc-fetch-json-output.md create mode 100644 packages/cli/src/cli/commands/doc/fetch.test.ts create mode 100644 packages/cli/src/cli/services/commands/doc/fetch-result.ts create mode 100644 packages/cli/src/cli/services/commands/doc/types.test.ts create mode 100644 packages/cli/src/cli/services/commands/doc/types.ts diff --git a/.changeset/doc-fetch-json-output.md b/.changeset/doc-fetch-json-output.md new file mode 100644 index 00000000000..f270d0cf1b5 --- /dev/null +++ b/.changeset/doc-fetch-json-output.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add typed JSON output and schema discovery to `doc fetch`. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 8585bf8b9e2..ff195fb7ec2 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -5154,7 +5154,7 @@ "syntaxKind": "PropertySignature", "name": "--output ", "value": "string", - "description": "Write the document to this file path instead of printing it to stdout.", + "description": "Write the document to this file path instead of printing it to stdout. With --json, stdout contains the absolute path and Markdown format of the written file.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_OUTPUT" }, @@ -5174,9 +5174,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-fetch.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 docfetch {\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Filter code examples in the returned Markdown to this language. Supply the language of the app you are building so examples match your stack. Optional — if omitted, or if shopify.dev does not recognize the language for a given page, the document includes examples in every language.\n * @environment SHOPIFY_FLAG_LANGUAGE\n */\n '--language '?: string\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 * Write the document to this file path instead of printing it to stdout.\n * @environment SHOPIFY_FLAG_OUTPUT\n */\n '--output '?: string\n\n /**\n * The shopify.dev URL to fetch.\n * @environment SHOPIFY_FLAG_URL\n */\n '--url ': 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 docfetch {\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 * Filter code examples in the returned Markdown to this language. Supply the language of the app you are building so examples match your stack. Optional — if omitted, or if shopify.dev does not recognize the language for a given page, the document includes examples in every language.\n * @environment SHOPIFY_FLAG_LANGUAGE\n */\n '--language '?: string\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 * Write the document to this file path instead of printing it to stdout. With --json, stdout contains the absolute path and Markdown format of the written file.\n * @environment SHOPIFY_FLAG_OUTPUT\n */\n '--output '?: string\n\n /**\n * The shopify.dev URL to fetch.\n * @environment SHOPIFY_FLAG_URL\n */\n '--url ': string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "docsearch": { diff --git a/packages/cli/README.md b/packages/cli/README.md index 0fcda30b8f6..cbddf9f639f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4475,11 +4475,15 @@ Download a complete document from shopify.dev. Every page on shopify.dev has a M ``` USAGE - $ shopify doc fetch --url [--json-schema] [--language + $ shopify doc fetch --url [-j] [--json-schema] [--language javascript|typescript|python|ruby|php|rust|curl|liquid|graphql|html] [--no-color] [--no-input] [--output ] [--verbose] FLAGS + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + --json-schema Print the command's JSON schemas. [env: SHOPIFY_FLAG_JSON_SCHEMA] @@ -4500,7 +4504,8 @@ FLAGS [env: SHOPIFY_FLAG_NO_INPUT] --output= - Write the document to this file path instead of printing it to stdout. + Write the document to this file path instead of printing it to stdout. With --json, stdout contains the absolute + path and Markdown format of the written file. [env: SHOPIFY_FLAG_OUTPUT] --url= @@ -4517,6 +4522,75 @@ DESCRIPTION a centrally-served skill. Pass `--language` for the language of the app you are building so code examples match your stack. For finding the relevant pieces of content across shopify.dev instead, use `doc search`. + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `DocFetchResult` schema. + + ```json + { + "anyOf": [ + { + "type": "object", + "properties": { + "document": { + "$ref": "#/definitions/Document" + } + }, + "required": [ + "document" + ], + "additionalProperties": false + }, + { + "$ref": "#/definitions/DocumentFile" + } + ], + "title": "DocFetchResult", + "definitions": { + "Document": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "The requested shopify.dev document URL." + }, + "content": { + "type": "string", + "description": "The document in Markdown, with the requested language filter applied." + } + }, + "required": [ + "url", + "content" + ], + "additionalProperties": false + }, + "DocumentFile": { + "type": "object", + "properties": { + "path": { + "type": "string", + "pattern": "^(?:\\/|[A-Za-z]:[\\\\/]|\\\\\\\\)", + "description": "The absolute native path of the written file." + }, + "format": { + "type": "string", + "const": "markdown", + "description": "The file contains the original Markdown document, not a JSON wrapper." + } + }, + "required": [ + "path", + "format" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` + EXAMPLES # fetch the Markdown version of a Shopify.dev page @@ -4529,6 +4603,10 @@ EXAMPLES # save the document to a file instead of printing it $ shopify doc fetch --url https://shopify.dev/docs/api/shopify-cli --output docs/shopify-cli.md + + # return a typed document as JSON + + $ shopify doc fetch --url https://shopify.dev/docs/api/shopify-cli --json ``` ## `shopify doc search` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 9a714accbc2..2a705203ba1 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -6138,14 +6138,25 @@ ], "args": { }, - "description": "Download a complete document from shopify.dev. Every page on shopify.dev has a Markdown version, and that is what this tool returns. Use this to pull an entire document verbatim — for example, a set of instructions an agent follows like a centrally-served skill. Pass `--language` for the language of the app you are building so code examples match your stack. For finding the relevant pieces of content across shopify.dev instead, use `doc search`.", + "description": "Download a complete document from shopify.dev. Every page on shopify.dev has a Markdown version, and that is what this tool returns. Use this to pull an entire document verbatim — for example, a set of instructions an agent follows like a centrally-served skill. Pass `--language` for the language of the app you are building so code examples match your stack. For finding the relevant pieces of content across shopify.dev instead, use `doc search`.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `DocFetchResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"document\": {\n \"$ref\": \"#/definitions/Document\"\n }\n },\n \"required\": [\n \"document\"\n ],\n \"additionalProperties\": false\n },\n {\n \"$ref\": \"#/definitions/DocumentFile\"\n }\n ],\n \"title\": \"DocFetchResult\",\n \"definitions\": {\n \"Document\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"type\": \"string\",\n \"format\": \"uri\",\n \"description\": \"The requested shopify.dev document URL.\"\n },\n \"content\": {\n \"type\": \"string\",\n \"description\": \"The document in Markdown, with the requested language filter applied.\"\n }\n },\n \"required\": [\n \"url\",\n \"content\"\n ],\n \"additionalProperties\": false\n },\n \"DocumentFile\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\",\n \"pattern\": \"^(?:\\\\/|[A-Za-z]:[\\\\\\\\/]|\\\\\\\\\\\\\\\\)\",\n \"description\": \"The absolute native path of the written file.\"\n },\n \"format\": {\n \"type\": \"string\",\n \"const\": \"markdown\",\n \"description\": \"The file contains the original Markdown document, not a JSON wrapper.\"\n }\n },\n \"required\": [\n \"path\",\n \"format\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "descriptionWithMarkdown": "Download a complete document from shopify.dev. Every page on shopify.dev has a Markdown version, and that is what this tool returns. Use this to pull an entire document verbatim — for example, a set of instructions an agent follows like a centrally-served skill. Pass `--language` for the language of the app you are building so code examples match your stack. For finding the relevant pieces of content across shopify.dev instead, use `doc search`.", "enableJsonFlag": false, "examples": [ "# fetch the Markdown version of a Shopify.dev page\nshopify doc fetch --url https://shopify.dev/docs/api/shopify-cli", "# filter code examples to the language of the app you are building\nshopify doc fetch --url https://shopify.dev/docs/api/shopify-cli --language ruby", - "# save the document to a file instead of printing it\nshopify doc fetch --url https://shopify.dev/docs/api/shopify-cli --output docs/shopify-cli.md" + "# save the document to a file instead of printing it\nshopify doc fetch --url https://shopify.dev/docs/api/shopify-cli --output docs/shopify-cli.md", + "# return a typed document as JSON\nshopify doc fetch --url https://shopify.dev/docs/api/shopify-cli --json" ], "flags": { + "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.", @@ -6189,7 +6200,7 @@ "type": "boolean" }, "output": { - "description": "Write the document to this file path instead of printing it to stdout.", + "description": "Write the document to this file path instead of printing it to stdout. With --json, stdout contains the absolute path and Markdown format of the written file.", "env": "SHOPIFY_FLAG_OUTPUT", "hasDynamicHelp": false, "multiple": false, diff --git a/packages/cli/src/cli/commands/doc/fetch.test.ts b/packages/cli/src/cli/commands/doc/fetch.test.ts new file mode 100644 index 00000000000..9af4eab987d --- /dev/null +++ b/packages/cli/src/cli/commands/doc/fetch.test.ts @@ -0,0 +1,165 @@ +import DocFetch from './fetch.js' +import {docFetchJsonOutputSchema} from '../../services/commands/doc/types.js' +import {fetch, Response} from '@shopify/cli-kit/node/http' +import {inTemporaryDirectory, readFile, writeFile, fileExists} from '@shopify/cli-kit/node/fs' +import {joinPath, relativePath, cwd} from '@shopify/cli-kit/node/path' +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()), + fetch: vi.fn(), +})) + +const url = 'https://shopify.dev/docs/api/shopify-cli' +const content = '# Shopify CLI\n\nA document.\n' + +beforeEach(() => { + vi.stubEnv('CI', '1') + vi.stubEnv('SHOPIFY_CLI_NO_ANALYTICS', '1') + vi.mocked(fetch).mockResolvedValue(new Response(content)) + vi.spyOn(process, 'exit').mockReturnValue(undefined as never) +}) + +afterEach(() => { + vi.unstubAllEnvs() + mockAndCaptureOutput().clear() +}) + +describe('doc fetch command', () => { + test('preserves the Markdown output and stdout channel', async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await DocFetch.run(['--url', url, '--no-input'], import.meta.url) + expect(stdout()).toBe(`${content}\n`) + expect(stderr()).toBe('') + }) + }) + + test.each([{flags: []}, {flags: ['--no-input']}])( + 'writes one document object with JSON and flags $flags', + async ({flags}) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await DocFetch.run(['--url', url, '--language', 'ruby', '--json', ...flags], import.meta.url) + expect(JSON.parse(stdout())).toEqual({document: {url, content}}) + expect(stderr()).toBe('') + expect(fetch).toHaveBeenCalledWith(url, { + headers: {Accept: 'text/markdown', 'X-Shopify-Surface': 'cli', 'Accept-Language': 'ruby'}, + }) + }) + }, + ) + + test('supports the shared JSON environment flag', async () => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await DocFetch.run(['--url', url], import.meta.url) + expect(JSON.parse(stdout())).toEqual({document: {url, content}}) + expect(stderr()).toBe('') + }) + }) + + test('reports missing required input before fetching', async () => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await DocFetch.run(['--json', '--no-input'], import.meta.url) + expect(JSON.parse(stdout())).toHaveProperty('error') + expect(process.exit).toHaveBeenCalledWith(2) + expect(stderr()).toBe('') + expect(fetch).not.toHaveBeenCalled() + }) + }) + + test.each(['text', 'json'] as const)('saves exact Markdown bytes with a relative path in %s mode', async (format) => { + await inTemporaryDirectory(async (directory) => { + const path = joinPath(directory, 'docs/shopify-cli.md') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await DocFetch.run( + ['--url', url, '--output', relativePath(cwd(), path), ...(format === 'json' ? ['--json'] : [])], + import.meta.url, + ) + + await expect(readFile(path)).resolves.toBe(content) + if (format === 'json') { + expect(JSON.parse(stdout())).toEqual({path, format: 'markdown'}) + expect(JSON.parse(stderr())).toMatchObject({ + type: 'diagnostic', + level: 'info', + message: `Saved ${url} to ${path}`, + }) + } else { + expect(stdout()).toBe('') + expect(stderr()).toBe(`Saved ${url} to ${path}\n`) + } + }) + }) + }) + + test('does not emit a receipt when the file write fails', async () => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + await inTemporaryDirectory(async (directory) => { + const parent = joinPath(directory, 'file') + await writeFile(parent, 'existing content') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect( + DocFetch.run(['--url', url, '--output', joinPath(parent, 'doc.md'), '--json'], import.meta.url), + ).resolves.toBeUndefined() + expect(process.exit).toHaveBeenCalledWith(1) + expect(JSON.parse(stdout())).toHaveProperty('error') + expect(stdout()).not.toContain('"format": "markdown"') + expect(stderr()).toBe('') + await expect(readFile(parent)).resolves.toBe('existing content') + }) + }) + }) + + test('does not create a file when fetching fails', async () => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + vi.mocked(fetch).mockResolvedValue(new Response('', {status: 404, statusText: 'Not Found'})) + await inTemporaryDirectory(async (directory) => { + const path = joinPath(directory, 'doc.md') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect(DocFetch.run(['--url', url, '--output', path, '--json'], import.meta.url)).resolves.toBeUndefined() + expect(process.exit).toHaveBeenCalledWith(1) + expect(JSON.parse(stdout())).toEqual({error: {type: 'abort', message: `Failed to fetch ${url}: 404 Not Found`}}) + expect(stderr()).toBe('') + await expect(fileExists(path)).resolves.toBe(false) + }) + }) + }) + + test.each(['not a url', 'https://example.com/docs'])( + 'reports invalid input as one fatal document: %s', + async (input) => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect(DocFetch.run(['--url', input, '--json'], import.meta.url)).resolves.toBeUndefined() + expect(process.exit).toHaveBeenCalledWith(1) + expect(JSON.parse(stdout()).error.type).toBe('abort') + expect(stderr()).toBe('') + expect(fetch).not.toHaveBeenCalled() + }) + }, + ) + + test('exposes the schema in help and through the launcher without fetching', async () => { + expect(DocFetch.jsonOutputSchema).toBe(docFetchJsonOutputSchema) + expect(DocFetch.flags.json).toBeDefined() + expect(DocFetch.description).toContain('Output from `--json` conforms to the `DocFetchResult` schema.') + vi.spyOn(ShopifyConfig.prototype, 'runHook').mockResolvedValue({successes: [], failures: []}) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await launchCLI({ + moduleURL: import.meta.url, + argv: ['doc', 'fetch', '--json-schema'], + lazyCommandLoader: async () => DocFetch, + }) + const schema = JSON.parse(stdout()) + expect(schema.definitions.Result.anyOf).toHaveLength(2) + expect(schema.definitions.Result.anyOf[0].properties.document.properties.content.type).toBe('string') + expect(schema.definitions.Result.anyOf[1].properties.format.const).toBe('markdown') + expect(stderr()).toBe('') + expect(fetch).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/cli/src/cli/commands/doc/fetch.ts b/packages/cli/src/cli/commands/doc/fetch.ts index 6658ce06014..50e287623b5 100644 --- a/packages/cli/src/cli/commands/doc/fetch.ts +++ b/packages/cli/src/cli/commands/doc/fetch.ts @@ -1,12 +1,16 @@ import {docFetchService} from '../../services/commands/doc/fetch.js' +import {presentDocFetchResult} from '../../services/commands/doc/fetch-result.js' +import {docFetchJsonOutputSchema} 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 DocFetch extends Command { - static description = + static descriptionWithMarkdown = 'Download a complete document from shopify.dev. Every page on shopify.dev has a Markdown version, and that is what this tool returns. Use this to pull an entire document verbatim — for example, a set of instructions an agent follows like a centrally-served skill. Pass `--language` for the language of the app you are building so code examples match your stack. For finding the relevant pieces of content across shopify.dev instead, use `doc search`.' + static description = this.descriptionForHelp() + static examples = [ `# fetch the Markdown version of a Shopify.dev page shopify doc fetch --url https://shopify.dev/docs/api/shopify-cli`, @@ -14,10 +18,13 @@ shopify doc fetch --url https://shopify.dev/docs/api/shopify-cli`, shopify doc fetch --url https://shopify.dev/docs/api/shopify-cli --language ruby`, `# save the document to a file instead of printing it shopify doc fetch --url https://shopify.dev/docs/api/shopify-cli --output docs/shopify-cli.md`, + `# return a typed document as JSON +shopify doc fetch --url https://shopify.dev/docs/api/shopify-cli --json`, ] static flags = { ...globalFlags, + ...jsonFlag, url: Flags.string({ description: 'The shopify.dev URL to fetch.', env: 'SHOPIFY_FLAG_URL', @@ -30,13 +37,19 @@ shopify doc fetch --url https://shopify.dev/docs/api/shopify-cli --output docs/s options: ['javascript', 'typescript', 'python', 'ruby', 'php', 'rust', 'curl', 'liquid', 'graphql', 'html'], }), output: Flags.string({ - description: 'Write the document to this file path instead of printing it to stdout.', + description: + 'Write the document to this file path instead of printing it to stdout. With --json, stdout contains the absolute path and Markdown format of the written file.', env: 'SHOPIFY_FLAG_OUTPUT', }), } + static get jsonOutputSchema() { + return docFetchJsonOutputSchema + } + async run(): Promise { const {flags} = await this.parse(DocFetch) - await docFetchService(flags.url, flags.output, flags.language) + const result = await docFetchService(flags.url, flags.language) + await presentDocFetchResult(result, flags.json ? 'json' : 'text', flags.output) } } diff --git a/packages/cli/src/cli/services/commands/doc/fetch-result.ts b/packages/cli/src/cli/services/commands/doc/fetch-result.ts new file mode 100644 index 00000000000..6e13a25eb5a --- /dev/null +++ b/packages/cli/src/cli/services/commands/doc/fetch-result.ts @@ -0,0 +1,24 @@ +import {docFetchJsonOutputSchema, type DocFetchDocument} from './types.js' +import {mkdir, writeFile} from '@shopify/cli-kit/node/fs' +import {dirname, resolvePath} from '@shopify/cli-kit/node/path' +import {outputInfo, outputResult} from '@shopify/cli-kit/node/output' + +export async function presentDocFetchResult( + result: DocFetchDocument, + format: 'json' | 'text', + outputPath?: string, +): Promise { + if (outputPath) { + const absolutePath = resolvePath(outputPath) + await mkdir(dirname(absolutePath)) + await writeFile(absolutePath, result.document.content) + outputInfo(`Saved ${result.document.url} to ${absolutePath}`) + if (format === 'json') { + outputResult(docFetchJsonOutputSchema.encode({path: absolutePath, format: 'markdown'})) + } + } else if (format === 'json') { + outputResult(docFetchJsonOutputSchema.encode(result)) + } else { + outputResult(result.document.content) + } +} diff --git a/packages/cli/src/cli/services/commands/doc/fetch.test.ts b/packages/cli/src/cli/services/commands/doc/fetch.test.ts index 804897640ae..904f87e9200 100644 --- a/packages/cli/src/cli/services/commands/doc/fetch.test.ts +++ b/packages/cli/src/cli/services/commands/doc/fetch.test.ts @@ -1,29 +1,25 @@ import {docFetchService} from './fetch.js' import {describe, expect, test, vi, beforeEach} from 'vitest' -import {fetch} from '@shopify/cli-kit/node/http' -import {outputResult} from '@shopify/cli-kit/node/output' +import {fetch, Response} from '@shopify/cli-kit/node/http' import {AbortError} from '@shopify/cli-kit/node/error' -import {inTemporaryDirectory, readFile, fileExistsSync} from '@shopify/cli-kit/node/fs' -import {joinPath} from '@shopify/cli-kit/node/path' -vi.mock('@shopify/cli-kit/node/http') -vi.mock('@shopify/cli-kit/node/output') - -const okResponse = (body: string) => - ({ok: true, status: 200, statusText: 'OK', text: () => Promise.resolve(body)}) as any +vi.mock('@shopify/cli-kit/node/http', async (importOriginal) => ({ + ...(await importOriginal()), + fetch: vi.fn(), +})) beforeEach(() => { - vi.mocked(fetch).mockResolvedValue(okResponse('# Doc')) + vi.mocked(fetch).mockResolvedValue(new Response('# Doc')) }) describe('docFetchService', () => { - test('requests Markdown and prints the body to stdout', async () => { - await docFetchService('https://shopify.dev/docs/api/shopify-cli') + test('requests Markdown and returns a document', async () => { + const result = await docFetchService('https://shopify.dev/docs/api/shopify-cli') expect(fetch).toHaveBeenCalledWith('https://shopify.dev/docs/api/shopify-cli', { headers: {Accept: 'text/markdown', 'X-Shopify-Surface': 'cli'}, }) - expect(outputResult).toHaveBeenCalledWith('# Doc') + expect(result).toEqual({document: {url: 'https://shopify.dev/docs/api/shopify-cli', content: '# Doc'}}) }) test('accepts shopify.dev subdomains', async () => { @@ -42,23 +38,15 @@ describe('docFetchService', () => { expect(fetch).not.toHaveBeenCalled() }) - test('writes the document to the output path instead of stdout', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - const outputPath = joinPath(tmpDir, 'docs/shopify-cli.md') - - // When - await docFetchService('https://shopify.dev/docs/api/shopify-cli', outputPath) - - // Then - expect(fileExistsSync(outputPath)).toBe(true) - await expect(readFile(outputPath)).resolves.toBe('# Doc') - expect(outputResult).not.toHaveBeenCalled() + test('returns an empty document as a valid result', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('')) + await expect(docFetchService('https://shopify.dev/docs')).resolves.toEqual({ + document: {url: 'https://shopify.dev/docs', content: ''}, }) }) test('sends Accept-Language when a language is provided', async () => { - await docFetchService('https://shopify.dev/docs/api/shopify-cli', undefined, 'ruby') + await docFetchService('https://shopify.dev/docs/api/shopify-cli', 'ruby') expect(fetch).toHaveBeenCalledWith('https://shopify.dev/docs/api/shopify-cli', { headers: {Accept: 'text/markdown', 'X-Shopify-Surface': 'cli', 'Accept-Language': 'ruby'}, @@ -66,9 +54,10 @@ describe('docFetchService', () => { }) test('throws when the response is not ok', async () => { - vi.mocked(fetch).mockResolvedValue({ok: false, status: 404, statusText: 'Not Found'} as any) + vi.mocked(fetch).mockResolvedValue(new Response('', {status: 404, statusText: 'Not Found'})) - await expect(docFetchService('https://shopify.dev/missing')).rejects.toThrowError(AbortError) - expect(outputResult).not.toHaveBeenCalled() + const result = docFetchService('https://shopify.dev/missing') + await expect(result).rejects.toThrowError(AbortError) + await expect(result).rejects.toThrow('Failed to fetch https://shopify.dev/missing: 404 Not Found') }) }) diff --git a/packages/cli/src/cli/services/commands/doc/fetch.ts b/packages/cli/src/cli/services/commands/doc/fetch.ts index 34b2aaefb40..28920d54daa 100644 --- a/packages/cli/src/cli/services/commands/doc/fetch.ts +++ b/packages/cli/src/cli/services/commands/doc/fetch.ts @@ -1,8 +1,6 @@ import {fetch} from '@shopify/cli-kit/node/http' -import {outputInfo, outputResult} from '@shopify/cli-kit/node/output' import {AbortError} from '@shopify/cli-kit/node/error' -import {mkdir, writeFile} from '@shopify/cli-kit/node/fs' -import {dirname, resolvePath} from '@shopify/cli-kit/node/path' +import type {DocFetchDocument} from './types.js' // Every page on shopify.dev has a Markdown representation, which is the clean, // parseable content agents want — so we always request it. @@ -17,7 +15,7 @@ const SURFACE = 'cli' // hostname is one of these or a subdomain of one of these. const ALLOWED_HOSTS = ['shopify.dev'] -export async function docFetchService(url: string, outputPath?: string, language?: string) { +export async function docFetchService(url: string, language?: string): Promise { let parsedURL: URL try { parsedURL = new URL(url) @@ -46,17 +44,5 @@ export async function docFetchService(url: string, outputPath?: string, language throw new AbortError(`Failed to fetch ${url}: ${response.status} ${response.statusText}`) } - const body = await response.text() - - // When an output path is provided, write the document to disk (creating any - // missing parent directories) instead of printing it to stdout. - if (outputPath) { - const absolutePath = resolvePath(outputPath) - await mkdir(dirname(absolutePath)) - await writeFile(absolutePath, body) - outputInfo(`Saved ${url} to ${absolutePath}`) - return - } - - outputResult(body) + return {document: {url, content: await response.text()}} } diff --git a/packages/cli/src/cli/services/commands/doc/types.test.ts b/packages/cli/src/cli/services/commands/doc/types.test.ts new file mode 100644 index 00000000000..6d030a73fbf --- /dev/null +++ b/packages/cli/src/cli/services/commands/doc/types.test.ts @@ -0,0 +1,32 @@ +import {docFetchJsonOutputSchema} from './types.js' +import {describe, expect, test} from 'vitest' + +const document = {url: 'https://shopify.dev/docs', content: ''} + +describe('documentation JSON schemas', () => { + test('encodes a document, including empty Markdown', () => { + expect(JSON.parse(docFetchJsonOutputSchema.encode({document}))).toEqual({document}) + }) + + test.each(['/tmp/doc.md', 'C:\\docs\\doc.md', '\\\\server\\docs\\doc.md'])( + 'encodes an absolute file receipt: %s', + (path) => { + expect(JSON.parse(docFetchJsonOutputSchema.encode({path, format: 'markdown'}))).toEqual({ + path, + format: 'markdown', + }) + }, + ) + + test.each([ + {document: {...document, url: 'invalid'}}, + {document: {...document, content: null}}, + {document: {...document, internal: true}}, + {document, extra: true}, + {path: 'docs/doc.md', format: 'markdown'}, + {path: '/tmp/doc.md', format: 'json'}, + {path: '/tmp/doc.md', format: 'markdown', document}, + ])('rejects an invalid fetch result: %j', (result) => { + expect(() => docFetchJsonOutputSchema.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 new file mode 100644 index 00000000000..ea624ba2d46 --- /dev/null +++ b/packages/cli/src/cli/services/commands/doc/types.ts @@ -0,0 +1,28 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const DocumentSchema = zod + .object({ + url: zod.string().url().describe('The requested shopify.dev document URL.'), + content: zod.string().describe('The document in Markdown, with the requested language filter applied.'), + }) + .strict() + +const DocumentFileSchema = zod + .object({ + path: zod + .string() + .regex(/^(?:\/|[A-Za-z]:[\\/]|\\\\)/) + .describe('The absolute native path of the written file.'), + format: zod.literal('markdown').describe('The file contains the original Markdown document, not a JSON wrapper.'), + }) + .strict() + +export const docFetchJsonOutputSchema = defineJsonOutputSchema({ + name: 'DocFetchResult', + schema: zod.union([zod.object({document: DocumentSchema}).strict(), DocumentFileSchema]), + definitions: {Document: DocumentSchema, DocumentFile: DocumentFileSchema}, +}) + +export type DocFetchResult = InferJsonOutputSchema +export type DocFetchDocument = Extract 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 f961d13c8e5..f80d04d0b10 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/fetch.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',