diff --git a/docs-shopify.dev/commands/interfaces/theme-console.interface.ts b/docs-shopify.dev/commands/interfaces/theme-console.interface.ts index e4d41e6d0f..ded0ae8e77 100644 --- a/docs-shopify.dev/commands/interfaces/theme-console.interface.ts +++ b/docs-shopify.dev/commands/interfaces/theme-console.interface.ts @@ -30,6 +30,12 @@ export interface themeconsole { */ '-s, --store '?: string + /** + * The password for the storefront. + * @environment SHOPIFY_FLAG_STORE_PASSWORD + */ + '--store-password '?: string + /** * The url to be used as context * @environment SHOPIFY_FLAG_URL diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index 2319925ce8..048647de0c 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -4255,6 +4255,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_PORT" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-console.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--store-password ", + "value": "string", + "description": "The password for the storefront.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STORE_PASSWORD" + }, { "filePath": "docs-shopify.dev/commands/interfaces/theme-console.interface.ts", "syntaxKind": "PropertySignature", @@ -4292,7 +4301,7 @@ "environmentValue": "SHOPIFY_FLAG_STORE" } ], - "value": "export interface themeconsole {\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Password generated from the Theme Access app.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * Local port to serve authentication service.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port '?: string\n\n /**\n * Store URL. It can be the store prefix (johns-apparel) or the full myshopify.com URL (johns-apparel.myshopify.com, https://johns-apparel.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The url to be used as context\n * @environment SHOPIFY_FLAG_URL\n */\n '--url '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface themeconsole {\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Password generated from the Theme Access app.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * Local port to serve authentication service.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port '?: string\n\n /**\n * Store URL. It can be the store prefix (johns-apparel) or the full myshopify.com URL (johns-apparel.myshopify.com, https://johns-apparel.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The password for the storefront.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * The url to be used as context\n * @environment SHOPIFY_FLAG_URL\n */\n '--url '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } } } diff --git a/packages/cli/README.md b/packages/cli/README.md index 429d6d41ca..0541e5bb23 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1611,14 +1611,15 @@ USAGE $ shopify theme console --url /products/classic-leather-jacket FLAGS - -e, --environment= The environment to apply to the current command. - -s, --store= Store URL. It can be the store prefix (johns-apparel) or the full myshopify.com URL - (johns-apparel.myshopify.com, https://johns-apparel.myshopify.com). - --no-color Disable color output. - --password= Password generated from the Theme Access app. - --port= [default: 9293] Local port to serve authentication service. - --url= [default: /] The url to be used as context - --verbose Increase the verbosity of the output. + -e, --environment= The environment to apply to the current command. + -s, --store= Store URL. It can be the store prefix (johns-apparel) or the full myshopify.com URL + (johns-apparel.myshopify.com, https://johns-apparel.myshopify.com). + --no-color Disable color output. + --password= Password generated from the Theme Access app. + --port= [default: 9293] Local port to serve authentication service. + --store-password= The password for the storefront. + --url= [default: /] The url to be used as context + --verbose Increase the verbosity of the output. DESCRIPTION Shopify Liquid REPL (read-eval-print loop) tool diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 8073365a05..7475a4ea25 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4688,6 +4688,14 @@ "name": "store", "type": "option" }, + "store-password": { + "description": "The password for the storefront.", + "env": "SHOPIFY_FLAG_STORE_PASSWORD", + "hasDynamicHelp": false, + "multiple": false, + "name": "store-password", + "type": "option" + }, "url": { "default": "/", "description": "The url to be used as context", diff --git a/packages/theme/src/cli/commands/theme/console.ts b/packages/theme/src/cli/commands/theme/console.ts index b9c9afc3a7..20ccc647d0 100644 --- a/packages/theme/src/cli/commands/theme/console.ts +++ b/packages/theme/src/cli/commands/theme/console.ts @@ -1,7 +1,7 @@ import {themeFlags} from '../../flags.js' import ThemeCommand from '../../utilities/theme-command.js' import {ensureThemeStore} from '../../utilities/theme-store.js' -import {repl} from '../../services/console.js' +import {ensureReplEnv, repl} from '../../services/console.js' import {globalFlags} from '@shopify/cli-kit/node/cli' import {ensureAuthenticatedStorefront, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {execCLI2} from '@shopify/cli-kit/node/ruby' @@ -36,6 +36,10 @@ export default class Console extends ThemeCommand { env: 'SHOPIFY_FLAG_PORT', default: '9293', }), + 'store-password': Flags.string({ + description: 'The password for the storefront.', + env: 'SHOPIFY_FLAG_STORE_PASSWORD', + }), 'dev-preview': Flags.boolean({ hidden: true, description: 'Enables the developer preview for the upcoming `theme console` implementation.', @@ -46,17 +50,18 @@ export default class Console extends ThemeCommand { async run() { const {flags} = await this.parse(Console) const store = ensureThemeStore(flags) - const {password, url, port} = flags + const {url, port, password: themeAccessPassword} = flags const cliVersion = CLI_KIT_VERSION const theme = `liquid-console-repl-${cliVersion}` - const adminSession = await ensureAuthenticatedThemes(store, password, [], true) - const storefrontToken = await ensureAuthenticatedStorefront([], password) + const adminSession = await ensureAuthenticatedThemes(store, themeAccessPassword, [], true) + const storefrontToken = await ensureAuthenticatedStorefront([], themeAccessPassword) const authUrl = `http://localhost:${port}/password` if (flags['dev-preview']) { outputInfo('This feature is currently in development and is not ready for use or testing yet.') - await repl(adminSession, storefrontToken, flags.password) + const {themeId, storePassword} = await ensureReplEnv(store, flags['store-password']) + await repl(adminSession, storefrontToken, themeId, storePassword) return } diff --git a/packages/theme/src/cli/services/console.test.ts b/packages/theme/src/cli/services/console.test.ts new file mode 100644 index 0000000000..040699dd98 --- /dev/null +++ b/packages/theme/src/cli/services/console.test.ts @@ -0,0 +1,42 @@ +import {ensureReplEnv} from './console.js' +import { + isStorefrontPasswordCorrect, + isStorefrontPasswordProtected, +} from '../utilities/theme-environment/storefront-session.js' +import {ensureValidPassword} from '../utilities/prompts.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('../utilities/theme-environment/storefront-session.js') +vi.mock('../utilities/prompts.js') + +describe('ensureReplEnv', () => { + beforeEach(() => { + vi.mocked(ensureValidPassword).mockResolvedValue('testPassword') + }) + + test('should prompt for password when storefront is password protected', async () => { + // Given + vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(true) + vi.mocked(isStorefrontPasswordCorrect).mockResolvedValue(true) + + // When + const {storePassword} = await ensureReplEnv('test-store') + + // Then + expect(ensureValidPassword).toHaveBeenCalled() + expect(storePassword).toBe('testPassword') + }) + + test('should skip prompt for password when storefront is not password protected', async () => { + // Given + vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(false) + vi.mocked(isStorefrontPasswordCorrect).mockResolvedValue(true) + + // When + const {storePassword} = await ensureReplEnv('test-store') + + // Then + expect(ensureValidPassword).not.toHaveBeenCalled() + expect(storePassword).toBeUndefined() + }) +}) diff --git a/packages/theme/src/cli/services/console.ts b/packages/theme/src/cli/services/console.ts index b879e90e5f..ade47eba36 100644 --- a/packages/theme/src/cli/services/console.ts +++ b/packages/theme/src/cli/services/console.ts @@ -1,3 +1,27 @@ +import {ensureValidPassword} from '../utilities/prompts.js' +import {isStorefrontPasswordProtected} from '../utilities/theme-environment/storefront-session.js' import {AdminSession} from '@shopify/cli-kit/node/session' -export async function repl(_adminSession: AdminSession, _storefrontToken: string, _password?: string) {} +export async function ensureReplEnv(store: string, storePasswordFlag?: string) { + const themeId = await findOrCreateReplTheme() + + const storePassword = (await isStorefrontPasswordProtected(store)) + ? await ensureValidPassword(storePasswordFlag, store) + : undefined + + return { + themeId, + storePassword, + } +} + +async function findOrCreateReplTheme(): Promise { + return '' +} + +export async function repl( + _adminSession: AdminSession, + _storefrontToken: string, + _themeId: string, + _password: string | undefined, +) {} diff --git a/packages/theme/src/cli/utilities/prompts.test.ts b/packages/theme/src/cli/utilities/prompts.test.ts new file mode 100644 index 0000000000..688ede6dd0 --- /dev/null +++ b/packages/theme/src/cli/utilities/prompts.test.ts @@ -0,0 +1,54 @@ +import {isStorefrontPasswordProtected, isStorefrontPasswordCorrect} from './theme-environment/storefront-session.js' +import {ensureValidPassword} from './prompts.js' +import {AdminSession} from '@shopify/cli-kit/node/session' +import {renderTextPrompt} from '@shopify/cli-kit/node/ui' +import {describe, beforeEach, vi, test, expect} from 'vitest' + +vi.mock('../utilities/theme-environment/storefront-session.js') +vi.mock('@shopify/cli-kit/node/ui') +vi.mock('../utilities/repl-theme-manager.js', () => { + const REPLThemeManager = vi.fn() + REPLThemeManager.prototype.findOrCreate = () => ({ + id: 1, + name: 'theme', + role: 'development', + createdAtRuntime: true, + processing: true, + }) + return {REPLThemeManager} +}) + +describe('ensureValidPassword', () => { + beforeEach(() => { + vi.mocked(renderTextPrompt).mockResolvedValue('testPassword') + }) + + const adminSession: AdminSession = {storeFqdn: 'test-store.myshopify.com', token: 'token'} + + test('should skip prompt for password when correct storefront password is provided', async () => { + // Given + vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(true) + vi.mocked(isStorefrontPasswordCorrect).mockResolvedValue(true) + + // When + await ensureValidPassword('correctPassword', 'test-store') + + // Then + expect(renderTextPrompt).not.toHaveBeenCalled() + }) + + test('should prompt for correct password when incorrect password is provided', async () => { + // Given + vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(true) + vi.mocked(isStorefrontPasswordCorrect) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValue(true) + + // When + await ensureValidPassword('incorrectPassword', 'test-store') + + // Then + expect(renderTextPrompt).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/theme/src/cli/utilities/prompts.ts b/packages/theme/src/cli/utilities/prompts.ts new file mode 100644 index 0000000000..0f95f0b475 --- /dev/null +++ b/packages/theme/src/cli/utilities/prompts.ts @@ -0,0 +1,20 @@ +import {isStorefrontPasswordCorrect} from './theme-environment/storefront-session.js' +import {renderTextPrompt} from '@shopify/cli-kit/node/ui' + +export async function ensureValidPassword(password: string | undefined, store: string) { + let finalPassword = password || (await promptPassword('Enter your theme password')) + + // eslint-disable-next-line no-await-in-loop + while (!(await isStorefrontPasswordCorrect(finalPassword, store))) { + // eslint-disable-next-line no-await-in-loop + finalPassword = await promptPassword('Incorrect password provided. Please try again') + } + return finalPassword +} + +async function promptPassword(prompt: string): Promise { + return renderTextPrompt({ + message: prompt, + password: true, + }) +} diff --git a/packages/theme/src/cli/utilities/theme-environment/storefront-session.test.ts b/packages/theme/src/cli/utilities/theme-environment/storefront-session.test.ts index 2579eddd33..56255b1496 100644 --- a/packages/theme/src/cli/utilities/theme-environment/storefront-session.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/storefront-session.test.ts @@ -1,4 +1,8 @@ -import {getStorefrontSessionCookies, isStorefrontPasswordProtected} from './storefront-session.js' +import { + getStorefrontSessionCookies, + isStorefrontPasswordCorrect, + isStorefrontPasswordProtected, +} from './storefront-session.js' import {describe, expect, test, vi} from 'vitest' import {fetch} from '@shopify/cli-kit/node/http' @@ -126,7 +130,79 @@ describe('Storefront API', () => { headers: { ...mock.headers, raw: vi.fn().mockReturnValue({'set-cookie': setCookieArray}), + get: vi.fn().mockImplementation((key) => mock.headers?.[key]), }, } as any } + + describe('storefrontPasswordIsCorrect', () => { + test('returns true when the password is correct', async () => { + // Given + vi.mocked(fetch).mockResolvedValueOnce( + response({ + status: 302, + headers: { + location: 'https://store.myshopify.com/', + }, + }), + ) + + // When + const result = await isStorefrontPasswordCorrect('correct-password', 'store.myshopify.com') + + // Then + expect(result).toBe(true) + }) + + test('returns false when the password is incorrect', async () => { + // Given + vi.mocked(fetch).mockResolvedValueOnce( + response({ + status: 401, + }), + ) + + // When + const result = await isStorefrontPasswordCorrect('wrong-password', 'store.myshopify.com') + + // Then + expect(result).toBe(false) + }) + + test('returns false when the redirect location is incorrect', async () => { + // Given + vi.mocked(fetch).mockResolvedValueOnce( + response({ + status: 302, + headers: { + location: 'https://random-location.com/', + }, + }), + ) + + // When + const result = await isStorefrontPasswordCorrect('correct-password', 'store.myshopify.com') + + // Then + expect(result).toBe(false) + }) + + test('throws an error when the server responds with "Too Many Requests"', async () => { + // Given + vi.mocked(fetch).mockResolvedValueOnce( + response({ + status: 429, + headers: { + 'retry-after': '60', + }, + }), + ) + + // When + const result = isStorefrontPasswordCorrect('wrong-password', 'store.myshopify.com') + + // Then + await expect(result).rejects.toThrow('Too many incorrect password attempts. Please try again after 60 seconds.') + }) + }) }) diff --git a/packages/theme/src/cli/utilities/theme-environment/storefront-session.ts b/packages/theme/src/cli/utilities/theme-environment/storefront-session.ts index 19bf55ff3a..d9bd475618 100644 --- a/packages/theme/src/cli/utilities/theme-environment/storefront-session.ts +++ b/packages/theme/src/cli/utilities/theme-environment/storefront-session.ts @@ -4,7 +4,7 @@ import {fetch} from '@shopify/cli-kit/node/http' import {AbortError} from '@shopify/cli-kit/node/error' export async function isStorefrontPasswordProtected(storeURL: string): Promise { - const response = await fetch(storeURL, { + const response = await fetch(prependHttps(storeURL), { method: 'GET', redirect: 'manual', }) @@ -12,6 +12,29 @@ export async function isStorefrontPasswordProtected(storeURL: string): Promise