diff --git a/.changeset/add-doc-fetch-command.md b/.changeset/add-doc-fetch-command.md new file mode 100644 index 00000000000..d36c83864a0 --- /dev/null +++ b/.changeset/add-doc-fetch-command.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add a `shopify doc fetch` command that downloads a document from shopify.dev and prints it to stdout, or writes it to a file with `--output`. It requests the Markdown representation that every shopify.dev page has, giving agents an easy way to pull instructional content from the centralized docs verbatim. diff --git a/docs-shopify.dev/commands/interfaces/doc-fetch.interface.ts b/docs-shopify.dev/commands/interfaces/doc-fetch.interface.ts new file mode 100644 index 00000000000..221e84e9869 --- /dev/null +++ b/docs-shopify.dev/commands/interfaces/doc-fetch.interface.ts @@ -0,0 +1,24 @@ +// This is an autogenerated file. Don't edit this file manually. +/** + * The following flags are available for the `doc fetch` command: + * @publicDocs + */ +export interface docfetch { + /** + * Disable color output. + * @environment SHOPIFY_FLAG_NO_COLOR + */ + '--no-color'?: '' + + /** + * Write the document to this file path instead of printing it to stdout. + * @environment SHOPIFY_FLAG_OUTPUT + */ + '-o, --output '?: string + + /** + * Increase the verbosity of the output. + * @environment SHOPIFY_FLAG_VERBOSE + */ + '--verbose'?: '' +} diff --git a/docs-shopify.dev/commands/interfaces/search.interface.ts b/docs-shopify.dev/commands/interfaces/search.interface.ts index 041775f0570..618c86f1f7f 100644 --- a/docs-shopify.dev/commands/interfaces/search.interface.ts +++ b/docs-shopify.dev/commands/interfaces/search.interface.ts @@ -4,5 +4,15 @@ * @publicDocs */ export interface search { + /** + * Disable color output. + * @environment SHOPIFY_FLAG_NO_COLOR + */ + '--no-color'?: '' + /** + * Increase the verbosity of the output. + * @environment SHOPIFY_FLAG_VERBOSE + */ + '--verbose'?: '' } diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 782fa48b708..5686e9f510f 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -2742,6 +2742,44 @@ "value": "export interface configautoupgradestatus {\n\n}" } }, + "docfetch": { + "docs-shopify.dev/commands/interfaces/doc-fetch.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/doc-fetch.interface.ts", + "name": "docfetch", + "description": "The following flags are available for the `doc fetch` command:", + "isPublicDocs": true, + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/doc-fetch.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "''", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/doc-fetch.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/doc-fetch.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-o, --output ", + "value": "string", + "description": "Write the document to this file path instead of printing it to stdout.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_OUTPUT" + } + ], + "value": "export interface docfetch {\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Write the document to this file path instead of printing it to stdout.\n * @environment SHOPIFY_FLAG_OUTPUT\n */\n '-o, --output '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + } + }, "help": { "docs-shopify.dev/commands/interfaces/help.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/help.interface.ts", @@ -4126,8 +4164,27 @@ "name": "search", "description": "The following flags are available for the `search` command:", "isPublicDocs": true, - "members": [], - "value": "export interface search {\n\n}" + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/search.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "''", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/search.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + } + ], + "value": "export interface search {\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "storeauth": { diff --git a/packages/cli/README.md b/packages/cli/README.md index f7d6f3620d6..73d8d2d9800 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -39,6 +39,7 @@ * [`shopify config autoupgrade off`](#shopify-config-autoupgrade-off) * [`shopify config autoupgrade on`](#shopify-config-autoupgrade-on) * [`shopify config autoupgrade status`](#shopify-config-autoupgrade-status) +* [`shopify doc fetch [URL]`](#shopify-doc-fetch-url) * [`shopify help [command] [flags]`](#shopify-help-command-flags) * [`shopify hydrogen build`](#shopify-hydrogen-build) * [`shopify hydrogen check RESOURCE`](#shopify-hydrogen-check-resource) @@ -1212,6 +1213,38 @@ DESCRIPTION Run `shopify config autoupgrade on` or `shopify config autoupgrade off` to configure it. ``` +## `shopify doc fetch [URL]` + +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. For finding the relevant pieces of content across shopify.dev instead, use `doc search`. + +``` +USAGE + $ shopify doc fetch [URL] + +ARGUMENTS + URL The shopify.dev URL to fetch. + +FLAGS + -o, --output= [env: SHOPIFY_FLAG_OUTPUT] Write the document to this file path instead of printing it to + stdout. + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + +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. For finding the relevant pieces of content across shopify.dev instead, use `doc search`. + +EXAMPLES + # fetch the Markdown version of a Shopify.dev page + + $ shopify doc fetch https://shopify.dev/docs/api/shopify-cli + + # save the document to a file instead of printing it + + $ shopify doc fetch https://shopify.dev/docs/api/shopify-cli --output docs/shopify-cli.md +``` + ## `shopify help [command] [flags]` Display help for Shopify CLI @@ -2083,14 +2116,20 @@ DESCRIPTION ## `shopify search [query]` -Starts a search on shopify.dev. +Search shopify.dev for the most relevant content matching a query. Best for 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`. ``` USAGE $ shopify search [query] +FLAGS + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + DESCRIPTION - Starts a search on shopify.dev. + Search shopify.dev for the most relevant content matching a query. Best for 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`. EXAMPLES # open the search modal on Shopify.dev diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index eeacfe2b751..536d16c107a 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3424,6 +3424,59 @@ "strict": true, "summary": "Watch and prints out changes to an app." }, + "doc:fetch": { + "aliases": [ + ], + "args": { + "url": { + "description": "The shopify.dev URL to fetch.", + "name": "url", + "required": true + } + }, + "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. 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 https://shopify.dev/docs/api/shopify-cli", + "# save the document to a file instead of printing it\nshopify doc fetch https://shopify.dev/docs/api/shopify-cli --output docs/shopify-cli.md" + ], + "flags": { + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "output": { + "char": "o", + "description": "Write the document to this file path instead of printing it to stdout.", + "env": "SHOPIFY_FLAG_OUTPUT", + "hasDynamicHelp": false, + "multiple": false, + "name": "output", + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "doc:fetch", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "usage": "doc fetch [URL]" + }, "docs:generate": { "aliases": [ ], @@ -5652,12 +5705,28 @@ "name": "query" } }, - "description": "Starts a search on shopify.dev.", + "description": "Search shopify.dev for the most relevant content matching a query. Best for 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": [ "# open the search modal on Shopify.dev\n shopify search\n\n # search for a term on Shopify.dev\n shopify search \n\n # search for a phrase on Shopify.dev\n shopify search \"\"\n " ], "flags": { + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } }, "hasDynamicHelp": false, "hiddenAliases": [ diff --git a/packages/cli/package.json b/packages/cli/package.json index 715e12c3c6f..536d106982b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -94,6 +94,9 @@ "scope": "shopify", "topicSeparator": " ", "topics": { + "doc": { + "description": "Search and fetch documentation from shopify.dev." + }, "hydrogen": { "description": "Build Hydrogen storefronts." }, diff --git a/packages/cli/src/cli/commands/doc/fetch.ts b/packages/cli/src/cli/commands/doc/fetch.ts new file mode 100644 index 00000000000..022c77380fa --- /dev/null +++ b/packages/cli/src/cli/commands/doc/fetch.ts @@ -0,0 +1,40 @@ +import {docFetchService} from '../../services/commands/doc/fetch.js' +import Command from '@shopify/cli-kit/node/base-command' +import {globalFlags} from '@shopify/cli-kit/node/cli' +import {Args, Flags} from '@oclif/core' + +export default class DocFetch extends Command { + static 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. For finding the relevant pieces of content across shopify.dev instead, use `doc search`.' + + static usage = `doc fetch [URL]` + + static examples = [ + `# fetch the Markdown version of a Shopify.dev page +shopify doc fetch https://shopify.dev/docs/api/shopify-cli`, + `# save the document to a file instead of printing it +shopify doc fetch https://shopify.dev/docs/api/shopify-cli --output docs/shopify-cli.md`, + ] + + static args = { + url: Args.string({ + name: 'url', + required: true, + description: 'The shopify.dev URL to fetch.', + }), + } + + static flags = { + ...globalFlags, + output: Flags.string({ + char: 'o', + description: 'Write the document to this file path instead of printing it to stdout.', + env: 'SHOPIFY_FLAG_OUTPUT', + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(DocFetch) + await docFetchService(args.url, flags.output) + } +} diff --git a/packages/cli/src/cli/commands/search.ts b/packages/cli/src/cli/commands/search.ts index bb1346b8341..07d96c3d75c 100644 --- a/packages/cli/src/cli/commands/search.ts +++ b/packages/cli/src/cli/commands/search.ts @@ -1,9 +1,11 @@ import {searchService} from '../services/commands/search.js' import Command from '@shopify/cli-kit/node/base-command' +import {globalFlags} from '@shopify/cli-kit/node/cli' import {Args} from '@oclif/core' export default class Search extends Command { - static description = 'Starts a search on shopify.dev.' + static description = + 'Search shopify.dev for the most relevant content matching a query. Best for 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 usage = `search [query]` @@ -23,6 +25,10 @@ export default class Search extends Command { query: Args.string(), } + static flags = { + ...globalFlags, + } + async run(): Promise { const {args} = await this.parse(Search) await searchService(args.query) diff --git a/packages/cli/src/cli/services/commands/doc/fetch.test.ts b/packages/cli/src/cli/services/commands/doc/fetch.test.ts new file mode 100644 index 00000000000..8c9a0b8e5b4 --- /dev/null +++ b/packages/cli/src/cli/services/commands/doc/fetch.test.ts @@ -0,0 +1,61 @@ +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 {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' + +vi.mock('@shopify/cli-kit/node/http') +vi.mock('@shopify/cli-kit/node/output') +vi.mock('@shopify/cli-kit/node/fs') + +const okResponse = (body: string) => + ({ok: true, status: 200, statusText: 'OK', text: () => Promise.resolve(body)}) as any + +beforeEach(() => { + vi.mocked(fetch).mockResolvedValue(okResponse('# Doc')) +}) + +describe('docFetchService', () => { + test('requests Markdown and prints the body to stdout', async () => { + await docFetchService('https://shopify.dev/docs/api/shopify-cli') + + expect(fetch).toHaveBeenCalledWith('https://shopify.dev/docs/api/shopify-cli', { + headers: {Accept: 'text/markdown'}, + }) + expect(outputResult).toHaveBeenCalledWith('# Doc') + }) + + test('accepts shopify.dev subdomains', async () => { + await docFetchService('https://www.shopify.dev/docs') + + expect(fetch).toHaveBeenCalledOnce() + }) + + test('rejects URLs from disallowed hosts without fetching', async () => { + await expect(docFetchService('https://example.com/docs')).rejects.toThrowError(AbortError) + expect(fetch).not.toHaveBeenCalled() + }) + + test('rejects malformed URLs without fetching', async () => { + await expect(docFetchService('not a url')).rejects.toThrowError(AbortError) + expect(fetch).not.toHaveBeenCalled() + }) + + test('writes the document to the output path instead of stdout', async () => { + await docFetchService('https://shopify.dev/docs/api/shopify-cli', 'docs/shopify-cli.md') + + const expectedPath = resolvePath('docs/shopify-cli.md') + expect(mkdir).toHaveBeenCalledWith(dirname(expectedPath)) + expect(writeFile).toHaveBeenCalledWith(expectedPath, '# Doc') + expect(outputResult).not.toHaveBeenCalled() + }) + + test('throws when the response is not ok', async () => { + vi.mocked(fetch).mockResolvedValue({ok: false, status: 404, statusText: 'Not Found'} as any) + + await expect(docFetchService('https://shopify.dev/missing')).rejects.toThrowError(AbortError) + expect(outputResult).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cli/src/cli/services/commands/doc/fetch.ts b/packages/cli/src/cli/services/commands/doc/fetch.ts new file mode 100644 index 00000000000..aa7bdde5f07 --- /dev/null +++ b/packages/cli/src/cli/services/commands/doc/fetch.ts @@ -0,0 +1,48 @@ +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' + +// Every page on shopify.dev has a Markdown representation, which is the clean, +// parseable content agents want — so we always request it. +const MARKDOWN_CONTENT_TYPE = 'text/markdown' + +// Hosts whose documents are allowed to be fetched. A URL matches when its +// 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) { + let parsedURL: URL + try { + parsedURL = new URL(url) + } catch { + throw new AbortError(`Invalid URL: ${url}`) + } + + const {hostname} = parsedURL + const isAllowed = ALLOWED_HOSTS.some((host) => hostname === host || hostname.endsWith(`.${host}`)) + if (!isAllowed) { + throw new AbortError(`Only documents from the following hosts can be fetched: ${ALLOWED_HOSTS.join(', ')}.`) + } + + const response = await fetch(url, {headers: {Accept: MARKDOWN_CONTENT_TYPE}}) + + if (!response.ok) { + 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) +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ff506419c63..8dbb25be062 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,5 +1,6 @@ import VersionCommand from './cli/commands/version.js' import Search from './cli/commands/search.js' +import DocFetch from './cli/commands/doc/fetch.js' import Upgrade from './cli/commands/upgrade.js' import Logout from './cli/commands/auth/logout.js' import Login from './cli/commands/auth/login.js' @@ -145,6 +146,7 @@ export const COMMANDS: any = { ...HydrogenCommands, ...StoreCommands, search: Search, + 'doc:fetch': DocFetch, upgrade: Upgrade, version: VersionCommand, help: HelpCommand, diff --git a/packages/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index 9e9afb6c946..262cd4a91ae 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -49,6 +49,8 @@ │ ├─ off │ ├─ on │ └─ status +├─ doc +│ └─ fetch ├─ help ├─ hydrogen │ ├─ build