diff --git a/.changeset/theme-airlock-atomic-toml-writes.md b/.changeset/theme-airlock-atomic-toml-writes.md new file mode 100644 index 00000000000..af0f40cacf5 --- /dev/null +++ b/.changeset/theme-airlock-atomic-toml-writes.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-kit': minor +--- + +Write TOML configuration updates atomically while preserving existing file permissions and creating new files securely. diff --git a/.changeset/theme-airlock-trust-preflight.md b/.changeset/theme-airlock-trust-preflight.md new file mode 100644 index 00000000000..a5bde1e1ab3 --- /dev/null +++ b/.changeset/theme-airlock-trust-preflight.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme': minor +--- + +Require project-trusted stores for theme push and dev, with visible preflight and an authenticated Airlock setup command. diff --git a/UPGRADE_GUIDE.md b/UPGRADE_GUIDE.md index a64de6f94bd..af4fececfd4 100644 --- a/UPGRADE_GUIDE.md +++ b/UPGRADE_GUIDE.md @@ -18,3 +18,22 @@ root of your app and run `npm shopify migrate-app-toml-to-xml`. But seriously, PLEASE DO NOT MAKE THIS FILE NECESSARY. --> + +# Themes + +## Project trust for theme push and dev + +`shopify theme push` and `shopify theme dev` now require the target store to be trusted in the nearest `shopify.theme.toml` file. Existing non-interactive scripts must complete this one-time migration by adding a named environment: + +```toml +[environments.production] +store = "example.myshopify.com" +``` + +Then select that environment in the script: + +```sh +shopify theme push --environment production +``` + +After this one-time project trust configuration, future runs remain non-interactive. Passing `--store` and valid credentials does not establish project trust. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 55c279dd06c..162ecf01c7e 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -5398,6 +5398,70 @@ "value": "export interface storeopen {\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The myshopify.com domain of the store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, + "themeairlockadd": { + "docs-shopify.dev/commands/interfaces/theme-airlock-add.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/theme-airlock-add.interface.ts", + "name": "themeairlockadd", + "description": "The following flags are available for the `theme airlock add` command:", + "isPublicDocs": true, + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-airlock-add.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--auth-alias ", + "value": "string", + "description": "Alias of the Shopify account to use for authentication.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-airlock-add.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--environment ", + "value": "string", + "description": "Environment name to add.", + "environmentValue": "SHOPIFY_FLAG_ENVIRONMENT" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-airlock-add.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/theme-airlock-add.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--password ", + "value": "string", + "description": "Password generated from the Theme Access app or an Admin API token.", + "isOptional": true, + "environmentValue": "SHOPIFY_CLI_THEME_TOKEN" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-airlock-add.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--path ", + "value": "string", + "description": "The path where you want to run the command. Defaults to the current working directory.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_PATH" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-airlock-add.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + } + ], + "value": "export interface themeairlockadd {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * Environment name to add.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '--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 or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + } + }, "themecheck": { "docs-shopify.dev/commands/interfaces/theme-check.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/theme-check.interface.ts", diff --git a/packages/cli-kit/src/public/node/fs.test.ts b/packages/cli-kit/src/public/node/fs.test.ts index d30612b5b96..ef3f74d3b4a 100644 --- a/packages/cli-kit/src/public/node/fs.test.ts +++ b/packages/cli-kit/src/public/node/fs.test.ts @@ -22,10 +22,155 @@ import { copyDirectoryContents, symlink, fileRealPath, + readdir, + writeFileAtomically, } from './fs.js' import {joinPath, normalizePath} from './path.js' import * as array from '../common/array.js' import {describe, expect, test, vi} from 'vitest' +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import {remove as fsRemove} from 'fs-extra/esm' +import {rename as fsRename, stat, lstat, writeFile as fsWriteFile} from 'node:fs/promises' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, rename: vi.fn(actual.rename), writeFile: vi.fn(actual.writeFile)} +}) + +vi.mock('fs-extra/esm', async (importOriginal) => { + const actual = await importOriginal<{remove: typeof fsRemove}>() + return {...actual, remove: vi.fn(actual.remove)} +}) + +describe('writeFileAtomically', () => { + test('replaces existing file content', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const path = joinPath(tmpDir, 'test-file') + await writeFile(path, 'original content') + + await writeFileAtomically(path, 'replacement content') + + await expect(readFile(path)).resolves.toBe('replacement content') + }) + }) + + test('removes the temporary sibling after success', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const fileName = 'test-file' + const path = joinPath(tmpDir, fileName) + + await writeFileAtomically(path, 'content') + + const entries = await readdir(tmpDir) + expect(entries.filter((entry) => entry.startsWith(`${fileName}.`) && entry.endsWith('.tmp'))).toStrictEqual([]) + }) + }) + + test('passes a restrictive mode when creating the temporary sibling', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const path = joinPath(tmpDir, 'test-file') + vi.mocked(fsWriteFile).mockClear() + + await writeFileAtomically(path, 'content') + + const temporaryWrite = vi.mocked(fsWriteFile).mock.calls.find(([writePath]) => String(writePath).endsWith('.tmp')) + expect(temporaryWrite?.[2]).toMatchObject({encoding: 'utf8', mode: 0o600}) + }) + }) + + test.skipIf(process.platform === 'win32')('uses restrictive permissions for a new file', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const path = joinPath(tmpDir, 'test-file') + + await writeFileAtomically(path, 'content') + + expect((await stat(path)).mode & 0o777).toBe(0o600) + }) + }) + + test.skipIf(process.platform === 'win32')('preserves the mode of an existing file', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const path = joinPath(tmpDir, 'test-file') + await writeFile(path, 'original content') + await chmod(path, 0o754) + const originalMode = (await stat(path)).mode + vi.mocked(fsWriteFile).mockClear() + + await writeFileAtomically(path, 'replacement content') + + const temporaryWrite = vi.mocked(fsWriteFile).mock.calls.find(([writePath]) => String(writePath).endsWith('.tmp')) + expect(temporaryWrite?.[2]).toMatchObject({encoding: 'utf8', mode: 0o600}) + expect((await stat(path)).mode).toBe(originalMode) + }) + }) + + test('leaves the original unchanged and removes the temporary sibling when replacement fails', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const fileName = 'test-file' + const path = joinPath(tmpDir, fileName) + await writeFile(path, 'original content') + vi.mocked(fsRename).mockRejectedValueOnce(new Error('replacement failed')) + + await expect(writeFileAtomically(path, 'replacement content')).rejects.toThrow('replacement failed') + + await expect(readFile(path)).resolves.toBe('original content') + const entries = await readdir(tmpDir) + expect(entries.filter((entry) => entry.startsWith(`${fileName}.`) && entry.endsWith('.tmp'))).toStrictEqual([]) + }) + }) + + test('rejects the cleanup error after a successful replacement', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const path = joinPath(tmpDir, 'test-file') + vi.mocked(fsRemove).mockRejectedValueOnce(new Error('cleanup failed')) + + await expect(writeFileAtomically(path, 'replacement content')).rejects.toThrow('cleanup failed') + await expect(readFile(path)).resolves.toBe('replacement content') + }) + }) + + test('preserves the replacement error when temporary cleanup also fails', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const path = joinPath(tmpDir, 'test-file') + await writeFile(path, 'original content') + vi.mocked(fsRename).mockRejectedValueOnce(new Error('replacement failed')) + vi.mocked(fsRemove).mockRejectedValueOnce(new Error('cleanup failed')) + + await expect(writeFileAtomically(path, 'replacement content')).rejects.toThrow('replacement failed') + }) + }) + + test('writes through an existing file symlink and preserves the link', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const targetPath = joinPath(tmpDir, 'target-file') + const linkPath = joinPath(tmpDir, 'linked-file') + await writeFile(targetPath, 'original content') + await symlink(targetPath, linkPath) + + await writeFileAtomically(linkPath, 'replacement content') + + await expect(readFile(targetPath)).resolves.toBe('replacement content') + expect((await lstat(linkPath)).isSymbolicLink()).toBe(true) + const entries = await readdir(tmpDir) + expect(entries.filter((entry) => entry.endsWith('.tmp'))).toStrictEqual([]) + }) + }) + + test('rejects a dangling symlink without replacing it', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const linkPath = joinPath(tmpDir, 'linked-file') + await symlink(joinPath(tmpDir, 'missing-target'), linkPath) + + await expect(writeFileAtomically(linkPath, 'replacement content')).rejects.toThrow( + `Unable to atomically write ${linkPath}: destination is a dangling symlink`, + ) + + expect((await lstat(linkPath)).isSymbolicLink()).toBe(true) + await expect(readdir(tmpDir)).resolves.toEqual(['linked-file']) + }) + }) +}) describe('inTemporaryDirectory', () => { test('ties the lifecycle of the temporary directory to the lifecycle of the callback', async () => { diff --git a/packages/cli-kit/src/public/node/fs.ts b/packages/cli-kit/src/public/node/fs.ts index 5948c9f6bb6..e5a15817115 100644 --- a/packages/cli-kit/src/public/node/fs.ts +++ b/packages/cli-kit/src/public/node/fs.ts @@ -54,6 +54,7 @@ import { symlink as fsSymlink, } from 'fs/promises' import {pathToFileURL as pathToFile} from 'url' +import {randomUUID} from 'node:crypto' import * as os from 'os' import type {Pattern, Options as GlobOptions} from 'fast-glob' @@ -211,6 +212,7 @@ export function appendFileSync(path: string, data: string): void { export interface WriteOptions { encoding: BufferEncoding + mode?: number } /** @@ -229,6 +231,61 @@ export async function writeFile( await fsWriteFile(path, data, options) } +/** + * Atomically writes text content to a file through a sibling temporary file. + * + * @param path - Path to the file to be written. + * @param data - Text content to be written. + */ +export async function writeFileAtomically(path: string, data: string): Promise { + let destinationStats + try { + destinationStats = await fsLstat(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + + let destinationPath = path + if (destinationStats) { + try { + destinationPath = await fileRealPath(path) + } catch (error) { + if (destinationStats.isSymbolicLink() && (error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`Unable to atomically write ${path}: destination is a dangling symlink.`) + } + throw error + } + } + + const existingMode = destinationStats ? (await fsStat(destinationPath)).mode & 0o7777 : undefined + const temporaryPath = `${destinationPath}.${process.pid}.${randomUUID()}.tmp` + + let primaryOperationFailed = false + let cleanupFailed = false + let cleanupError: unknown + try { + await writeFile(temporaryPath, data, {encoding: 'utf8', mode: 0o600}) + if (existingMode !== undefined) await chmod(temporaryPath, existingMode) + await renameFile(temporaryPath, destinationPath) + } catch (error) { + primaryOperationFailed = true + throw error + } finally { + try { + await removeFile(temporaryPath) + // Preserve the primary operation error when cleanup also fails. + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + if (!primaryOperationFailed) { + cleanupFailed = true + cleanupError = error + } + } + } + + if (cleanupFailed) throw cleanupError +} + /** * Synchronously writes content to file at path. * diff --git a/packages/cli-kit/src/public/node/toml/toml-file.test.ts b/packages/cli-kit/src/public/node/toml/toml-file.test.ts index 9e507c60df0..e9f8515a6b6 100644 --- a/packages/cli-kit/src/public/node/toml/toml-file.test.ts +++ b/packages/cli-kit/src/public/node/toml/toml-file.test.ts @@ -1,8 +1,14 @@ import {TomlFile, TomlFileError} from './toml-file.js' import {shouldReportErrorAsUnexpected} from '../error.js' -import {writeFile, readFile, inTemporaryDirectory} from '../fs.js' +import {writeFile, readFile, inTemporaryDirectory, readdir} from '../fs.js' import {joinPath} from '../path.js' -import {describe, expect, test} from 'vitest' +import {describe, expect, test, vi} from 'vitest' +import {rename as fsRename} from 'node:fs/promises' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, rename: vi.fn(actual.rename)} +}) describe('TomlFile', () => { describe('read', () => { @@ -137,6 +143,24 @@ describe('TomlFile', () => { expect(content.auth.redirect_urls).toStrictEqual(['https://new.com', 'https://other.com']) }) }) + + test('leaves the original unchanged when atomic replacement fails', async () => { + await inTemporaryDirectory(async (dir) => { + const fileName = 'test.toml' + const path = joinPath(dir, fileName) + const originalContent = 'name = "old"\n' + await writeFile(path, originalContent) + const file = await TomlFile.read(path) + vi.mocked(fsRename).mockRejectedValueOnce(new Error('replacement failed')) + + await expect(file.patch({name: 'new'})).rejects.toThrow('replacement failed') + + await expect(readFile(path)).resolves.toBe(originalContent) + expect(file.content).toStrictEqual({name: 'old'}) + const entries = await readdir(dir) + expect(entries.filter((entry) => entry.startsWith(`${fileName}.`) && entry.endsWith('.tmp'))).toStrictEqual([]) + }) + }) }) describe('remove', () => { diff --git a/packages/cli-kit/src/public/node/toml/toml-file.ts b/packages/cli-kit/src/public/node/toml/toml-file.ts index a2ee81b3236..7e9287ae76c 100644 --- a/packages/cli-kit/src/public/node/toml/toml-file.ts +++ b/packages/cli-kit/src/public/node/toml/toml-file.ts @@ -1,6 +1,6 @@ import {JsonMapType, decodeToml, encodeToml} from './codec.js' import {AbortError} from '../error.js' -import {fileExists, readFile, writeFile} from '../fs.js' +import {fileExists, readFile, writeFileAtomically} from '../fs.js' import {updateTomlValues} from '@shopify/toml-patch' type TomlPatchValue = string | number | boolean | undefined | (string | number | boolean)[] @@ -76,7 +76,7 @@ export class TomlFile { const raw = await readFile(this.path) const updated = updateTomlValues(raw, patches) const parsed = this.decode(updated) - await writeFile(this.path, updated) + await writeFileAtomically(this.path, updated) this.content = parsed } @@ -94,7 +94,7 @@ export class TomlFile { const raw = await readFile(this.path) const updated = updateTomlValues(raw, [[keys, undefined]]) const parsed = this.decode(updated) - await writeFile(this.path, updated) + await writeFileAtomically(this.path, updated) this.content = parsed } @@ -111,7 +111,7 @@ export class TomlFile { async replace(content: JsonMapType): Promise { const encoded = encodeToml(content) this.decode(encoded) - await writeFile(this.path, encoded) + await writeFileAtomically(this.path, encoded) this.content = content } @@ -133,7 +133,7 @@ export class TomlFile { const raw = await readFile(this.path) const transformed = transform(raw) const parsed = this.decode(transformed) - await writeFile(this.path, transformed) + await writeFileAtomically(this.path, transformed) this.content = parsed } diff --git a/packages/cli/README.md b/packages/cli/README.md index 798716d6e56..643824eaefc 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -90,6 +90,7 @@ * [`shopify store info`](#shopify-store-info) * [`shopify store list`](#shopify-store-list) * [`shopify store open`](#shopify-store-open) +* [`shopify theme airlock add STORE`](#shopify-theme-airlock-add-store) * [`shopify theme check`](#shopify-theme-check) * [`shopify theme console`](#shopify-theme-console) * [`shopify theme delete`](#shopify-theme-delete) @@ -3919,6 +3920,44 @@ EXAMPLES $ shopify store open --store shop.myshopify.com ``` +## `shopify theme airlock add STORE` + +Trust a store for this theme project. + +``` +USAGE + $ shopify theme airlock add STORE --environment [--auth-alias ] [--no-color] [--password ] + [--path ] [--verbose] + +ARGUMENTS + STORE Store domain to trust. + +FLAGS + --auth-alias= + Alias of the Shopify account to use for authentication. + [env: SHOPIFY_FLAG_AUTH_ALIAS] + + --environment= + (required) Environment name to add. + [env: SHOPIFY_FLAG_ENVIRONMENT] + + --no-color + Disable color output. + [env: SHOPIFY_FLAG_NO_COLOR] + + --password= + Password generated from the Theme Access app or an Admin API token. + [env: SHOPIFY_CLI_THEME_TOKEN] + + --path= + The path where you want to run the command. Defaults to the current working directory. + [env: SHOPIFY_FLAG_PATH] + + --verbose + Increase the verbosity of the output. + [env: SHOPIFY_FLAG_VERBOSE] +``` + ## `shopify theme check` Validate the theme. @@ -4244,6 +4283,9 @@ DESCRIPTION Uploads the current theme as the specified theme, or a "development theme" (https://shopify.dev/docs/themes/tools/cli#development-themes), to a store so you can preview it. + The target store must be trusted in the nearest `shopify.theme.toml` file. To trust a store, run `shopify theme + airlock add`. + This command returns the following information: - A link to your development theme at http://127.0.0.1:9292. This URL can hot reload local changes to CSS and @@ -5076,6 +5118,9 @@ DESCRIPTION Uploads your local theme files to Shopify, overwriting the remote version if specified. + The target store must be trusted in the nearest `shopify.theme.toml` file. To trust a store, run `shopify theme + airlock add`. + If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 9e97221a1f3..5e994ec7814 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -7274,6 +7274,80 @@ "strict": true, "summary": "Authenticate for store commands." }, + "theme:airlock:add": { + "aliases": [ + ], + "args": { + "store": { + "description": "Store domain to trust.", + "name": "store", + "required": true + } + }, + "customPluginName": "@shopify/theme", + "enableJsonFlag": false, + "flags": { + "auth-alias": { + "description": "Alias of the Shopify account to use for authentication.", + "env": "SHOPIFY_FLAG_AUTH_ALIAS", + "hasDynamicHelp": false, + "multiple": false, + "name": "auth-alias", + "type": "option" + }, + "environment": { + "description": "Environment name to add.", + "env": "SHOPIFY_FLAG_ENVIRONMENT", + "hasDynamicHelp": false, + "multiple": false, + "name": "environment", + "required": true, + "type": "option" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "password": { + "description": "Password generated from the Theme Access app or an Admin API token.", + "env": "SHOPIFY_CLI_THEME_TOKEN", + "hasDynamicHelp": false, + "multiple": false, + "name": "password", + "type": "option" + }, + "path": { + "description": "The path where you want to run the command. Defaults to the current working directory.", + "env": "SHOPIFY_FLAG_PATH", + "hasDynamicHelp": false, + "multiple": false, + "name": "path", + "noCacheDefault": 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, + "hiddenAliases": [ + ], + "id": "theme:airlock:add", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Trust a store for this theme project." + }, "theme:check": { "aliases": [ ], @@ -7654,8 +7728,8 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "\n Uploads the current theme as the specified theme, or a \"development theme\" (https://shopify.dev/docs/themes/tools/cli#development-themes), to a store so you can preview it.\n\nThis command returns the following information:\n\n- A link to your development theme at http://127.0.0.1:9292. This URL can hot reload local changes to CSS and sections, or refresh the entire page when a file changes, enabling you to preview changes in real time using the store's data.\n\n You can specify a different network interface and port using `--host` and `--port`.\n\n- A link to the \"editor\" (https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n\n- A \"preview link\" (https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with other developers.\n\nIf you already have a development theme for your current environment, then this command replaces the development theme with your local theme. You can override this using the `--theme-editor-sync` flag.\n\n> Note: You can't preview checkout customizations using http://127.0.0.1:9292.\n\nDevelopment themes are deleted when you run `shopify auth logout`. If you need a preview link that can be used after you log out, then you should \"share\" (https://shopify.dev/docs/api/shopify-cli/theme/theme-share) your theme or \"push\" (https://shopify.dev/docs/api/shopify-cli/theme/theme-push) to an unpublished theme on your store.\n\nYou can run this command only in a directory that matches the \"default Shopify theme folder structure\" (https://shopify.dev/docs/themes/tools/cli#directory-structure).", - "descriptionWithMarkdown": "\n Uploads the current theme as the specified theme, or a [development theme](https://shopify.dev/docs/themes/tools/cli#development-themes), to a store so you can preview it.\n\nThis command returns the following information:\n\n- A link to your development theme at http://127.0.0.1:9292. This URL can hot reload local changes to CSS and sections, or refresh the entire page when a file changes, enabling you to preview changes in real time using the store's data.\n\n You can specify a different network interface and port using `--host` and `--port`.\n\n- A link to the [editor](https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n\n- A [preview link](https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with other developers.\n\nIf you already have a development theme for your current environment, then this command replaces the development theme with your local theme. You can override this using the `--theme-editor-sync` flag.\n\n> Note: You can't preview checkout customizations using http://127.0.0.1:9292.\n\nDevelopment themes are deleted when you run `shopify auth logout`. If you need a preview link that can be used after you log out, then you should [share](https://shopify.dev/docs/api/shopify-cli/theme/theme-share) your theme or [push](https://shopify.dev/docs/api/shopify-cli/theme/theme-push) to an unpublished theme on your store.\n\nYou can run this command only in a directory that matches the [default Shopify theme folder structure](https://shopify.dev/docs/themes/tools/cli#directory-structure).", + "description": "\n Uploads the current theme as the specified theme, or a \"development theme\" (https://shopify.dev/docs/themes/tools/cli#development-themes), to a store so you can preview it.\n\nThe target store must be trusted in the nearest `shopify.theme.toml` file. To trust a store, run `shopify theme airlock add`.\n\nThis command returns the following information:\n\n- A link to your development theme at http://127.0.0.1:9292. This URL can hot reload local changes to CSS and sections, or refresh the entire page when a file changes, enabling you to preview changes in real time using the store's data.\n\n You can specify a different network interface and port using `--host` and `--port`.\n\n- A link to the \"editor\" (https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n\n- A \"preview link\" (https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with other developers.\n\nIf you already have a development theme for your current environment, then this command replaces the development theme with your local theme. You can override this using the `--theme-editor-sync` flag.\n\n> Note: You can't preview checkout customizations using http://127.0.0.1:9292.\n\nDevelopment themes are deleted when you run `shopify auth logout`. If you need a preview link that can be used after you log out, then you should \"share\" (https://shopify.dev/docs/api/shopify-cli/theme/theme-share) your theme or \"push\" (https://shopify.dev/docs/api/shopify-cli/theme/theme-push) to an unpublished theme on your store.\n\nYou can run this command only in a directory that matches the \"default Shopify theme folder structure\" (https://shopify.dev/docs/themes/tools/cli#directory-structure).", + "descriptionWithMarkdown": "\n Uploads the current theme as the specified theme, or a [development theme](https://shopify.dev/docs/themes/tools/cli#development-themes), to a store so you can preview it.\n\nThe target store must be trusted in the nearest `shopify.theme.toml` file. To trust a store, run `shopify theme airlock add`.\n\nThis command returns the following information:\n\n- A link to your development theme at http://127.0.0.1:9292. This URL can hot reload local changes to CSS and sections, or refresh the entire page when a file changes, enabling you to preview changes in real time using the store's data.\n\n You can specify a different network interface and port using `--host` and `--port`.\n\n- A link to the [editor](https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n\n- A [preview link](https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with other developers.\n\nIf you already have a development theme for your current environment, then this command replaces the development theme with your local theme. You can override this using the `--theme-editor-sync` flag.\n\n> Note: You can't preview checkout customizations using http://127.0.0.1:9292.\n\nDevelopment themes are deleted when you run `shopify auth logout`. If you need a preview link that can be used after you log out, then you should [share](https://shopify.dev/docs/api/shopify-cli/theme/theme-share) your theme or [push](https://shopify.dev/docs/api/shopify-cli/theme/theme-push) to an unpublished theme on your store.\n\nYou can run this command only in a directory that matches the [default Shopify theme folder structure](https://shopify.dev/docs/themes/tools/cli#directory-structure).", "enableJsonFlag": false, "flags": { "allow-live": { @@ -9115,8 +9189,8 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You can run this command only in a directory that matches the \"default Shopify theme folder structure\" (https://shopify.dev/docs/themes/tools/cli#directory-structure).\n\n This command returns the following information:\n\n - A link to the \"editor\" (https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A \"preview link\" (https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\",\n \"editor_url\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"preview_url\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n ", - "descriptionWithMarkdown": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You can run this command only in a directory that matches the [default Shopify theme folder structure](https://shopify.dev/docs/themes/tools/cli#directory-structure).\n\n This command returns the following information:\n\n - A link to the [editor](https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A [preview link](https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\",\n \"editor_url\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"preview_url\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n ", + "description": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n The target store must be trusted in the nearest `shopify.theme.toml` file. To trust a store, run `shopify theme airlock add`.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You can run this command only in a directory that matches the \"default Shopify theme folder structure\" (https://shopify.dev/docs/themes/tools/cli#directory-structure).\n\n This command returns the following information:\n\n - A link to the \"editor\" (https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A \"preview link\" (https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\",\n \"editor_url\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"preview_url\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n ", + "descriptionWithMarkdown": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n The target store must be trusted in the nearest `shopify.theme.toml` file. To trust a store, run `shopify theme airlock add`.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You can run this command only in a directory that matches the [default Shopify theme folder structure](https://shopify.dev/docs/themes/tools/cli#directory-structure).\n\n This command returns the following information:\n\n - A link to the [editor](https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A [preview link](https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\",\n \"editor_url\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"preview_url\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n ", "enableJsonFlag": false, "flags": { "allow-live": { diff --git a/packages/theme/package.json b/packages/theme/package.json index 636ac3c3273..cf1dcf14b25 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -46,10 +46,12 @@ "@shopify/theme-language-server-node": "2.21.4", "chokidar": "3.6.0", "h3": "1.15.11", + "proper-lockfile": "4.1.2", "yaml": "2.9.0" }, "devDependencies": { "@shopify/theme-hot-reload": "^0.0.22", + "@types/proper-lockfile": "4.1.4", "@vitest/coverage-istanbul": "^3.2.7", "node-stream-zip": "^1.15.0" }, diff --git a/packages/theme/src/cli/commands/theme/airlock/add.test.ts b/packages/theme/src/cli/commands/theme/airlock/add.test.ts new file mode 100644 index 00000000000..80e1360871a --- /dev/null +++ b/packages/theme/src/cli/commands/theme/airlock/add.test.ts @@ -0,0 +1,145 @@ +import Add from './add.js' +import {themeAirlockAdd} from '../../../services/theme-airlock-add.js' +import {getThemeStore, setThemeStore} from '../../../services/local-storage.js' +import {configurationFileName} from '../../../constants.js' +import {Config} from '@oclif/core' +import {authAliasFlag} from '@shopify/cli-kit/node/cli' +import {inTemporaryDirectory, mkdir, readFile, readdir} from '@shopify/cli-kit/node/fs' +import {LocalStorage} from '@shopify/cli-kit/node/local-storage' +import {joinPath} from '@shopify/cli-kit/node/path' +import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {renderSuccess} from '@shopify/cli-kit/node/ui' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +import type {ThemeLocalStorageSchema} from '../../../services/local-storage.js' + +const localStorageState = vi.hoisted(() => ({ + storage: undefined as LocalStorage | undefined, + setThemeStore: vi.fn<(store: string) => void>(), +})) + +vi.mock('../../../services/theme-airlock-add.js', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, themeAirlockAdd: vi.fn(actual.themeAirlockAdd)} +}) +vi.mock('../../../services/local-storage.js', async (importOriginal) => { + const actual = await importOriginal() + const storage = () => { + if (!localStorageState.storage) throw new Error('Theme storage is not initialized') + return localStorageState.storage + } + + return { + ...actual, + getThemeStore: () => actual.getThemeStore(storage()), + setThemeStore: localStorageState.setThemeStore, + } +}) +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/ui') + +const CommandConfig = new Config({root: __dirname}) +const serviceResult = { + environment: 'preview', + store: 'trusted-store.myshopify.com', + configurationPath: '/theme/shopify.theme.toml', +} + +async function runCommand(argv: string[]) { + await CommandConfig.load() + const command = new Add(argv, CommandConfig) + return command.run() +} + +describe('theme airlock add', () => { + beforeEach(() => { + vi.mocked(themeAirlockAdd).mockResolvedValue(serviceResult) + }) + + test('rejects a missing store argument before calling the service', async () => { + await expect(runCommand(['--path', '/theme', '--environment', 'preview'])).rejects.toThrow() + + expect(themeAirlockAdd).not.toHaveBeenCalled() + expect(renderSuccess).not.toHaveBeenCalled() + }) + + test('rejects a missing environment flag before calling the service', async () => { + await expect(runCommand(['trusted-store', '--path', '/theme'])).rejects.toThrow() + + expect(themeAirlockAdd).not.toHaveBeenCalled() + expect(renderSuccess).not.toHaveBeenCalled() + }) + + test('passes parsed arguments and flags to the service without applying business logic', async () => { + await inTemporaryDirectory(async (themePath) => { + await runCommand([ + 'https://TRUSTED-STORE.myshopify.com/admin/', + '--path', + themePath, + '--environment', + ' preview ', + '--password', + 'shptka_secret', + ]) + + expect(themeAirlockAdd).toHaveBeenCalledOnce() + expect(themeAirlockAdd).toHaveBeenCalledWith({ + themePath, + environment: ' preview ', + store: 'https://TRUSTED-STORE.myshopify.com/admin/', + password: 'shptka_secret', + }) + }) + }) + + test('renders success from the service result', async () => { + await inTemporaryDirectory(async (themePath) => { + await runCommand(['trusted-store', '--path', themePath, '--environment', 'preview']) + + expect(renderSuccess).toHaveBeenCalledWith({ + headline: 'Store added to Theme Airlock.', + body: [ + 'Environment: preview', + 'Store: trusted-store.myshopify.com', + 'Configuration: /theme/shopify.theme.toml', + ], + }) + }) + }) + + test('leaves remembered-store storage byte-for-byte unchanged without calling setThemeStore', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = joinPath(root, 'theme') + const storagePath = joinPath(root, 'storage') + await Promise.all([mkdir(themePath), mkdir(storagePath)]) + localStorageState.storage = new LocalStorage({cwd: storagePath}) + const rememberedStore = 'remembered-store.myshopify.com' + localStorageState.storage.set('themeStore', rememberedStore) + const [storageFileName] = await readdir(storagePath) + if (!storageFileName) throw new Error('Expected remembered-store storage to exist') + const storageFilePath = joinPath(storagePath, storageFileName) + const originalStorage = await readFile(storageFilePath) + const actualService = await vi.importActual( + '../../../services/theme-airlock-add.js', + ) + vi.mocked(themeAirlockAdd).mockImplementation(actualService.themeAirlockAdd) + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue({ + token: 'test-token', + storeFqdn: 'trusted-store.myshopify.com', + }) + + await runCommand(['trusted-store', '--path', themePath, '--environment', 'preview']) + + expect(getThemeStore()).toBe(rememberedStore) + await expect(readFile(storageFilePath)).resolves.toBe(originalStorage) + expect(setThemeStore).not.toHaveBeenCalled() + await expect(readFile(joinPath(themePath, configurationFileName))).resolves.toContain( + '[environments.preview]\nstore = "trusted-store.myshopify.com"', + ) + }) + }) + + test('exposes the standard auth-alias base flag', () => { + expect(Add.baseFlags).toBe(authAliasFlag) + }) +}) diff --git a/packages/theme/src/cli/commands/theme/airlock/add.ts b/packages/theme/src/cli/commands/theme/airlock/add.ts new file mode 100644 index 00000000000..12cbe45933f --- /dev/null +++ b/packages/theme/src/cli/commands/theme/airlock/add.ts @@ -0,0 +1,49 @@ +import {themeAirlockAdd} from '../../../services/theme-airlock-add.js' +import {themeFlags} from '../../../flags.js' +import {Args, Flags} from '@oclif/core' +import BaseCommand from '@shopify/cli-kit/node/base-command' +import {authAliasFlag, globalFlags} from '@shopify/cli-kit/node/cli' +import {renderSuccess} from '@shopify/cli-kit/node/ui' + +export default class Add extends BaseCommand { + static baseFlags = authAliasFlag + + static summary = 'Trust a store for this theme project.' + + static args = { + store: Args.string({ + description: 'Store domain to trust.', + required: true, + }), + } + + static flags = { + ...globalFlags, + path: themeFlags.path, + password: themeFlags.password, + environment: Flags.string({ + description: 'Environment name to add.', + env: 'SHOPIFY_FLAG_ENVIRONMENT', + required: true, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(Add) + const result = await themeAirlockAdd({ + themePath: flags.path, + environment: flags.environment, + store: args.store, + password: flags.password, + }) + + renderSuccess({ + headline: 'Store added to Theme Airlock.', + body: [ + `Environment: ${result.environment}`, + `Store: ${result.store}`, + `Configuration: ${result.configurationPath}`, + ], + }) + } +} diff --git a/packages/theme/src/cli/commands/theme/dev.test.ts b/packages/theme/src/cli/commands/theme/dev.test.ts new file mode 100644 index 00000000000..3ca34fe5012 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/dev.test.ts @@ -0,0 +1,227 @@ +import Dev from './dev.js' +import {loadThemeProjectTrust} from '../../utilities/theme-airlock/config.js' +import {ThemeAirlockError} from '../../utilities/theme-airlock/types.js' +import {ensureThemeStore} from '../../utilities/theme-store.js' +import {DevelopmentThemeManager} from '../../utilities/development-theme-manager.js' +import {dev} from '../../services/dev.js' +import {metafieldsPull} from '../../services/metafields-pull.js' +import { + getDevelopmentTheme, + getStorefrontPassword, + getThemeStore, + setDevelopmentTheme, + setStorefrontPassword, + setThemeStore, + useThemeStoreContext, +} from '../../services/local-storage.js' +import {findOrSelectTheme} from '../../utilities/theme-selector.js' +import {ensureLiveThemeConfirmed} from '../../utilities/theme-ui.js' +import {Config} from '@oclif/core' +import {inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs' +import {LocalStorage} from '@shopify/cli-kit/node/local-storage' +import {joinPath} from '@shopify/cli-kit/node/path' +import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {renderConcurrent} from '@shopify/cli-kit/node/ui' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +import type {ThemeLocalStorageSchema} from '../../services/local-storage.js' + +const localStorageState = vi.hoisted(() => ({ + theme: undefined as LocalStorage | undefined, + development: undefined as LocalStorage> | undefined, + storefront: undefined as LocalStorage> | undefined, + setThemeStore: vi.fn<(store: string) => void>(), +})) + +vi.mock('../../utilities/theme-airlock/config.js') +vi.mock('../../utilities/theme-store.js') +vi.mock('../../services/dev.js') +vi.mock('../../services/metafields-pull.js') +vi.mock('../../utilities/theme-selector.js') +vi.mock('../../utilities/theme-ui.js') +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/ui') +vi.mock('../../services/local-storage.js', async () => { + const actual = await vi.importActual( + '../../services/local-storage.js', + ) + + const themeStorage = () => { + if (!localStorageState.theme) throw new Error('Theme storage is not initialized') + return localStorageState.theme + } + const developmentStorage = () => { + if (!localStorageState.development) throw new Error('Development theme storage is not initialized') + return localStorageState.development + } + const storefrontStorage = () => { + if (!localStorageState.storefront) throw new Error('Storefront password storage is not initialized') + return localStorageState.storefront + } + const developmentThemeStore = () => { + const storage = themeStorage() + const store = actual.getThemeStore(storage) + if (store) return store + actual.getDevelopmentTheme(storage) + throw new Error('Expected getDevelopmentTheme to require a theme store') + } + const storefrontPasswordStore = () => { + const storage = themeStorage() + const store = actual.getThemeStore(storage) + if (store) return store + actual.getStorefrontPassword(storage) + throw new Error('Expected getStorefrontPassword to require a theme store') + } + + return { + ...actual, + getThemeStore: () => actual.getThemeStore(themeStorage()), + setThemeStore: localStorageState.setThemeStore, + getDevelopmentTheme: () => developmentStorage().get(developmentThemeStore()), + setDevelopmentTheme: (theme: string) => developmentStorage().set(developmentThemeStore(), theme), + removeDevelopmentTheme: () => developmentStorage().delete(developmentThemeStore()), + getStorefrontPassword: () => storefrontStorage().get(storefrontPasswordStore()), + setStorefrontPassword: (password: string) => storefrontStorage().set(storefrontPasswordStore(), password), + removeStorefrontPassword: () => storefrontStorage().delete(storefrontPasswordStore()), + } +}) + +const CommandConfig = new Config({root: __dirname}) +const trustedStore = 'trusted-store.myshopify.com' +const adminSession = {token: 'test-token', storeFqdn: trustedStore} +const developmentTheme = {id: 1, createdAtRuntime: false} + +async function run(args: string[]) { + await CommandConfig.load() + const command = new Dev(args, CommandConfig) + return command.run() +} + +async function useActualProtectedLifecycle() { + const [airlockConfig, themeStore] = await Promise.all([ + vi.importActual( + '../../utilities/theme-airlock/config.js', + ), + vi.importActual('../../utilities/theme-store.js'), + ]) + vi.mocked(loadThemeProjectTrust).mockImplementation(airlockConfig.loadThemeProjectTrust) + vi.mocked(ensureThemeStore).mockImplementation(themeStore.ensureThemeStore) +} + +async function initializeLocalStorage(root: string) { + const themeStoragePath = joinPath(root, 'theme-storage') + const developmentStoragePath = joinPath(root, 'development-storage') + const storefrontStoragePath = joinPath(root, 'storefront-storage') + await Promise.all([mkdir(themeStoragePath), mkdir(developmentStoragePath), mkdir(storefrontStoragePath)]) + localStorageState.theme = new LocalStorage({cwd: themeStoragePath}) + localStorageState.development = new LocalStorage>({cwd: developmentStoragePath}) + localStorageState.storefront = new LocalStorage>({cwd: storefrontStoragePath}) + localStorageState.setThemeStore.mockImplementation((store) => localStorageState.theme?.set('themeStore', store)) +} + +async function createConfiguredTheme(themePath: string) { + await writeFile(joinPath(themePath, 'shopify.theme.toml'), `[environments.default]\nstore = "${trustedStore}"\n`) +} + +describe('theme dev', () => { + beforeEach(() => { + vi.spyOn(DevelopmentThemeManager.prototype, 'findOrCreate').mockResolvedValue(developmentTheme as never) + vi.mocked(loadThemeProjectTrust).mockRejectedValue( + new ThemeAirlockError('Theme project trust is blocked', 'unconfigured-project'), + ) + vi.mocked(ensureThemeStore).mockReturnValue(adminSession.storeFqdn) + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(adminSession) + vi.mocked(findOrSelectTheme).mockResolvedValue({id: 1} as never) + vi.mocked(ensureLiveThemeConfirmed).mockResolvedValue(true) + vi.mocked(dev).mockResolvedValue(undefined) + vi.mocked(metafieldsPull).mockResolvedValue(undefined) + }) + + test('blocks before development lifecycle', async () => { + await expect(run(['--store=test-store.myshopify.com'])).rejects.toMatchObject({reason: 'unconfigured-project'}) + + expect(loadThemeProjectTrust).toHaveBeenCalledOnce() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(ensureThemeStore).not.toHaveBeenCalled() + expect(setThemeStore).not.toHaveBeenCalled() + expect(dev).not.toHaveBeenCalled() + expect(findOrSelectTheme).not.toHaveBeenCalled() + expect(DevelopmentThemeManager.prototype.findOrCreate).not.toHaveBeenCalled() + expect(ensureLiveThemeConfirmed).not.toHaveBeenCalled() + expect(metafieldsPull).not.toHaveBeenCalled() + expect(renderConcurrent).not.toHaveBeenCalled() + }) + + test('runs a trusted target in its store context without remembering it', async () => { + await inTemporaryDirectory(async (themePath) => { + await initializeLocalStorage(themePath) + await createConfiguredTheme(themePath) + await useActualProtectedLifecycle() + let developmentThemeStore: string | undefined + vi.spyOn(DevelopmentThemeManager.prototype, 'findOrCreate').mockImplementation(async () => { + developmentThemeStore = getThemeStore() + return developmentTheme as never + }) + + expect(getThemeStore()).toBeUndefined() + + await run(['--path', themePath, '--password', 'theme-password']) + + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith(trustedStore, 'theme-password') + expect(developmentThemeStore).toBe(trustedStore) + expect(dev).toHaveBeenCalledWith( + expect.objectContaining({adminSession, directory: themePath, store: trustedStore}), + ) + expect(vi.mocked(dev).mock.calls[0]?.[0].adminSession).toBe(adminSession) + expect(metafieldsPull).toHaveBeenCalledWith(expect.objectContaining({store: trustedStore}), adminSession) + expect(vi.mocked(metafieldsPull).mock.calls[0]?.[1]).toBe(adminSession) + expect(setThemeStore).not.toHaveBeenCalled() + expect(getThemeStore()).toBeUndefined() + }) + }) + + test('keeps development state scoped to a trusted target instead of a different remembered store', async () => { + await inTemporaryDirectory(async (themePath) => { + await initializeLocalStorage(themePath) + await createConfiguredTheme(themePath) + await useActualProtectedLifecycle() + const rememberedStore = 'other-store.myshopify.com' + localStorageState.theme?.set('themeStore', rememberedStore) + + await useThemeStoreContext(rememberedStore, async () => { + setDevelopmentTheme('other-development-theme') + setStorefrontPassword('other-storefront-password') + }) + await useThemeStoreContext(trustedStore, async () => { + setDevelopmentTheme('trusted-development-theme') + setStorefrontPassword('trusted-storefront-password') + }) + + let observedDevelopmentTheme: string | undefined + let observedStorefrontPassword: string | undefined + let observedStore: string | undefined + vi.spyOn(DevelopmentThemeManager.prototype, 'findOrCreate').mockImplementation(async function ( + this: DevelopmentThemeManager, + ) { + observedDevelopmentTheme = (this as unknown as {themeId: string | undefined}).themeId + return developmentTheme as never + }) + vi.mocked(dev).mockImplementation(async () => { + observedStore = getThemeStore() + observedStorefrontPassword = getStorefrontPassword() + }) + + await run(['--path', themePath, '--password', 'theme-password']) + + expect(observedStore).toBe(trustedStore) + expect(observedDevelopmentTheme).toBe('trusted-development-theme') + expect(observedStorefrontPassword).toBe('trusted-storefront-password') + expect(getThemeStore()).toBe(rememberedStore) + await useThemeStoreContext(rememberedStore, async () => { + expect(getDevelopmentTheme()).toBe('other-development-theme') + expect(getStorefrontPassword()).toBe('other-storefront-password') + }) + expect(setThemeStore).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/theme/src/cli/commands/theme/dev.ts b/packages/theme/src/cli/commands/theme/dev.ts index 88979191f54..d2073e3701a 100644 --- a/packages/theme/src/cli/commands/theme/dev.ts +++ b/packages/theme/src/cli/commands/theme/dev.ts @@ -23,6 +23,8 @@ export default class Dev extends ThemeCommand { static descriptionWithMarkdown = ` Uploads the current theme as the specified theme, or a [development theme](https://shopify.dev/docs/themes/tools/cli#development-themes), to a store so you can preview it. +The target store must be trusted in the nearest \`shopify.theme.toml\` file. To trust a store, run \`shopify theme airlock add\`. + This command returns the following information: - A link to your development theme at http://127.0.0.1:9292. This URL can hot reload local changes to CSS and sections, or refresh the entire page when a file changes, enabling you to preview changes in real time using the store's data. @@ -200,14 +202,21 @@ You can run this command only in a directory that matches the [default Shopify t notify: flags.notify, }) - await metafieldsPull({ - path: flags.path, - password: flags.password, - store: flags.store, - force: flags.force, - verbose: flags.verbose, - noColor: flags['no-color'], - silent: true, - }) + await metafieldsPull( + { + path: flags.path, + password: flags.password, + store: flags.store, + force: flags.force, + verbose: flags.verbose, + noColor: flags['no-color'], + silent: true, + }, + adminSession, + ) + } + + protected airlockPolicy(): 'upload' { + return 'upload' } } diff --git a/packages/theme/src/cli/commands/theme/push.test.ts b/packages/theme/src/cli/commands/theme/push.test.ts new file mode 100644 index 00000000000..a91d02e1212 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/push.test.ts @@ -0,0 +1,384 @@ +import Push from './push.js' +import {loadThemeProjectTrust} from '../../utilities/theme-airlock/config.js' +import {ThemeAirlockError} from '../../utilities/theme-airlock/types.js' +import {DevelopmentThemeManager} from '../../utilities/development-theme-manager.js' +import {ensureThemeStore} from '../../utilities/theme-store.js' +import {createOrSelectTheme, push} from '../../services/push.js' +import { + getDevelopmentTheme, + getThemeStore, + setDevelopmentTheme, + setThemeStore, + useThemeStoreContext, +} from '../../services/local-storage.js' +import {Config} from '@oclif/core' +import {AbortController} from '@shopify/cli-kit/node/abort' +import {inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs' +import {LocalStorage} from '@shopify/cli-kit/node/local-storage' +import {joinPath} from '@shopify/cli-kit/node/path' +import {ensureAuthenticatedThemes, setLastSeenUserId} from '@shopify/cli-kit/node/session' +import { + getCurrentStoredStoreAppSession, + listCurrentStoredStoreAppSessions, +} from '@shopify/cli-kit/node/store-auth-session' +import {renderConcurrent, renderInfo} from '@shopify/cli-kit/node/ui' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +import type {ThemeLocalStorageSchema} from '../../services/local-storage.js' + +const pushMock = vi.hoisted(() => vi.fn()) +const localStorageState = vi.hoisted(() => ({ + theme: undefined as LocalStorage | undefined, + development: undefined as LocalStorage> | undefined, + setThemeStore: vi.fn<(store: string) => void>(), +})) + +vi.mock('../../utilities/theme-airlock/config.js') +vi.mock('../../utilities/theme-store.js') +vi.mock('../../services/push.js', async () => { + const actual = await vi.importActual('../../services/push.js') + return {...actual, push: pushMock} +}) +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/store-auth-session') +vi.mock('@shopify/cli-kit/node/ui') +vi.mock('../../services/local-storage.js', async () => { + const actual = await vi.importActual( + '../../services/local-storage.js', + ) + + const themeStorage = () => { + if (!localStorageState.theme) throw new Error('Theme storage is not initialized') + return localStorageState.theme + } + const developmentStorage = () => { + if (!localStorageState.development) throw new Error('Development theme storage is not initialized') + return localStorageState.development + } + const developmentThemeStore = () => { + const storage = themeStorage() + const store = actual.getThemeStore(storage) + if (store) return store + actual.getDevelopmentTheme(storage) + throw new Error('Expected getDevelopmentTheme to require a theme store') + } + + return { + ...actual, + getThemeStore: () => actual.getThemeStore(themeStorage()), + setThemeStore: localStorageState.setThemeStore, + getDevelopmentTheme: () => developmentStorage().get(developmentThemeStore()), + setDevelopmentTheme: (theme: string) => developmentStorage().set(developmentThemeStore(), theme), + removeDevelopmentTheme: () => developmentStorage().delete(developmentThemeStore()), + } +}) + +const CommandConfig = new Config({root: __dirname}) +const trustedStore = 'trusted-store.myshopify.com' +const adminSession = {token: 'test-token', storeFqdn: trustedStore} +const developmentTheme = {id: 1, name: 'Development', role: 'development', createdAtRuntime: false} + +async function run(args: string[]) { + await CommandConfig.load() + const command = new Push(args, CommandConfig) + return command.run() +} + +async function runFrom(root: string, args: string[]) { + vi.stubEnv('INIT_CWD', root) + try { + return await run(args) + } finally { + vi.unstubAllEnvs() + } +} + +async function useActualProtectedLifecycle() { + const [airlockConfig, themeStore] = await Promise.all([ + vi.importActual( + '../../utilities/theme-airlock/config.js', + ), + vi.importActual('../../utilities/theme-store.js'), + ]) + vi.mocked(loadThemeProjectTrust).mockImplementation(airlockConfig.loadThemeProjectTrust) + vi.mocked(ensureThemeStore).mockImplementation(themeStore.ensureThemeStore) +} + +async function initializeLocalStorage(root: string) { + const themeStoragePath = joinPath(root, 'theme-storage') + const developmentStoragePath = joinPath(root, 'development-storage') + await Promise.all([mkdir(themeStoragePath), mkdir(developmentStoragePath)]) + localStorageState.theme = new LocalStorage({cwd: themeStoragePath}) + localStorageState.development = new LocalStorage>({cwd: developmentStoragePath}) + localStorageState.setThemeStore.mockImplementation((store) => localStorageState.theme?.set('themeStore', store)) +} + +async function createConfiguredTheme(themePath: string) { + await writeFile(joinPath(themePath, 'shopify.theme.toml'), `[environments.default]\nstore = "${trustedStore}"\n`) +} + +interface BatchEnvironment { + name: string + store: string + password: string + theme: string +} + +async function createBatchProject(root: string, environments: BatchEnvironment[]) { + const rootConfiguration: string[] = [] + + for (const environment of environments) { + const themePath = joinPath(root, environment.name) + // eslint-disable-next-line no-await-in-loop + await mkdir(themePath) + // eslint-disable-next-line no-await-in-loop + await writeFile( + joinPath(themePath, 'shopify.theme.toml'), + `[environments.${environment.name}]\nstore = "${environment.store}"\n`, + ) + rootConfiguration.push( + `[environments.${environment.name}]`, + `store = "${environment.store}"`, + `password = "${environment.password}"`, + `path = ${JSON.stringify(themePath)}`, + `theme = "${environment.theme}"`, + '', + ) + } + + await writeFile(joinPath(root, 'shopify.theme.toml'), rootConfiguration.join('\n')) +} + +async function executeConcurrentProcesses(options: Parameters[0]) { + const abortController = new AbortController() + await Promise.all( + options.processes.map((outputProcess) => + outputProcess.action(process.stdout, process.stderr, abortController.signal), + ), + ) +} + +describe('theme push', () => { + beforeEach(() => { + vi.spyOn(DevelopmentThemeManager.prototype, 'findOrCreate').mockResolvedValue(developmentTheme as never) + vi.mocked(loadThemeProjectTrust).mockRejectedValue( + new ThemeAirlockError('Theme project trust is blocked', 'unconfigured-project'), + ) + vi.mocked(ensureThemeStore).mockReturnValue(adminSession.storeFqdn) + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(adminSession) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) + vi.mocked(listCurrentStoredStoreAppSessions).mockReturnValue([]) + vi.mocked(push).mockResolvedValue(undefined) + }) + + test.each([ + {name: 'without force', args: []}, + {name: 'with force', args: ['--force']}, + ])('blocks before upload lifecycle $name', async ({args}) => { + await expect(run(['--store=test-store.myshopify.com', ...args])).rejects.toMatchObject({ + reason: 'unconfigured-project', + }) + + expect(loadThemeProjectTrust).toHaveBeenCalledOnce() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(ensureThemeStore).not.toHaveBeenCalled() + expect(setThemeStore).not.toHaveBeenCalled() + expect(push).not.toHaveBeenCalled() + expect(renderConcurrent).not.toHaveBeenCalled() + }) + + test('runs preflight before pushing a trusted target without changing the remembered store', async () => { + await inTemporaryDirectory(async (themePath) => { + await initializeLocalStorage(themePath) + await createConfiguredTheme(themePath) + await useActualProtectedLifecycle() + const rememberedStore = 'other-store.myshopify.com' + localStorageState.theme?.set('themeStore', rememberedStore) + const events: string[] = [] + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: trustedStore, + clientId: 'store-auth-client-id', + userId: 'user-id', + accessToken: 'read-only-token', + scopes: ['read_themes'], + acquiredAt: '2026-07-23T00:00:00.000Z', + }) + vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store) => { + events.push(`authenticate:${store}`) + return adminSession + }) + vi.mocked(renderInfo).mockImplementation((options) => { + if (options.headline === 'Theme Airlock') events.push('preflight') + return undefined + }) + vi.mocked(push).mockImplementation(async () => { + events.push('push') + }) + + await run(['--path', themePath, '--theme', '123']) + + expect(events).toEqual([`authenticate:${trustedStore}`, 'preflight', 'push']) + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith(trustedStore, undefined) + expect(push).toHaveBeenCalledWith(expect.objectContaining({store: trustedStore}), adminSession, false, undefined) + expect(vi.mocked(push).mock.calls[0]?.[1]).toBe(adminSession) + expect(setLastSeenUserId).not.toHaveBeenCalled() + expect(setThemeStore).not.toHaveBeenCalled() + expect(getThemeStore()).toBe(rememberedStore) + }) + }) + + test('uses trusted development-theme state for a development push', async () => { + await inTemporaryDirectory(async (themePath) => { + await initializeLocalStorage(themePath) + await createConfiguredTheme(themePath) + await useActualProtectedLifecycle() + const rememberedStore = 'other-store.myshopify.com' + localStorageState.theme?.set('themeStore', rememberedStore) + await useThemeStoreContext(rememberedStore, async () => setDevelopmentTheme('other-development-theme')) + await useThemeStoreContext(trustedStore, async () => setDevelopmentTheme('trusted-development-theme')) + + let observedDevelopmentTheme: string | undefined + vi.spyOn(DevelopmentThemeManager.prototype, 'findOrCreate').mockImplementation(async function ( + this: DevelopmentThemeManager, + ) { + observedDevelopmentTheme = (this as unknown as {themeId: string | undefined}).themeId + return developmentTheme as never + }) + vi.mocked(push).mockImplementation(async (flags, session, multiEnvironment) => { + if (!session) throw new Error('Expected the Push command to supply an AdminSession') + await createOrSelectTheme(session, flags, multiEnvironment) + }) + + await run(['--path', themePath, '--password', 'theme-password', '--development']) + + expect(observedDevelopmentTheme).toBe('trusted-development-theme') + expect(push).toHaveBeenCalledWith( + expect.objectContaining({development: true, store: trustedStore}), + adminSession, + false, + undefined, + ) + expect(getThemeStore()).toBe(rememberedStore) + await useThemeStoreContext(rememberedStore, async () => { + expect(getDevelopmentTheme()).toBe('other-development-theme') + }) + expect(setThemeStore).not.toHaveBeenCalled() + }) + }) + + test('authenticates a trusted same-store batch before sequential pushes', async () => { + await inTemporaryDirectory(async (root) => { + await initializeLocalStorage(root) + await useActualProtectedLifecycle() + const environments = [ + {name: 'first', store: trustedStore, password: 'first-password', theme: '101'}, + {name: 'second', store: trustedStore, password: 'second-password', theme: '102'}, + ] + await createBatchProject(root, environments) + const events: string[] = [] + const sessionsByPassword = new Map() + const observedStores: string[] = [] + vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store, password) => { + events.push(`authenticate:${password}`) + const session = {token: password ?? '', storeFqdn: store} + sessionsByPassword.set(password ?? '', session) + return session + }) + vi.mocked(renderConcurrent).mockImplementation(async (options) => { + const [outputProcess] = options.processes + if (!outputProcess) throw new Error('Expected one sequential Push process') + events.push(`group:${outputProcess.prefix}`) + const abortController = new AbortController() + await outputProcess.action(process.stdout, process.stderr, abortController.signal) + }) + vi.mocked(push).mockImplementation(async (flags) => { + const environment = flags.environment?.[0] + events.push(`push:start:${environment}`) + observedStores.push(getThemeStore() ?? '') + await Promise.resolve() + events.push(`push:end:${environment}`) + }) + + await runFrom(root, ['--environment', 'first', '--environment', 'second', '--force']) + + expect(events).toEqual([ + 'authenticate:first-password', + 'authenticate:second-password', + 'group:first', + 'push:start:first', + 'push:end:first', + 'group:second', + 'push:start:second', + 'push:end:second', + ]) + expect(renderConcurrent).toHaveBeenCalledTimes(2) + expect( + vi.mocked(renderConcurrent).mock.calls.map(([options]) => options.processes.map(({prefix}) => prefix)), + ).toEqual([['first'], ['second']]) + expect(observedStores).toEqual([trustedStore, trustedStore]) + expect(vi.mocked(push).mock.calls[0]?.[1]).toBe(sessionsByPassword.get('first-password')) + expect(vi.mocked(push).mock.calls[1]?.[1]).toBe(sessionsByPassword.get('second-password')) + expect(setThemeStore).not.toHaveBeenCalled() + }) + }) + + test('authenticates a trusted distinct-store batch before one concurrent push group', async () => { + await inTemporaryDirectory(async (root) => { + await initializeLocalStorage(root) + await useActualProtectedLifecycle() + const firstStore = 'first-store.myshopify.com' + const secondStore = 'second-store.myshopify.com' + const environments = [ + {name: 'first', store: firstStore, password: 'first-password', theme: '201'}, + {name: 'second', store: secondStore, password: 'second-password', theme: '202'}, + ] + await createBatchProject(root, environments) + const events: string[] = [] + const sessionsByStore = new Map() + const observedStoresByEnvironment = new Map() + vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store, password) => { + events.push(`authenticate:${store}`) + const session = {token: password ?? '', storeFqdn: store} + sessionsByStore.set(store, session) + return session + }) + vi.mocked(renderConcurrent).mockImplementation(async (options) => { + events.push(`group:${options.processes.map(({prefix}) => prefix).join(',')}`) + await executeConcurrentProcesses(options) + }) + vi.mocked(push).mockImplementation(async (flags) => { + const environment = flags.environment?.[0] ?? '' + events.push(`push:start:${environment}`) + observedStoresByEnvironment.set(environment, getThemeStore()) + await Promise.resolve() + events.push(`push:end:${environment}`) + }) + + await runFrom(root, ['--environment', 'first', '--environment', 'second', '--force']) + + expect(events).toEqual([ + `authenticate:${firstStore}`, + `authenticate:${secondStore}`, + 'group:first,second', + 'push:start:first', + 'push:start:second', + 'push:end:first', + 'push:end:second', + ]) + expect(renderConcurrent).toHaveBeenCalledOnce() + expect(vi.mocked(renderConcurrent).mock.calls[0]?.[0].processes.map(({prefix}) => prefix)).toEqual([ + 'first', + 'second', + ]) + expect(observedStoresByEnvironment).toEqual( + new Map([ + ['first', firstStore], + ['second', secondStore], + ]), + ) + expect(vi.mocked(push).mock.calls[0]?.[1]).toBe(sessionsByStore.get(firstStore)) + expect(vi.mocked(push).mock.calls[1]?.[1]).toBe(sessionsByStore.get(secondStore)) + expect(setThemeStore).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/theme/src/cli/commands/theme/push.ts b/packages/theme/src/cli/commands/theme/push.ts index 7fe6c584cc1..8c05dc22e13 100644 --- a/packages/theme/src/cli/commands/theme/push.ts +++ b/packages/theme/src/cli/commands/theme/push.ts @@ -19,6 +19,8 @@ export default class Push extends ThemeCommand { static descriptionWithMarkdown = `Uploads your local theme files to Shopify, overwriting the remote version if specified. + The target store must be trusted in the nearest \`shopify.theme.toml\` file. To trust a store, run \`shopify theme airlock add\`. + If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store. You can run this command only in a directory that matches the [default Shopify theme folder structure](https://shopify.dev/docs/themes/tools/cli#directory-structure). @@ -138,6 +140,10 @@ export default class Push extends ThemeCommand { recordTiming('theme-command:push') } + protected airlockPolicy(): 'upload' { + return 'upload' + } + protected storeAuthScopes(): string[] { return ['read_themes', 'write_themes'] } diff --git a/packages/theme/src/cli/services/metafields-pull.test.ts b/packages/theme/src/cli/services/metafields-pull.test.ts index ad449c083b2..bd3e1edefd5 100644 --- a/packages/theme/src/cli/services/metafields-pull.test.ts +++ b/packages/theme/src/cli/services/metafields-pull.test.ts @@ -45,6 +45,19 @@ describe('metafields-pull', () => { capturedOutput.clear() }) + test('reuses a supplied session without store lookup or authentication', async () => { + const suppliedSession = {token: 'supplied-token', storeFqdn: 'example.myshopify.com'} + vi.mocked(metafieldDefinitionsByOwnerType).mockResolvedValue([]) + + await inTemporaryDirectory(async (tmpDir) => { + await metafieldsPull({path: tmpDir}, suppliedSession) + }) + + expect(ensureThemeStore).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(metafieldDefinitionsByOwnerType).toHaveBeenCalledWith('ARTICLE', suppliedSession) + }) + test('should download metafields for each ownerType and write to file', async () => { // Given vi.mocked(metafieldDefinitionsByOwnerType).mockImplementation((type: any, _session: AdminSession) => { diff --git a/packages/theme/src/cli/services/metafields-pull.ts b/packages/theme/src/cli/services/metafields-pull.ts index f0bdf77feae..e1c0db0c251 100644 --- a/packages/theme/src/cli/services/metafields-pull.ts +++ b/packages/theme/src/cli/services/metafields-pull.ts @@ -58,11 +58,11 @@ export interface MetafieldsPullFlags { * * @param flags - All flags are optional. */ -export async function metafieldsPull(flags: MetafieldsPullFlags): Promise { +export async function metafieldsPull(flags: MetafieldsPullFlags, suppliedSession?: AdminSession): Promise { configureCLIEnvironment({verbose: flags.verbose, noColor: flags.noColor}) - const store = ensureThemeStore({store: flags.store}) - const adminSession = await ensureAuthenticatedThemes(store, flags.password) + const adminSession = + suppliedSession ?? (await ensureAuthenticatedThemes(ensureThemeStore({store: flags.store}), flags.password)) await executeMetafieldsPull(adminSession, { path: flags.path ?? cwd(), diff --git a/packages/theme/src/cli/services/theme-airlock-add.test.ts b/packages/theme/src/cli/services/theme-airlock-add.test.ts new file mode 100644 index 00000000000..0069539d238 --- /dev/null +++ b/packages/theme/src/cli/services/theme-airlock-add.test.ts @@ -0,0 +1,182 @@ +import {themeAirlockAdd} from './theme-airlock-add.js' +import {getThemeStore, setThemeStore} from './local-storage.js' +import {addTrustedThemeEnvironment} from '../utilities/theme-airlock/writer.js' +import {ThemeAirlockError} from '../utilities/theme-airlock/types.js' +import {configurationFileName} from '../constants.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {fileExistsSync, inTemporaryDirectory, mkdir, readFile, readdir, writeFile} from '@shopify/cli-kit/node/fs' +import {LocalStorage} from '@shopify/cli-kit/node/local-storage' +import {joinPath} from '@shopify/cli-kit/node/path' +import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +import type {ThemeLocalStorageSchema} from './local-storage.js' + +vi.mock('@shopify/cli-kit/node/session') +vi.mock('../utilities/theme-airlock/writer.js', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, addTrustedThemeEnvironment: vi.fn(actual.addTrustedThemeEnvironment)} +}) + +const normalizedStore = 'trusted-store.myshopify.com' +const session = {token: 'test-token', storeFqdn: normalizedStore} + +async function createTheme(root: string): Promise { + const themePath = joinPath(root, 'theme') + await mkdir(themePath) + return themePath +} + +async function writeConfiguration(themePath: string, content: string): Promise { + const configurationPath = joinPath(themePath, configurationFileName) + await writeFile(configurationPath, content) + return configurationPath +} + +describe('themeAirlockAdd', () => { + beforeEach(() => { + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) + }) + + test('trims the environment and normalizes the store', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = await createTheme(root) + const configurationPath = joinPath(themePath, configurationFileName) + + await expect( + themeAirlockAdd({ + themePath, + environment: ' preview ', + store: 'https://TRUSTED-STORE.myshopify.com/admin/', + }), + ).resolves.toEqual({environment: 'preview', store: normalizedStore, configurationPath}) + + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith(normalizedStore, undefined) + await expect(readFile(configurationPath)).resolves.toContain( + '[environments.preview]\nstore = "trusted-store.myshopify.com"', + ) + }) + }) + + test('rejects an empty environment before authentication or writing', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = await createTheme(root) + + await expect(themeAirlockAdd({themePath, environment: ' \t ', store: normalizedStore})).rejects.toMatchObject({ + reason: 'invalid-environment', + message: expect.stringContaining("Environment name to add can't be empty"), + }) + + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(fileExistsSync(joinPath(themePath, configurationFileName))).toBe(false) + }) + }) + + test('preserves the invalid store AbortError from normalization', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = await createTheme(root) + const result = themeAirlockAdd({themePath, environment: 'preview', store: 'not a store'}) + + await expect(result).rejects.toBeInstanceOf(AbortError) + await expect(result).rejects.not.toBeInstanceOf(ThemeAirlockError) + await expect(result).rejects.toMatchObject({message: 'Invalid store value: not a store'}) + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(fileExistsSync(joinPath(themePath, configurationFileName))).toBe(false) + }) + }) + + test('forwards the password exactly', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = await createTheme(root) + + await themeAirlockAdd({ + themePath, + environment: 'preview', + store: normalizedStore, + password: 'shptka_secret', + }) + + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith(normalizedStore, 'shptka_secret') + }) + }) + + test('authenticates before calling the writer', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = await createTheme(root) + const events: string[] = [] + vi.mocked(ensureAuthenticatedThemes).mockImplementation(async () => { + events.push('authenticate') + return session + }) + vi.mocked(addTrustedThemeEnvironment).mockImplementation(async () => { + events.push('write') + return {path: joinPath(themePath, configurationFileName), store: normalizedStore} + }) + + await themeAirlockAdd({themePath, environment: 'preview', store: normalizedStore}) + + expect(events).toEqual(['authenticate', 'write']) + }) + }) + + test('leaves an existing configuration unchanged when authentication fails', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = await createTheme(root) + const original = '# Existing trust\nname = "theme"\n' + const configurationPath = await writeConfiguration(themePath, original) + vi.mocked(ensureAuthenticatedThemes).mockRejectedValue(new Error('authentication failed')) + + await expect(themeAirlockAdd({themePath, environment: 'preview', store: normalizedStore})).rejects.toThrow( + 'authentication failed', + ) + + expect(addTrustedThemeEnvironment).not.toHaveBeenCalled() + await expect(readFile(configurationPath)).resolves.toBe(original) + }) + }) + + test.each([ + { + name: 'environment conflict', + original: '[environments.preview]\nstore = "other-store.myshopify.com"\n', + reason: 'environment-conflict', + }, + { + name: 'store conflict', + original: '[environments.default]\nstore = "trusted-store.myshopify.com"\n', + reason: 'store-conflict', + }, + ])('propagates a $name without changing the configuration', async ({original, reason}) => { + await inTemporaryDirectory(async (root) => { + const themePath = await createTheme(root) + const configurationPath = await writeConfiguration(themePath, original) + + await expect(themeAirlockAdd({themePath, environment: 'preview', store: normalizedStore})).rejects.toMatchObject({ + reason, + }) + + expect(ensureAuthenticatedThemes).toHaveBeenCalledOnce() + await expect(readFile(configurationPath)).resolves.toBe(original) + }) + }) + + test('leaves remembered-store storage byte-for-byte unchanged', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = await createTheme(root) + const storagePath = joinPath(root, 'storage') + await mkdir(storagePath) + const storage = new LocalStorage({cwd: storagePath}) + const rememberedStore = 'remembered-store.myshopify.com' + setThemeStore(rememberedStore, storage) + const [storageFileName] = await readdir(storagePath) + if (!storageFileName) throw new Error('Expected remembered-store storage to exist') + const storageFilePath = joinPath(storagePath, storageFileName) + const originalStorage = await readFile(storageFilePath) + + await themeAirlockAdd({themePath, environment: 'preview', store: normalizedStore}) + + expect(getThemeStore(storage)).toBe(rememberedStore) + await expect(readFile(storageFilePath)).resolves.toBe(originalStorage) + }) + }) +}) diff --git a/packages/theme/src/cli/services/theme-airlock-add.ts b/packages/theme/src/cli/services/theme-airlock-add.ts new file mode 100644 index 00000000000..dedb71069a5 --- /dev/null +++ b/packages/theme/src/cli/services/theme-airlock-add.ts @@ -0,0 +1,39 @@ +import {addTrustedThemeEnvironment} from '../utilities/theme-airlock/writer.js' +import {ThemeAirlockError} from '../utilities/theme-airlock/types.js' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' + +interface ThemeAirlockAddOptions { + themePath: string + environment: string + store: string + password?: string +} + +interface ThemeAirlockAddResult { + environment: string + store: string + configurationPath: string +} + +export async function themeAirlockAdd(options: ThemeAirlockAddOptions): Promise { + const environment = options.environment.trim() + if (!environment) { + throw new ThemeAirlockError("Environment name to add can't be empty.", 'invalid-environment') + } + + const store = normalizeStoreFqdn(options.store) + await ensureAuthenticatedThemes(store, options.password) + + const configuration = await addTrustedThemeEnvironment({ + themePath: options.themePath, + environment, + store, + }) + + return { + environment, + store: configuration.store, + configurationPath: configuration.path, + } +} diff --git a/packages/theme/src/cli/utilities/theme-airlock/bootstrap.test.ts b/packages/theme/src/cli/utilities/theme-airlock/bootstrap.test.ts new file mode 100644 index 00000000000..c3221a241f4 --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/bootstrap.test.ts @@ -0,0 +1,356 @@ +import {bootstrapThemeAirlock, interactiveBootstrapUI} from './bootstrap.js' +import {ThemeAirlockError} from './types.js' +import {configurationFileName} from '../../constants.js' +import {describe, expect, test, vi} from 'vitest' +import {fileExists, inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {renderSelectPrompt, renderTextPrompt} from '@shopify/cli-kit/node/ui' + +vi.mock('@shopify/cli-kit/node/ui') + +interface Session { + token: string +} + +async function createTheme(tmpDir: string): Promise { + const themePath = joinPath(tmpDir, 'theme') + await mkdir(themePath) + return themePath +} + +async function captureError(operation: () => Promise): Promise { + try { + await operation() + } catch (error) { + if (error instanceof Error) return error + throw error + } + throw new Error('Expected operation to throw') +} + +function optionsFor(themePath: string, overrides: Partial>[0]> = {}) { + return { + themePath, + confirmStore: async () => 'trust' as const, + promptStore: async () => 'prompted-store', + promptEnvironment: async () => 'prompted-environment', + authenticate: async (_store: string) => ({token: 'session'}) satisfies Session, + ...overrides, + } +} + +describe('interactiveBootstrapUI', () => { + test('presents the untrusted store choices', async () => { + vi.mocked(renderSelectPrompt).mockResolvedValue('trust') + + await interactiveBootstrapUI().confirmStore('example-store') + + expect(renderSelectPrompt).toHaveBeenCalledWith({ + message: 'The store example-store is untrusted. Choose how to continue.', + choices: [ + {label: 'Trust example-store', value: 'trust'}, + {label: 'Choose a different store', value: 'choose'}, + {label: 'Cancel', value: 'cancel'}, + ], + }) + }) + + test.each([ + { + name: 'store', + prompt: 'promptStore' as const, + message: 'Enter the Shopify store to trust', + }, + { + name: 'environment', + prompt: 'promptEnvironment' as const, + message: 'Enter a name for this theme environment', + }, + ])('presents the $name prompt and returns trimmed non-empty input', async ({prompt, message}) => { + const input = ' response ' + vi.mocked(renderTextPrompt).mockResolvedValue(input) + + const result = await interactiveBootstrapUI()[prompt]() + + expect(renderTextPrompt).toHaveBeenCalledWith({message}) + expect(result).toBe('response') + }) + + test.each([ + {name: 'empty store', prompt: 'promptStore' as const, input: ''}, + {name: 'whitespace-only store', prompt: 'promptStore' as const, input: ' '}, + {name: 'empty environment', prompt: 'promptEnvironment' as const, input: ''}, + {name: 'whitespace-only environment', prompt: 'promptEnvironment' as const, input: ' '}, + ])('returns undefined for $name input', async ({prompt, input}) => { + vi.mocked(renderTextPrompt).mockResolvedValue(input) + + await expect(interactiveBootstrapUI()[prompt]()).resolves.toBeUndefined() + }) +}) + +describe('bootstrapThemeAirlock', () => { + test('uses an explicit candidate instead of an unrelated remembered store', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const confirmed: string[] = [] + const remembered = async () => 'remembered-store' + const result = await bootstrapThemeAirlock( + optionsFor(themePath, { + candidate: 'candidate-store', + rememberedStore: 'remembered-store', + confirmStore: async (store) => { + confirmed.push(store) + return 'trust' + }, + promptStore: remembered, + }), + ) + + expect(confirmed).toEqual(['candidate-store']) + expect(result.target).toEqual({ + environment: 'prompted-environment', + store: 'candidate-store.myshopify.com', + source: 'bootstrap', + implicit: false, + }) + }) + }) + + test('continues with a candidate when confirmation trusts it', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const authenticatedStores: string[] = [] + + await bootstrapThemeAirlock( + optionsFor(themePath, { + candidate: 'candidate-store', + confirmStore: async (store) => { + expect(store).toBe('candidate-store') + return 'trust' + }, + authenticate: async (store) => { + authenticatedStores.push(store) + return {token: 'trusted'} + }, + }), + ) + + expect(authenticatedStores).toEqual(['candidate-store.myshopify.com']) + }) + }) + + test('chooses a new store after declining to trust a candidate', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const result = await bootstrapThemeAirlock( + optionsFor(themePath, { + candidate: 'candidate-store', + confirmStore: async () => 'choose', + promptStore: async () => 'chosen-store', + }), + ) + + expect(result.target.store).toBe('chosen-store.myshopify.com') + }) + }) + + test('presents a remembered store only when explicitly supplied', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const confirmed: string[] = [] + await bootstrapThemeAirlock( + optionsFor(themePath, { + rememberedStore: 'remembered-store', + confirmStore: async (store) => { + confirmed.push(store) + return 'trust' + }, + }), + ) + + expect(confirmed).toEqual(['remembered-store']) + }) + }) + + test('prompts for a store when no candidate is supplied', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const prompted: string[] = [] + const result = await bootstrapThemeAirlock( + optionsFor(themePath, { + promptStore: async () => { + prompted.push('store') + return 'prompted-store' + }, + }), + ) + + expect(prompted).toEqual(['store']) + expect(result.target.store).toBe('prompted-store.myshopify.com') + }) + }) + + test.each([ + {name: 'confirmation', overrides: {candidate: 'candidate-store', confirmStore: async () => 'cancel' as const}}, + {name: 'store prompt', overrides: {promptStore: async () => undefined}}, + {name: 'environment prompt', overrides: {promptEnvironment: async () => undefined}}, + ])('cancelling at the $name stage does not authenticate or write', async ({overrides}) => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + let authenticated = false + const error = await captureError(() => + bootstrapThemeAirlock( + optionsFor(themePath, { + ...overrides, + authenticate: async () => { + authenticated = true + return {token: 'never'} + }, + }), + ), + ) + + expect(error).toBeInstanceOf(ThemeAirlockError) + expect((error as ThemeAirlockError).reason).toBe('bootstrap-cancelled') + expect(authenticated).toBe(false) + await expect(readFile(joinPath(themePath, configurationFileName))).rejects.toThrow() + }) + }) + + test('rejects an invalid selected store before authentication or writing', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + let authenticated = false + const error = await captureError(() => + bootstrapThemeAirlock( + optionsFor(themePath, { + candidate: 'not a store', + authenticate: async () => { + authenticated = true + return {token: 'never'} + }, + }), + ), + ) + + expect(error).toBeInstanceOf(ThemeAirlockError) + expect((error as ThemeAirlockError).reason).toBe('invalid-store') + expect(authenticated).toBe(false) + await expect(readFile(joinPath(themePath, configurationFileName))).rejects.toThrow() + }) + }) + + test('authentication failure does not write configuration', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + + await expect( + bootstrapThemeAirlock( + optionsFor(themePath, { + authenticate: async () => { + throw new Error('authentication failed') + }, + }), + ), + ).rejects.toThrow('authentication failed') + await expect(readFile(joinPath(themePath, configurationFileName))).rejects.toThrow() + }) + }) + + test('authenticates before writing and returns the same session', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const session = {token: 'same-session'} + let configurationDuringAuthentication: string | undefined + const result = await bootstrapThemeAirlock( + optionsFor(themePath, { + authenticate: async () => { + await expect(fileExists(joinPath(themePath, configurationFileName))).resolves.toBe(false) + configurationDuringAuthentication = undefined + return session + }, + }), + ) + + expect(configurationDuringAuthentication).toBeUndefined() + expect(result.session).toBe(session) + expect(result.configurationPath).toBe(joinPath(themePath, configurationFileName)) + await expect(readFile(result.configurationPath)).resolves.toContain('[environments.prompted-environment]') + }) + }) + + test('preserves an existing configuration while authenticating', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const configurationPath = joinPath(themePath, configurationFileName) + const original = '[environments.default]\nstore = "existing-store"\n' + await writeFile(configurationPath, original) + + await bootstrapThemeAirlock( + optionsFor(themePath, { + candidate: 'new-store', + authenticate: async () => { + await expect(readFile(configurationPath)).resolves.toBe(original) + return {token: 'session'} + }, + promptEnvironment: async () => 'preview', + }), + ) + + await expect(readFile(configurationPath)).resolves.toContain('preview.store = "new-store.myshopify.com"') + }) + }) + + test('does not prompt for environment when a proposed environment is supplied', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + let promptedForStore = false + let promptedForEnvironment = false + const result = await bootstrapThemeAirlock( + optionsFor(themePath, { + proposedEnvironment: 'explicit-environment', + promptStore: async () => { + promptedForStore = true + return 'explicit-store' + }, + promptEnvironment: async () => { + promptedForEnvironment = true + return 'wrong-environment' + }, + }), + ) + + expect(promptedForStore).toBe(true) + expect(promptedForEnvironment).toBe(false) + expect(result.target.environment).toBe('explicit-environment') + }) + }) + + test('propagates writer conflicts after authentication without changing configuration', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const configurationPath = joinPath(themePath, configurationFileName) + const original = '[environments.preview]\nstore = "existing-store"\n' + await writeFile(configurationPath, original) + let authenticated = false + + const error = await captureError(() => + bootstrapThemeAirlock( + optionsFor(themePath, { + candidate: 'new-store', + proposedEnvironment: 'preview', + authenticate: async () => { + authenticated = true + return {token: 'session'} + }, + }), + ), + ) + + expect(authenticated).toBe(true) + expect(error).toBeInstanceOf(ThemeAirlockError) + expect((error as ThemeAirlockError).reason).toBe('environment-conflict') + await expect(readFile(configurationPath)).resolves.toBe(original) + }) + }) +}) diff --git a/packages/theme/src/cli/utilities/theme-airlock/bootstrap.ts b/packages/theme/src/cli/utilities/theme-airlock/bootstrap.ts new file mode 100644 index 00000000000..2982d54c386 --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/bootstrap.ts @@ -0,0 +1,107 @@ +import {addTrustedThemeEnvironment} from './writer.js' +import {ThemeAirlockError} from './types.js' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {renderSelectPrompt, renderTextPrompt} from '@shopify/cli-kit/node/ui' + +import type {AirlockTarget} from './types.js' + +type BootstrapUI = Pick, 'confirmStore' | 'promptStore' | 'promptEnvironment'> + +interface BootstrapOptions { + themePath: string + candidate?: string + rememberedStore?: string + proposedEnvironment?: string + confirmStore: (store: string) => Promise<'trust' | 'choose' | 'cancel'> + promptStore: () => Promise + promptEnvironment: () => Promise + authenticate: (store: string) => Promise +} + +function bootstrapCancelled(message: string): ThemeAirlockError { + return new ThemeAirlockError(message, 'bootstrap-cancelled') +} + +function hasValue(value: string | undefined): value is string { + return value !== undefined && value.trim().length > 0 +} + +function normalizeBootstrapStore(store: string): string { + try { + return normalizeStoreFqdn(store) + } catch { + throw new ThemeAirlockError(`Invalid store value during theme bootstrap: ${store}.`, 'invalid-store') + } +} + +function requiredBootstrapValue(value: string | undefined, message: string): string { + if (hasValue(value)) return value + throw bootstrapCancelled(message) +} + +export function interactiveBootstrapUI(): BootstrapUI { + return { + confirmStore: async (store) => + renderSelectPrompt({ + message: `The store ${store} is untrusted. Choose how to continue.`, + choices: [ + {label: `Trust ${store}`, value: 'trust' as const}, + {label: 'Choose a different store', value: 'choose' as const}, + {label: 'Cancel', value: 'cancel' as const}, + ], + }), + promptStore: async () => { + const store = await renderTextPrompt({message: 'Enter the Shopify store to trust'}) + return hasValue(store) ? store.trim() : undefined + }, + promptEnvironment: async () => { + const environment = await renderTextPrompt({message: 'Enter a name for this theme environment'}) + return hasValue(environment) ? environment.trim() : undefined + }, + } +} + +export async function bootstrapThemeAirlock( + options: BootstrapOptions, +): Promise<{target: AirlockTarget; session: TSession; configurationPath: string}> { + let selectedStore: string | undefined + const suppliedStore = options.candidate ?? options.rememberedStore + + if (suppliedStore === undefined) { + selectedStore = await options.promptStore() + } else { + const confirmation = await options.confirmStore(suppliedStore) + if (confirmation === 'cancel') { + throw bootstrapCancelled('Theme bootstrap was cancelled while selecting a store.') + } + selectedStore = confirmation === 'trust' ? suppliedStore : await options.promptStore() + } + + const selectedStoreValue = requiredBootstrapValue( + selectedStore, + 'Theme bootstrap was cancelled without selecting a store.', + ) + const normalizedStore = normalizeBootstrapStore(selectedStoreValue) + const environment = requiredBootstrapValue( + options.proposedEnvironment ?? (await options.promptEnvironment()), + 'Theme bootstrap was cancelled without selecting an environment.', + ) + + const session = await options.authenticate(normalizedStore) + const configuration = await addTrustedThemeEnvironment({ + themePath: options.themePath, + environment, + store: normalizedStore, + }) + + return { + target: { + environment, + store: configuration.store, + source: 'bootstrap', + implicit: false, + }, + session, + configurationPath: configuration.path, + } +} diff --git a/packages/theme/src/cli/utilities/theme-airlock/config.test.ts b/packages/theme/src/cli/utilities/theme-airlock/config.test.ts new file mode 100644 index 00000000000..2cfc2098c7a --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/config.test.ts @@ -0,0 +1,226 @@ +import {loadThemeProjectTrust} from './config.js' +import {ThemeAirlockError} from './types.js' +import {configurationFileName} from '../../constants.js' +import {describe, expect, test} from 'vitest' +import {inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {TomlFileError} from '@shopify/cli-kit/node/toml/toml-file' + +async function writeConfiguration(directory: string, content: string): Promise { + await mkdir(directory) + const configurationPath = joinPath(directory, configurationFileName) + await writeFile(configurationPath, content) + return configurationPath +} + +async function captureError(operation: () => Promise): Promise { + try { + await operation() + } catch (error) { + if (error instanceof Error) return error + throw error + } + throw new Error('Expected operation to throw') +} + +describe('loadThemeProjectTrust', () => { + test('returns unconfigured when no configuration file exists', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = joinPath(tmpDir, 'theme') + await mkdir(themePath) + + await expect(loadThemeProjectTrust(themePath)).resolves.toEqual({state: 'unconfigured', themePath}) + }) + }) + + test('returns unconfigured with the discovered path when the file has no environments', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = joinPath(tmpDir, 'theme') + const configurationPath = await writeConfiguration(tmpDir, 'name = "theme-project"\n') + await mkdir(themePath) + + await expect(loadThemeProjectTrust(themePath)).resolves.toEqual({ + state: 'unconfigured', + path: configurationPath, + themePath, + }) + }) + }) + + test.each(['environments = "preview"\n', 'environments = []\n'])( + 'throws when environments is not a table: %s', + async (content) => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = joinPath(tmpDir, 'theme') + await writeConfiguration(tmpDir, content) + await mkdir(themePath) + + await expect(loadThemeProjectTrust(themePath)).rejects.toMatchObject({ + reason: 'malformed-configuration', + message: expect.stringContaining('environments'), + }) + }) + }, + ) + + test('uses the nearest ancestor configuration', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const projectPath = joinPath(tmpDir, 'project') + const themePath = joinPath(projectPath, 'themes', 'dawn') + await writeConfiguration(tmpDir, '[environments.default]\nstore = "root-store"\n') + const configurationPath = await writeConfiguration( + projectPath, + '[environments.default]\nstore = "project-store"\n', + ) + await mkdir(themePath) + + await expect(loadThemeProjectTrust(themePath)).resolves.toEqual({ + state: 'configured', + path: configurationPath, + themePath, + environments: [{name: 'default', store: 'project-store.myshopify.com'}], + }) + }) + }) + + test('ignores configuration files in sibling directories', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = joinPath(tmpDir, 'theme') + await writeConfiguration(joinPath(tmpDir, 'sibling'), '[environments.default]\nstore = "sibling-store"\n') + await mkdir(themePath) + + await expect(loadThemeProjectTrust(themePath)).resolves.toEqual({state: 'unconfigured', themePath}) + }) + }) + + test('normalizes default and named stores in TOML iteration order', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = joinPath(tmpDir, 'theme') + const configurationPath = await writeConfiguration( + tmpDir, + [ + '[environments.default]', + 'store = "default-store"', + '', + '[environments.preview]', + 'store = "https://Named-Store.myshopify.com/admin/"', + '', + ].join('\n'), + ) + await mkdir(themePath) + + await expect(loadThemeProjectTrust(themePath)).resolves.toEqual({ + state: 'configured', + path: configurationPath, + themePath, + environments: [ + {name: 'default', store: 'default-store.myshopify.com'}, + {name: 'preview', store: 'named-store.myshopify.com'}, + ], + }) + }) + }) + + test('does not trust environment entries without stores', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = joinPath(tmpDir, 'theme') + const configurationPath = await writeConfiguration( + tmpDir, + '[environments.default]\ntheme = "123456789"\n\n[environments.preview]\npassword = "secret"\n', + ) + await mkdir(themePath) + + await expect(loadThemeProjectTrust(themePath)).resolves.toEqual({ + state: 'unconfigured', + path: configurationPath, + themePath, + }) + }) + }) + + test('throws for a non-string store without modifying the configuration', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = joinPath(tmpDir, 'theme') + const originalContent = '[environments.production]\nstore = 123\n' + const configurationPath = await writeConfiguration(tmpDir, originalContent) + await mkdir(themePath) + + const error = await captureError(() => loadThemeProjectTrust(themePath)) + + expect(error).toBeInstanceOf(ThemeAirlockError) + if (!(error instanceof ThemeAirlockError)) throw error + expect(error.reason).toBe('malformed-configuration') + expect(error.targets).toEqual([]) + expect(error.tryMessage).toBe('No files were uploaded.') + expect(error.message).toContain(configurationPath) + expect(error.message).toContain('production') + await expect(readFile(configurationPath)).resolves.toBe(originalContent) + }) + }) + + test('throws for an invalid string store without modifying the configuration', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = joinPath(tmpDir, 'theme') + const originalContent = '[environments.preview]\nstore = ""\n' + const configurationPath = await writeConfiguration(tmpDir, originalContent) + await mkdir(themePath) + + const error = await captureError(() => loadThemeProjectTrust(themePath)) + + expect(error).toBeInstanceOf(ThemeAirlockError) + if (!(error instanceof ThemeAirlockError)) throw error + expect(error.reason).toBe('malformed-configuration') + expect(error.targets).toEqual([]) + expect(error.tryMessage).toBe('No files were uploaded.') + expect(error.message).toContain(configurationPath) + expect(error.message).toContain('preview') + await expect(readFile(configurationPath)).resolves.toBe(originalContent) + }) + }) + + test('throws when two environment names normalize to the same store without modifying the configuration', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = joinPath(tmpDir, 'theme') + const originalContent = [ + '[environments.default]', + 'store = "shared-store"', + '', + '[environments.production]', + 'store = "shared-store.myshopify.com"', + '', + ].join('\n') + const configurationPath = await writeConfiguration(tmpDir, originalContent) + await mkdir(themePath) + + const error = await captureError(() => loadThemeProjectTrust(themePath)) + + expect(error).toBeInstanceOf(ThemeAirlockError) + if (!(error instanceof ThemeAirlockError)) throw error + expect(error.reason).toBe('ambiguous-configuration') + expect(error.targets).toEqual([]) + expect(error.tryMessage).toBe('No files were uploaded.') + expect(error.message).toContain(configurationPath) + expect(error.message).toContain('default') + expect(error.message).toContain('production') + expect(error.message).toContain('shared-store.myshopify.com') + await expect(readFile(configurationPath)).resolves.toBe(originalContent) + }) + }) + + test('reports malformed TOML with its exact path without modifying the configuration', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = joinPath(tmpDir, 'theme') + const originalContent = '[environments.default\nstore = "broken"\n' + const configurationPath = await writeConfiguration(tmpDir, originalContent) + await mkdir(themePath) + + const error = await captureError(() => loadThemeProjectTrust(themePath)) + + expect(error).toBeInstanceOf(TomlFileError) + if (!(error instanceof TomlFileError)) throw error + expect(error.path).toBe(configurationPath) + expect(error.message).toContain(configurationPath) + await expect(readFile(configurationPath)).resolves.toBe(originalContent) + }) + }) +}) diff --git a/packages/theme/src/cli/utilities/theme-airlock/config.ts b/packages/theme/src/cli/utilities/theme-airlock/config.ts new file mode 100644 index 00000000000..520697d1287 --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/config.ts @@ -0,0 +1,84 @@ +import {ThemeAirlockError} from './types.js' +import {configurationFileName} from '../../constants.js' +import {environmentFilePath} from '@shopify/cli-kit/node/environments' +import {AbortError} from '@shopify/cli-kit/node/error' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {TomlFile, TomlFileError} from '@shopify/cli-kit/node/toml/toml-file' + +import type {ThemeProjectTrust, TrustedThemeEnvironment} from './types.js' + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +async function readConfiguration(configurationPath: string): Promise { + try { + return await TomlFile.read(configurationPath) + } catch (error) { + if (error instanceof TomlFileError) { + throw new TomlFileError( + configurationPath, + `Unable to parse theme configuration at ${configurationPath}: ${error.message}`, + ) + } + throw error + } +} + +export async function loadThemeProjectTrust(themePath: string): Promise { + const configurationPath = await environmentFilePath(configurationFileName, {from: themePath}) + if (!configurationPath) return {state: 'unconfigured', themePath} + + const configuration = await readConfiguration(configurationPath) + const environmentsValue: unknown = configuration.content.environments + if (environmentsValue === undefined) { + return {state: 'unconfigured', path: configurationPath, themePath} + } + if (!isObject(environmentsValue)) { + throw new ThemeAirlockError( + `Invalid environments in ${configurationPath}: expected a table.`, + 'malformed-configuration', + ) + } + + const environments: TrustedThemeEnvironment[] = [] + const environmentNameByStore = new Map() + + for (const [name, environmentValue] of Object.entries(environmentsValue)) { + if (!isObject(environmentValue) || environmentValue.store === undefined) continue + if (typeof environmentValue.store !== 'string') { + throw new ThemeAirlockError( + `Invalid store in ${configurationPath} for environment "${name}": expected a string.`, + 'malformed-configuration', + ) + } + + let store: string + try { + store = normalizeStoreFqdn(environmentValue.store) + } catch (error) { + if (!(error instanceof AbortError)) throw error + throw new ThemeAirlockError( + `Invalid store in ${configurationPath} for environment "${name}": expected a valid Shopify store handle or domain.`, + 'malformed-configuration', + ) + } + + if (environmentNameByStore.has(store)) { + const existingEnvironmentName = environmentNameByStore.get(store) as string + throw new ThemeAirlockError( + `Theme configuration ${configurationPath} maps environments "${existingEnvironmentName}" and "${name}" to the same store ${store}.`, + 'ambiguous-configuration', + ) + } + + environmentNameByStore.set(store, name) + environments.push({name, store}) + } + + if (environments.length === 0) { + return {state: 'unconfigured', path: configurationPath, themePath} + } + + return {state: 'configured', path: configurationPath, themePath, environments} +} diff --git a/packages/theme/src/cli/utilities/theme-airlock/coordinator.test.ts b/packages/theme/src/cli/utilities/theme-airlock/coordinator.test.ts new file mode 100644 index 00000000000..caf02d9758c --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/coordinator.test.ts @@ -0,0 +1,569 @@ +import {ThemeAirlockCoordinator} from './coordinator.js' +import {ThemeAirlockError} from './types.js' +import {getThemeStore, setThemeStore} from '../../services/local-storage.js' +import {configurationFileName} from '../../constants.js' +import {inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs' +import {LocalStorage} from '@shopify/cli-kit/node/local-storage' +import {joinPath} from '@shopify/cli-kit/node/path' +import {describe, expect, test, vi} from 'vitest' + +import type {ThemeLocalStorageSchema} from '../../services/local-storage.js' +import type {AdminSession} from '@shopify/cli-kit/node/session' + +const trustedStore = 'trusted.myshopify.com' +const session: AdminSession = {token: 'theme-token', storeFqdn: trustedStore} + +type CoordinatorOptions = ConstructorParameters[0] + +async function createUnconfiguredTheme(root: string): Promise { + const themePath = joinPath(root, 'theme') + await mkdir(themePath) + return themePath +} + +async function createThemeWithTrust(root: string, configuration: string): Promise { + const themePath = await createUnconfiguredTheme(root) + await writeFile(joinPath(themePath, configurationFileName), configuration) + return themePath +} + +async function createConfiguredTheme(root: string): Promise { + return createThemeWithTrust(root, `[environments.default]\nstore = "${trustedStore}"\n`) +} + +async function createBatchTheme(root: string, environment: string, store: string): Promise { + const themePath = joinPath(root, environment) + await mkdir(themePath) + await writeFile(joinPath(themePath, configurationFileName), `[environments.${environment}]\nstore = "${store}"\n`) + return themePath +} + +async function createStorage(root: string): Promise> { + const storagePath = joinPath(root, 'storage') + await mkdir(storagePath) + return new LocalStorage({cwd: storagePath}) +} + +function createCoordinator( + storage: LocalStorage, + overrides: Partial = {}, +) { + return new ThemeAirlockCoordinator({ + argv: [], + env: {}, + authenticate: async (flags) => ({token: 'theme-token', storeFqdn: flags.store as string}), + rememberedStore: () => getThemeStore(storage), + supportsPrompting: () => false, + renderPreflight: vi.fn(), + storedSessionsFor: vi.fn(() => new Map()), + storedSessionFromCache: vi.fn(), + missingRequiredFlags: vi.fn(() => []), + ...overrides, + }) +} + +function environment(name: string, path: string, store: string, password?: string) { + const suppliedFlags = {...(password === undefined ? {} : {password}), store} + return { + environment: name, + flags: {...suppliedFlags, path}, + validationFlags: suppliedFlags, + requiresAuth: true, + } +} + +async function captureAirlockError(operation: () => Promise): Promise { + try { + await operation() + } catch (error) { + expect(error).toBeInstanceOf(ThemeAirlockError) + if (error instanceof ThemeAirlockError) return error + throw error + } + throw new Error('Expected operation to throw') +} + +describe('ThemeAirlockCoordinator', () => { + describe('runSingle', () => { + test('guides a non-interactive explicit store and environment with the exact setup command', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = await createUnconfiguredTheme(root) + const storage = await createStorage(root) + const authenticate = vi.fn() + const execute = vi.fn() + const coordinator = createCoordinator(storage, { + argv: ['--store', 'example.myshopify.com', '--environment', 'production'], + authenticate, + }) + + const error = await captureAirlockError(() => + coordinator.runSingle({ + flags: {path: themePath, store: 'example.myshopify.com', environment: 'production'}, + requiresAuth: true, + execute, + }), + ) + + expect(error.message).toContain('shopify theme airlock add example.myshopify.com --environment production') + expect(authenticate).not.toHaveBeenCalled() + expect(execute).not.toHaveBeenCalled() + }) + }) + + test('asks the developer to choose an environment name for an explicit store', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = await createUnconfiguredTheme(root) + const storage = await createStorage(root) + const coordinator = createCoordinator(storage, {argv: ['--store', 'example.myshopify.com']}) + + const error = await captureAirlockError(() => + coordinator.runSingle({ + flags: {path: themePath, store: 'example.myshopify.com'}, + requiresAuth: true, + execute: vi.fn(), + }), + ) + + expect(error.message).toContain('choose the environment name') + expect(error.message).toContain('shopify theme airlock add example.myshopify.com --environment ') + }) + }) + + test('provides generic setup guidance when no candidate store is available', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = await createUnconfiguredTheme(root) + const storage = await createStorage(root) + const coordinator = createCoordinator(storage) + + const error = await captureAirlockError(() => + coordinator.runSingle({ + flags: {path: themePath}, + requiresAuth: true, + execute: vi.fn(), + }), + ) + + expect(error.message).toContain('shopify theme airlock add --environment ') + }) + }) + + test('does not include credential values in non-interactive setup guidance', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = await createUnconfiguredTheme(root) + const storage = await createStorage(root) + const coordinator = createCoordinator(storage, { + argv: ['--store', 'example.myshopify.com', '--environment', 'production'], + }) + + const error = await captureAirlockError(() => + coordinator.runSingle({ + flags: { + path: themePath, + store: 'example.myshopify.com', + environment: 'production', + password: 'secret-password', + 'auth-alias': 'secret-auth-alias', + token: 'secret-token', + }, + requiresAuth: true, + execute: vi.fn(), + }), + ) + + expect(error.message).not.toContain('secret-password') + expect(error.message).not.toContain('secret-auth-alias') + expect(error.message).not.toContain('secret-token') + }) + }) + + test('executes a configured named environment without prompting', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = await createThemeWithTrust( + root, + `[environments.production]\nstore = "production.myshopify.com"\n`, + ) + const storage = await createStorage(root) + const authenticate = vi.fn(async () => ({ + token: 'production-token', + storeFqdn: 'production.myshopify.com', + })) + const execute = vi.fn() + const coordinator = createCoordinator(storage, { + argv: ['--environment', 'production'], + authenticate, + supportsPrompting: () => false, + }) + + await coordinator.runSingle({ + flags: {path: themePath, environment: 'production'}, + requiresAuth: true, + execute, + }) + + expect(authenticate).toHaveBeenCalledWith(expect.objectContaining({store: 'production.myshopify.com'})) + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({store: 'production.myshopify.com'}), + expect.objectContaining({environment: 'production', store: 'production.myshopify.com'}), + expect.objectContaining({token: 'production-token'}), + ) + }) + }) + + test('executes the sole trusted store implicitly without prompting', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = await createThemeWithTrust(root, `[environments.preview]\nstore = "preview.myshopify.com"\n`) + const storage = await createStorage(root) + const execute = vi.fn() + const coordinator = createCoordinator(storage, {supportsPrompting: () => false}) + + await coordinator.runSingle({ + flags: {path: themePath}, + requiresAuth: true, + execute, + }) + + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({store: 'preview.myshopify.com'}), + expect.objectContaining({store: 'preview.myshopify.com', source: 'sole-store', implicit: true}), + expect.objectContaining({storeFqdn: 'preview.myshopify.com'}), + ) + }) + }) + + test('rejects multiple trusted stores without a non-interactive selection', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = await createThemeWithTrust( + root, + '[environments.preview]\nstore = "preview.myshopify.com"\n\n[environments.production]\nstore = "production.myshopify.com"\n', + ) + const storage = await createStorage(root) + const authenticate = vi.fn() + const execute = vi.fn() + const coordinator = createCoordinator(storage, { + authenticate, + supportsPrompting: () => false, + }) + + const error = await captureAirlockError(() => + coordinator.runSingle({ + flags: {path: themePath}, + requiresAuth: true, + execute, + }), + ) + + expect(error.reason).toBe('ambiguous-selection') + expect(authenticate).not.toHaveBeenCalled() + expect(execute).not.toHaveBeenCalled() + }) + }) + + test('executes with the trusted store without changing a different remembered store', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = await createConfiguredTheme(root) + const storage = await createStorage(root) + setThemeStore('remembered.myshopify.com', storage) + const coordinator = createCoordinator(storage) + let observedStore: string | undefined + + await coordinator.runSingle({ + flags: {path: themePath}, + requiresAuth: true, + execute: async () => { + observedStore = getThemeStore(storage) + }, + }) + + expect(observedStore).toBe(trustedStore) + expect(getThemeStore(storage)).toBe('remembered.myshopify.com') + }) + }) + + test('provides the trusted store context when no store is remembered', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = await createConfiguredTheme(root) + const storage = await createStorage(root) + const coordinator = createCoordinator(storage) + let observedStore: string | undefined + + expect(getThemeStore(storage)).toBeUndefined() + + await coordinator.runSingle({ + flags: {path: themePath}, + requiresAuth: true, + execute: async () => { + observedStore = getThemeStore(storage) + }, + }) + + expect(observedStore).toBe(trustedStore) + expect(getThemeStore(storage)).toBeUndefined() + }) + }) + }) + + describe('runBatch', () => { + test('validates every target before projecting stored sessions', async () => { + await inTemporaryDirectory(async (root) => { + const firstPath = await createBatchTheme(root, 'first', 'first-store') + const secondPath = await createBatchTheme(root, 'second', 'other-store') + const storage = await createStorage(root) + const storedSessionsFor = vi.fn(() => new Map()) + const authenticate = vi.fn() + const coordinator = createCoordinator(storage, {storedSessionsFor, authenticate}) + + await expect( + coordinator.runBatch({ + environments: [ + environment('first', firstPath, 'first-store.myshopify.com'), + environment('second', secondPath, 'second-store.myshopify.com'), + ], + requiredFlags: ['store'], + requiresAuth: true, + execute: vi.fn(), + }), + ).rejects.toMatchObject({reason: 'invalid-batch'}) + + expect(storedSessionsFor).not.toHaveBeenCalled() + expect(authenticate).not.toHaveBeenCalled() + }) + }) + + test('lets a projected stored session satisfy the required password flag', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = await createBatchTheme(root, 'preview', trustedStore) + const storage = await createStorage(root) + const storedSession: AdminSession = {token: 'stored-token', storeFqdn: trustedStore} + const storedSessionsFor = vi.fn(() => new Map([[trustedStore, storedSession]])) + const storedSessionFromCache = vi.fn((_flags: Record, sessions: Map) => + sessions.get(trustedStore), + ) + const missingRequiredFlags = vi.fn( + (flags: Record, requiredFlags: (string | string[])[], suppliedSession?: AdminSession) => + requiredFlags + .filter((requiredFlag) => { + const alternatives = Array.isArray(requiredFlag) ? requiredFlag : [requiredFlag] + return !alternatives.some((flag) => (flag === 'password' ? suppliedSession : flags[flag])) + }) + .map((requiredFlag) => (Array.isArray(requiredFlag) ? requiredFlag.join(' or ') : requiredFlag)), + ) + const authenticate = vi.fn(async (_flags, suppliedSession?: AdminSession) => suppliedSession ?? session) + const execute = vi.fn() + const coordinator = createCoordinator(storage, { + authenticate, + storedSessionsFor, + storedSessionFromCache, + missingRequiredFlags, + }) + + await coordinator.runBatch({ + environments: [environment('preview', themePath, trustedStore)], + requiredFlags: ['store', 'password'], + requiresAuth: true, + execute, + }) + + expect(missingRequiredFlags).toHaveBeenCalledWith({store: trustedStore}, ['store', 'password'], storedSession) + expect(authenticate).toHaveBeenCalledWith(expect.objectContaining({store: trustedStore}), storedSession) + expect(execute.mock.calls[0]?.[0][0]?.session).toBe(storedSession) + }) + }) + + test('stops after validation when batch execution is not confirmed', async () => { + await inTemporaryDirectory(async (root) => { + const themePath = await createBatchTheme(root, 'preview', trustedStore) + const storage = await createStorage(root) + const events: string[] = [] + const storedSessionsFor = vi.fn(() => { + events.push('project sessions') + return new Map() + }) + const authenticate = vi.fn() + const renderPreflight = vi.fn() + const confirm = vi.fn(async () => { + events.push('confirm') + return false + }) + const execute = vi.fn() + const coordinator = createCoordinator(storage, {storedSessionsFor, authenticate, renderPreflight}) + + await coordinator.runBatch({ + environments: [environment('preview', themePath, trustedStore)], + requiredFlags: ['store'], + requiresAuth: true, + confirm, + execute, + }) + + expect(events).toEqual(['project sessions', 'confirm']) + expect(authenticate).not.toHaveBeenCalled() + expect(renderPreflight).not.toHaveBeenCalled() + expect(execute).not.toHaveBeenCalled() + }) + }) + + test('authenticates every environment before preflight', async () => { + await inTemporaryDirectory(async (root) => { + const firstPath = await createBatchTheme(root, 'first', 'first-store') + const secondPath = await createBatchTheme(root, 'second', 'second-store') + const storage = await createStorage(root) + const events: string[] = [] + const authenticate = vi.fn(async (flags) => { + events.push(`authenticate:${flags.store}`) + return {token: String(flags.store), storeFqdn: flags.store as string} + }) + const renderPreflight = vi.fn(() => events.push('preflight')) + const coordinator = createCoordinator(storage, {authenticate, renderPreflight}) + + await coordinator.runBatch({ + environments: [ + environment('first', firstPath, 'first-store.myshopify.com'), + environment('second', secondPath, 'second-store.myshopify.com'), + ], + requiredFlags: ['store'], + requiresAuth: true, + execute: async () => { + events.push('execute') + }, + }) + + expect(events).toEqual([ + 'authenticate:first-store.myshopify.com', + 'authenticate:second-store.myshopify.com', + 'preflight', + 'execute', + ]) + }) + }) + + test('does not preflight or execute when authentication fails', async () => { + await inTemporaryDirectory(async (root) => { + const firstPath = await createBatchTheme(root, 'first', 'first-store') + const secondPath = await createBatchTheme(root, 'second', 'second-store') + const storage = await createStorage(root) + const authenticate = vi + .fn() + .mockResolvedValueOnce({token: 'first-token', storeFqdn: 'first-store.myshopify.com'}) + .mockRejectedValueOnce(new Error('authentication failed')) + const renderPreflight = vi.fn() + const execute = vi.fn() + const coordinator = createCoordinator(storage, {authenticate, renderPreflight}) + + await expect( + coordinator.runBatch({ + environments: [ + environment('first', firstPath, 'first-store.myshopify.com'), + environment('second', secondPath, 'second-store.myshopify.com'), + ], + requiredFlags: ['store'], + requiresAuth: true, + execute, + }), + ).rejects.toThrow('authentication failed') + + expect(authenticate).toHaveBeenCalledTimes(2) + expect(renderPreflight).not.toHaveBeenCalled() + expect(execute).not.toHaveBeenCalled() + }) + }) + + test('authenticates the same normalized store and password once', async () => { + await inTemporaryDirectory(async (root) => { + const firstPath = await createBatchTheme(root, 'first', 'shared-store') + const secondPath = await createBatchTheme(root, 'second', 'SHARED-STORE.myshopify.com') + const storage = await createStorage(root) + const authenticatedSession: AdminSession = {token: 'shared-token', storeFqdn: 'shared-store.myshopify.com'} + const authenticate = vi.fn().mockResolvedValue(authenticatedSession) + const execute = vi.fn() + const coordinator = createCoordinator(storage, {authenticate}) + + await coordinator.runBatch({ + environments: [ + environment('first', firstPath, 'shared-store.myshopify.com', 'shared-password'), + environment('second', secondPath, 'SHARED-STORE.myshopify.com', 'shared-password'), + ], + requiredFlags: ['store', 'password'], + requiresAuth: true, + execute, + }) + + expect(authenticate).toHaveBeenCalledOnce() + expect(execute.mock.calls[0]?.[0][0]?.session).toBe(authenticatedSession) + expect(execute.mock.calls[0]?.[0][1]?.session).toBe(authenticatedSession) + }) + }) + + test('authenticates distinct passwords for the same normalized store separately', async () => { + await inTemporaryDirectory(async (root) => { + const firstPath = await createBatchTheme(root, 'first', 'shared-store') + const secondPath = await createBatchTheme(root, 'second', 'SHARED-STORE.myshopify.com') + const storage = await createStorage(root) + const authenticate = vi.fn(async (flags) => ({ + token: flags.password as string, + storeFqdn: flags.store as string, + })) + const coordinator = createCoordinator(storage, {authenticate}) + + await coordinator.runBatch({ + environments: [ + environment('first', firstPath, 'shared-store.myshopify.com', 'first-password'), + environment('second', secondPath, 'SHARED-STORE.myshopify.com', 'second-password'), + ], + requiredFlags: ['store', 'password'], + requiresAuth: true, + execute: vi.fn(), + }) + + expect(authenticate).toHaveBeenCalledTimes(2) + expect(authenticate).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({store: 'shared-store.myshopify.com', password: 'first-password'}), + undefined, + ) + expect(authenticate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({store: 'shared-store.myshopify.com', password: 'second-password'}), + undefined, + ) + }) + }) + + test('executes once with the complete authenticated batch', async () => { + await inTemporaryDirectory(async (root) => { + const firstPath = await createBatchTheme(root, 'first', 'first-store') + const secondPath = await createBatchTheme(root, 'second', 'second-store') + const storage = await createStorage(root) + const execute = vi.fn() + const coordinator = createCoordinator(storage) + + await coordinator.runBatch({ + environments: [ + environment('first', firstPath, 'first-store.myshopify.com'), + environment('second', secondPath, 'second-store.myshopify.com'), + ], + requiredFlags: ['store'], + requiresAuth: true, + execute, + }) + + expect(execute).toHaveBeenCalledOnce() + expect(execute.mock.calls[0]?.[0]).toEqual([ + expect.objectContaining({ + environment: 'first', + flags: expect.objectContaining({store: 'first-store.myshopify.com'}), + session: expect.objectContaining({storeFqdn: 'first-store.myshopify.com'}), + }), + expect.objectContaining({ + environment: 'second', + flags: expect.objectContaining({store: 'second-store.myshopify.com'}), + session: expect.objectContaining({storeFqdn: 'second-store.myshopify.com'}), + }), + ]) + expect(execute.mock.calls[0]?.[1]).toEqual([ + expect.objectContaining({environment: 'first', store: 'first-store.myshopify.com'}), + expect.objectContaining({environment: 'second', store: 'second-store.myshopify.com'}), + ]) + }) + }) + }) +}) diff --git a/packages/theme/src/cli/utilities/theme-airlock/coordinator.ts b/packages/theme/src/cli/utilities/theme-airlock/coordinator.ts new file mode 100644 index 00000000000..0534eee853b --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/coordinator.ts @@ -0,0 +1,212 @@ +import {bootstrapThemeAirlock, interactiveBootstrapUI} from './bootstrap.js' +import {loadThemeProjectTrust} from './config.js' +import {resolveBatchAirlockTargets, resolveSingleAirlockTarget} from './resolver.js' +import {ThemeAirlockError} from './types.js' +import {useThemeStoreContext} from '../../services/local-storage.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {cwd} from '@shopify/cli-kit/node/path' +import {fileExistsSync} from '@shopify/cli-kit/node/fs' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' + +import type {AirlockTarget} from './types.js' +import type {AdminSession} from '@shopify/cli-kit/node/session' + +export type AirlockFlagValues = Record + +interface ThemeAirlockCoordinatorOptions { + argv: string[] + env: NodeJS.ProcessEnv + authenticate: (flags: AirlockFlagValues, suppliedSession?: AdminSession) => Promise + rememberedStore: () => string | undefined + supportsPrompting: () => boolean + renderPreflight: (targets: AirlockTarget[]) => void + storedSessionsFor: (flagsList: AirlockFlagValues[]) => Map + storedSessionFromCache: (flags: AirlockFlagValues, sessions: Map) => AdminSession | undefined + missingRequiredFlags: ( + flags: AirlockFlagValues, + requiredFlags: (string | string[])[], + suppliedSession?: AdminSession, + ) => string[] +} + +interface RunSingleOptions { + flags: AirlockFlagValues + requiresAuth: boolean + execute: (flags: AirlockFlagValues, target: AirlockTarget, session?: AdminSession) => Promise +} + +function nonInteractiveSetupGuidance(resolution: {candidate?: string; proposedEnvironment?: string}): string { + const introduction = 'This theme project is not configured.' + + if (resolution.candidate && resolution.proposedEnvironment) { + return `${introduction} Trust the selected store, then rerun this command:\n\`shopify theme airlock add ${resolution.candidate} --environment ${resolution.proposedEnvironment}\`` + } + + if (resolution.candidate) { + return `${introduction} You must choose the environment name, then run:\n\`shopify theme airlock add ${resolution.candidate} --environment \`` + } + + return `${introduction} Choose the trusted store and environment name, then run:\n\`shopify theme airlock add --environment \`` +} + +interface AirlockBatchEnvironment { + environment: string + flags: AirlockFlagValues + validationFlags: AirlockFlagValues +} + +export interface AirlockBatchExecutionEnvironment { + environment: string + flags: AirlockFlagValues + requiresAuth: boolean + session?: AdminSession +} + +interface RunBatchOptions { + environments: AirlockBatchEnvironment[] + requiredFlags: (string | string[])[] + requiresAuth: boolean + confirm?: (environments: AirlockBatchExecutionEnvironment[]) => Promise + execute: (environments: AirlockBatchExecutionEnvironment[], targets: AirlockTarget[]) => Promise +} + +export class ThemeAirlockCoordinator { + constructor(private readonly options: ThemeAirlockCoordinatorOptions) {} + + async runSingle({flags, requiresAuth, execute}: RunSingleOptions): Promise { + const themePath = typeof flags.path === 'string' ? flags.path : cwd() + if (!fileExistsSync(themePath)) { + throw new AbortError(`Path does not exist: ${themePath}`) + } + + const trust = await loadThemeProjectTrust(themePath) + const resolution = resolveSingleAirlockTarget({ + trust, + flags, + argv: this.options.argv, + env: this.options.env, + }) + + let approvedFlags: AirlockFlagValues + let target: AirlockTarget + let session: AdminSession | undefined + + if ('bootstrap' in resolution) { + if (!this.options.supportsPrompting()) { + throw new ThemeAirlockError(nonInteractiveSetupGuidance(resolution), 'unconfigured-project') + } + + const rememberedStore = resolution.allowRememberedCandidate ? this.options.rememberedStore() : undefined + const bootstrapUI = interactiveBootstrapUI() + const bootstrap = await bootstrapThemeAirlock({ + themePath, + candidate: resolution.candidate, + proposedEnvironment: resolution.proposedEnvironment, + rememberedStore, + confirmStore: bootstrapUI.confirmStore, + promptStore: bootstrapUI.promptStore, + promptEnvironment: bootstrapUI.promptEnvironment, + authenticate: async (store) => this.options.authenticate({...flags, store}), + }) + approvedFlags = {...flags, store: bootstrap.target.store} + target = bootstrap.target + session = requiresAuth ? bootstrap.session : undefined + } else { + approvedFlags = {...flags, store: resolution.store} + target = resolution + session = requiresAuth ? await this.options.authenticate(approvedFlags) : undefined + } + + this.options.renderPreflight([target]) + return useThemeStoreContext(target.store, () => execute(approvedFlags, target, session)) + } + + async runBatch({ + environments, + requiredFlags, + requiresAuth, + confirm, + execute, + }: RunBatchOptions): Promise { + const validatedEnvironments: { + environment: AirlockBatchEnvironment + target: AirlockTarget + }[] = [] + + for (const environment of environments) { + const themePath = typeof environment.flags.path === 'string' ? environment.flags.path : cwd() + if (!fileExistsSync(themePath)) { + throw new ThemeAirlockError( + `Invalid batch environment "${environment.environment}": path does not exist: ${themePath}.`, + 'invalid-batch', + ) + } + + // Trust is loaded from each environment's effective path so nested projects retain nearest-config semantics. + // eslint-disable-next-line no-await-in-loop + const trust = await loadThemeProjectTrust(themePath) + const [target] = resolveBatchAirlockTargets({ + trust, + environments: [{name: environment.environment, store: environment.flags.store as string}], + }) + if (!target) { + throw new ThemeAirlockError( + `Invalid batch environment "${environment.environment}": no trust target resolved.`, + 'invalid-batch', + ) + } + + validatedEnvironments.push({environment, target}) + } + + // Do not project cached sessions until all effective paths and trust targets have been validated. + const storedSessions = requiresAuth + ? this.options.storedSessionsFor(validatedEnvironments.map(({environment}) => environment.validationFlags)) + : new Map() + const executionEnvironments: AirlockBatchExecutionEnvironment[] = [] + const targets: AirlockTarget[] = [] + + for (const {environment, target} of validatedEnvironments) { + const storedSession = this.options.storedSessionFromCache(environment.validationFlags, storedSessions) + const missingFlags = this.options.missingRequiredFlags(environment.validationFlags, requiredFlags, storedSession) + if (missingFlags.length > 0) { + throw new ThemeAirlockError( + `Invalid batch environment "${environment.environment}": missing required flags: ${missingFlags.join(', ')}.`, + 'invalid-batch', + ) + } + + executionEnvironments.push({ + environment: environment.environment, + flags: {...environment.flags, store: target.store}, + requiresAuth, + session: storedSession, + }) + targets.push(target) + } + + if (confirm && !(await confirm(executionEnvironments))) return undefined + + const authenticatedSessionsByStore = new Map>() + for (const environment of executionEnvironments) { + if (!environment.requiresAuth) continue + + const store = normalizeStoreFqdn(environment.flags.store as string) + const password = typeof environment.flags.password === 'string' ? environment.flags.password : undefined + const sessionsByPassword = authenticatedSessionsByStore.get(store) ?? new Map() + const authenticatedSession = sessionsByPassword.get(password ?? '') + if (authenticatedSession) { + environment.session = authenticatedSession + continue + } + + // eslint-disable-next-line no-await-in-loop + environment.session = await this.options.authenticate(environment.flags, environment.session) + sessionsByPassword.set(password ?? '', environment.session) + authenticatedSessionsByStore.set(store, sessionsByPassword) + } + + this.options.renderPreflight(targets) + return execute(executionEnvironments, targets) + } +} diff --git a/packages/theme/src/cli/utilities/theme-airlock/preflight.test.ts b/packages/theme/src/cli/utilities/theme-airlock/preflight.test.ts new file mode 100644 index 00000000000..4618270569a --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/preflight.test.ts @@ -0,0 +1,116 @@ +import {airlockEnvironmentLabel, renderAirlockPreflight} from './preflight.js' +import {renderInfo} from '@shopify/cli-kit/node/ui' +import {describe, expect, test, vi} from 'vitest' + +import type {AirlockTarget} from './types.js' + +vi.mock('@shopify/cli-kit/node/ui') + +function target(overrides: Partial = {}): AirlockTarget { + return { + environment: 'development', + store: 'development.myshopify.com', + source: 'explicit-environment', + implicit: false, + ...overrides, + } +} + +describe('airlockEnvironmentLabel', () => { + test('labels the default target as implicit', () => { + expect(airlockEnvironmentLabel(target({environment: 'default', source: 'default', implicit: true}))).toBe( + 'default (implicit)', + ) + }) + + test('labels the sole store target as the implicit sole project store', () => { + expect(airlockEnvironmentLabel(target({environment: 'production', source: 'sole-store', implicit: true}))).toBe( + 'sole project store (implicit)', + ) + }) + + test('does not mark an explicit environment as implicit', () => { + expect(airlockEnvironmentLabel(target({environment: 'production', implicit: true}))).toBe('production') + }) + + test('marks other implicit environments as implicit', () => { + expect(airlockEnvironmentLabel(target({environment: 'development', source: 'bootstrap', implicit: true}))).toBe( + 'development (implicit)', + ) + }) +}) + +describe('renderAirlockPreflight', () => { + test.each([ + { + name: 'default', + target: target({environment: 'default', source: 'default', implicit: true}), + selectedBy: 'shopify.theme.toml', + }, + { + name: 'sole store', + target: target({environment: 'production', source: 'sole-store', implicit: true}), + selectedBy: 'shopify.theme.toml', + }, + { + name: 'explicit environment', + target: target({environment: 'production'}), + selectedBy: 'shopify.theme.toml', + }, + { + name: 'explicit store', + target: target({source: 'explicit-store'}), + selectedBy: '--store', + }, + { + name: 'environment variable store', + target: target({source: 'environment-variable'}), + selectedBy: 'SHOPIFY_FLAG_STORE', + }, + { + name: 'bootstrap', + target: target({environment: 'new-environment', source: 'bootstrap'}), + selectedBy: 'bootstrap setup', + }, + ])('renders semantic rows for a single $name target', ({target: selectedTarget, selectedBy}) => { + renderAirlockPreflight('push', [selectedTarget]) + + const options = vi.mocked(renderInfo).mock.calls[0]?.[0] + expect(options?.headline).toBe('Theme Airlock') + const body = options?.customSections?.[0]?.body + expect(body).toMatchObject({ + tabularData: [ + ['Environment', airlockEnvironmentLabel(selectedTarget)], + ['Store', selectedTarget.store], + ['Selected by', selectedBy], + ['Operation', 'theme push'], + ], + }) + }) + + test('renders every explicitly selected store with source, status, and operation in request order', () => { + const targets = [ + target({environment: 'first', store: 'first.myshopify.com'}), + target({ + environment: 'second', + store: 'second.myshopify.com', + source: 'explicit-store', + implicit: true, + }), + ] + + renderAirlockPreflight('push', targets) + + const options = vi.mocked(renderInfo).mock.calls[0]?.[0] + expect(options?.headline).toBe('Theme Airlock') + expect(options?.body).toBe('The following explicitly selected stores will be used.') + expect(options?.customSections?.[0]?.title).toBe('explicitly selected stores') + expect(options?.customSections?.[0]?.body).toMatchObject({ + tabularData: [ + ['Environment', 'Store', 'Selected by', 'Status', 'Operation'], + ['first', 'first.myshopify.com', 'shopify.theme.toml', 'explicit', 'theme push'], + ['second', 'second.myshopify.com', '--store', 'implicit', 'theme push'], + ], + }) + }) +}) diff --git a/packages/theme/src/cli/utilities/theme-airlock/preflight.ts b/packages/theme/src/cli/utilities/theme-airlock/preflight.ts new file mode 100644 index 00000000000..f32f74db977 --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/preflight.ts @@ -0,0 +1,81 @@ +import {renderInfo} from '@shopify/cli-kit/node/ui' + +import type {AirlockTarget} from './types.js' + +export function airlockEnvironmentLabel(target: AirlockTarget): string { + if (target.source === 'default') return 'default (implicit)' + if (target.source === 'sole-store') return 'sole project store (implicit)' + + const environment = target.environment ?? 'unknown environment' + if (target.source === 'explicit-environment') return environment + + return target.implicit ? `${environment} (implicit)` : environment +} + +function selectedByLabel(target: AirlockTarget): string { + switch (target.source) { + case 'explicit-store': + return '--store' + case 'environment-variable': + return 'SHOPIFY_FLAG_STORE' + case 'bootstrap': + return 'bootstrap setup' + case 'default': + case 'sole-store': + case 'explicit-environment': + return 'shopify.theme.toml' + } +} + +export function renderAirlockPreflight(command: string, targets: AirlockTarget[]): void { + if (targets.length === 1) { + const [target] = targets + if (!target) return + + renderInfo({ + // The exact headline is part of the Airlock output contract. + // eslint-disable-next-line @shopify/cli/banner-headline-format + headline: 'Theme Airlock', + customSections: [ + { + title: 'Target', + body: { + tabularData: [ + ['Environment', airlockEnvironmentLabel(target)], + ['Store', target.store], + ['Selected by', selectedByLabel(target)], + ['Operation', `theme ${command}`], + ], + firstColumnSubdued: true, + }, + }, + ], + }) + return + } + + renderInfo({ + // The exact headline is part of the Airlock output contract. + // eslint-disable-next-line @shopify/cli/banner-headline-format + headline: 'Theme Airlock', + body: 'The following explicitly selected stores will be used.', + customSections: [ + { + title: 'explicitly selected stores', + body: { + tabularData: [ + ['Environment', 'Store', 'Selected by', 'Status', 'Operation'], + ...targets.map((target) => [ + target.environment ?? 'unknown environment', + target.store, + selectedByLabel(target), + target.implicit ? 'implicit' : 'explicit', + `theme ${command}`, + ]), + ], + firstColumnSubdued: true, + }, + }, + ], + }) +} diff --git a/packages/theme/src/cli/utilities/theme-airlock/resolver.test.ts b/packages/theme/src/cli/utilities/theme-airlock/resolver.test.ts new file mode 100644 index 00000000000..f2c50b3c624 --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/resolver.test.ts @@ -0,0 +1,664 @@ +import { + resolveBatchAirlockTargets, + resolveSingleAirlockTarget, + validateAirlockStoreSelectionSources, +} from './resolver.js' +import {ThemeAirlockError} from './types.js' +import {describe, expect, test, vi} from 'vitest' + +import type {ThemeProjectTrust} from './types.js' + +const configuredTrust: ThemeProjectTrust = { + state: 'configured', + path: '/theme/shopify.theme.toml', + themePath: '/theme', + environments: [ + {name: 'default', store: 'default-store.myshopify.com'}, + {name: 'preview', store: 'preview-store.myshopify.com'}, + {name: 'production', store: 'production-store.myshopify.com'}, + ], +} + +const unconfiguredTrust: ThemeProjectTrust = { + state: 'unconfigured', + themePath: '/theme', +} + +function captureAirlockError(operation: () => unknown): ThemeAirlockError { + try { + operation() + } catch (error) { + expect(error).toBeInstanceOf(ThemeAirlockError) + if (error instanceof ThemeAirlockError) return error + throw error + } + throw new Error('Expected operation to throw') +} + +function resolveSingle({ + trust = configuredTrust, + flags = {}, + argv = [], + env = {}, +}: { + trust?: ThemeProjectTrust + flags?: Record + argv?: string[] + env?: NodeJS.ProcessEnv +} = {}) { + return resolveSingleAirlockTarget({trust, flags, argv, env}) +} + +describe('resolveSingleAirlockTarget', () => { + test.each([ + { + name: 'a long CLI environment with a separate value', + flags: {environment: 'preview'}, + argv: ['--environment', 'preview'], + }, + { + name: 'a short CLI environment with an equals value', + flags: {environment: 'preview'}, + argv: ['-e=preview'], + }, + { + name: 'a short CLI environment with an attached value', + flags: {environment: 'preview'}, + argv: ['-epreview'], + }, + ])('resolves $name', ({flags, argv}) => { + expect(resolveSingle({flags, argv})).toEqual({ + environment: 'preview', + store: 'preview-store.myshopify.com', + source: 'explicit-environment', + implicit: false, + }) + }) + + test('resolves an environment from SHOPIFY_FLAG_ENVIRONMENT', () => { + expect( + resolveSingle({ + flags: {environment: 'production'}, + env: {SHOPIFY_FLAG_ENVIRONMENT: 'production'}, + }), + ).toEqual({ + environment: 'production', + store: 'production-store.myshopify.com', + source: 'explicit-environment', + implicit: false, + }) + }) + + test.each([ + { + name: 'a long CLI store with an equals value', + flags: {store: 'preview-store.myshopify.com'}, + argv: ['--store=preview-store'], + }, + { + name: 'a short CLI store with a separate value', + flags: {store: 'preview-store.myshopify.com'}, + argv: ['-s', 'preview-store'], + }, + { + name: 'a short CLI store with an attached value', + flags: {store: 'preview-store.myshopify.com'}, + argv: ['-spreview-store'], + }, + ])('resolves $name', ({flags, argv}) => { + expect(resolveSingle({flags, argv})).toEqual({ + environment: 'preview', + store: 'preview-store.myshopify.com', + source: 'explicit-store', + implicit: false, + }) + }) + + test('ignores unrelated flags that contain selector names', () => { + expect(resolveSingle({argv: ['--storefront', 'preview-store', '--environmental', 'preview', '-xpreview']})).toEqual( + { + environment: 'default', + store: 'default-store.myshopify.com', + source: 'default', + implicit: true, + }, + ) + }) + + test('resolves a store selected only by SHOPIFY_FLAG_STORE', () => { + expect( + resolveSingle({ + flags: {store: 'preview-store.myshopify.com'}, + env: {SHOPIFY_FLAG_STORE: 'preview-store'}, + }), + ).toEqual({ + environment: 'preview', + store: 'preview-store.myshopify.com', + source: 'environment-variable', + implicit: false, + }) + }) + + test.each([ + { + name: '--store=', + argv: ['--store='], + env: {}, + reason: 'unknown-store' as const, + message: '--store requires a value.', + }, + { + name: "SHOPIFY_FLAG_STORE: ''", + argv: [], + env: {SHOPIFY_FLAG_STORE: ''}, + reason: 'unknown-store' as const, + message: 'SHOPIFY_FLAG_STORE requires a value.', + }, + { + name: '--environment=', + argv: ['--environment='], + env: {}, + reason: 'unknown-environment' as const, + message: '--environment requires a value.', + }, + { + name: "SHOPIFY_FLAG_ENVIRONMENT: ''", + argv: [], + env: {SHOPIFY_FLAG_ENVIRONMENT: ''}, + reason: 'unknown-environment' as const, + message: 'SHOPIFY_FLAG_ENVIRONMENT requires a value.', + }, + ])('rejects an empty explicit selection from $name', ({argv, env, reason, message}) => { + const error = captureAirlockError(() => resolveSingle({argv, env})) + + expect(error.reason).toBe(reason) + expect(error.message).toBe(message) + }) + + test.each([ + { + name: 'repeated long store selectors', + argv: ['--store', 'preview-store', '--store', 'production-store'], + message: 'Multiple --store selections were provided. Provide only one.', + }, + { + name: 'repeated short store selectors', + argv: ['-spreview-store', '-sproduction-store'], + message: 'Multiple --store selections were provided. Provide only one.', + }, + ])('rejects $name', ({argv, message}) => { + const error = captureAirlockError(() => resolveSingle({argv})) + + expect(error.reason).toBe('conflicting-selection') + expect(error.targets).toEqual([]) + expect(error.message).toBe(message) + }) + + test.each([ + { + name: 'repeated long environment selectors', + argv: ['--environment', 'preview', '--environment', 'production'], + message: 'Multiple --environment selections were provided. Provide only one.', + }, + { + name: 'repeated short environment selectors', + argv: ['-epreview', '-eproduction'], + message: 'Multiple --environment selections were provided. Provide only one.', + }, + ])('rejects $name', ({argv, message}) => { + const error = captureAirlockError(() => resolveSingle({argv})) + + expect(error.reason).toBe('conflicting-selection') + expect(error.targets).toEqual([]) + expect(error.message).toBe(message) + }) + + test.each([ + { + name: 'CLI store', + argv: ['--store', 'not a store'], + env: {}, + message: 'Invalid store value for --store: not a store.', + }, + { + name: 'environment-variable store', + argv: [], + env: {SHOPIFY_FLAG_STORE: 'not a store'}, + message: 'Invalid store value for SHOPIFY_FLAG_STORE: not a store.', + }, + ])('rejects a malformed $name', ({argv, env, message}) => { + const error = captureAirlockError(() => resolveSingle({argv, env})) + + expect(error.reason).toBe('invalid-store') + expect(error.targets).toEqual([]) + expect(error.message).toBe(message) + }) + + test('attributes a matching CLI and environment-variable store to the CLI', () => { + expect( + resolveSingle({ + flags: {store: 'preview-store.myshopify.com'}, + argv: ['--store', 'https://PREVIEW-STORE.myshopify.com/admin/'], + env: {SHOPIFY_FLAG_STORE: 'preview-store'}, + }), + ).toEqual({ + environment: 'preview', + store: 'preview-store.myshopify.com', + source: 'explicit-store', + implicit: false, + }) + }) + + test('rejects mismatching CLI and environment-variable stores', () => { + const error = captureAirlockError(() => + resolveSingle({ + flags: {store: 'preview-store.myshopify.com'}, + argv: ['--store', 'preview-store'], + env: {SHOPIFY_FLAG_STORE: 'production-store'}, + }), + ) + + expect(error.reason).toBe('conflicting-selection') + expect(error.targets).toEqual([]) + expect(error.message).toBe( + 'Store selections conflict: --store selects preview-store.myshopify.com, while SHOPIFY_FLAG_STORE selects production-store.myshopify.com.', + ) + }) + + test('rejects conflicting CLI and environment-variable stores before resolving an explicit environment', () => { + const error = captureAirlockError(() => + resolveSingle({ + argv: ['--environment', 'unknown', '--store', 'preview-store'], + env: {SHOPIFY_FLAG_STORE: 'production-store'}, + }), + ) + + expect(error.reason).toBe('conflicting-selection') + expect(error.targets).toEqual([]) + expect(error.message).toBe( + 'Store selections conflict: --store selects preview-store.myshopify.com, while SHOPIFY_FLAG_STORE selects production-store.myshopify.com.', + ) + }) + + test.each([ + { + name: 'a matching CLI store', + argv: ['--environment', 'preview', '--store', 'https://PREVIEW-STORE.myshopify.com/admin/'], + env: {}, + }, + { + name: 'a matching environment-variable store', + argv: ['--environment=preview'], + env: {SHOPIFY_FLAG_STORE: 'preview-store'}, + }, + ])('resolves an explicit environment combined with $name', ({argv, env}) => { + expect(resolveSingle({argv, env})).toEqual({ + environment: 'preview', + store: 'preview-store.myshopify.com', + source: 'explicit-environment', + implicit: false, + }) + }) + + test.each([ + { + name: 'a mismatching CLI store', + argv: ['--environment', 'preview', '--store', 'production-store'], + env: {}, + }, + { + name: 'a mismatching environment-variable store', + argv: ['--environment=preview'], + env: {SHOPIFY_FLAG_STORE: 'production-store'}, + }, + ])('rejects an explicit environment combined with $name', ({argv, env}) => { + const error = captureAirlockError(() => resolveSingle({argv, env})) + + expect(error.reason).toBe('conflicting-selection') + expect(error.targets).toEqual([]) + expect(error.message).toBe( + 'Environment "preview" selects preview-store.myshopify.com, but the store selection resolves to production-store.myshopify.com.', + ) + }) + + test('uses the CLI environment instead of SHOPIFY_FLAG_ENVIRONMENT', () => { + expect( + resolveSingle({ + flags: {environment: 'preview'}, + argv: ['--environment', 'preview'], + env: {SHOPIFY_FLAG_ENVIRONMENT: 'production'}, + }), + ).toEqual({ + environment: 'preview', + store: 'preview-store.myshopify.com', + source: 'explicit-environment', + implicit: false, + }) + }) + + test('resolves the configured default implicitly', () => { + expect(resolveSingle()).toEqual({ + environment: 'default', + store: 'default-store.myshopify.com', + source: 'default', + implicit: true, + }) + }) + + test('resolves the sole trusted store implicitly', () => { + const trust: ThemeProjectTrust = { + state: 'configured', + path: '/theme/shopify.theme.toml', + themePath: '/theme', + environments: [{name: 'preview', store: 'preview-store'}], + } + + expect(resolveSingle({trust})).toEqual({ + environment: 'preview', + store: 'preview-store.myshopify.com', + source: 'sole-store', + implicit: true, + }) + }) + + test.each([{flags: {}}, {flags: {force: true}}, {flags: {yes: true}}])( + 'rejects multiple trusted stores without a default regardless of non-selection flags: $flags', + ({flags}) => { + const trust: ThemeProjectTrust = { + state: 'configured', + path: '/theme/shopify.theme.toml', + themePath: '/theme', + environments: [ + {name: 'preview', store: 'preview-store'}, + {name: 'production', store: 'production-store'}, + ], + } + + const error = captureAirlockError(() => resolveSingle({trust, flags})) + + expect(error.reason).toBe('ambiguous-selection') + expect(error.targets).toEqual([]) + expect(error.message).toBe('Multiple trusted stores are configured. Use --environment or --store to select one.') + }, + ) + + test('rejects an unknown environment', () => { + const error = captureAirlockError(() => + resolveSingle({flags: {environment: 'staging'}, argv: ['--environment=staging']}), + ) + + expect(error.reason).toBe('unknown-environment') + expect(error.targets).toEqual([]) + expect(error.message).toBe('Environment "staging" is not configured for this theme project.') + }) + + test.each([ + { + name: 'CLI store', + flags: {store: 'unknown-store.myshopify.com'}, + argv: ['--store', 'unknown-store'], + env: {}, + source: 'explicit-store' as const, + }, + { + name: 'environment-variable store', + flags: {store: 'unknown-store.myshopify.com'}, + argv: [], + env: {SHOPIFY_FLAG_STORE: 'unknown-store'}, + source: 'environment-variable' as const, + }, + ])('rejects an unknown $name and exposes the attempted target', ({flags, argv, env, source}) => { + const error = captureAirlockError(() => resolveSingle({flags, argv, env})) + + expect(error.reason).toBe('unknown-store') + expect(error.targets).toEqual([ + { + store: 'unknown-store.myshopify.com', + source, + implicit: false, + }, + ]) + expect(error.message).toBe('Store unknown-store.myshopify.com is not configured for this theme project.') + }) + + test.each([ + { + name: 'CLI store', + flags: {store: 'candidate-store.myshopify.com'}, + argv: ['--store', 'candidate-store'], + env: {}, + }, + { + name: 'environment-variable store', + flags: {store: 'candidate-store.myshopify.com'}, + argv: [], + env: {SHOPIFY_FLAG_STORE: 'https://CANDIDATE-STORE.myshopify.com/admin/'}, + }, + ])('returns an untrusted normalized bootstrap candidate from a $name', ({flags, argv, env}) => { + expect(resolveSingle({trust: unconfiguredTrust, flags, argv, env})).toEqual({ + bootstrap: true, + candidate: 'candidate-store.myshopify.com', + allowRememberedCandidate: false, + }) + }) + + test('allows a remembered candidate for an unconfigured bare command without returning one', () => { + expect(resolveSingle({trust: unconfiguredTrust})).toEqual({ + bootstrap: true, + allowRememberedCandidate: true, + }) + }) + + test.each([ + { + name: 'CLI environment', + flags: {environment: 'staging'}, + argv: ['-e', 'staging'], + env: {}, + }, + { + name: 'environment-variable environment', + flags: {environment: 'staging'}, + argv: [], + env: {SHOPIFY_FLAG_ENVIRONMENT: 'staging'}, + }, + ])('proposes an explicit $name during bootstrap', ({flags, argv, env}) => { + expect(resolveSingle({trust: unconfiguredTrust, flags, argv, env})).toEqual({ + bootstrap: true, + proposedEnvironment: 'staging', + allowRememberedCandidate: false, + }) + }) + + test('normalizes selection and configured trust stores before comparing and returning them', () => { + const trust: ThemeProjectTrust = { + state: 'configured', + path: '/theme/shopify.theme.toml', + themePath: '/theme', + environments: [{name: 'preview', store: 'https://PREVIEW-STORE.myshopify.com/admin/'}], + } + + expect( + resolveSingle({ + trust, + flags: {store: 'preview-store.myshopify.com'}, + argv: ['--store', 'preview-store'], + }), + ).toEqual({ + environment: 'preview', + store: 'preview-store.myshopify.com', + source: 'explicit-store', + implicit: false, + }) + }) + + test('uses only the supplied env object', () => { + vi.stubEnv('SHOPIFY_FLAG_STORE', 'production-store') + vi.stubEnv('SHOPIFY_FLAG_ENVIRONMENT', 'production') + + try { + expect(resolveSingle({env: {}})).toEqual({ + environment: 'default', + store: 'default-store.myshopify.com', + source: 'default', + implicit: true, + }) + } finally { + vi.unstubAllEnvs() + } + }) + + test('does not mutate any single-target input', () => { + const trust: ThemeProjectTrust = { + state: 'configured', + path: '/theme/shopify.theme.toml', + themePath: '/theme', + environments: [{name: 'preview', store: 'PREVIEW-STORE'}], + } + const flags = {store: 'preview-store.myshopify.com', force: true, files: ['layout/theme.liquid']} + const argv = ['--store', 'preview-store'] + const env = {SHOPIFY_FLAG_STORE: 'preview-store'} + const originalInputs = structuredClone({trust, flags, argv, env}) + + resolveSingleAirlockTarget({trust, flags, argv, env}) + + expect({trust, flags, argv, env}).toEqual(originalInputs) + }) +}) + +describe('validateAirlockStoreSelectionSources', () => { + test('rejects conflicting normalized CLI and environment-variable stores without reading process.env', () => { + vi.stubEnv('SHOPIFY_FLAG_STORE', 'ignored-store') + + try { + const flags = {store: 'cli-store.myshopify.com'} + const argv = ['--store', 'https://CLI-STORE.myshopify.com/admin/'] + const env = {SHOPIFY_FLAG_STORE: 'environment-store'} + const originalInputs = structuredClone({flags, argv, env}) + + const error = captureAirlockError(() => validateAirlockStoreSelectionSources({flags, argv, env})) + expect(error.reason).toBe('conflicting-selection') + expect(error.message).toBe( + 'Store selections conflict: --store selects cli-store.myshopify.com, while SHOPIFY_FLAG_STORE selects environment-store.myshopify.com.', + ) + expect({flags, argv, env}).toEqual(originalInputs) + } finally { + vi.unstubAllEnvs() + } + }) + + test.each([ + { + name: 'a CLI-only store', + flags: {store: 'cli-store.myshopify.com'}, + argv: ['--store', 'cli-store'], + env: {}, + }, + { + name: 'an environment-variable-only store', + flags: {}, + argv: [], + env: {SHOPIFY_FLAG_STORE: 'environment-store'}, + }, + { + name: 'matching normalized stores', + flags: {store: 'cli-store.myshopify.com'}, + argv: ['--store', 'https://CLI-STORE.myshopify.com/admin/'], + env: {SHOPIFY_FLAG_STORE: 'cli-store'}, + }, + ])('accepts $name', ({flags, argv, env}) => { + expect(() => validateAirlockStoreSelectionSources({flags, argv, env})).not.toThrow() + }) +}) + +describe('resolveBatchAirlockTargets', () => { + test('resolves multiple environments in request order after normalizing both sides', () => { + const trust: ThemeProjectTrust = { + state: 'configured', + path: '/theme/shopify.theme.toml', + themePath: '/theme', + environments: [ + {name: 'preview', store: 'https://PREVIEW-STORE.myshopify.com/admin/'}, + {name: 'production', store: 'PRODUCTION-STORE'}, + ], + } + const environments = [ + {name: 'production', store: 'https://production-store.myshopify.com/admin/'}, + {name: 'preview', store: 'preview-store'}, + ] + + expect(resolveBatchAirlockTargets({trust, environments})).toEqual([ + { + environment: 'production', + store: 'production-store.myshopify.com', + source: 'explicit-environment', + implicit: false, + }, + { + environment: 'preview', + store: 'preview-store.myshopify.com', + source: 'explicit-environment', + implicit: false, + }, + ]) + }) + + test.each([ + { + name: 'an unknown environment', + environments: [ + {name: 'preview', store: 'preview-store'}, + {name: 'staging', store: 'staging-store'}, + ], + message: 'Invalid batch environment "staging": it is not configured for this theme project.', + }, + { + name: 'a missing requested store', + environments: [{name: 'preview', store: 'preview-store'}, {name: 'production'}], + message: 'Invalid batch environment "production": a store is required.', + }, + { + name: 'a mismatching requested store', + environments: [ + {name: 'preview', store: 'preview-store'}, + {name: 'production', store: 'other-store'}, + ], + message: + 'Invalid batch environment "production": other-store.myshopify.com does not match configured store production-store.myshopify.com.', + }, + ])('rejects the whole batch when it contains $name', ({environments, message}) => { + const error = captureAirlockError(() => resolveBatchAirlockTargets({trust: configuredTrust, environments})) + + expect(error.reason).toBe('invalid-batch') + expect(error.targets).toEqual([]) + expect(error.message).toBe(message) + }) + + test('rejects a batch for an unconfigured project', () => { + const error = captureAirlockError(() => + resolveBatchAirlockTargets({ + trust: unconfiguredTrust, + environments: [{name: 'preview', store: 'preview-store'}], + }), + ) + + expect(error.reason).toBe('unconfigured-project') + expect(error.targets).toEqual([]) + expect(error.message).toBe("Can't resolve batch environments for an unconfigured theme project.") + }) + + test('does not mutate trust or requested environments', () => { + const trust: ThemeProjectTrust = { + state: 'configured', + path: '/theme/shopify.theme.toml', + themePath: '/theme', + environments: [{name: 'preview', store: 'PREVIEW-STORE'}], + } + const environments = [{name: 'preview', store: 'https://preview-store.myshopify.com/admin/'}] + const originalInputs = structuredClone({trust, environments}) + + resolveBatchAirlockTargets({trust, environments}) + + expect({trust, environments}).toEqual(originalInputs) + }) +}) diff --git a/packages/theme/src/cli/utilities/theme-airlock/resolver.ts b/packages/theme/src/cli/utilities/theme-airlock/resolver.ts new file mode 100644 index 00000000000..3346ccdfc4a --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/resolver.ts @@ -0,0 +1,365 @@ +import {ThemeAirlockError} from './types.js' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' + +import type {AirlockTarget, StoreSelectionSource, ThemeProjectTrust, TrustedThemeEnvironment} from './types.js' + +type FlagValues = Record + +interface SuppliedValue { + supplied: boolean + value?: string + occurrences: number + values: (string | undefined)[] +} + +interface RawSelections { + cliStore: SuppliedValue + cliEnvironment: SuppliedValue + environmentStore: SuppliedValue + environmentName: SuppliedValue +} + +interface NormalizedTrust { + environmentsByName: Map + environmentsByStore: Map +} + +function argvValue(argv: string[], longName: string, shortName: string): SuppliedValue { + let selection: SuppliedValue = {supplied: false, occurrences: 0, values: []} + + for (let index = 0; index < argv.length; index++) { + const argument = argv[index] + if (argument === undefined || argument === '--') break + + let value: string | undefined + let matched = false + if (argument === longName || argument === shortName) { + value = argv[index + 1] + index++ + matched = true + } else { + const matchingName = [longName, shortName].find((name) => argument.startsWith(`${name}=`)) + if (matchingName) { + value = argument.slice(matchingName.length + 1) + matched = true + } else if (argument.startsWith(shortName) && argument.length > shortName.length) { + value = argument.slice(shortName.length) + matched = true + } + } + + if (matched) { + selection = { + supplied: true, + value, + occurrences: selection.occurrences + 1, + values: [...selection.values, value], + } + } + } + + return selection +} + +function environmentValue(env: NodeJS.ProcessEnv, name: string): SuppliedValue { + const value = env[name] + return value === undefined + ? {supplied: false, occurrences: 0, values: []} + : {supplied: true, value, occurrences: 1, values: [value]} +} + +function flagString(flags: FlagValues, name: string): string | undefined { + const value = flags[name] + return typeof value === 'string' ? value : undefined +} + +function rawSelections(flags: FlagValues, argv: string[], env: NodeJS.ProcessEnv): RawSelections { + const cliStore = argvValue(argv, '--store', '-s') + const cliEnvironment = argvValue(argv, '--environment', '-e') + + return { + cliStore: { + supplied: cliStore.supplied, + value: cliStore.value ?? (cliStore.supplied ? flagString(flags, 'store') : undefined), + occurrences: cliStore.occurrences, + values: cliStore.values, + }, + cliEnvironment: { + supplied: cliEnvironment.supplied, + value: cliEnvironment.value ?? (cliEnvironment.supplied ? flagString(flags, 'environment') : undefined), + occurrences: cliEnvironment.occurrences, + values: cliEnvironment.values, + }, + environmentStore: environmentValue(env, 'SHOPIFY_FLAG_STORE'), + environmentName: environmentValue(env, 'SHOPIFY_FLAG_ENVIRONMENT'), + } +} + +function normalizeTrust(trust: Extract): NormalizedTrust { + const environmentsByName = new Map() + const environmentsByStore = new Map() + + for (const environment of trust.environments) { + const normalizedEnvironment = {...environment, store: normalizeStoreFqdn(environment.store)} + environmentsByName.set(environment.name, normalizedEnvironment) + environmentsByStore.set(normalizedEnvironment.store, normalizedEnvironment) + } + + return {environmentsByName, environmentsByStore} +} + +function targetForEnvironment( + environment: TrustedThemeEnvironment, + source: StoreSelectionSource, + implicit: boolean, +): AirlockTarget { + return { + environment: environment.name, + store: environment.store, + source, + implicit, + } +} + +function unknownStoreError(store: string, source: StoreSelectionSource): ThemeAirlockError { + return new ThemeAirlockError(`Store ${store} is not configured for this theme project.`, 'unknown-store', [ + {store, source, implicit: false}, + ]) +} + +function invalidStoreError(value: string, source: string): ThemeAirlockError { + return new ThemeAirlockError(`Invalid store value for ${source}: ${value}.`, 'invalid-store') +} + +function assertSelectionHasValue( + selection: SuppliedValue, + source: string, + reason: 'unknown-store' | 'unknown-environment', +): void { + if (!selection.supplied || (selection.value !== undefined && selection.value.length > 0)) return + + throw new ThemeAirlockError(`${source} requires a value.`, reason) +} + +function normalizeSelectedStore(value: string | undefined, sourceName: string): string | undefined { + if (value === undefined) return undefined + + try { + return normalizeStoreFqdn(value) + } catch { + throw invalidStoreError(value, sourceName) + } +} + +function assertNoRepeatedSelection(selection: SuppliedValue, name: string): void { + if (selection.occurrences < 2) return + + throw new ThemeAirlockError(`Multiple ${name} selections were provided. Provide only one.`, 'conflicting-selection') +} + +function assertCompatibleStoreSelections(cliStore?: string, environmentStore?: string): void { + if (cliStore === undefined || environmentStore === undefined || cliStore === environmentStore) return + + throw new ThemeAirlockError( + `Store selections conflict: --store selects ${cliStore}, while SHOPIFY_FLAG_STORE selects ${environmentStore}.`, + 'conflicting-selection', + ) +} + +function selectedEnvironmentName(selections: RawSelections): string | undefined { + return selections.cliEnvironment.supplied ? selections.cliEnvironment.value : selections.environmentName.value +} + +function validateStoreSelectionSources(options: {flags: FlagValues; argv: string[]; env: NodeJS.ProcessEnv}): { + cliStore?: string + environmentStore?: string +} { + const selections = rawSelections(options.flags, options.argv, options.env) + assertNoRepeatedSelection(selections.cliStore, '--store') + assertSelectionHasValue(selections.cliStore, '--store', 'unknown-store') + assertSelectionHasValue(selections.environmentStore, 'SHOPIFY_FLAG_STORE', 'unknown-store') + + const cliStore = normalizeSelectedStore(selections.cliStore.value, '--store') + const environmentStore = normalizeSelectedStore(selections.environmentStore.value, 'SHOPIFY_FLAG_STORE') + assertCompatibleStoreSelections(cliStore, environmentStore) + + return {cliStore, environmentStore} +} + +export function validateAirlockStoreSelectionSources(options: { + flags: FlagValues + argv: string[] + env: NodeJS.ProcessEnv +}): void { + validateStoreSelectionSources(options) +} + +export function validateAirlockBatchEnvironmentSelections(options: {flags: FlagValues; argv: string[]}): void { + const selections = rawSelections(options.flags, options.argv, {}) + let environmentValues: (string | undefined)[] + if (selections.cliEnvironment.supplied) { + environmentValues = selections.cliEnvironment.values + } else if (typeof options.flags.environment === 'string') { + environmentValues = [options.flags.environment] + } else if (Array.isArray(options.flags.environment)) { + environmentValues = options.flags.environment.filter((value): value is string => typeof value === 'string') + } else { + environmentValues = [] + } + + if (environmentValues.length < 2) return + + const selectedEnvironments = new Set() + for (const [index, environment] of environmentValues.entries()) { + if (environment === undefined || environment.length === 0) { + throw new ThemeAirlockError( + `Invalid batch environment selection: empty environment selector at position ${index + 1}.`, + 'invalid-batch', + ) + } + + if (selectedEnvironments.has(environment)) { + throw new ThemeAirlockError( + `Invalid batch environment selection: environment "${environment}" was selected more than once.`, + 'invalid-batch', + ) + } + + selectedEnvironments.add(environment) + } +} + +export function resolveSingleAirlockTarget(options: { + trust: ThemeProjectTrust + flags: FlagValues + argv: string[] + env: NodeJS.ProcessEnv +}): + | AirlockTarget + | { + bootstrap: true + candidate?: string + proposedEnvironment?: string + allowRememberedCandidate: boolean + } { + const selections = rawSelections(options.flags, options.argv, options.env) + const {cliStore, environmentStore} = validateStoreSelectionSources(options) + assertNoRepeatedSelection(selections.cliEnvironment, '--environment') + + assertSelectionHasValue(selections.cliEnvironment, '--environment', 'unknown-environment') + assertSelectionHasValue(selections.environmentName, 'SHOPIFY_FLAG_ENVIRONMENT', 'unknown-environment') + + const environmentName = selectedEnvironmentName(selections) + const selectedStore = selections.cliStore.supplied ? cliStore : environmentStore + + if (options.trust.state === 'unconfigured') { + return { + bootstrap: true, + ...(selectedStore === undefined ? {} : {candidate: selectedStore}), + ...(environmentName === undefined ? {} : {proposedEnvironment: environmentName}), + allowRememberedCandidate: + !selections.cliStore.supplied && + !selections.environmentStore.supplied && + !selections.cliEnvironment.supplied && + !selections.environmentName.supplied, + } + } + + const {environmentsByName, environmentsByStore} = normalizeTrust(options.trust) + + if (environmentName !== undefined) { + const environment = environmentsByName.get(environmentName) + if (!environment) { + throw new ThemeAirlockError( + `Environment "${environmentName}" is not configured for this theme project.`, + 'unknown-environment', + ) + } + + if (selectedStore !== undefined && selectedStore !== environment.store) { + throw new ThemeAirlockError( + `Environment "${environmentName}" selects ${environment.store}, but the store selection resolves to ${selectedStore}.`, + 'conflicting-selection', + ) + } + + return targetForEnvironment(environment, 'explicit-environment', false) + } + + if (selectedStore !== undefined) { + const source = selections.cliStore.supplied ? 'explicit-store' : 'environment-variable' + const environment = environmentsByStore.get(selectedStore) + if (!environment) throw unknownStoreError(selectedStore, source) + return targetForEnvironment(environment, source, false) + } + + const defaultEnvironment = environmentsByName.get('default') + if (defaultEnvironment) return targetForEnvironment(defaultEnvironment, 'default', true) + + if (environmentsByStore.size === 1) { + const soleEnvironment = environmentsByStore.values().next().value as TrustedThemeEnvironment + return targetForEnvironment(soleEnvironment, 'sole-store', true) + } + + throw new ThemeAirlockError( + 'Multiple trusted stores are configured. Use --environment or --store to select one.', + 'ambiguous-selection', + ) +} + +function normalizeBatchStore(environment: {name: string; store?: string}): string { + if (environment.store === undefined) { + throw new ThemeAirlockError( + `Invalid batch environment "${environment.name}": a store is required.`, + 'invalid-batch', + ) + } + + try { + return normalizeStoreFqdn(environment.store) + } catch { + throw new ThemeAirlockError( + `Invalid batch environment "${environment.name}": ${environment.store} is not a valid store.`, + 'invalid-batch', + ) + } +} + +export function resolveBatchAirlockTargets(options: { + trust: ThemeProjectTrust + environments: {name: string; store?: string}[] +}): AirlockTarget[] { + if (options.trust.state === 'unconfigured') { + throw new ThemeAirlockError( + "Can't resolve batch environments for an unconfigured theme project.", + 'unconfigured-project', + ) + } + + const {environmentsByName} = normalizeTrust(options.trust) + const normalizedRequests = options.environments.map((environment) => ({ + name: environment.name, + store: normalizeBatchStore(environment), + })) + + const validatedEnvironments = normalizedRequests.map((requestedEnvironment) => { + const configuredEnvironment = environmentsByName.get(requestedEnvironment.name) + if (!configuredEnvironment) { + throw new ThemeAirlockError( + `Invalid batch environment "${requestedEnvironment.name}": it is not configured for this theme project.`, + 'invalid-batch', + ) + } + + if (requestedEnvironment.store !== configuredEnvironment.store) { + throw new ThemeAirlockError( + `Invalid batch environment "${requestedEnvironment.name}": ${requestedEnvironment.store} does not match configured store ${configuredEnvironment.store}.`, + 'invalid-batch', + ) + } + + return configuredEnvironment + }) + + return validatedEnvironments.map((environment) => targetForEnvironment(environment, 'explicit-environment', false)) +} diff --git a/packages/theme/src/cli/utilities/theme-airlock/types.ts b/packages/theme/src/cli/utilities/theme-airlock/types.ts new file mode 100644 index 00000000000..2d9b22b7869 --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/types.ts @@ -0,0 +1,37 @@ +import {AbortError} from '@shopify/cli-kit/node/error' + +export type StoreSelectionSource = + | 'explicit-environment' + | 'explicit-store' + | 'environment-variable' + | 'default' + | 'sole-store' + | 'bootstrap' + +export interface TrustedThemeEnvironment { + name: string + store: string +} + +export type ThemeProjectTrust = + | {state: 'unconfigured'; path?: string; themePath: string} + | {state: 'configured'; path: string; themePath: string; environments: TrustedThemeEnvironment[]} + +export interface AirlockTarget { + environment?: string + store: string + source: StoreSelectionSource + implicit: boolean +} + +export class ThemeAirlockError extends AbortError { + readonly targets: AirlockTarget[] + readonly reason: string + + constructor(message: string, reason: string, targets: AirlockTarget[] = []) { + super(message, 'No files were uploaded.') + this.name = 'ThemeAirlockError' + this.targets = targets + this.reason = reason + } +} diff --git a/packages/theme/src/cli/utilities/theme-airlock/writer.test.ts b/packages/theme/src/cli/utilities/theme-airlock/writer.test.ts new file mode 100644 index 00000000000..4aee0917a39 --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/writer.test.ts @@ -0,0 +1,376 @@ +import {addTrustedThemeEnvironment} from './writer.js' +import {loadThemeProjectTrust} from './config.js' +import {ThemeAirlockError} from './types.js' +import {configurationFileName} from '../../constants.js' +import {describe, expect, test, vi} from 'vitest' +import {chmod, inTemporaryDirectory, mkdir, readFile, readdir, symlink, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {rename as fsRename, lstat} from 'node:fs/promises' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, rename: vi.fn(actual.rename)} +}) + +async function createTheme(tmpDir: string): Promise { + const themePath = joinPath(tmpDir, 'theme') + await mkdir(themePath) + return themePath +} + +async function writeConfiguration(directory: string, content: string): Promise { + const configurationPath = joinPath(directory, configurationFileName) + await writeFile(configurationPath, content) + return configurationPath +} + +async function captureError(operation: () => Promise): Promise { + try { + await operation() + } catch (error) { + if (error instanceof Error) return error + throw error + } + throw new Error('Expected operation to throw') +} + +async function coordinateConcurrentRenames(): Promise { + const renameSpy = vi.mocked(fsRename) + const {rename: originalRename} = await vi.importActual('node:fs/promises') + let renameCount = 0 + let releaseSecondRename!: () => void + const secondRename = new Promise((resolve) => { + releaseSecondRename = resolve + }) + renameSpy.mockImplementation(async (...arguments_: Parameters) => { + renameCount += 1 + if (renameCount === 2) releaseSecondRename() + if (renameCount === 1) { + await Promise.race([secondRename, new Promise((resolve) => setTimeout(resolve, 350))]) + } + return originalRename(...arguments_) + }) +} + +describe('addTrustedThemeEnvironment', () => { + test('creates a new configuration in the theme directory', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + + await expect( + addTrustedThemeEnvironment({themePath, environment: 'preview', store: 'preview-store'}), + ).resolves.toEqual({path: joinPath(themePath, configurationFileName), store: 'preview-store.myshopify.com'}) + + await expect(readFile(joinPath(themePath, configurationFileName))).resolves.toContain( + '[environments.preview]\nstore = "preview-store.myshopify.com"', + ) + }) + }) + + test('patches the nearest existing ancestor configuration', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const projectPath = joinPath(tmpDir, 'project') + const themePath = joinPath(projectPath, 'themes', 'dawn') + await mkdir(themePath) + const configurationPath = await writeConfiguration(projectPath, 'name = "project"\n') + + const result = await addTrustedThemeEnvironment({themePath, environment: 'preview', store: 'preview-store'}) + + expect(result.path).toBe(configurationPath) + await expect(readFile(configurationPath)).resolves.toContain('[environments.preview]') + await expect(readdir(joinPath(themePath))).resolves.not.toContain(configurationFileName) + }) + }) + + test('patches an existing valid configuration without environments', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const original = '# Keep this comment\nname = "theme"\n\n[other]\nvalue = true\n' + const configurationPath = await writeConfiguration(tmpDir, original) + + await addTrustedThemeEnvironment({themePath, environment: 'preview', store: 'preview-store'}) + + const updated = await readFile(configurationPath) + expect(updated).toContain('# Keep this comment') + expect(updated).toContain('name = "theme"') + expect(updated).toContain('[other]\nvalue = true') + expect(updated).toContain('[environments.preview]\nstore = "preview-store.myshopify.com"') + }) + }) + + test('preserves comments, unrelated keys, and ordering while patching', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const original = [ + '# Header', + 'name = "theme"', + '', + '[other]', + 'value = "before"', + '', + '# Existing environment', + '[environments.default]', + 'store = "default-store"', + '', + ].join('\n') + const configurationPath = await writeConfiguration(tmpDir, original) + + await addTrustedThemeEnvironment({themePath, environment: 'preview', store: 'preview-store'}) + + const expected = [ + '# Header', + 'name = "theme"', + '', + '[other]', + 'value = "before"', + '', + '[environments]', + 'preview.store = "preview-store.myshopify.com"', + '', + '# Existing environment', + '[environments.default]', + 'store = "default-store"', + '', + ].join('\n') + + await expect(readFile(configurationPath)).resolves.toBe(expected) + + const trust = await loadThemeProjectTrust(themePath) + expect(trust).toMatchObject({ + state: 'configured', + environments: [ + {name: 'preview', store: 'preview-store.myshopify.com'}, + {name: 'default', store: 'default-store.myshopify.com'}, + ], + }) + }) + }) + + test('normalizes the requested store', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + + await expect( + addTrustedThemeEnvironment({ + themePath, + environment: 'preview', + store: 'https://PREVIEW-STORE.myshopify.com/admin/', + }), + ).resolves.toMatchObject({store: 'preview-store.myshopify.com'}) + }) + }) + + test('is idempotent for the same environment and normalized store without writing', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const configurationPath = await writeConfiguration(tmpDir, '[environments.preview]\nstore = "preview-store"\n') + const original = await readFile(configurationPath) + const renameSpy = vi.mocked(fsRename) + + await expect( + addTrustedThemeEnvironment({ + themePath, + environment: 'preview', + store: 'https://PREVIEW-STORE.myshopify.com/admin/', + }), + ).resolves.toEqual({path: configurationPath, store: 'preview-store.myshopify.com'}) + + await expect(readFile(configurationPath)).resolves.toBe(original) + expect(renameSpy).not.toHaveBeenCalled() + }) + }) + + test('rejects an environment name conflict without writing', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const configurationPath = await writeConfiguration(tmpDir, '[environments.preview]\nstore = "other-store"\n') + const original = await readFile(configurationPath) + + const error = await captureError(() => + addTrustedThemeEnvironment({themePath, environment: 'preview', store: 'preview-store'}), + ) + + expect(error).toBeInstanceOf(ThemeAirlockError) + expect((error as ThemeAirlockError).reason).toBe('environment-conflict') + await expect(readFile(configurationPath)).resolves.toBe(original) + }) + }) + + test('rejects a normalized store conflict under another environment without writing', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const configurationPath = await writeConfiguration(tmpDir, '[environments.default]\nstore = "shared-store"\n') + const original = await readFile(configurationPath) + + const error = await captureError(() => + addTrustedThemeEnvironment({themePath, environment: 'preview', store: 'https://SHARED-STORE.myshopify.com/'}), + ) + + expect(error).toBeInstanceOf(ThemeAirlockError) + expect((error as ThemeAirlockError).reason).toBe('store-conflict') + await expect(readFile(configurationPath)).resolves.toBe(original) + }) + }) + + test('rejects invalid requested stores contextually without writing', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const configurationPath = await writeConfiguration(tmpDir, 'name = "theme"\n') + const original = await readFile(configurationPath) + + const error = await captureError(() => + addTrustedThemeEnvironment({themePath, environment: 'preview', store: 'not a store'}), + ) + + expect(error).toBeInstanceOf(ThemeAirlockError) + expect((error as ThemeAirlockError).reason).toBe('invalid-store') + await expect(readFile(configurationPath)).resolves.toBe(original) + }) + }) + + test('rejects a non-table environments value without modifying the configuration', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const original = '# important note\nenvironments = ["a", "b"]\nname = "keepme"\n' + const configurationPath = await writeConfiguration(tmpDir, original) + + await expect( + addTrustedThemeEnvironment({themePath, environment: 'preview', store: 'preview-store'}), + ).rejects.toMatchObject({reason: 'malformed-configuration'}) + await expect(readFile(configurationPath)).resolves.toBe(original) + }) + }) + + test('leaves malformed configuration unchanged', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const original = '[environments.preview\nstore = "broken"\n' + const configurationPath = await writeConfiguration(tmpDir, original) + + await expect( + addTrustedThemeEnvironment({themePath, environment: 'preview', store: 'preview-store'}), + ).rejects.toThrow(configurationPath) + await expect(readFile(configurationPath)).resolves.toBe(original) + }) + }) + + test.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'rejects a read-only existing file', + async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const configurationPath = await writeConfiguration(tmpDir, 'name = "theme"\n') + await chmod(configurationPath, 0o444) + const original = await readFile(configurationPath) + + const error = await captureError(() => + addTrustedThemeEnvironment({themePath, environment: 'preview', store: 'preview-store'}), + ) + + expect(error).toBeInstanceOf(ThemeAirlockError) + expect((error as ThemeAirlockError).reason).toBe('permission-denied') + await expect(readFile(configurationPath)).resolves.toBe(original) + }) + }, + ) + + test('serializes concurrent additions to preserve both environments', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + await coordinateConcurrentRenames() + + const results = await Promise.all([ + addTrustedThemeEnvironment({themePath, environment: 'preview', store: 'preview-store'}), + addTrustedThemeEnvironment({themePath, environment: 'production', store: 'production-store'}), + ]) + + expect(results).toHaveLength(2) + await expect(loadThemeProjectTrust(themePath)).resolves.toMatchObject({ + state: 'configured', + environments: expect.arrayContaining([ + {name: 'preview', store: 'preview-store.myshopify.com'}, + {name: 'production', store: 'production-store.myshopify.com'}, + ]), + }) + await expect(readdir(themePath)).resolves.not.toContain(`${configurationFileName}.lock`) + }) + }) + + test.skipIf(process.platform === 'win32')( + 'serializes distinct symlinked configurations on their shared target', + async () => { + await inTemporaryDirectory(async (tmpDir) => { + const firstThemePath = await createTheme(joinPath(tmpDir, 'first')) + const secondThemePath = await createTheme(joinPath(tmpDir, 'second')) + const targetPath = joinPath(tmpDir, 'shared', configurationFileName) + await mkdir(joinPath(tmpDir, 'shared')) + await writeFile(targetPath, 'name = "shared"\n') + const firstConfigurationPath = joinPath(firstThemePath, configurationFileName) + const secondConfigurationPath = joinPath(secondThemePath, configurationFileName) + await symlink(targetPath, firstConfigurationPath) + await symlink(targetPath, secondConfigurationPath) + + const results = await Promise.all([ + addTrustedThemeEnvironment({themePath: firstThemePath, environment: 'preview', store: 'preview-store'}), + addTrustedThemeEnvironment({ + themePath: secondThemePath, + environment: 'production', + store: 'production-store', + }), + ]) + + expect(results).toEqual([ + {path: firstConfigurationPath, store: 'preview-store.myshopify.com'}, + {path: secondConfigurationPath, store: 'production-store.myshopify.com'}, + ]) + expect((await lstat(firstConfigurationPath)).isSymbolicLink()).toBe(true) + expect((await lstat(secondConfigurationPath)).isSymbolicLink()).toBe(true) + const targetContent = await readFile(targetPath) + expect(targetContent).toContain('preview-store.myshopify.com') + expect(targetContent).toContain('production-store.myshopify.com') + await expect(readdir(joinPath(tmpDir, 'shared'))).resolves.toEqual([configurationFileName]) + await expect(readdir(firstThemePath)).resolves.toEqual([configurationFileName]) + await expect(readdir(secondThemePath)).resolves.toEqual([configurationFileName]) + }) + }, + ) + + test('revalidates a concurrent environment conflict under the lock', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const configurationPath = await writeConfiguration(tmpDir, 'name = "theme"\n') + await coordinateConcurrentRenames() + + const results = await Promise.allSettled([ + addTrustedThemeEnvironment({themePath, environment: 'preview', store: 'first-store'}), + addTrustedThemeEnvironment({themePath, environment: 'preview', store: 'second-store'}), + ]) + + expect(results.filter(({status}) => status === 'fulfilled')).toHaveLength(1) + const rejected = results.filter(({status}) => status === 'rejected') + expect(rejected).toHaveLength(1) + expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({reason: 'environment-conflict'}) + await expect(readFile(configurationPath)).resolves.toMatch(/store = "(?:first|second)-store\.myshopify\.com"/) + await expect(readdir(tmpDir)).resolves.not.toContain(`${configurationFileName}.lock`) + }) + }) + + test('cleans up the temporary sibling and preserves the original on native rename failure', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const configurationPath = await writeConfiguration(tmpDir, 'name = "theme"\n') + const original = await readFile(configurationPath) + vi.mocked(fsRename).mockRejectedValueOnce(new Error('injected rename failure')) + + await expect( + addTrustedThemeEnvironment({themePath, environment: 'preview', store: 'preview-store'}), + ).rejects.toThrow('injected rename failure') + + await expect(readFile(configurationPath)).resolves.toBe(original) + const entries = await readdir(tmpDir) + expect( + entries.filter((entry) => entry.startsWith(`${configurationFileName}.`) && entry.endsWith('.tmp')), + ).toEqual([]) + }) + }) +}) diff --git a/packages/theme/src/cli/utilities/theme-airlock/writer.ts b/packages/theme/src/cli/utilities/theme-airlock/writer.ts new file mode 100644 index 00000000000..cbf8d04a2bd --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/writer.ts @@ -0,0 +1,176 @@ +import {loadThemeProjectTrust} from './config.js' +import {ThemeAirlockError} from './types.js' +import {configurationFileName} from '../../constants.js' +import {fileHasWritePermissions, fileRealPath} from '@shopify/cli-kit/node/fs' +import {joinPath, dirname, resolvePath} from '@shopify/cli-kit/node/path' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file' +import lockfile from 'proper-lockfile' + +const configurationLockOptions = { + realpath: false, + retries: {retries: 40, factor: 1, minTimeout: 50, maxTimeout: 50}, +} +const maximumConfigurationPathRedirects = 3 + +type TrustedThemeTrust = Awaited> +interface TrustedEnvironmentResult { + path: string + store: string +} +type LockedWriteResult = + | {kind: 'redirect'; path: string; displayPath: string} + | {kind: 'written'; value: TrustedEnvironmentResult} + +async function canonicalConfigurationPath(path: string): Promise { + try { + return await fileRealPath(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return path + throw error + } +} + +function normalizeRequestedStore(store: string): string { + try { + return normalizeStoreFqdn(store) + } catch { + throw new ThemeAirlockError(`Invalid store value for theme trust: ${store}.`, 'invalid-store') + } +} + +function conflictError(message: string, reason: 'environment-conflict' | 'store-conflict'): ThemeAirlockError { + return new ThemeAirlockError(message, reason) +} + +async function assertWritable(path: string, description: string): Promise { + if (await fileHasWritePermissions(path)) return + + throw new ThemeAirlockError(`Unable to write ${description} at ${path}: permission denied.`, 'permission-denied') +} + +async function writeTrustedThemeEnvironment(options: { + configurationPath: string + displayPath: string + trust: TrustedThemeTrust + environment: string + normalizedStore: string +}): Promise { + const {configurationPath, displayPath, trust, environment, normalizedStore} = options + + if (trust.state === 'configured') { + const existingEnvironment = trust.environments.find(({name}) => name === environment) + if (existingEnvironment) { + if (existingEnvironment.store === normalizedStore) return {path: displayPath, store: normalizedStore} + + throw conflictError( + `Environment "${environment}" is already trusted for ${existingEnvironment.store}, not ${normalizedStore}.`, + 'environment-conflict', + ) + } + + const existingStore = trust.environments.find(({store}) => store === normalizedStore) + if (existingStore) { + throw conflictError( + `Store ${normalizedStore} is already trusted under environment "${existingStore.name}".`, + 'store-conflict', + ) + } + } + + const changes = {environments: {[environment]: {store: normalizedStore}}} + + if (trust.path) { + await assertWritable(configurationPath, 'theme configuration') + const configuration = await TomlFile.read(configurationPath) + await configuration.patch(changes) + } else { + await assertWritable(dirname(configurationPath), 'theme configuration directory') + const configuration = new TomlFile(configurationPath, {}) + await configuration.replace(changes) + } + + return {path: displayPath, store: normalizedStore} +} + +function configurationLockError(configurationPath: string): ThemeAirlockError { + return new ThemeAirlockError( + `Unable to acquire the theme configuration lock at ${configurationPath}.`, + 'configuration-lock-failed', + ) +} + +async function lockAndWriteConfiguration(options: { + themePath: string + configurationPath: string + displayPath: string + environment: string + normalizedStore: string +}): Promise { + let releaseLock: (() => Promise) | undefined + try { + try { + releaseLock = await lockfile.lock(options.configurationPath, configurationLockOptions) + } catch { + throw configurationLockError(options.configurationPath) + } + + const freshTrust = await loadThemeProjectTrust(options.themePath) + const freshDisplayPath = resolvePath(freshTrust.path ?? joinPath(options.themePath, configurationFileName)) + const freshConfigurationPath = await canonicalConfigurationPath(freshDisplayPath) + if (freshConfigurationPath !== options.configurationPath) { + return {kind: 'redirect', path: freshConfigurationPath, displayPath: freshDisplayPath} + } + + const value = await writeTrustedThemeEnvironment({ + configurationPath: options.configurationPath, + displayPath: freshDisplayPath, + trust: freshTrust, + environment: options.environment, + normalizedStore: options.normalizedStore, + }) + return {kind: 'written', value} + } finally { + if (releaseLock) await releaseLock() + } +} + +async function writeWithConfigurationRedirects(options: { + themePath: string + configurationPath: string + environment: string + normalizedStore: string + redirects: number + displayPath: string +}): Promise { + const result = await lockAndWriteConfiguration(options) + if (result.kind === 'written') return result.value + if (options.redirects >= maximumConfigurationPathRedirects) throw configurationLockError(result.path) + + return writeWithConfigurationRedirects({ + ...options, + configurationPath: result.path, + displayPath: result.displayPath, + redirects: options.redirects + 1, + }) +} + +export async function addTrustedThemeEnvironment(options: { + themePath: string + environment: string + store: string +}): Promise { + const normalizedStore = normalizeRequestedStore(options.store) + const initialTrust = await loadThemeProjectTrust(options.themePath) + const displayPath = resolvePath(initialTrust.path ?? joinPath(options.themePath, configurationFileName)) + const configurationPath = await canonicalConfigurationPath(displayPath) + + return writeWithConfigurationRedirects({ + themePath: options.themePath, + configurationPath, + displayPath, + environment: options.environment, + normalizedStore, + redirects: 0, + }) +} diff --git a/packages/theme/src/cli/utilities/theme-command.test.ts b/packages/theme/src/cli/utilities/theme-command.test.ts index 616e8563d78..1a68ca8faa0 100644 --- a/packages/theme/src/cli/utilities/theme-command.test.ts +++ b/packages/theme/src/cli/utilities/theme-command.test.ts @@ -1,8 +1,12 @@ import ThemeCommand, {RequiredFlags} from './theme-command.js' import {ensureThemeStore} from './theme-store.js' +import {loadThemeProjectTrust} from './theme-airlock/config.js' +import {bootstrapThemeAirlock, interactiveBootstrapUI} from './theme-airlock/bootstrap.js' +import {ThemeAirlockError} from './theme-airlock/types.js' +import {getThemeStore} from '../services/local-storage.js' import {describe, vi, expect, test, beforeEach} from 'vitest' import {Config, Flags} from '@oclif/core' -import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {AdminSession, ensureAuthenticatedThemes, setLastSeenUserId} from '@shopify/cli-kit/node/session' import { getCurrentStoredStoreAppSession, listCurrentStoredStoreAppSessions, @@ -10,9 +14,17 @@ import { import {loadEnvironment} from '@shopify/cli-kit/node/environments' import {fileExistsSync} from '@shopify/cli-kit/node/fs' import {resolvePath} from '@shopify/cli-kit/node/path' -import {renderConcurrent, renderConfirmationPrompt, renderError, renderWarning} from '@shopify/cli-kit/node/ui' +import { + renderConcurrent, + renderConfirmationPrompt, + renderError, + renderInfo, + renderWarning, +} from '@shopify/cli-kit/node/ui' import {addPublicMetadata, addSensitiveMetadata} from '@shopify/cli-kit/node/metadata' import {hashString} from '@shopify/cli-kit/node/crypto' +import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' +import {AbortError} from '@shopify/cli-kit/node/error' import type {Writable} from 'stream' @@ -26,6 +38,13 @@ vi.mock('@shopify/cli-kit/node/metadata', () => ({ })) vi.mock('./theme-store.js') vi.mock('@shopify/cli-kit/node/fs') +vi.mock('./theme-airlock/config.js') +vi.mock('./theme-airlock/bootstrap.js') +vi.mock('@shopify/cli-kit/node/system') +vi.mock('../services/local-storage.js', async () => { + const actual = await vi.importActual('../services/local-storage.js') + return {...actual, getThemeStore: vi.fn()} +}) const CommandConfig = new Config({root: __dirname}) @@ -68,6 +87,10 @@ class TestThemeCommand extends ThemeCommand { if (flags.environment && flags.environment[0] === 'command-error') { throw new Error('Mocking a command error') } + if (flags.environment && flags.environment[0] === 'command-string-error') { + // eslint-disable-next-line no-throw-literal, @typescript-eslint/only-throw-error + throw 'Mocking a string command error' + } } } @@ -143,6 +166,44 @@ class TestNoMultiEnvThemeCommand extends TestThemeCommand { static multiEnvironmentsFlags: RequiredFlags = null } +class TestProtectedThemeCommand extends TestThemeCommand { + preflightTargets: any[] = [] + + protected airlockPolicy(): 'upload' { + return 'upload' + } + + protected airlockPreflight(targets: any[]): void { + this.preflightTargets = targets + } +} + +class TestDefaultProtectedThemeCommand extends TestThemeCommand { + events: string[] = [] + + async command(...args: Parameters): Promise { + this.events.push('command') + await super.command(...args) + } + + protected airlockPolicy(): 'upload' { + return 'upload' + } +} + +class TestProtectedThemeCommandWithForce extends TestProtectedThemeCommand { + static multiEnvironmentsFlags: RequiredFlags = ['store', 'password'] + + static flags = { + ...TestProtectedThemeCommand.flags, + force: Flags.boolean({ + char: 'f', + description: 'Skip confirmation', + env: 'SHOPIFY_FLAG_FORCE', + }), + } +} + class TestThemeCommandWithoutStoreRequired extends ThemeCommand { static flags = { environment: Flags.string({ @@ -196,6 +257,685 @@ describe('ThemeCommand', () => { }) describe('run', () => { + test.each([ + { + name: 'an empty environment selector', + argv: ['--environment', 'first', '--environment', ''], + message: 'Invalid batch environment selection: empty environment selector at position 2.', + }, + { + name: 'a repeated environment selector', + argv: ['--environment', 'first', '--environment', 'first'], + message: 'Invalid batch environment selection: environment "first" was selected more than once.', + }, + ])('protected batch rejects $name before any lifecycle work', async ({argv, message}) => { + await CommandConfig.load() + const command = new TestProtectedThemeCommand(argv, CommandConfig) + + await expect(command.run()).rejects.toMatchObject({reason: 'invalid-batch', message}) + expect(loadEnvironment).not.toHaveBeenCalled() + expect(loadThemeProjectTrust).not.toHaveBeenCalled() + expect(getCurrentStoredStoreAppSession).not.toHaveBeenCalled() + expect(listCurrentStoredStoreAppSessions).not.toHaveBeenCalled() + expect(ensureThemeStore).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.preflightTargets).toHaveLength(0) + expect(renderConcurrent).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(0) + }) + + test('protected batch rejects conflicting CLI and environment-variable stores before loading environments', async () => { + vi.stubEnv('SHOPIFY_FLAG_STORE', 'environment-store') + + try { + await CommandConfig.load() + const command = new TestProtectedThemeCommandWithForce( + ['--environment', 'first', '--environment', 'second', '--store', 'cli-store', '--force'], + CommandConfig, + ) + + await expect(command.run()).rejects.toMatchObject({ + reason: 'conflicting-selection', + message: + 'Store selections conflict: --store selects cli-store.myshopify.com, while SHOPIFY_FLAG_STORE selects environment-store.myshopify.com.', + }) + expect(loadEnvironment).not.toHaveBeenCalled() + expect(loadThemeProjectTrust).not.toHaveBeenCalled() + expect(listCurrentStoredStoreAppSessions).not.toHaveBeenCalled() + expect(ensureThemeStore).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.preflightTargets).toHaveLength(0) + expect(renderConcurrent).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(0) + } finally { + vi.unstubAllEnvs() + } + }) + + test('protected batch accepts matching normalized CLI and environment-variable stores', async () => { + vi.stubEnv('SHOPIFY_FLAG_STORE', 'trusted-store') + vi.mocked(loadEnvironment).mockResolvedValue({store: 'trusted-store.myshopify.com'}) + vi.mocked(loadThemeProjectTrust).mockResolvedValue({ + state: 'configured', + themePath: 'current/working/directory', + path: 'shopify.theme.toml', + environments: [ + {name: 'first', store: 'trusted-store.myshopify.com'}, + {name: 'second', store: 'trusted-store'}, + ], + }) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + try { + await CommandConfig.load() + const command = new TestProtectedThemeCommand( + [ + '--environment', + 'first', + '--environment', + 'second', + '--store', + 'https://TRUSTED-STORE.myshopify.com/admin/', + ], + CommandConfig, + ) + + await command.run() + + expect(loadEnvironment).toHaveBeenCalledTimes(2) + expect(command.preflightTargets).toHaveLength(2) + expect(renderConcurrent).toHaveBeenCalledTimes(2) + } finally { + vi.unstubAllEnvs() + } + }) + + test('protected malformed batch store rejects as invalid-batch before authentication or command work', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'trusted.myshopify.com'}) + .mockResolvedValueOnce({store: 'invalid/store'}) + + await CommandConfig.load() + const command = new TestProtectedThemeCommand( + ['--environment', 'trusted', '--environment', 'malformed'], + CommandConfig, + ) + + await expect(command.run()).rejects.toMatchObject({ + reason: 'invalid-batch', + message: expect.stringContaining('malformed'), + }) + expect(ensureThemeStore).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.preflightTargets).toHaveLength(0) + expect(renderConcurrent).not.toHaveBeenCalled() + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(0) + }) + + test('protected malformed effective batch store rejects as invalid-batch before authentication or command work', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'trusted-a.myshopify.com'}) + .mockResolvedValueOnce({store: 'trusted-b.myshopify.com'}) + + await CommandConfig.load() + const command = new TestProtectedThemeCommand( + ['--environment', 'trusted-a', '--environment', 'trusted-b', '--store', 'invalid/store'], + CommandConfig, + ) + + const error = await command.run().catch((error) => error) + expect(error).toMatchObject({ + reason: 'invalid-store', + message: 'Invalid store value for --store: invalid/store.', + }) + expect(ensureThemeStore).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.preflightTargets).toHaveLength(0) + expect(renderConcurrent).not.toHaveBeenCalled() + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(0) + }) + + test('unprotected malformed batch store retains the generic normalization error', async () => { + vi.mocked(loadEnvironment).mockResolvedValue({store: 'invalid/store'}) + + await CommandConfig.load() + const command = new TestThemeCommand( + ['--environment', 'malformed-a', '--environment', 'malformed-b'], + CommandConfig, + ) + + await expect(command.run()).rejects.toBeInstanceOf(AbortError) + expect(loadThemeProjectTrust).not.toHaveBeenCalled() + expect(ensureThemeStore).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(renderConcurrent).not.toHaveBeenCalled() + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(0) + }) + + test('protected unknown store rejects before authentication or command work', async () => { + vi.mocked(loadThemeProjectTrust).mockResolvedValue({ + state: 'configured', + themePath: 'current/working/directory', + path: 'shopify.theme.toml', + environments: [{name: 'default', store: 'trusted.myshopify.com'}], + }) + + await CommandConfig.load() + const command = new TestProtectedThemeCommand(['--store', 'unknown.myshopify.com'], CommandConfig) + + await expect(command.run()).rejects.toMatchObject({reason: 'unknown-store'}) + expect(ensureThemeStore).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.preflightTargets).toHaveLength(0) + expect(renderConcurrent).not.toHaveBeenCalled() + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(0) + }) + + test('protected trusted target authenticates without remembering the store', async () => { + vi.mocked(loadThemeProjectTrust).mockResolvedValue({ + state: 'configured', + themePath: 'current/working/directory', + path: 'shopify.theme.toml', + environments: [{name: 'default', store: 'test-store.myshopify.com'}], + }) + + await CommandConfig.load() + const command = new TestProtectedThemeCommand([], CommandConfig) + + await command.run() + + expect(ensureAuthenticatedThemes).toHaveBeenCalledOnce() + expect(ensureThemeStore).toHaveBeenCalledTimes(1) + expect(ensureThemeStore).toHaveBeenCalledWith({store: 'test-store.myshopify.com', remember: false}) + expect(command.preflightTargets).toEqual([ + expect.objectContaining({store: 'test-store.myshopify.com', source: 'default'}), + ]) + expect(command.commandCalls[0]).toMatchObject({flags: {store: 'test-store.myshopify.com'}}) + }) + + test('protected trusted target renders the default preflight between authentication and command', async () => { + vi.mocked(loadThemeProjectTrust).mockResolvedValue({ + state: 'configured', + themePath: 'current/working/directory', + path: 'shopify.theme.toml', + environments: [{name: 'default', store: 'test-store.myshopify.com'}], + }) + await CommandConfig.load() + const command = new TestDefaultProtectedThemeCommand([], CommandConfig) + vi.mocked(ensureAuthenticatedThemes).mockImplementation(async () => { + command.events.push('authentication') + return mockSession + }) + vi.mocked(renderInfo).mockImplementation(() => { + command.events.push('preflight') + return undefined + }) + + await command.run() + + expect(command.events).toEqual(['authentication', 'preflight', 'command']) + expect(renderInfo).toHaveBeenCalledWith( + expect.objectContaining({ + headline: 'Theme Airlock', + customSections: expect.arrayContaining([ + expect.objectContaining({ + body: expect.objectContaining({ + tabularData: expect.arrayContaining([['Operation', 'theme testdefaultprotectedthemecommand']]), + }), + }), + ]), + }), + ) + }) + + test('malformed project trust rejects before shared lifecycle work', async () => { + vi.mocked(loadThemeProjectTrust).mockRejectedValue( + new ThemeAirlockError('Malformed theme trust', 'malformed-configuration'), + ) + + await CommandConfig.load() + const command = new TestProtectedThemeCommand([], CommandConfig) + + await expect(command.run()).rejects.toMatchObject({reason: 'malformed-configuration'}) + expect(ensureThemeStore).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.preflightTargets).toHaveLength(0) + expect(renderConcurrent).not.toHaveBeenCalled() + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(0) + }) + + test('ambiguous project trust rejects before shared lifecycle work', async () => { + vi.mocked(loadThemeProjectTrust).mockRejectedValue( + new ThemeAirlockError('Ambiguous theme trust', 'ambiguous-configuration'), + ) + + await CommandConfig.load() + const command = new TestProtectedThemeCommand([], CommandConfig) + + await expect(command.run()).rejects.toMatchObject({reason: 'ambiguous-configuration'}) + expect(ensureThemeStore).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.preflightTargets).toHaveLength(0) + expect(renderConcurrent).not.toHaveBeenCalled() + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(0) + }) + + test('interactive bootstrap cancellation rejects before shared lifecycle work or store mutation', async () => { + vi.mocked(terminalSupportsPrompting).mockReturnValue(true) + vi.mocked(getThemeStore).mockReturnValue('remembered.myshopify.com') + vi.mocked(interactiveBootstrapUI).mockReturnValue({ + confirmStore: vi.fn(), + promptStore: vi.fn(), + promptEnvironment: vi.fn(), + }) + vi.mocked(bootstrapThemeAirlock).mockRejectedValue( + new ThemeAirlockError('Theme bootstrap was cancelled', 'bootstrap-cancelled'), + ) + vi.mocked(loadThemeProjectTrust).mockResolvedValue({ + state: 'unconfigured', + themePath: 'current/working/directory', + }) + + await CommandConfig.load() + const command = new TestProtectedThemeCommand([], CommandConfig) + + await expect(command.run()).rejects.toMatchObject({reason: 'bootstrap-cancelled'}) + expect(bootstrapThemeAirlock).toHaveBeenCalledOnce() + expect(ensureThemeStore).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.preflightTargets).toHaveLength(0) + expect(renderConcurrent).not.toHaveBeenCalled() + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(0) + }) + + test('interactive bootstrap authenticates with the supplied password and reuses its session', async () => { + const resolvedStore = 'resolved.myshopify.com' + vi.mocked(terminalSupportsPrompting).mockReturnValue(true) + vi.mocked(interactiveBootstrapUI).mockReturnValue({ + confirmStore: vi.fn(), + promptStore: vi.fn(), + promptEnvironment: vi.fn(), + }) + vi.mocked(bootstrapThemeAirlock).mockImplementation(async (options) => { + const session = await options.authenticate(resolvedStore) + return { + target: {environment: 'default', store: resolvedStore, source: 'bootstrap', implicit: false}, + session, + configurationPath: 'shopify.theme.toml', + } + }) + vi.mocked(ensureThemeStore).mockImplementation(({store}) => store ?? '') + vi.mocked(loadThemeProjectTrust).mockResolvedValue({ + state: 'unconfigured', + themePath: 'current/working/directory', + }) + + await CommandConfig.load() + const command = new TestProtectedThemeCommand(['--password', 'supplied-password'], CommandConfig) + + await command.run() + + expect(ensureAuthenticatedThemes).toHaveBeenCalledOnce() + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith(resolvedStore, 'supplied-password') + expect(ensureThemeStore).toHaveBeenCalledOnce() + expect(ensureThemeStore).toHaveBeenCalledWith({store: resolvedStore, remember: false}) + expect(command.commandCalls).toHaveLength(1) + expect(command.commandCalls[0]).toMatchObject({ + flags: {password: 'supplied-password', store: resolvedStore}, + session: mockSession, + }) + }) + + test('noninteractive unconfigured resolution rejects before bootstrap or shared lifecycle work', async () => { + vi.mocked(terminalSupportsPrompting).mockReturnValue(false) + vi.mocked(loadThemeProjectTrust).mockResolvedValue({ + state: 'unconfigured', + themePath: 'current/working/directory', + }) + + await CommandConfig.load() + const command = new TestProtectedThemeCommand([], CommandConfig) + + await expect(command.run()).rejects.toMatchObject({reason: 'unconfigured-project'}) + expect(bootstrapThemeAirlock).not.toHaveBeenCalled() + expect(getThemeStore).not.toHaveBeenCalled() + expect(ensureThemeStore).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.preflightTargets).toHaveLength(0) + expect(renderConcurrent).not.toHaveBeenCalled() + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(0) + }) + + test('protected batch with an invalid trust target rejects before shared lifecycle work', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'first.myshopify.com'}) + .mockResolvedValueOnce({store: 'second.myshopify.com'}) + vi.mocked(loadThemeProjectTrust).mockResolvedValueOnce({ + state: 'configured', + themePath: 'current/working/directory', + path: 'shopify.theme.toml', + environments: [{name: 'first', store: 'first.myshopify.com'}], + }) + vi.mocked(loadThemeProjectTrust).mockResolvedValueOnce({ + state: 'configured', + themePath: 'current/working/directory', + path: 'shopify.theme.toml', + environments: [{name: 'second', store: 'other.myshopify.com'}], + }) + + await CommandConfig.load() + const command = new TestProtectedThemeCommand( + ['--environment', 'first', '--environment', 'second'], + CommandConfig, + ) + + await expect(command.run()).rejects.toMatchObject({reason: 'invalid-batch'}) + expect(ensureThemeStore).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.preflightTargets).toHaveLength(0) + expect(renderConcurrent).not.toHaveBeenCalled() + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(0) + }) + + test('protected batch validates every trust target before projecting cached sessions', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'first.myshopify.com'}) + .mockResolvedValueOnce({store: 'second.myshopify.com'}) + vi.mocked(loadThemeProjectTrust).mockResolvedValueOnce({ + state: 'configured', + themePath: 'current/working/directory', + path: 'shopify.theme.toml', + environments: [{name: 'first', store: 'first.myshopify.com'}], + }) + vi.mocked(loadThemeProjectTrust).mockResolvedValueOnce({ + state: 'configured', + themePath: 'current/working/directory', + path: 'shopify.theme.toml', + environments: [{name: 'second', store: 'other.myshopify.com'}], + }) + vi.mocked(listCurrentStoredStoreAppSessions).mockReturnValue([ + { + store: 'first.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: [], + acquiredAt: '2026-06-08T11:00:00.000Z', + }, + ]) + + await CommandConfig.load() + const command = new TestProtectedThemeCommand( + ['--environment', 'first', '--environment', 'second'], + CommandConfig, + ) + + await expect(command.run()).rejects.toMatchObject({reason: 'invalid-batch'}) + expect(listCurrentStoredStoreAppSessions).not.toHaveBeenCalled() + expect(setLastSeenUserId).not.toHaveBeenCalled() + expect(ensureThemeStore).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + }) + + test('protected batch reuses a newly authenticated session for the same normalized store and password', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'STORE.myshopify.com', password: 'same-password'}) + .mockResolvedValueOnce({store: 'store.myshopify.com', password: 'same-password'}) + vi.mocked(loadThemeProjectTrust).mockResolvedValue({ + state: 'configured', + themePath: 'current/working/directory', + path: 'shopify.theme.toml', + environments: [ + {name: 'first', store: 'store.myshopify.com'}, + {name: 'second', store: 'store.myshopify.com'}, + ], + }) + vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) + vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store, password) => ({ + token: password ?? '', + storeFqdn: store, + })) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + await CommandConfig.load() + const command = new TestProtectedThemeCommandWithForce( + ['--environment', 'first', '--environment', 'second', '--force'], + CommandConfig, + ) + + await command.run() + + expect(ensureAuthenticatedThemes).toHaveBeenCalledOnce() + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('store.myshopify.com', 'same-password') + expect(command.commandCalls).toHaveLength(2) + expect(command.commandCalls[0]?.session).toBe(command.commandCalls[1]?.session) + }) + + test('protected batch authenticates separately for distinct passwords on the same store', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store.myshopify.com', password: 'first-password'}) + .mockResolvedValueOnce({store: 'STORE.myshopify.com', password: 'second-password'}) + vi.mocked(loadThemeProjectTrust).mockResolvedValue({ + state: 'configured', + themePath: 'current/working/directory', + path: 'shopify.theme.toml', + environments: [ + {name: 'first', store: 'store.myshopify.com'}, + {name: 'second', store: 'store.myshopify.com'}, + ], + }) + vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) + vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store, password) => ({ + token: password ?? '', + storeFqdn: store, + })) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + await CommandConfig.load() + const command = new TestProtectedThemeCommandWithForce( + ['--environment', 'first', '--environment', 'second', '--force'], + CommandConfig, + ) + + await command.run() + + expect(ensureAuthenticatedThemes).toHaveBeenCalledTimes(2) + expect(ensureAuthenticatedThemes).toHaveBeenNthCalledWith(1, 'store.myshopify.com', 'first-password') + expect(ensureAuthenticatedThemes).toHaveBeenNthCalledWith(2, 'store.myshopify.com', 'second-password') + expect(command.commandCalls[0]?.session).not.toBe(command.commandCalls[1]?.session) + }) + + test('protected batch throws the first command error after all concurrent groups finish', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store.myshopify.com'}) + .mockResolvedValueOnce({store: 'store.myshopify.com'}) + vi.mocked(loadThemeProjectTrust).mockResolvedValue({ + state: 'configured', + themePath: 'current/working/directory', + path: 'shopify.theme.toml', + environments: [ + {name: 'command-error', store: 'store.myshopify.com'}, + {name: 'development', store: 'store.myshopify.com'}, + ], + }) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + await CommandConfig.load() + const command = new TestProtectedThemeCommand( + ['--environment', 'command-error', '--environment', 'development'], + CommandConfig, + ) + + await expect(command.run()).rejects.toThrow('Environment command-error failed') + expect(renderConcurrent).toHaveBeenCalledTimes(2) + expect(command.commandCalls).toHaveLength(2) + expect(renderError).toHaveBeenCalledWith( + expect.objectContaining({body: ['Environment command-error failed: \n\nMocking a command error']}), + ) + expect(addPublicMetadata).toHaveBeenCalled() + expect(addSensitiveMetadata).toHaveBeenCalled() + }) + + test('protected batch normalizes non-Error command failures after rendering all groups', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store.myshopify.com'}) + .mockResolvedValueOnce({store: 'store.myshopify.com'}) + vi.mocked(loadThemeProjectTrust).mockResolvedValue({ + state: 'configured', + themePath: 'current/working/directory', + path: 'shopify.theme.toml', + environments: [ + {name: 'command-string-error', store: 'store.myshopify.com'}, + {name: 'development', store: 'store.myshopify.com'}, + ], + }) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + await CommandConfig.load() + const command = new TestProtectedThemeCommand( + ['--environment', 'command-string-error', '--environment', 'development'], + CommandConfig, + ) + + await expect(command.run()).rejects.toThrow( + 'Environment command-string-error failed: \n\nMocking a string command error', + ) + expect(renderConcurrent).toHaveBeenCalledTimes(2) + expect(command.commandCalls).toHaveLength(2) + expect(renderError).toHaveBeenCalledWith( + expect.objectContaining({ + body: ['Environment command-string-error failed: \n\nMocking a string command error'], + }), + ) + }) + + test('protected batch with invalid required configuration rejects before confirmation even with force', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'first.myshopify.com', password: 'first-password'}) + .mockResolvedValueOnce({store: 'second.myshopify.com'}) + vi.mocked(loadThemeProjectTrust) + .mockResolvedValueOnce({ + state: 'configured', + themePath: 'current/working/directory', + path: 'shopify.theme.toml', + environments: [{name: 'first', store: 'first.myshopify.com'}], + }) + .mockResolvedValueOnce({ + state: 'configured', + themePath: 'current/working/directory', + path: 'shopify.theme.toml', + environments: [{name: 'missing', store: 'second.myshopify.com'}], + }) + + await CommandConfig.load() + const command = new TestProtectedThemeCommandWithForce( + ['--environment', 'first', '--environment', 'missing', '--force'], + CommandConfig, + ) + + await expect(command.run()).rejects.toMatchObject({reason: 'invalid-batch'}) + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(ensureThemeStore).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.preflightTargets).toHaveLength(0) + expect(renderConcurrent).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(0) + }) + + test('protected batch authentication failure starts no commands and skips preflight and rendering', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'first.myshopify.com'}) + .mockResolvedValueOnce({store: 'second.myshopify.com'}) + vi.mocked(loadThemeProjectTrust).mockResolvedValueOnce({ + state: 'configured', + themePath: 'current/working/directory', + path: 'shopify.theme.toml', + environments: [{name: 'first', store: 'first.myshopify.com'}], + }) + vi.mocked(loadThemeProjectTrust).mockResolvedValueOnce({ + state: 'configured', + themePath: 'current/working/directory', + path: 'shopify.theme.toml', + environments: [{name: 'second', store: 'second.myshopify.com'}], + }) + vi.mocked(ensureAuthenticatedThemes).mockRejectedValueOnce(new Error('authentication failed')) + + await CommandConfig.load() + const command = new TestProtectedThemeCommand( + ['--environment', 'first', '--environment', 'second'], + CommandConfig, + ) + + await expect(command.run()).rejects.toThrow('authentication failed') + expect(ensureThemeStore).toHaveBeenCalledTimes(1) + expect(ensureThemeStore).toHaveBeenCalledWith({store: 'first.myshopify.com', remember: false}) + expect(ensureAuthenticatedThemes).toHaveBeenCalledOnce() + expect(command.preflightTargets).toHaveLength(0) + expect(renderConcurrent).not.toHaveBeenCalled() + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(0) + }) + + test('protected batch authentication does not remember stores', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'first.myshopify.com'}) + .mockResolvedValueOnce({store: 'second.myshopify.com'}) + vi.mocked(loadThemeProjectTrust).mockResolvedValueOnce({ + state: 'configured', + themePath: 'current/working/directory', + path: 'shopify.theme.toml', + environments: [{name: 'first', store: 'first.myshopify.com'}], + }) + vi.mocked(loadThemeProjectTrust).mockResolvedValueOnce({ + state: 'configured', + themePath: 'current/working/directory', + path: 'shopify.theme.toml', + environments: [{name: 'second', store: 'second.myshopify.com'}], + }) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestProtectedThemeCommand( + ['--environment', 'first', '--environment', 'second'], + CommandConfig, + ) + + await command.run() + + expect(ensureThemeStore).toHaveBeenCalledTimes(2) + expect(ensureThemeStore).toHaveBeenNthCalledWith(1, {store: 'first.myshopify.com', remember: false}) + expect(ensureThemeStore).toHaveBeenNthCalledWith(2, {store: 'second.myshopify.com', remember: false}) + }) + test('no environment provided', async () => { // Given await CommandConfig.load() diff --git a/packages/theme/src/cli/utilities/theme-command.ts b/packages/theme/src/cli/utilities/theme-command.ts index f8a7d578fcc..3f7e1817afb 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -1,6 +1,13 @@ import {ensureThemeStore} from './theme-store.js' +import {ThemeAirlockCoordinator} from './theme-airlock/coordinator.js' +import { + validateAirlockBatchEnvironmentSelections, + validateAirlockStoreSelectionSources, +} from './theme-airlock/resolver.js' +import {renderAirlockPreflight} from './theme-airlock/preflight.js' +import {ThemeAirlockError} from './theme-airlock/types.js' import {configurationFileName} from '../constants.js' -import {useThemeStoreContext} from '../services/local-storage.js' +import {getThemeStore, useThemeStoreContext} from '../services/local-storage.js' import {hashString} from '@shopify/cli-kit/node/crypto' import {Input} from '@oclif/core/interfaces' @@ -27,7 +34,10 @@ import {addPublicMetadata, addSensitiveMetadata} from '@shopify/cli-kit/node/met import {cwd, joinPath, resolvePath} from '@shopify/cli-kit/node/path' import {fileExistsSync} from '@shopify/cli-kit/node/fs' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' +import type {AirlockBatchExecutionEnvironment} from './theme-airlock/coordinator.js' +import type {AirlockTarget} from './theme-airlock/types.js' import type {Writable} from 'stream' type FlagValues = Record @@ -36,8 +46,10 @@ interface ValidEnvironment { flags: FlagValues requiresAuth: boolean storeAuthSession?: AdminSession + session?: AdminSession } type EnvironmentName = string + /** * Flags required to run a command in multiple environments * @@ -84,33 +96,58 @@ export default abstract class ThemeCommand extends Command { const {args, flags} = await this.parse(klass) const commandRequiresAuth = 'password' in klass.flags - const environments = (Array.isArray(flags.environment) ? flags.environment : [flags.environment]).filter(Boolean) + const environmentSelectors = Array.isArray(flags.environment) ? flags.environment : [flags.environment] + const environments = environmentSelectors.filter(Boolean) // Check if store flag is required by the command const storeIsRequired = requiredFlags !== null && requiredFlags.some((flag) => (Array.isArray(flag) ? flag.includes('store') : flag === 'store')) + const airlockPolicy = this.airlockPolicy() + + if (airlockPolicy) { + validateAirlockBatchEnvironmentSelections({flags, argv: this.argv}) + } + // Single environment or no environment if (environments.length <= 1) { - if (environments[0] && !flags.store && storeIsRequired) { + if (!airlockPolicy && environments[0] && !flags.store && storeIsRequired) { throw new AbortError(`Please provide a valid environment.`) } - const session = commandRequiresAuth ? await this.createSession(flags) : undefined - const commandName = this.constructor.name.toLowerCase() + if (!airlockPolicy) { + const session = commandRequiresAuth ? await this.createSession(flags) : undefined + const commandName = this.constructor.name.toLowerCase() - recordEvent(`theme-command:${commandName}:single-env:authenticated`) + recordEvent(`theme-command:${commandName}:single-env:authenticated`) - if (flags.path && !fileExistsSync(flags.path)) { - throw new AbortError(`Path does not exist: ${flags.path}`) - } + if (flags.path && !fileExistsSync(flags.path)) { + throw new AbortError(`Path does not exist: ${flags.path}`) + } - try { - await this.command(flags, session, false, args) - } finally { - await this.logAnalyticsData(session) + try { + await this.command(flags, session, false, args) + } finally { + await this.logAnalyticsData(session) + } + return } + + await this.createAirlockCoordinator().runSingle({ + flags, + requiresAuth: commandRequiresAuth, + execute: async (airlockFlags, _target, session) => { + const commandName = this.constructor.name.toLowerCase() + recordEvent(`theme-command:${commandName}:single-env:authenticated`) + + try { + await this.command(airlockFlags, session, false, args) + } finally { + await this.logAnalyticsData(session) + } + }, + }) return } @@ -120,13 +157,41 @@ export default abstract class ThemeCommand extends Command { return } + if (airlockPolicy) { + validateAirlockStoreSelectionSources({flags, argv: this.argv, env: process.env}) + } + const {flags: flagsWithoutDefaults} = await this.parse(noDefaultsOptions(klass), this.argv) if ('path' in flagsWithoutDefaults) { this.errorOnGlobalPath() return } - const environmentsMap = await this.loadEnvironments(environments, flags, flagsWithoutDefaults) + const environmentsMap = await this.loadEnvironments( + environments, + flags, + flagsWithoutDefaults, + airlockPolicy !== undefined, + ) + + if (airlockPolicy) { + const commandAllowsForceFlag = 'force' in klass.flags + const confirm = + commandAllowsForceFlag && !flags.force + ? (valid: AirlockBatchExecutionEnvironment[]) => + this.showConfirmation(this.constructor.name, requiredFlags, {valid, invalid: []}) + : undefined + + await this.createAirlockCoordinator().runBatch({ + environments: Array.from(environmentsMap, ([environment, value]) => ({environment, ...value})), + requiredFlags, + requiresAuth: commandRequiresAuth, + ...(confirm ? {confirm} : {}), + execute: (valid) => this.runConcurrent(valid, true), + }) + return + } + const validationResults = await this.validateEnvironments(environmentsMap, requiredFlags, commandRequiresAuth) const commandAllowsForceFlag = 'force' in klass.flags @@ -143,6 +208,29 @@ export default abstract class ThemeCommand extends Command { return [] } + protected airlockPolicy(): 'upload' | undefined { + return undefined + } + + protected airlockPreflight(targets: AirlockTarget[]): void { + renderAirlockPreflight(this.constructor.name.toLowerCase(), targets) + } + + private createAirlockCoordinator(): ThemeAirlockCoordinator { + return new ThemeAirlockCoordinator({ + argv: this.argv, + env: process.env, + authenticate: (flags, suppliedSession) => this.createSession(flags, suppliedSession, false), + rememberedStore: getThemeStore, + supportsPrompting: terminalSupportsPrompting, + renderPreflight: (targets) => this.airlockPreflight(targets), + storedSessionsFor: (flagsList) => this.storeAuthSessionsForTheme(flagsList), + storedSessionFromCache: (flags, sessions) => this.storeAuthSessionFromCache(flags, sessions), + missingRequiredFlags: (flags, requiredFlags, suppliedSession) => + this.missingRequiredFlags(flags, requiredFlags, suppliedSession), + }) + } + /** * Create a map of environments from the shopify.theme.toml file * @param environments - Names of environments to load @@ -150,7 +238,12 @@ export default abstract class ThemeCommand extends Command { * @param flagsWithoutDefaults - Flags provided via the CLI * @returns The map of environments */ - private async loadEnvironments(environments: EnvironmentName[], flags: FlagValues, flagsWithoutDefaults: FlagValues) { + private async loadEnvironments( + environments: EnvironmentName[], + flags: FlagValues, + flagsWithoutDefaults: FlagValues, + protectedCommand: boolean, + ) { const environmentMap = new Map() for (const environmentName of environments) { @@ -160,7 +253,7 @@ export default abstract class ThemeCommand extends Command { silent: true, }) - if (environmentFlags?.store && typeof environmentFlags.store === 'string') { + if (!protectedCommand && environmentFlags?.store && typeof environmentFlags.store === 'string') { environmentFlags.store = normalizeStoreFqdn(environmentFlags.store) } @@ -168,27 +261,49 @@ export default abstract class ThemeCommand extends Command { environmentFlags.path = resolvePath(environmentFlags.path) } + const effectiveFlags: FlagValues = { + ...flags, + ...environmentFlags, + ...flagsWithoutDefaults, + environment: [environmentName], + } + const effectiveValidationFlags = {...environmentFlags, ...flagsWithoutDefaults} as FlagValues + + if (protectedCommand && typeof effectiveFlags.store === 'string') { + try { + effectiveFlags.store = normalizeStoreFqdn(effectiveFlags.store) + effectiveValidationFlags.store = effectiveFlags.store + } catch (error) { + throw new ThemeAirlockError( + `Invalid batch environment "${environmentName}": ${effectiveFlags.store} is not a valid store. No files were uploaded.`, + 'invalid-batch', + ) + } + } + environmentMap.set(environmentName, { - flags: { - ...flags, - ...environmentFlags, - ...flagsWithoutDefaults, - environment: [environmentName], - }, - validationFlags: {...environmentFlags, ...flagsWithoutDefaults} as FlagValues, + flags: effectiveFlags, + validationFlags: effectiveValidationFlags, }) } return environmentMap } - /** - * Split environments into valid and invalid based on flags - * @param environmentMap - The map of environments to validate - * @param requiredFlags - The required flags to check for - * @param requiresAuth - Whether the command requires authentication - * @returns An object containing valid and invalid environment arrays - */ + private missingRequiredFlags( + environmentFlags: FlagValues, + requiredFlags: Exclude, + storeAuthSession?: AdminSession, + ): string[] { + return requiredFlags + .filter((flag) => + Array.isArray(flag) + ? !flag.some((requiredFlag) => this.hasRequiredFlag(environmentFlags, requiredFlag, storeAuthSession)) + : !this.hasRequiredFlag(environmentFlags, flag, storeAuthSession), + ) + .map((flag) => (Array.isArray(flag) ? flag.join(' or ') : flag)) + } + private async validateEnvironments( environmentMap: Map, requiredFlags: Exclude, @@ -282,8 +397,9 @@ export default abstract class ThemeCommand extends Command { * Run the command in each valid environment concurrently * @param validEnvironments - The valid environments to run the command in */ - private async runConcurrent(validEnvironments: ValidEnvironment[]) { + private async runConcurrent(validEnvironments: ValidEnvironment[], throwOnActionError = false) { const abortController = new AbortController() + const actionErrors: Error[] = [] const stores = validEnvironments.map((env) => env.flags.store as string) const uniqueStores = new Set(stores) @@ -293,13 +409,14 @@ export default abstract class ThemeCommand extends Command { for (const runGroup of runGroups) { // eslint-disable-next-line no-await-in-loop await renderConcurrent({ - processes: runGroup.map(({environment, flags, requiresAuth, storeAuthSession}) => ({ + processes: runGroup.map(({environment, flags, requiresAuth, storeAuthSession, session: suppliedSession}) => ({ prefix: environment, action: async (stdout: Writable, stderr: Writable, _signal) => { try { const store = flags.store as string await useThemeStoreContext(store, async () => { - const session = requiresAuth ? await this.createSession(flags, storeAuthSession) : undefined + const session = + requiresAuth && !suppliedSession ? await this.createSession(flags, storeAuthSession) : suppliedSession const commandName = this.constructor.name.toLowerCase() recordEvent(`theme-command:${commandName}:multi-env:authenticated`) @@ -313,10 +430,10 @@ export default abstract class ThemeCommand extends Command { // eslint-disable-next-line no-catch-all/no-catch-all } catch (error) { - if (error instanceof Error) { - error.message = `Environment ${environment} failed: \n\n${error.message}` - renderError({body: [error.message]}) - } + const normalizedError = error instanceof Error ? error : new Error(String(error)) + normalizedError.message = `Environment ${environment} failed: \n\n${normalizedError.message}` + renderError({body: [normalizedError.message]}) + if (throwOnActionError) actionErrors.push(normalizedError) } }, })), @@ -325,6 +442,11 @@ export default abstract class ThemeCommand extends Command { renderOptions: {stdout: process.stderr}, }) } + + if (throwOnActionError && actionErrors.length > 0) { + const firstError = actionErrors[0] + if (firstError) throw firstError + } } /** @@ -349,8 +471,8 @@ export default abstract class ThemeCommand extends Command { * @param flags - The environment flags containing store and password * @returns The unauthenticated session object */ - private async createSession(flags: FlagValues, storeAuthSession?: AdminSession) { - const store = ensureThemeStore({store: flags.store as string | undefined}) + private async createSession(flags: FlagValues, storeAuthSession?: AdminSession, rememberStore = true) { + const store = ensureThemeStore({store: flags.store as string | undefined, remember: rememberStore}) const password = flags.password as string | undefined const session = password ? await ensureAuthenticatedThemes(store, password) diff --git a/packages/theme/src/cli/utilities/theme-store.test.ts b/packages/theme/src/cli/utilities/theme-store.test.ts index fc4ff19418f..fb1cf5a1fae 100644 --- a/packages/theme/src/cli/utilities/theme-store.test.ts +++ b/packages/theme/src/cli/utilities/theme-store.test.ts @@ -34,6 +34,16 @@ describe('ensureThemeStore', () => { expect(setThemeStore).toHaveBeenCalledWith('stored.myshopify.com') }) + test('does not persist the store when remembering is disabled', () => { + const flags = {store: 'example.myshopify.com'} + vi.mocked(setThemeStore).mockClear() + + const result = ensureThemeStore({store: flags.store, remember: false}) + + expect(result).toBe('example.myshopify.com') + expect(setThemeStore).not.toHaveBeenCalled() + }) + test('throws AbortError if store is not provided and not in local storage', () => { // Given const flags = {store: undefined} diff --git a/packages/theme/src/cli/utilities/theme-store.ts b/packages/theme/src/cli/utilities/theme-store.ts index 4fa695ed625..4f6fc8ea759 100644 --- a/packages/theme/src/cli/utilities/theme-store.ts +++ b/packages/theme/src/cli/utilities/theme-store.ts @@ -4,7 +4,7 @@ import {recordError} from '@shopify/cli-kit/node/analytics' import {AbortError} from '@shopify/cli-kit/node/error' import {outputContent, outputToken} from '@shopify/cli-kit/node/output' -export function ensureThemeStore(flags: {store: string | undefined}): string { +export function ensureThemeStore(flags: {store: string | undefined; remember?: boolean}): string { const store = flags.store ?? getThemeStore() if (!store) { throw recordError( @@ -18,6 +18,6 @@ export function ensureThemeStore(flags: {store: string | undefined}): string { ), ) } - setThemeStore(store) + if (flags.remember ?? true) setThemeStore(store) return store } diff --git a/packages/theme/src/index.test.ts b/packages/theme/src/index.test.ts new file mode 100644 index 00000000000..5aacaaab1dc --- /dev/null +++ b/packages/theme/src/index.test.ts @@ -0,0 +1,9 @@ +import COMMANDS from './index.js' +import Add from './cli/commands/theme/airlock/add.js' +import {describe, expect, test} from 'vitest' + +describe('theme command registry', () => { + test('registers the Theme Airlock add command', () => { + expect(COMMANDS).toHaveProperty('theme:airlock:add', Add) + }) +}) diff --git a/packages/theme/src/index.ts b/packages/theme/src/index.ts index e69636c85e2..caa05e20fc7 100644 --- a/packages/theme/src/index.ts +++ b/packages/theme/src/index.ts @@ -1,3 +1,4 @@ +import AddCommand from './cli/commands/theme/airlock/add.js' import CheckCommand from './cli/commands/theme/check.js' import ConsoleCommand from './cli/commands/theme/console.js' import DeleteCommand from './cli/commands/theme/delete.js' @@ -19,6 +20,7 @@ import Rename from './cli/commands/theme/rename.js' import Share from './cli/commands/theme/share.js' const COMMANDS = { + 'theme:airlock:add': AddCommand, 'theme:init': Init, 'theme:check': CheckCommand, 'theme:console': ConsoleCommand, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90357c9c908..393a008903e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -704,6 +704,9 @@ importers: h3: specifier: 1.15.11 version: 1.15.11 + proper-lockfile: + specifier: 4.1.2 + version: 4.1.2 yaml: specifier: 2.9.0 version: 2.9.0 @@ -711,6 +714,9 @@ importers: '@shopify/theme-hot-reload': specifier: ^0.0.22 version: 0.0.22 + '@types/proper-lockfile': + specifier: 4.1.4 + version: 4.1.4 '@vitest/coverage-istanbul': specifier: ^3.2.7 version: 3.2.7(vitest@4.1.10)