diff --git a/.changeset/store-open-command.md b/.changeset/store-open-command.md new file mode 100644 index 00000000000..6765ca106d8 --- /dev/null +++ b/.changeset/store-open-command.md @@ -0,0 +1,6 @@ +--- +'@shopify/cli': minor +'@shopify/store': minor +--- + +Add `shopify store open` to open a store's storefront in your default browser. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index ad60fffaed5..803785a5e11 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -6577,6 +6577,56 @@ "strict": true, "summary": "List stores in a Shopify organization." }, + "store:open": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/store", + "description": "Opens the storefront for a store you have access to in your default web browser.", + "descriptionWithMarkdown": "Opens the storefront for a store you have access to in your default web browser.", + "examples": [ + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com" + ], + "flags": { + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "store": { + "char": "s", + "description": "The myshopify.com domain of the store.", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "required": true, + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "store:open", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Open your Shopify store in the default web browser." + }, "theme:check": { "aliases": [ ], diff --git a/packages/store/src/cli/commands/store/open.test.ts b/packages/store/src/cli/commands/store/open.test.ts new file mode 100644 index 00000000000..51a4a5c227e --- /dev/null +++ b/packages/store/src/cli/commands/store/open.test.ts @@ -0,0 +1,17 @@ +import StoreOpen from './open.js' +import {openStore} from '../../services/store/open.js' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../../services/store/open.js') + +describe('store open command', () => { + test('passes the store flag through to the service', async () => { + await StoreOpen.run(['--store', 'shop.myshopify.com']) + + expect(openStore).toHaveBeenCalledWith({store: 'shop.myshopify.com'}) + }) + + test('defines the expected flags', () => { + expect(StoreOpen.flags.store).toBeDefined() + }) +}) diff --git a/packages/store/src/cli/commands/store/open.ts b/packages/store/src/cli/commands/store/open.ts new file mode 100644 index 00000000000..f4c25417bcc --- /dev/null +++ b/packages/store/src/cli/commands/store/open.ts @@ -0,0 +1,27 @@ +import {openStore} from '../../services/store/open.js' +import StoreCommand from '../../utilities/store-command.js' +import {storeFlags} from '../../flags.js' +import {globalFlags} from '@shopify/cli-kit/node/cli' + +export default class StoreOpen extends StoreCommand { + static hidden = true + + static summary = 'Open your Shopify store in the default web browser.' + + static descriptionWithMarkdown = `Opens the storefront for a store you have access to in your default web browser.` + + static description = this.descriptionWithoutMarkdown() + + static examples = ['<%= config.bin %> <%= command.id %> --store shop.myshopify.com'] + + static flags = { + ...globalFlags, + store: storeFlags.store, + } + + public async run(): Promise { + const {flags} = await this.parse(StoreOpen) + + await openStore({store: flags.store}) + } +} diff --git a/packages/store/src/cli/services/store/open.test.ts b/packages/store/src/cli/services/store/open.test.ts new file mode 100644 index 00000000000..11b7c6aec46 --- /dev/null +++ b/packages/store/src/cli/services/store/open.test.ts @@ -0,0 +1,49 @@ +import {openStore} from './open.js' +import {getStoreInfo} from './info/index.js' +import {openURL} from '@shopify/cli-kit/node/system' +import {renderInfo} from '@shopify/cli-kit/node/ui' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('./info/index.js') +vi.mock('@shopify/cli-kit/node/system') +vi.mock('@shopify/cli-kit/node/ui') + +describe('openStore', () => { + beforeEach(() => { + vi.mocked(openURL).mockResolvedValue(true) + }) + + test('opens the canonical storefront URL for a regular store', async () => { + vi.mocked(getStoreInfo).mockResolvedValue({subdomain: 'shop.myshopify.com'}) + + await openStore({store: 'shop.myshopify.com'}) + + expect(getStoreInfo).toHaveBeenCalledWith({store: 'shop.myshopify.com'}) + expect(openURL).toHaveBeenCalledWith('https://shop.myshopify.com') + expect(renderInfo).toHaveBeenCalledWith( + expect.objectContaining({headline: expect.stringContaining('Opening the storefront')}), + ) + }) + + test('prefers the preview-store access URL when present', async () => { + vi.mocked(getStoreInfo).mockResolvedValue({ + subdomain: 'preview.myshopify.com', + accessUrl: 'https://preview.myshopify.com/?token=abc', + }) + + await openStore({store: 'preview.myshopify.com'}) + + expect(openURL).toHaveBeenCalledWith('https://preview.myshopify.com/?token=abc') + }) + + test('prints the URL manually when the browser does not open', async () => { + vi.mocked(getStoreInfo).mockResolvedValue({subdomain: 'shop.myshopify.com'}) + vi.mocked(openURL).mockResolvedValue(false) + + await openStore({store: 'shop.myshopify.com'}) + + expect(renderInfo).toHaveBeenCalledWith( + expect.objectContaining({headline: expect.stringContaining("didn't open automatically")}), + ) + }) +}) diff --git a/packages/store/src/cli/services/store/open.ts b/packages/store/src/cli/services/store/open.ts new file mode 100644 index 00000000000..10a3f3c128f --- /dev/null +++ b/packages/store/src/cli/services/store/open.ts @@ -0,0 +1,48 @@ +import {getStoreInfo} from './info/index.js' +import {openURL as defaultOpenURL} from '@shopify/cli-kit/node/system' +import {renderInfo} from '@shopify/cli-kit/node/ui' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import type {StoreInfoResult} from './info/types.js' + +interface OpenStoreOptions { + store: string +} + +interface OpenStoreDependencies { + getStoreInfo: typeof getStoreInfo + openURL: typeof defaultOpenURL +} + +const defaultDependencies: OpenStoreDependencies = { + getStoreInfo, + openURL: defaultOpenURL, +} + +/** + * Opens a store's storefront in the default browser. + */ +export async function openStore( + options: OpenStoreOptions, + dependencies: Partial = {}, +): Promise { + const {getStoreInfo: getInfo, openURL} = {...defaultDependencies, ...dependencies} + + const info = await getInfo({store: options.store}) + const url = storefrontUrl(info) + + const opened = await openURL(url) + if (opened) { + renderInfo({headline: `Opening the storefront for ${options.store} in your browser.`}) + return + } + + renderInfo({ + headline: `Browser didn't open automatically. Open the storefront manually:`, + body: [outputContent`${outputToken.link(url, url)}`.value], + }) +} + +function storefrontUrl(info: StoreInfoResult): string { + // Preview stores surface a tokenized access URL; everyone else resolves to the canonical domain. + return info.accessUrl ?? `https://${info.subdomain}` +} diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 1b989133e3a..3d91e134b84 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -8,6 +8,7 @@ import StoreCreatePreview from './cli/commands/store/create/preview.js' import StoreExecute from './cli/commands/store/execute.js' import StoreInfo from './cli/commands/store/info.js' import StoreList from './cli/commands/store/list.js' +import StoreOpen from './cli/commands/store/open.js' export {loadAdminSessionFromStoreAuth} from './cli/services/store/auth/admin-session.js' @@ -22,6 +23,7 @@ const COMMANDS = { 'store:execute': StoreExecute, 'store:info': StoreInfo, 'store:list': StoreList, + 'store:open': StoreOpen, } export default COMMANDS