diff --git a/.changeset/store-open-admin-flag.md b/.changeset/store-open-admin-flag.md new file mode 100644 index 00000000000..f120c32de4d --- /dev/null +++ b/.changeset/store-open-admin-flag.md @@ -0,0 +1,6 @@ +--- +'@shopify/cli': minor +'@shopify/store': minor +--- + +Add `--admin` to `shopify store open` to open the Shopify admin instead of the storefront. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 803785a5e11..a60253475d3 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -6583,12 +6583,21 @@ "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.", + "description": "Opens the storefront for a store you have access to in your default web browser.\n\nUse `--admin` to open the Shopify admin instead. For preview stores that aren't fully set up yet, `--admin` first saves the store and then brings you to the admin in your browser.", + "descriptionWithMarkdown": "Opens the storefront for a store you have access to in your default web browser.\n\nUse `--admin` to open the Shopify admin instead. For preview stores that aren't fully set up yet, `--admin` first saves the store and then brings you to the admin in your browser.", "examples": [ - "<%= config.bin %> <%= command.id %> --store shop.myshopify.com" + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --admin" ], "flags": { + "admin": { + "allowNo": false, + "char": "a", + "description": "Open the admin instead of the storefront. For a preview store, this saves the store and then opens the admin.", + "env": "SHOPIFY_FLAG_ADMIN", + "name": "admin", + "type": "boolean" + }, "no-color": { "allowNo": false, "description": "Disable color output.", diff --git a/packages/store/src/cli/commands/store/open.test.ts b/packages/store/src/cli/commands/store/open.test.ts index 51a4a5c227e..c46dd872d05 100644 --- a/packages/store/src/cli/commands/store/open.test.ts +++ b/packages/store/src/cli/commands/store/open.test.ts @@ -8,10 +8,17 @@ 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'}) + expect(openStore).toHaveBeenCalledWith({store: 'shop.myshopify.com', admin: false}) + }) + + test('passes the admin flag through to the service', async () => { + await StoreOpen.run(['--store', 'shop.myshopify.com', '--admin']) + + expect(openStore).toHaveBeenCalledWith({store: 'shop.myshopify.com', admin: true}) }) test('defines the expected flags', () => { expect(StoreOpen.flags.store).toBeDefined() + expect(StoreOpen.flags.admin).toBeDefined() }) }) diff --git a/packages/store/src/cli/commands/store/open.ts b/packages/store/src/cli/commands/store/open.ts index f4c25417bcc..5b160736350 100644 --- a/packages/store/src/cli/commands/store/open.ts +++ b/packages/store/src/cli/commands/store/open.ts @@ -2,26 +2,39 @@ 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' +import {Flags} from '@oclif/core' 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 descriptionWithMarkdown = `Opens the storefront for a store you have access to in your default web browser. + +Use \`--admin\` to open the Shopify admin instead. For preview stores that aren't fully set up yet, \`--admin\` first saves the store and then brings you to the admin in your browser.` static description = this.descriptionWithoutMarkdown() - static examples = ['<%= config.bin %> <%= command.id %> --store shop.myshopify.com'] + static examples = [ + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --admin', + ] static flags = { ...globalFlags, store: storeFlags.store, + admin: Flags.boolean({ + char: 'a', + description: + 'Open the admin instead of the storefront. For a preview store, this saves the store and then opens the admin.', + env: 'SHOPIFY_FLAG_ADMIN', + default: false, + }), } public async run(): Promise { const {flags} = await this.parse(StoreOpen) - await openStore({store: flags.store}) + await openStore({store: flags.store, admin: flags.admin}) } } diff --git a/packages/store/src/cli/services/store/open.test.ts b/packages/store/src/cli/services/store/open.test.ts index 11b7c6aec46..0b21c170a84 100644 --- a/packages/store/src/cli/services/store/open.test.ts +++ b/packages/store/src/cli/services/store/open.test.ts @@ -46,4 +46,42 @@ describe('openStore', () => { expect.objectContaining({headline: expect.stringContaining("didn't open automatically")}), ) }) + + test('opens the admin URL when admin is requested', async () => { + vi.mocked(getStoreInfo).mockResolvedValue({ + subdomain: 'shop.myshopify.com', + adminUrl: 'https://admin.shopify.com/store/shop', + }) + + await openStore({store: 'shop.myshopify.com', admin: true}) + + expect(openURL).toHaveBeenCalledWith('https://admin.shopify.com/store/shop') + expect(renderInfo).toHaveBeenCalledWith( + expect.objectContaining({headline: expect.stringContaining('the Shopify admin')}), + ) + }) + + test('routes through the save URL for a preview store with admin, opening the admin after saving', async () => { + vi.mocked(getStoreInfo).mockResolvedValue({ + subdomain: 'preview.myshopify.com', + accessUrl: 'https://preview.myshopify.com/?token=abc', + saveUrl: 'https://app.shopify.com/auth/preview-store/123?preview_store_auth_token=xyz', + }) + + await openStore({store: 'preview.myshopify.com', admin: true}) + + expect(openURL).toHaveBeenCalledWith('https://app.shopify.com/auth/preview-store/123?preview_store_auth_token=xyz') + expect(renderInfo).toHaveBeenCalledWith( + expect.objectContaining({headline: expect.stringContaining('the Shopify admin (saving your store first)')}), + ) + }) + + test('throws when admin is requested but no admin or save URL is available', async () => { + vi.mocked(getStoreInfo).mockResolvedValue({subdomain: 'shop.myshopify.com'}) + + await expect(openStore({store: 'shop.myshopify.com', admin: true})).rejects.toThrow( + /Couldn't determine an admin URL/, + ) + expect(openURL).not.toHaveBeenCalled() + }) }) diff --git a/packages/store/src/cli/services/store/open.ts b/packages/store/src/cli/services/store/open.ts index 10a3f3c128f..30abbad590c 100644 --- a/packages/store/src/cli/services/store/open.ts +++ b/packages/store/src/cli/services/store/open.ts @@ -1,11 +1,13 @@ import {getStoreInfo} from './info/index.js' import {openURL as defaultOpenURL} from '@shopify/cli-kit/node/system' +import {AbortError} from '@shopify/cli-kit/node/error' 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 + admin?: boolean } interface OpenStoreDependencies { @@ -19,7 +21,11 @@ const defaultDependencies: OpenStoreDependencies = { } /** - * Opens a store's storefront in the default browser. + * Opens a store in the default browser. + * + * By default the storefront is opened. With `admin: true` the Shopify admin is opened instead. + * For preview stores that don't have a resolvable admin yet, opening with `admin: true` first + * saves the store and then lands you in the admin. */ export async function openStore( options: OpenStoreOptions, @@ -28,21 +34,37 @@ export async function openStore( const {getStoreInfo: getInfo, openURL} = {...defaultDependencies, ...dependencies} const info = await getInfo({store: options.store}) - const url = storefrontUrl(info) + const {url, label} = resolveTarget(options, info) const opened = await openURL(url) if (opened) { - renderInfo({headline: `Opening the storefront for ${options.store} in your browser.`}) + renderInfo({headline: `Opening ${label} for ${options.store} in your browser.`}) return } renderInfo({ - headline: `Browser didn't open automatically. Open the storefront manually:`, + headline: `Browser didn't open automatically. Open ${label} manually:`, body: [outputContent`${outputToken.link(url, url)}`.value], }) } -function storefrontUrl(info: StoreInfoResult): string { +function resolveTarget(options: OpenStoreOptions, info: StoreInfoResult): {url: string; label: string} { + if (options.admin) { + // Preview stores without a resolvable admin route through the save URL, which saves the + // store and then opens the admin. + if (info.adminUrl) { + return {url: info.adminUrl, label: 'the Shopify admin'} + } + if (info.saveUrl) { + return {url: info.saveUrl, label: 'the Shopify admin (saving your store first)'} + } + throw new AbortError( + `Couldn't determine an admin URL for ${options.store}.`, + 'Confirm you have access to the store and that it has been created.', + ) + } + // Preview stores surface a tokenized access URL; everyone else resolves to the canonical domain. - return info.accessUrl ?? `https://${info.subdomain}` + const storefrontTarget = info.accessUrl ?? `https://${info.subdomain}` + return {url: storefrontTarget, label: 'the storefront'} }