Skip to content

Commit b4cd637

Browse files
authored
Merge pull request #7955 from Shopify/store-open-command
Add `shopify store open` command
2 parents b1c02c4 + 0ca8964 commit b4cd637

6 files changed

Lines changed: 193 additions & 0 deletions

File tree

packages/cli/oclif.manifest.json

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6577,6 +6577,56 @@
65776577
"strict": true,
65786578
"summary": "List stores in a Shopify organization."
65796579
},
6580+
"store:open": {
6581+
"aliases": [
6582+
],
6583+
"args": {
6584+
},
6585+
"customPluginName": "@shopify/store",
6586+
"description": "Opens the storefront for a store you have access to in your default web browser.",
6587+
"descriptionWithMarkdown": "Opens the storefront for a store you have access to in your default web browser.",
6588+
"examples": [
6589+
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com"
6590+
],
6591+
"flags": {
6592+
"no-color": {
6593+
"allowNo": false,
6594+
"description": "Disable color output.",
6595+
"env": "SHOPIFY_FLAG_NO_COLOR",
6596+
"hidden": false,
6597+
"name": "no-color",
6598+
"type": "boolean"
6599+
},
6600+
"store": {
6601+
"char": "s",
6602+
"description": "The myshopify.com domain of the store.",
6603+
"env": "SHOPIFY_FLAG_STORE",
6604+
"hasDynamicHelp": false,
6605+
"multiple": false,
6606+
"name": "store",
6607+
"required": true,
6608+
"type": "option"
6609+
},
6610+
"verbose": {
6611+
"allowNo": false,
6612+
"description": "Increase the verbosity of the output.",
6613+
"env": "SHOPIFY_FLAG_VERBOSE",
6614+
"hidden": false,
6615+
"name": "verbose",
6616+
"type": "boolean"
6617+
}
6618+
},
6619+
"hasDynamicHelp": false,
6620+
"hidden": true,
6621+
"hiddenAliases": [
6622+
],
6623+
"id": "store:open",
6624+
"pluginAlias": "@shopify/cli",
6625+
"pluginName": "@shopify/cli",
6626+
"pluginType": "core",
6627+
"strict": true,
6628+
"summary": "Open your Shopify store in the default web browser."
6629+
},
65806630
"theme:check": {
65816631
"aliases": [
65826632
],
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import StoreOpen from './open.js'
2+
import {openStore} from '../../services/store/open.js'
3+
import {describe, expect, test, vi} from 'vitest'
4+
5+
vi.mock('../../services/store/open.js')
6+
7+
describe('store open command', () => {
8+
test('passes the store flag through to the service', async () => {
9+
await StoreOpen.run(['--store', 'shop.myshopify.com'])
10+
11+
expect(openStore).toHaveBeenCalledWith({store: 'shop.myshopify.com'})
12+
})
13+
14+
test('defines the expected flags', () => {
15+
expect(StoreOpen.flags.store).toBeDefined()
16+
})
17+
})
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {openStore} from '../../services/store/open.js'
2+
import StoreCommand from '../../utilities/store-command.js'
3+
import {storeFlags} from '../../flags.js'
4+
import {globalFlags} from '@shopify/cli-kit/node/cli'
5+
6+
export default class StoreOpen extends StoreCommand {
7+
static hidden = true
8+
9+
static summary = 'Open your Shopify store in the default web browser.'
10+
11+
static descriptionWithMarkdown = `Opens the storefront for a store you have access to in your default web browser.`
12+
13+
static description = this.descriptionWithoutMarkdown()
14+
15+
static examples = ['<%= config.bin %> <%= command.id %> --store shop.myshopify.com']
16+
17+
static flags = {
18+
...globalFlags,
19+
store: storeFlags.store,
20+
}
21+
22+
public async run(): Promise<void> {
23+
const {flags} = await this.parse(StoreOpen)
24+
25+
await openStore({store: flags.store})
26+
}
27+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {openStore} from './open.js'
2+
import {getStoreInfo} from './info/index.js'
3+
import {openURL} from '@shopify/cli-kit/node/system'
4+
import {renderInfo} from '@shopify/cli-kit/node/ui'
5+
import {beforeEach, describe, expect, test, vi} from 'vitest'
6+
7+
vi.mock('./info/index.js')
8+
vi.mock('@shopify/cli-kit/node/system')
9+
vi.mock('@shopify/cli-kit/node/ui')
10+
11+
describe('openStore', () => {
12+
beforeEach(() => {
13+
vi.mocked(openURL).mockResolvedValue(true)
14+
})
15+
16+
test('opens the canonical storefront URL for a regular store', async () => {
17+
vi.mocked(getStoreInfo).mockResolvedValue({subdomain: 'shop.myshopify.com'})
18+
19+
await openStore({store: 'shop.myshopify.com'})
20+
21+
expect(getStoreInfo).toHaveBeenCalledWith({store: 'shop.myshopify.com'})
22+
expect(openURL).toHaveBeenCalledWith('https://shop.myshopify.com')
23+
expect(renderInfo).toHaveBeenCalledWith(
24+
expect.objectContaining({headline: expect.stringContaining('Opening the storefront')}),
25+
)
26+
})
27+
28+
test('prefers the preview-store access URL when present', async () => {
29+
vi.mocked(getStoreInfo).mockResolvedValue({
30+
subdomain: 'preview.myshopify.com',
31+
accessUrl: 'https://preview.myshopify.com/?token=abc',
32+
})
33+
34+
await openStore({store: 'preview.myshopify.com'})
35+
36+
expect(openURL).toHaveBeenCalledWith('https://preview.myshopify.com/?token=abc')
37+
})
38+
39+
test('prints the URL manually when the browser does not open', async () => {
40+
vi.mocked(getStoreInfo).mockResolvedValue({subdomain: 'shop.myshopify.com'})
41+
vi.mocked(openURL).mockResolvedValue(false)
42+
43+
await openStore({store: 'shop.myshopify.com'})
44+
45+
expect(renderInfo).toHaveBeenCalledWith(
46+
expect.objectContaining({headline: expect.stringContaining("didn't open automatically")}),
47+
)
48+
})
49+
})
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {getStoreInfo} from './info/index.js'
2+
import {openURL as defaultOpenURL} from '@shopify/cli-kit/node/system'
3+
import {renderInfo} from '@shopify/cli-kit/node/ui'
4+
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
5+
import type {StoreInfoResult} from './info/types.js'
6+
7+
interface OpenStoreOptions {
8+
store: string
9+
}
10+
11+
interface OpenStoreDependencies {
12+
getStoreInfo: typeof getStoreInfo
13+
openURL: typeof defaultOpenURL
14+
}
15+
16+
const defaultDependencies: OpenStoreDependencies = {
17+
getStoreInfo,
18+
openURL: defaultOpenURL,
19+
}
20+
21+
/**
22+
* Opens a store's storefront in the default browser.
23+
*/
24+
export async function openStore(
25+
options: OpenStoreOptions,
26+
dependencies: Partial<OpenStoreDependencies> = {},
27+
): Promise<void> {
28+
const {getStoreInfo: getInfo, openURL} = {...defaultDependencies, ...dependencies}
29+
30+
const info = await getInfo({store: options.store})
31+
const url = storefrontUrl(info)
32+
33+
const opened = await openURL(url)
34+
if (opened) {
35+
renderInfo({headline: `Opening the storefront for ${options.store} in your browser.`})
36+
return
37+
}
38+
39+
renderInfo({
40+
headline: `Browser didn't open automatically. Open the storefront manually:`,
41+
body: [outputContent`${outputToken.link(url, url)}`.value],
42+
})
43+
}
44+
45+
function storefrontUrl(info: StoreInfoResult): string {
46+
// Preview stores surface a tokenized access URL; everyone else resolves to the canonical domain.
47+
return info.accessUrl ?? `https://${info.subdomain}`
48+
}

packages/store/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import StoreCreatePreview from './cli/commands/store/create/preview.js'
88
import StoreExecute from './cli/commands/store/execute.js'
99
import StoreInfo from './cli/commands/store/info.js'
1010
import StoreList from './cli/commands/store/list.js'
11+
import StoreOpen from './cli/commands/store/open.js'
1112

1213
export {loadAdminSessionFromStoreAuth} from './cli/services/store/auth/admin-session.js'
1314

@@ -22,6 +23,7 @@ const COMMANDS = {
2223
'store:execute': StoreExecute,
2324
'store:info': StoreInfo,
2425
'store:list': StoreList,
26+
'store:open': StoreOpen,
2527
}
2628

2729
export default COMMANDS

0 commit comments

Comments
 (0)