From 29bd11561079c3126ac8e6ffb05c8a2053a4f966 Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Tue, 21 Jul 2026 11:41:43 -0400 Subject: [PATCH 01/26] Write TOML updates atomically --- packages/cli-kit/src/public/node/fs.test.ts | 61 +++++++++++++++++++ packages/cli-kit/src/public/node/fs.ts | 20 ++++++ .../src/public/node/toml/toml-file.test.ts | 28 ++++++++- .../cli-kit/src/public/node/toml/toml-file.ts | 10 +-- 4 files changed, 112 insertions(+), 7 deletions(-) diff --git a/packages/cli-kit/src/public/node/fs.test.ts b/packages/cli-kit/src/public/node/fs.test.ts index d30612b5b96..09054ad923d 100644 --- a/packages/cli-kit/src/public/node/fs.test.ts +++ b/packages/cli-kit/src/public/node/fs.test.ts @@ -22,10 +22,71 @@ 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' +import {rename as fsRename, stat} from 'node:fs/promises' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, rename: vi.fn(actual.rename)} +}) + +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.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 + + await writeFileAtomically(path, 'replacement content') + + 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([]) + }) + }) +}) 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..709d8088cf0 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' @@ -229,6 +230,25 @@ 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 { + const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp` + const existingMode = (await fileExists(path)) ? (await fsStat(path)).mode : undefined + + try { + await writeFile(temporaryPath, data) + if (existingMode !== undefined) await chmod(temporaryPath, existingMode) + await renameFile(temporaryPath, path) + } finally { + await removeFile(temporaryPath) + } +} + /** * 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 } From 7743769f836c9c64c70e689f6e65e1d8eb5370aa Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Wed, 22 Jul 2026 11:01:22 -0400 Subject: [PATCH 02/26] Harden atomic writes --- packages/cli-kit/src/public/node/fs.test.ts | 59 ++++++++++++++++++++- packages/cli-kit/src/public/node/fs.ts | 28 ++++++++-- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/packages/cli-kit/src/public/node/fs.test.ts b/packages/cli-kit/src/public/node/fs.test.ts index 09054ad923d..8e0f76c383a 100644 --- a/packages/cli-kit/src/public/node/fs.test.ts +++ b/packages/cli-kit/src/public/node/fs.test.ts @@ -28,11 +28,11 @@ import { import {joinPath, normalizePath} from './path.js' import * as array from '../common/array.js' import {describe, expect, test, vi} from 'vitest' -import {rename as fsRename, stat} from 'node:fs/promises' +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)} + return {...actual, rename: vi.fn(actual.rename), writeFile: vi.fn(actual.writeFile)} }) describe('writeFileAtomically', () => { @@ -59,15 +59,40 @@ describe('writeFileAtomically', () => { }) }) + 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({mode: originalMode & 0o7777}) expect((await stat(path)).mode).toBe(originalMode) }) }) @@ -86,6 +111,36 @@ describe('writeFileAtomically', () => { expect(entries.filter((entry) => entry.startsWith(`${fileName}.`) && entry.endsWith('.tmp'))).toStrictEqual([]) }) }) + + 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', () => { diff --git a/packages/cli-kit/src/public/node/fs.ts b/packages/cli-kit/src/public/node/fs.ts index 709d8088cf0..b05e8b55128 100644 --- a/packages/cli-kit/src/public/node/fs.ts +++ b/packages/cli-kit/src/public/node/fs.ts @@ -212,6 +212,7 @@ export function appendFileSync(path: string, data: string): void { export interface WriteOptions { encoding: BufferEncoding + mode?: number } /** @@ -237,13 +238,32 @@ export async function writeFile( * @param data - Text content to be written. */ export async function writeFileAtomically(path: string, data: string): Promise { - const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp` - const existingMode = (await fileExists(path)) ? (await fsStat(path)).mode : undefined + 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` try { - await writeFile(temporaryPath, data) + await writeFile(temporaryPath, data, {encoding: 'utf8', mode: existingMode ?? 0o600}) if (existingMode !== undefined) await chmod(temporaryPath, existingMode) - await renameFile(temporaryPath, path) + await renameFile(temporaryPath, destinationPath) } finally { await removeFile(temporaryPath) } From 8ccf3e34a37e77690b043ef058ef3cdb287d0888 Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Wed, 22 Jul 2026 11:01:22 -0400 Subject: [PATCH 03/26] Add atomic write changeset --- .changeset/theme-airlock-atomic-toml-writes.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/theme-airlock-atomic-toml-writes.md diff --git a/.changeset/theme-airlock-atomic-toml-writes.md b/.changeset/theme-airlock-atomic-toml-writes.md new file mode 100644 index 00000000000..f84973ec8e4 --- /dev/null +++ b/.changeset/theme-airlock-atomic-toml-writes.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-kit': minor +--- + +Add atomic text writes and use them for TOML configuration updates. From a9b957f272f5ce41aa27a3591993bb4df3b679a0 Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Thu, 23 Jul 2026 11:52:38 -0400 Subject: [PATCH 04/26] Harden atomic temporary file handling --- .../theme-airlock-atomic-toml-writes.md | 2 +- packages/cli-kit/src/public/node/fs.test.ts | 31 ++++++++++++++++++- packages/cli-kit/src/public/node/fs.ts | 21 +++++++++++-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/.changeset/theme-airlock-atomic-toml-writes.md b/.changeset/theme-airlock-atomic-toml-writes.md index f84973ec8e4..af0f40cacf5 100644 --- a/.changeset/theme-airlock-atomic-toml-writes.md +++ b/.changeset/theme-airlock-atomic-toml-writes.md @@ -2,4 +2,4 @@ '@shopify/cli-kit': minor --- -Add atomic text writes and use them for TOML configuration updates. +Write TOML configuration updates atomically while preserving existing file permissions and creating new files securely. diff --git a/packages/cli-kit/src/public/node/fs.test.ts b/packages/cli-kit/src/public/node/fs.test.ts index 8e0f76c383a..ef3f74d3b4a 100644 --- a/packages/cli-kit/src/public/node/fs.test.ts +++ b/packages/cli-kit/src/public/node/fs.test.ts @@ -28,6 +28,9 @@ import { 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) => { @@ -35,6 +38,11 @@ vi.mock('node:fs/promises', async (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) => { @@ -92,7 +100,7 @@ describe('writeFileAtomically', () => { await writeFileAtomically(path, 'replacement content') const temporaryWrite = vi.mocked(fsWriteFile).mock.calls.find(([writePath]) => String(writePath).endsWith('.tmp')) - expect(temporaryWrite?.[2]).toMatchObject({mode: originalMode & 0o7777}) + expect(temporaryWrite?.[2]).toMatchObject({encoding: 'utf8', mode: 0o600}) expect((await stat(path)).mode).toBe(originalMode) }) }) @@ -112,6 +120,27 @@ describe('writeFileAtomically', () => { }) }) + 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') diff --git a/packages/cli-kit/src/public/node/fs.ts b/packages/cli-kit/src/public/node/fs.ts index b05e8b55128..e5a15817115 100644 --- a/packages/cli-kit/src/public/node/fs.ts +++ b/packages/cli-kit/src/public/node/fs.ts @@ -260,13 +260,30 @@ export async function writeFileAtomically(path: string, data: string): Promise Date: Tue, 21 Jul 2026 12:13:19 -0400 Subject: [PATCH 05/26] Load project-scoped store trust --- .../utilities/theme-airlock/config.test.ts | 227 ++++++++++++++++++ .../src/cli/utilities/theme-airlock/config.ts | 78 ++++++ .../src/cli/utilities/theme-airlock/types.ts | 37 +++ 3 files changed, 342 insertions(+) create mode 100644 packages/theme/src/cli/utilities/theme-airlock/config.test.ts create mode 100644 packages/theme/src/cli/utilities/theme-airlock/config.ts create mode 100644 packages/theme/src/cli/utilities/theme-airlock/types.ts 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..b8fe456b303 --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/config.test.ts @@ -0,0 +1,227 @@ +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'])( + 'returns unconfigured when environments is not an object: %s', + async (content) => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = joinPath(tmpDir, 'theme') + const configurationPath = await writeConfiguration(tmpDir, content) + await mkdir(themePath) + + await expect(loadThemeProjectTrust(themePath)).resolves.toEqual({ + state: 'unconfigured', + path: configurationPath, + themePath, + }) + }) + }, + ) + + 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..19d3164d505 --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/config.ts @@ -0,0 +1,78 @@ +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 (!isObject(environmentsValue)) { + return {state: 'unconfigured', path: configurationPath, themePath} + } + + 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/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 + } +} From 091d7dc0b72e83696c44e21e778fed4980569dbe Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Tue, 21 Jul 2026 12:42:36 -0400 Subject: [PATCH 06/26] Resolve trusted Airlock targets --- .../utilities/theme-airlock/resolver.test.ts | 615 ++++++++++++++++++ .../cli/utilities/theme-airlock/resolver.ts | 302 +++++++++ 2 files changed, 917 insertions(+) create mode 100644 packages/theme/src/cli/utilities/theme-airlock/resolver.test.ts create mode 100644 packages/theme/src/cli/utilities/theme-airlock/resolver.ts 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..221200c117a --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/resolver.test.ts @@ -0,0 +1,615 @@ +import {resolveBatchAirlockTargets, resolveSingleAirlockTarget} 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('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..11e7d7a9e9b --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/resolver.ts @@ -0,0 +1,302 @@ +import {ThemeAirlockError} from './types.js' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' + +import type {AirlockTarget, StoreSelectionSource, ThemeProjectTrust, TrustedThemeEnvironment} from './types.js' + +export type FlagValues = Record + +interface SuppliedValue { + supplied: boolean + value?: string + occurrences: number +} + +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} + + 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} + } + } + + return selection +} + +function environmentValue(env: NodeJS.ProcessEnv, name: string): SuppliedValue { + const value = env[name] + return value === undefined ? {supplied: false, occurrences: 0} : {supplied: true, value, occurrences: 1} +} + +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, + }, + cliEnvironment: { + supplied: cliEnvironment.supplied, + value: cliEnvironment.value ?? (cliEnvironment.supplied ? flagString(flags, 'environment') : undefined), + occurrences: cliEnvironment.occurrences, + }, + 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 +} + +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) + assertNoRepeatedSelection(selections.cliStore, '--store') + assertNoRepeatedSelection(selections.cliEnvironment, '--environment') + 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) + + 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)) +} From 95ff21933ae321b14b90ec9cfbeb435d414fd5d9 Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Tue, 21 Jul 2026 14:53:49 -0400 Subject: [PATCH 07/26] Bootstrap trusted theme stores --- packages/theme/package.json | 2 + .../utilities/theme-airlock/bootstrap.test.ts | 304 ++++++++++++++++ .../cli/utilities/theme-airlock/bootstrap.ts | 107 ++++++ .../utilities/theme-airlock/writer.test.ts | 324 ++++++++++++++++++ .../src/cli/utilities/theme-airlock/writer.ts | 157 +++++++++ pnpm-lock.yaml | 6 + 6 files changed, 900 insertions(+) create mode 100644 packages/theme/src/cli/utilities/theme-airlock/bootstrap.test.ts create mode 100644 packages/theme/src/cli/utilities/theme-airlock/bootstrap.ts create mode 100644 packages/theme/src/cli/utilities/theme-airlock/writer.test.ts create mode 100644 packages/theme/src/cli/utilities/theme-airlock/writer.ts 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/utilities/theme-airlock/bootstrap.test.ts b/packages/theme/src/cli/utilities/theme-airlock/bootstrap.test.ts new file mode 100644 index 00000000000..447396a37f5 --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/bootstrap.test.ts @@ -0,0 +1,304 @@ +import {bootstrapThemeAirlock} from './bootstrap.js' +import {ThemeAirlockError} from './types.js' +import {configurationFileName} from '../../constants.js' +import {describe, expect, test} from 'vitest' +import {fileExists, inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' + +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('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..989f1ba9357 --- /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'> + +export 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 : undefined + }, + promptEnvironment: async () => { + const environment = await renderTextPrompt({message: 'Enter a name for this theme environment'}) + return hasValue(environment) ? environment : 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/writer.test.ts b/packages/theme/src/cli/utilities/theme-airlock/writer.test.ts new file mode 100644 index 00000000000..c0facfe06d4 --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/writer.test.ts @@ -0,0 +1,324 @@ +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, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +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)} +}) + +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, 250))]) + } + 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('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('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..d2c5b10a78c --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/writer.ts @@ -0,0 +1,157 @@ +import {loadThemeProjectTrust} from './config.js' +import {ThemeAirlockError} from './types.js' +import {configurationFileName} from '../../constants.js' +import {fileHasWritePermissions} 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: 10, factor: 1, minTimeout: 25, maxTimeout: 100}, +} +const maximumConfigurationPathRedirects = 3 + +type TrustedThemeTrust = Awaited> +interface TrustedEnvironmentResult { + path: string + store: string +} +type LockedWriteResult = {kind: 'redirect'; path: string} | {kind: 'written'; value: TrustedEnvironmentResult} + +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 + trust: TrustedThemeTrust + environment: string + normalizedStore: string +}): Promise { + const {configurationPath, trust, environment, normalizedStore} = options + + if (trust.state === 'configured') { + const existingEnvironment = trust.environments.find(({name}) => name === environment) + if (existingEnvironment) { + if (existingEnvironment.store === normalizedStore) return {path: configurationPath, 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: configurationPath, 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 + 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 freshConfigurationPath = resolvePath(freshTrust.path ?? joinPath(options.themePath, configurationFileName)) + if (freshConfigurationPath !== options.configurationPath) { + return {kind: 'redirect', path: freshConfigurationPath} + } + + const value = await writeTrustedThemeEnvironment({ + configurationPath: options.configurationPath, + 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 +}): 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, + 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 configurationPath = resolvePath(initialTrust.path ?? joinPath(options.themePath, configurationFileName)) + + return writeWithConfigurationRedirects({ + themePath: options.themePath, + configurationPath, + environment: options.environment, + normalizedStore, + redirects: 0, + }) +} 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) From e7585ef248ce18750a87ed12f72586247437c0b8 Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Wed, 22 Jul 2026 11:01:39 -0400 Subject: [PATCH 08/26] Harden Airlock trust writes --- .../utilities/theme-airlock/writer.test.ts | 43 ++++++++++++++++++- .../src/cli/utilities/theme-airlock/writer.ts | 35 +++++++++++---- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/packages/theme/src/cli/utilities/theme-airlock/writer.test.ts b/packages/theme/src/cli/utilities/theme-airlock/writer.test.ts index c0facfe06d4..4f71294144c 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/writer.test.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/writer.test.ts @@ -3,9 +3,9 @@ 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, writeFile} from '@shopify/cli-kit/node/fs' +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} from 'node:fs/promises' +import {rename as fsRename, lstat} from 'node:fs/promises' vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() @@ -283,6 +283,45 @@ describe('addTrustedThemeEnvironment', () => { }) }) + 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) diff --git a/packages/theme/src/cli/utilities/theme-airlock/writer.ts b/packages/theme/src/cli/utilities/theme-airlock/writer.ts index d2c5b10a78c..723780e79b6 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/writer.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/writer.ts @@ -1,7 +1,7 @@ import {loadThemeProjectTrust} from './config.js' import {ThemeAirlockError} from './types.js' import {configurationFileName} from '../../constants.js' -import {fileHasWritePermissions} from '@shopify/cli-kit/node/fs' +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' @@ -18,7 +18,18 @@ interface TrustedEnvironmentResult { path: string store: string } -type LockedWriteResult = {kind: 'redirect'; path: string} | {kind: 'written'; value: TrustedEnvironmentResult} +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 { @@ -40,16 +51,17 @@ async function assertWritable(path: string, description: string): Promise async function writeTrustedThemeEnvironment(options: { configurationPath: string + displayPath: string trust: TrustedThemeTrust environment: string normalizedStore: string }): Promise { - const {configurationPath, trust, environment, normalizedStore} = options + 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: configurationPath, store: normalizedStore} + if (existingEnvironment.store === normalizedStore) return {path: displayPath, store: normalizedStore} throw conflictError( `Environment "${environment}" is already trusted for ${existingEnvironment.store}, not ${normalizedStore}.`, @@ -78,7 +90,7 @@ async function writeTrustedThemeEnvironment(options: { await configuration.replace(changes) } - return {path: configurationPath, store: normalizedStore} + return {path: displayPath, store: normalizedStore} } function configurationLockError(configurationPath: string): ThemeAirlockError { @@ -91,6 +103,7 @@ function configurationLockError(configurationPath: string): ThemeAirlockError { async function lockAndWriteConfiguration(options: { themePath: string configurationPath: string + displayPath: string environment: string normalizedStore: string }): Promise { @@ -103,13 +116,15 @@ async function lockAndWriteConfiguration(options: { } const freshTrust = await loadThemeProjectTrust(options.themePath) - const freshConfigurationPath = resolvePath(freshTrust.path ?? joinPath(options.themePath, configurationFileName)) + const freshDisplayPath = resolvePath(freshTrust.path ?? joinPath(options.themePath, configurationFileName)) + const freshConfigurationPath = await canonicalConfigurationPath(freshDisplayPath) if (freshConfigurationPath !== options.configurationPath) { - return {kind: 'redirect', path: freshConfigurationPath} + return {kind: 'redirect', path: freshConfigurationPath, displayPath: freshDisplayPath} } const value = await writeTrustedThemeEnvironment({ configurationPath: options.configurationPath, + displayPath: freshDisplayPath, trust: freshTrust, environment: options.environment, normalizedStore: options.normalizedStore, @@ -126,6 +141,7 @@ async function writeWithConfigurationRedirects(options: { environment: string normalizedStore: string redirects: number + displayPath: string }): Promise { const result = await lockAndWriteConfiguration(options) if (result.kind === 'written') return result.value @@ -134,6 +150,7 @@ async function writeWithConfigurationRedirects(options: { return writeWithConfigurationRedirects({ ...options, configurationPath: result.path, + displayPath: result.displayPath, redirects: options.redirects + 1, }) } @@ -145,11 +162,13 @@ export async function addTrustedThemeEnvironment(options: { }): Promise { const normalizedStore = normalizeRequestedStore(options.store) const initialTrust = await loadThemeProjectTrust(options.themePath) - const configurationPath = resolvePath(initialTrust.path ?? joinPath(options.themePath, configurationFileName)) + 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, From 4ebf2ac454e360e7b8b4be8ed869bd22de3df80e Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Wed, 22 Jul 2026 10:47:59 -0400 Subject: [PATCH 09/26] Wait for concurrent Airlock writers --- packages/theme/src/cli/utilities/theme-airlock/writer.test.ts | 2 +- packages/theme/src/cli/utilities/theme-airlock/writer.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/theme/src/cli/utilities/theme-airlock/writer.test.ts b/packages/theme/src/cli/utilities/theme-airlock/writer.test.ts index 4f71294144c..9a7a39266c0 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/writer.test.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/writer.test.ts @@ -46,7 +46,7 @@ async function coordinateConcurrentRenames(): Promise { renameCount += 1 if (renameCount === 2) releaseSecondRename() if (renameCount === 1) { - await Promise.race([secondRename, new Promise((resolve) => setTimeout(resolve, 250))]) + await Promise.race([secondRename, new Promise((resolve) => setTimeout(resolve, 350))]) } return originalRename(...arguments_) }) diff --git a/packages/theme/src/cli/utilities/theme-airlock/writer.ts b/packages/theme/src/cli/utilities/theme-airlock/writer.ts index 723780e79b6..cbf8d04a2bd 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/writer.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/writer.ts @@ -9,7 +9,7 @@ import lockfile from 'proper-lockfile' const configurationLockOptions = { realpath: false, - retries: {retries: 10, factor: 1, minTimeout: 25, maxTimeout: 100}, + retries: {retries: 40, factor: 1, minTimeout: 50, maxTimeout: 50}, } const maximumConfigurationPathRedirects = 3 From 8ee3ee2ac8cbb307790739c895fbcdbac64dca62 Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Wed, 22 Jul 2026 13:55:43 -0400 Subject: [PATCH 10/26] Keep Airlock implementation details private --- .../cli/utilities/theme-airlock/bootstrap.ts | 27 +------------------ .../cli/utilities/theme-airlock/resolver.ts | 2 +- 2 files changed, 2 insertions(+), 27 deletions(-) diff --git a/packages/theme/src/cli/utilities/theme-airlock/bootstrap.ts b/packages/theme/src/cli/utilities/theme-airlock/bootstrap.ts index 989f1ba9357..a4628bd2df3 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/bootstrap.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/bootstrap.ts @@ -1,13 +1,10 @@ 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'> - -export interface BootstrapOptions { +interface BootstrapOptions { themePath: string candidate?: string rememberedStore?: string @@ -39,28 +36,6 @@ function requiredBootstrapValue(value: string | undefined, message: string): str 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 : undefined - }, - promptEnvironment: async () => { - const environment = await renderTextPrompt({message: 'Enter a name for this theme environment'}) - return hasValue(environment) ? environment : undefined - }, - } -} - export async function bootstrapThemeAirlock( options: BootstrapOptions, ): Promise<{target: AirlockTarget; session: TSession; configurationPath: string}> { diff --git a/packages/theme/src/cli/utilities/theme-airlock/resolver.ts b/packages/theme/src/cli/utilities/theme-airlock/resolver.ts index 11e7d7a9e9b..053fcf9bb85 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/resolver.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/resolver.ts @@ -3,7 +3,7 @@ import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import type {AirlockTarget, StoreSelectionSource, ThemeProjectTrust, TrustedThemeEnvironment} from './types.js' -export type FlagValues = Record +type FlagValues = Record interface SuppliedValue { supplied: boolean From 97f3a6ccf2fded1664478c76f88dfb387c07f4d4 Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Thu, 23 Jul 2026 12:13:31 -0400 Subject: [PATCH 11/26] Reject malformed Airlock environment tables --- .../src/cli/utilities/theme-airlock/config.test.ts | 11 +++++------ .../theme/src/cli/utilities/theme-airlock/config.ts | 8 +++++++- .../src/cli/utilities/theme-airlock/writer.test.ts | 13 +++++++++++++ 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/theme/src/cli/utilities/theme-airlock/config.test.ts b/packages/theme/src/cli/utilities/theme-airlock/config.test.ts index b8fe456b303..2cfc2098c7a 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/config.test.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/config.test.ts @@ -48,17 +48,16 @@ describe('loadThemeProjectTrust', () => { }) test.each(['environments = "preview"\n', 'environments = []\n'])( - 'returns unconfigured when environments is not an object: %s', + 'throws when environments is not a table: %s', async (content) => { await inTemporaryDirectory(async (tmpDir) => { const themePath = joinPath(tmpDir, 'theme') - const configurationPath = await writeConfiguration(tmpDir, content) + await writeConfiguration(tmpDir, content) await mkdir(themePath) - await expect(loadThemeProjectTrust(themePath)).resolves.toEqual({ - state: 'unconfigured', - path: configurationPath, - themePath, + await expect(loadThemeProjectTrust(themePath)).rejects.toMatchObject({ + reason: 'malformed-configuration', + message: expect.stringContaining('environments'), }) }) }, diff --git a/packages/theme/src/cli/utilities/theme-airlock/config.ts b/packages/theme/src/cli/utilities/theme-airlock/config.ts index 19d3164d505..520697d1287 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/config.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/config.ts @@ -31,9 +31,15 @@ export async function loadThemeProjectTrust(themePath: string): Promise() diff --git a/packages/theme/src/cli/utilities/theme-airlock/writer.test.ts b/packages/theme/src/cli/utilities/theme-airlock/writer.test.ts index 9a7a39266c0..4aee0917a39 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/writer.test.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/writer.test.ts @@ -228,6 +228,19 @@ describe('addTrustedThemeEnvironment', () => { }) }) + 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) From 6e50c70529d1961eba375314528fbab4bdb065d7 Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Tue, 21 Jul 2026 15:36:09 -0400 Subject: [PATCH 12/26] Protect theme push and dev before authentication --- .../theme/src/cli/commands/theme/dev.test.ts | 76 +++ packages/theme/src/cli/commands/theme/dev.ts | 25 +- .../theme/src/cli/commands/theme/push.test.ts | 58 ++ packages/theme/src/cli/commands/theme/push.ts | 4 + .../src/cli/services/metafields-pull.test.ts | 13 + .../theme/src/cli/services/metafields-pull.ts | 6 +- .../src/cli/utilities/theme-command.test.ts | 554 +++++++++++++++++- .../theme/src/cli/utilities/theme-command.ts | 277 ++++++++- .../src/cli/utilities/theme-store.test.ts | 10 + .../theme/src/cli/utilities/theme-store.ts | 4 +- 10 files changed, 990 insertions(+), 37 deletions(-) create mode 100644 packages/theme/src/cli/commands/theme/dev.test.ts create mode 100644 packages/theme/src/cli/commands/theme/push.test.ts 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..b1ef88ad5c8 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/dev.test.ts @@ -0,0 +1,76 @@ +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 {dev} from '../../services/dev.js' +import {metafieldsPull} from '../../services/metafields-pull.js' +import {setThemeStore} 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 {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {renderConcurrent} from '@shopify/cli-kit/node/ui' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +const setThemeStoreMock = vi.hoisted(() => vi.fn()) +const findOrCreate = vi.hoisted(() => vi.fn()) +const developmentThemeManagerConstructor = vi.hoisted(() => vi.fn()) + +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('../../utilities/development-theme-manager.js', () => ({ + DevelopmentThemeManager: developmentThemeManagerConstructor, +})) +vi.mock('../../services/local-storage.js', async () => { + const actual = await vi.importActual( + '../../services/local-storage.js', + ) + return {...actual, setThemeStore: setThemeStoreMock} +}) + +const CommandConfig = new Config({root: __dirname}) +const adminSession = {token: 'test-token', storeFqdn: 'test-store.myshopify.com'} + +async function run() { + await CommandConfig.load() + const command = new Dev(['--store=test-store.myshopify.com'], CommandConfig) + return command.run() +} + +describe('theme dev', () => { + beforeEach(() => { + developmentThemeManagerConstructor.mockImplementation(() => ({findOrCreate})) + findOrCreate.mockResolvedValue({id: 1, createdAtRuntime: false}) + 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()).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(developmentThemeManagerConstructor).not.toHaveBeenCalled() + expect(findOrCreate).not.toHaveBeenCalled() + expect(ensureLiveThemeConfirmed).not.toHaveBeenCalled() + expect(metafieldsPull).not.toHaveBeenCalled() + expect(renderConcurrent).not.toHaveBeenCalled() + }) +}) diff --git a/packages/theme/src/cli/commands/theme/dev.ts b/packages/theme/src/cli/commands/theme/dev.ts index 88979191f54..6890fe6ec57 100644 --- a/packages/theme/src/cli/commands/theme/dev.ts +++ b/packages/theme/src/cli/commands/theme/dev.ts @@ -200,14 +200,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..d47f7ab87e1 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/push.test.ts @@ -0,0 +1,58 @@ +import Push from './push.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 {push} from '../../services/push.js' +import {setThemeStore} from '../../services/local-storage.js' +import {Config} from '@oclif/core' +import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {renderConcurrent} from '@shopify/cli-kit/node/ui' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +const setThemeStoreMock = vi.hoisted(() => vi.fn()) + +vi.mock('../../utilities/theme-airlock/config.js') +vi.mock('../../utilities/theme-store.js') +vi.mock('../../services/push.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', + ) + return {...actual, setThemeStore: setThemeStoreMock} +}) + +const CommandConfig = new Config({root: __dirname}) +const adminSession = {token: 'test-token', storeFqdn: 'test-store.myshopify.com'} + +async function run(args: string[]) { + await CommandConfig.load() + const command = new Push(['--store=test-store.myshopify.com', ...args], CommandConfig) + return command.run() +} + +describe('theme push', () => { + beforeEach(() => { + 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(push).mockResolvedValue(undefined) + }) + + test.each([ + {name: 'without force', args: []}, + {name: 'with force', args: ['--force']}, + ])('blocks before upload lifecycle $name', async ({args}) => { + await expect(run(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() + }) +}) diff --git a/packages/theme/src/cli/commands/theme/push.ts b/packages/theme/src/cli/commands/theme/push.ts index 7fe6c584cc1..cf78b750662 100644 --- a/packages/theme/src/cli/commands/theme/push.ts +++ b/packages/theme/src/cli/commands/theme/push.ts @@ -138,6 +138,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/utilities/theme-command.test.ts b/packages/theme/src/cli/utilities/theme-command.test.ts index 616e8563d78..3142c4121b5 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, @@ -13,6 +17,8 @@ import {resolvePath} from '@shopify/cli-kit/node/path' import {renderConcurrent, renderConfirmationPrompt, renderError, 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 +32,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}) @@ -143,6 +156,31 @@ 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 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 +234,520 @@ describe('ThemeCommand', () => { }) describe('run', () => { + 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-batch', + message: expect.stringContaining('trusted-a'), + }) + expect(error.message).toContain('No files were uploaded.') + 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('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 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..fd7a854c34d 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -1,6 +1,10 @@ import {ensureThemeStore} from './theme-store.js' +import {loadThemeProjectTrust} from './theme-airlock/config.js' +import {resolveBatchAirlockTargets, resolveSingleAirlockTarget} from './theme-airlock/resolver.js' +import {bootstrapThemeAirlock, interactiveBootstrapUI} from './theme-airlock/bootstrap.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 +31,9 @@ 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 {AirlockTarget} from './theme-airlock/types.js' import type {Writable} from 'stream' type FlagValues = Record @@ -91,23 +97,39 @@ export default abstract class ThemeCommand extends Command { requiredFlags !== null && requiredFlags.some((flag) => (Array.isArray(flag) ? flag.includes('store') : flag === 'store')) + const airlockPolicy = this.airlockPolicy() + // 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) + } + return } + const {flags: airlockFlags, target, session} = await this.resolveAirlockSingle(flags, commandRequiresAuth) + const commandName = this.constructor.name.toLowerCase() + recordEvent(`theme-command:${commandName}:single-env:authenticated`) + this.airlockPreflight([target]) + try { - await this.command(flags, session, false, args) + await this.command(airlockFlags, session, false, args) } finally { await this.logAnalyticsData(session) } @@ -126,7 +148,34 @@ export default abstract class ThemeCommand extends Command { return } - const environmentsMap = await this.loadEnvironments(environments, flags, flagsWithoutDefaults) + const environmentsMap = await this.loadEnvironments( + environments, + flags, + flagsWithoutDefaults, + airlockPolicy !== undefined, + ) + + if (airlockPolicy) { + const protectedValidation = await this.validateProtectedEnvironments( + environmentsMap, + requiredFlags, + commandRequiresAuth, + ) + const commandAllowsForceFlag = 'force' in klass.flags + if (commandAllowsForceFlag && !flags.force) { + const confirmed = await this.showConfirmation(this.constructor.name, requiredFlags, { + valid: protectedValidation.valid, + invalid: [], + }) + if (!confirmed) return + } + + await this.authenticateProtectedEnvironments(protectedValidation.valid) + this.airlockPreflight(protectedValidation.targets) + await this.runConcurrent(protectedValidation.valid, true) + return + } + const validationResults = await this.validateEnvironments(environmentsMap, requiredFlags, commandRequiresAuth) const commandAllowsForceFlag = 'force' in klass.flags @@ -143,6 +192,57 @@ export default abstract class ThemeCommand extends Command { return [] } + protected airlockPolicy(): 'upload' | undefined { + return undefined + } + + protected airlockPreflight(_targets: AirlockTarget[]): void {} + + private async resolveAirlockSingle( + flags: FlagValues, + commandRequiresAuth: boolean, + ): Promise<{flags: FlagValues; target: AirlockTarget; session?: AdminSession}> { + 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.argv, env: process.env}) + + if ('bootstrap' in resolution) { + if (!terminalSupportsPrompting()) { + throw new ThemeAirlockError( + 'This theme project is not configured. Run `theme airlock add` before running this command without a terminal.', + 'unconfigured-project', + ) + } + + const rememberedStore = resolution.allowRememberedCandidate ? getThemeStore() : 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.createSession({...flags, store}, undefined, false), + }) + const mutableFlags = {...flags, store: bootstrap.target.store} + return { + flags: mutableFlags, + target: bootstrap.target, + session: commandRequiresAuth ? bootstrap.session : undefined, + } + } + + const mutableFlags = {...flags, store: resolution.store} + const session = commandRequiresAuth ? await this.createSession(mutableFlags, undefined, false) : undefined + return {flags: mutableFlags, target: resolution, session} + } + /** * Create a map of environments from the shopify.theme.toml file * @param environments - Names of environments to load @@ -150,7 +250,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 +265,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,14 +273,29 @@ 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, }) } @@ -189,6 +309,105 @@ export default abstract class ThemeCommand extends Command { * @param requiresAuth - Whether the command requires authentication * @returns An object containing valid and invalid environment arrays */ + private async validateProtectedEnvironments( + environmentMap: Map, + requiredFlags: Exclude, + requiresAuth: boolean, + ): Promise<{valid: ValidEnvironment[]; targets: AirlockTarget[]}> { + const valid: ValidEnvironment[] = [] + const targets: AirlockTarget[] = [] + const validatedEnvironments: { + environmentName: EnvironmentName + environment: {flags: FlagValues; validationFlags: FlagValues} + target: AirlockTarget + }[] = [] + + for (const [environmentName, environment] of environmentMap.entries()) { + const themePath = typeof environment.flags.path === 'string' ? environment.flags.path : cwd() + if (!fileExistsSync(themePath)) { + throw new ThemeAirlockError( + `Invalid batch environment "${environmentName}": 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: environmentName, store: environment.flags.store as string}], + }) + if (!target) { + throw new ThemeAirlockError( + `Invalid batch environment "${environmentName}": no trust target resolved.`, + 'invalid-batch', + ) + } + + validatedEnvironments.push({environmentName, environment, target}) + } + + // Do not project cached sessions until all effective paths and trust targets have been validated. + const storeAuthSessionsByStore = requiresAuth + ? this.storeAuthSessionsForTheme(validatedEnvironments.map(({environment}) => environment.validationFlags)) + : new Map() + + for (const {environmentName, environment, target} of validatedEnvironments) { + const storeAuthSession = this.storeAuthSessionFromCache(environment.validationFlags, storeAuthSessionsByStore) + const missingFlags = this.missingRequiredFlags(environment.validationFlags, requiredFlags, storeAuthSession) + if (missingFlags.length > 0) { + throw new ThemeAirlockError( + `Invalid batch environment "${environmentName}": missing required flags: ${missingFlags.join(', ')}.`, + 'invalid-batch', + ) + } + + const mutableFlags = {...environment.flags, store: target.store} + valid.push({environment: environmentName, flags: mutableFlags, requiresAuth, storeAuthSession}) + targets.push(target) + } + + return {valid, targets} + } + + private async authenticateProtectedEnvironments(validEnvironments: ValidEnvironment[]): Promise { + const authenticatedSessionsByStore = new Map>() + + for (const environment of validEnvironments) { + 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 cachedSession = sessionsByPassword.get(password ?? '') + if (cachedSession) { + environment.storeAuthSession = cachedSession + continue + } + + // eslint-disable-next-line no-await-in-loop + const session = await this.createSession(environment.flags, environment.storeAuthSession, false) + environment.storeAuthSession = session + sessionsByPassword.set(password ?? '', session) + authenticatedSessionsByStore.set(store, sessionsByPassword) + } + } + + 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 +501,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) @@ -299,7 +519,14 @@ export default abstract class ThemeCommand extends Command { try { const store = flags.store as string await useThemeStoreContext(store, async () => { - const session = requiresAuth ? await this.createSession(flags, storeAuthSession) : undefined + let session: AdminSession | undefined + if (requiresAuth) { + if (this.airlockPolicy() === undefined) { + session = await this.createSession(flags, storeAuthSession) + } else { + session = storeAuthSession ?? (await this.createSession(flags, undefined, false)) + } + } const commandName = this.constructor.name.toLowerCase() recordEvent(`theme-command:${commandName}:multi-env:authenticated`) @@ -316,6 +543,7 @@ export default abstract class ThemeCommand extends Command { if (error instanceof Error) { error.message = `Environment ${environment} failed: \n\n${error.message}` renderError({body: [error.message]}) + if (throwOnActionError) actionErrors.push(error) } } }, @@ -325,6 +553,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 +582,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 } From 4cac7999d6603f6f1f33628390009033ba692428 Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Tue, 21 Jul 2026 16:36:41 -0400 Subject: [PATCH 13/26] Show Airlock target preflight --- .../utilities/theme-airlock/preflight.test.ts | 111 ++++++++++++++++++ .../cli/utilities/theme-airlock/preflight.ts | 75 ++++++++++++ .../src/cli/utilities/theme-command.test.ts | 56 ++++++++- .../theme/src/cli/utilities/theme-command.ts | 5 +- 4 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 packages/theme/src/cli/utilities/theme-airlock/preflight.test.ts create mode 100644 packages/theme/src/cli/utilities/theme-airlock/preflight.ts 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..ee3d01a3b43 --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/preflight.test.ts @@ -0,0 +1,111 @@ +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 ordered environment and store pairs for explicitly selected stores', () => { + const targets = [ + target({environment: 'first', store: 'first.myshopify.com'}), + target({environment: 'second', store: 'second.myshopify.com'}), + ] + + renderAirlockPreflight('push', targets) + + const options = vi.mocked(renderInfo).mock.calls[0]?.[0] + expect(options?.headline).toBe('Theme Airlock') + expect(JSON.stringify(options)).toContain('explicitly selected stores') + expect(options?.customSections?.[0]?.body).toMatchObject({ + tabularData: [ + ['Environment', 'Store'], + ['first', 'first.myshopify.com'], + ['second', 'second.myshopify.com'], + ], + }) + expect(JSON.stringify(options)).not.toContain('(implicit)') + }) +}) 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..4e4a57a5347 --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/preflight.ts @@ -0,0 +1,75 @@ +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'], + ...targets.map((target) => [target.environment ?? 'unknown environment', target.store]), + ], + firstColumnSubdued: true, + }, + }, + ], + }) +} diff --git a/packages/theme/src/cli/utilities/theme-command.test.ts b/packages/theme/src/cli/utilities/theme-command.test.ts index 3142c4121b5..0196779c1e3 100644 --- a/packages/theme/src/cli/utilities/theme-command.test.ts +++ b/packages/theme/src/cli/utilities/theme-command.test.ts @@ -14,7 +14,13 @@ 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' @@ -168,6 +174,19 @@ class TestProtectedThemeCommand extends TestThemeCommand { } } +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'] @@ -342,6 +361,41 @@ describe('ThemeCommand', () => { 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'), diff --git a/packages/theme/src/cli/utilities/theme-command.ts b/packages/theme/src/cli/utilities/theme-command.ts index fd7a854c34d..e159da8c756 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -2,6 +2,7 @@ import {ensureThemeStore} from './theme-store.js' import {loadThemeProjectTrust} from './theme-airlock/config.js' import {resolveBatchAirlockTargets, resolveSingleAirlockTarget} from './theme-airlock/resolver.js' import {bootstrapThemeAirlock, interactiveBootstrapUI} from './theme-airlock/bootstrap.js' +import {renderAirlockPreflight} from './theme-airlock/preflight.js' import {ThemeAirlockError} from './theme-airlock/types.js' import {configurationFileName} from '../constants.js' import {getThemeStore, useThemeStoreContext} from '../services/local-storage.js' @@ -196,7 +197,9 @@ export default abstract class ThemeCommand extends Command { return undefined } - protected airlockPreflight(_targets: AirlockTarget[]): void {} + protected airlockPreflight(targets: AirlockTarget[]): void { + renderAirlockPreflight(this.constructor.name.toLowerCase(), targets) + } private async resolveAirlockSingle( flags: FlagValues, From cd139935037c6eb34f2d3aa78883cec9360036f7 Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Wed, 22 Jul 2026 09:23:28 -0400 Subject: [PATCH 14/26] Add explicit Airlock trust command --- packages/cli/oclif.manifest.json | 74 +++++ .../cli/commands/theme/airlock/add.test.ts | 291 ++++++++++++++++++ .../src/cli/commands/theme/airlock/add.ts | 55 ++++ packages/theme/src/index.test.ts | 9 + packages/theme/src/index.ts | 2 + 5 files changed, 431 insertions(+) create mode 100644 packages/theme/src/cli/commands/theme/airlock/add.test.ts create mode 100644 packages/theme/src/cli/commands/theme/airlock/add.ts create mode 100644 packages/theme/src/index.test.ts diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 9e97221a1f3..20e47981a87 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": [ ], 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..a451746bfb5 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/airlock/add.test.ts @@ -0,0 +1,291 @@ +import Add from './add.js' +import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {renderSuccess} from '@shopify/cli-kit/node/ui' +import {Config} from '@oclif/core' +import {authAliasFlag} from '@shopify/cli-kit/node/cli' +import {fileExistsSync, inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {describe, expect, test, vi, beforeEach} from 'vitest' + +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/ui') + +const CommandConfig = new Config({root: __dirname}) +const session = {token: 'test-token', storeFqdn: 'trusted-store.myshopify.com'} +const configurationFileName = 'shopify.theme.toml' + +async function runCommand(argv: string[]) { + await CommandConfig.load() + const command = new Add(argv, CommandConfig) + return command.run() +} + +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 +} + +describe('theme airlock add', () => { + beforeEach(() => { + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) + }) + + test('rejects a missing store argument without authenticating or writing', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + + await expect(runCommand(['--path', themePath, '--environment', 'preview'])).rejects.toThrow() + + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(fileExistsSync(joinPath(themePath, configurationFileName))).toBe(false) + }) + }) + + test('rejects a missing environment flag without authenticating or writing', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + + await expect(runCommand(['trusted-store', '--path', themePath])).rejects.toThrow() + + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(fileExistsSync(joinPath(themePath, configurationFileName))).toBe(false) + }) + }) + + test('rejects an empty environment before authenticating or writing', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + + await expect(runCommand(['trusted-store', '--path', themePath, '--environment', ' \t'])).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('normalizes the store before authentication and writing', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + + await runCommand([ + 'https://TRUSTED-STORE.myshopify.com/admin/', + '--path', + themePath, + '--environment', + ' preview ', + ]) + + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('trusted-store.myshopify.com', undefined) + await expect(readFile(joinPath(themePath, configurationFileName))).resolves.toContain( + '[environments.preview]\nstore = "trusted-store.myshopify.com"', + ) + }) + }) + + test('authenticates before creating a new configuration file', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const configurationPath = joinPath(themePath, configurationFileName) + vi.mocked(ensureAuthenticatedThemes).mockImplementation(async () => { + expect(fileExistsSync(configurationPath)).toBe(false) + return session + }) + + await runCommand(['trusted-store', '--path', themePath, '--environment', 'preview']) + + expect(fileExistsSync(configurationPath)).toBe(true) + }) + }) + + test('passes the password to authentication exactly', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + + await runCommand([ + 'trusted-store', + '--path', + themePath, + '--environment', + 'preview', + '--password', + 'shptka_secret', + ]) + + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('trusted-store.myshopify.com', 'shptka_secret') + }) + }) + + test('does not create a file when authentication fails', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + vi.mocked(ensureAuthenticatedThemes).mockRejectedValue(new Error('authentication failed')) + + await expect(runCommand(['trusted-store', '--path', themePath, '--environment', 'preview'])).rejects.toThrow( + 'authentication failed', + ) + + expect(fileExistsSync(joinPath(themePath, configurationFileName))).toBe(false) + expect(renderSuccess).not.toHaveBeenCalled() + }) + }) + + test('preserves an existing file byte-for-byte when authentication fails', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const original = '# Existing trust\nname = "theme"\n' + const configurationPath = await writeConfiguration(themePath, original) + vi.mocked(ensureAuthenticatedThemes).mockRejectedValue(new Error('authentication failed')) + + await expect(runCommand(['trusted-store', '--path', themePath, '--environment', 'preview'])).rejects.toThrow( + 'authentication failed', + ) + + await expect(readFile(configurationPath)).resolves.toBe(original) + expect(renderSuccess).not.toHaveBeenCalled() + }) + }) + + test('creates a new trust configuration after authentication', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + + await runCommand(['trusted-store', '--path', themePath, '--environment', 'preview']) + + await expect(readFile(joinPath(themePath, configurationFileName))).resolves.toContain( + '[environments.preview]\nstore = "trusted-store.myshopify.com"', + ) + expect(renderSuccess).toHaveBeenCalledOnce() + }) + }) + + test('preserves comments and unrelated keys when patching an existing file', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const configurationPath = await writeConfiguration( + tmpDir, + '# Keep this comment\nname = "theme"\n\n[other]\nvalue = true\n', + ) + + await runCommand(['trusted-store', '--path', themePath, '--environment', 'preview']) + + 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 = "trusted-store.myshopify.com"') + }) + }) + + test('is idempotent for the same environment and store after authentication', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const configurationPath = await writeConfiguration( + themePath, + '[environments.preview]\nstore = "trusted-store.myshopify.com"\n', + ) + const original = await readFile(configurationPath) + + await runCommand(['trusted-store', '--path', themePath, '--environment', 'preview']) + + expect(ensureAuthenticatedThemes).toHaveBeenCalledOnce() + await expect(readFile(configurationPath)).resolves.toBe(original) + }) + }) + + test('propagates an environment conflict after authentication without changing bytes', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const configurationPath = await writeConfiguration( + themePath, + '[environments.preview]\nstore = "other-store.myshopify.com"\n', + ) + const original = await readFile(configurationPath) + + await expect( + runCommand(['trusted-store', '--path', themePath, '--environment', 'preview']), + ).rejects.toMatchObject({reason: 'environment-conflict'}) + + expect(ensureAuthenticatedThemes).toHaveBeenCalledOnce() + await expect(readFile(configurationPath)).resolves.toBe(original) + expect(renderSuccess).not.toHaveBeenCalled() + }) + }) + + test('propagates a normalized store conflict after authentication without changing bytes', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const configurationPath = await writeConfiguration( + themePath, + '[environments.default]\nstore = "trusted-store.myshopify.com"\n', + ) + const original = await readFile(configurationPath) + + await expect( + runCommand(['https://TRUSTED-STORE.myshopify.com/', '--path', themePath, '--environment', 'preview']), + ).rejects.toMatchObject({reason: 'store-conflict'}) + + expect(ensureAuthenticatedThemes).toHaveBeenCalledOnce() + await expect(readFile(configurationPath)).resolves.toBe(original) + expect(renderSuccess).not.toHaveBeenCalled() + }) + }) + + test('uses the effective path and nearest existing 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') + + await runCommand(['trusted-store', '--path', themePath, '--environment', 'preview']) + + await expect(readFile(configurationPath)).resolves.toContain('[environments.preview]') + expect(fileExistsSync(joinPath(themePath, configurationFileName))).toBe(false) + }) + }) + + test('renders normalized store, environment, and configuration path after writing', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const themePath = await createTheme(tmpDir) + const configurationPath = joinPath(themePath, configurationFileName) + + await runCommand(['https://TRUSTED-STORE.myshopify.com/', '--path', themePath, '--environment', ' preview ']) + + expect(renderSuccess).toHaveBeenCalledWith({ + headline: 'Store added to Theme Airlock.', + body: expect.arrayContaining([ + expect.stringContaining('preview'), + expect.stringContaining('trusted-store.myshopify.com'), + expect.stringContaining(configurationPath), + ]), + }) + }) + }) + + test('exposes the standard auth-alias base flag', () => { + expect(Add.baseFlags).toBe(authAliasFlag) + }) + + test('does not declare bypass, force, or yes flags', () => { + expect(Add.flags).not.toHaveProperty('bypass') + expect(Add.flags).not.toHaveProperty('force') + expect(Add.flags).not.toHaveProperty('yes') + }) + + test('does not import or call global store utilities', async () => { + const source = await readFile(joinPath(__dirname, 'add.ts')) + + expect(source).not.toMatch(/ensureThemeStore|getThemeStore|setThemeStore/) + expect(source).not.toMatch(/node\/store/) + }) +}) 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..b46759732b2 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/airlock/add.ts @@ -0,0 +1,55 @@ +import {addTrustedThemeEnvironment} from '../../../utilities/theme-airlock/writer.js' +import {ThemeAirlockError} from '../../../utilities/theme-airlock/types.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 {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +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 environment = flags.environment.trim() + if (!environment) { + throw new ThemeAirlockError("Environment name to add can't be empty.", 'invalid-environment') + } + + const store = normalizeStoreFqdn(args.store) + await ensureAuthenticatedThemes(store, flags.password) + + const result = await addTrustedThemeEnvironment({ + themePath: flags.path, + environment, + store, + }) + + renderSuccess({ + headline: 'Store added to Theme Airlock.', + body: [`Environment: ${environment}`, `Store: ${result.store}`, `Configuration: ${result.path}`], + }) + } +} 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, From 94527d5b35ec65349fff8d40f66b5d7d9ac936ff Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Wed, 22 Jul 2026 10:09:50 -0400 Subject: [PATCH 15/26] Close Airlock batch safety gaps --- .../utilities/theme-airlock/preflight.test.ts | 19 ++-- .../cli/utilities/theme-airlock/preflight.ts | 10 +- .../utilities/theme-airlock/resolver.test.ts | 51 +++++++++- .../cli/utilities/theme-airlock/resolver.ts | 83 ++++++++++++++-- .../src/cli/utilities/theme-command.test.ts | 98 ++++++++++++++++++- .../theme/src/cli/utilities/theme-command.ts | 19 +++- 6 files changed, 255 insertions(+), 25 deletions(-) diff --git a/packages/theme/src/cli/utilities/theme-airlock/preflight.test.ts b/packages/theme/src/cli/utilities/theme-airlock/preflight.test.ts index ee3d01a3b43..4618270569a 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/preflight.test.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/preflight.test.ts @@ -88,24 +88,29 @@ describe('renderAirlockPreflight', () => { }) }) - test('renders ordered environment and store pairs for explicitly selected stores', () => { + 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'}), + 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(JSON.stringify(options)).toContain('explicitly selected stores') + 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'], - ['first', 'first.myshopify.com'], - ['second', 'second.myshopify.com'], + ['Environment', 'Store', 'Selected by', 'Status', 'Operation'], + ['first', 'first.myshopify.com', 'shopify.theme.toml', 'explicit', 'theme push'], + ['second', 'second.myshopify.com', '--store', 'implicit', 'theme push'], ], }) - expect(JSON.stringify(options)).not.toContain('(implicit)') }) }) diff --git a/packages/theme/src/cli/utilities/theme-airlock/preflight.ts b/packages/theme/src/cli/utilities/theme-airlock/preflight.ts index 4e4a57a5347..f32f74db977 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/preflight.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/preflight.ts @@ -64,8 +64,14 @@ export function renderAirlockPreflight(command: string, targets: AirlockTarget[] title: 'explicitly selected stores', body: { tabularData: [ - ['Environment', 'Store'], - ...targets.map((target) => [target.environment ?? 'unknown environment', target.store]), + ['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 index 221200c117a..f2c50b3c624 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/resolver.test.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/resolver.test.ts @@ -1,4 +1,8 @@ -import {resolveBatchAirlockTargets, resolveSingleAirlockTarget} from './resolver.js' +import { + resolveBatchAirlockTargets, + resolveSingleAirlockTarget, + validateAirlockStoreSelectionSources, +} from './resolver.js' import {ThemeAirlockError} from './types.js' import {describe, expect, test, vi} from 'vitest' @@ -522,6 +526,51 @@ describe('resolveSingleAirlockTarget', () => { }) }) +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 = { diff --git a/packages/theme/src/cli/utilities/theme-airlock/resolver.ts b/packages/theme/src/cli/utilities/theme-airlock/resolver.ts index 053fcf9bb85..3346ccdfc4a 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/resolver.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/resolver.ts @@ -9,6 +9,7 @@ interface SuppliedValue { supplied: boolean value?: string occurrences: number + values: (string | undefined)[] } interface RawSelections { @@ -24,7 +25,7 @@ interface NormalizedTrust { } function argvValue(argv: string[], longName: string, shortName: string): SuppliedValue { - let selection: SuppliedValue = {supplied: false, occurrences: 0} + let selection: SuppliedValue = {supplied: false, occurrences: 0, values: []} for (let index = 0; index < argv.length; index++) { const argument = argv[index] @@ -48,7 +49,12 @@ function argvValue(argv: string[], longName: string, shortName: string): Supplie } if (matched) { - selection = {supplied: true, value, occurrences: selection.occurrences + 1} + selection = { + supplied: true, + value, + occurrences: selection.occurrences + 1, + values: [...selection.values, value], + } } } @@ -57,7 +63,9 @@ function argvValue(argv: string[], longName: string, shortName: string): Supplie function environmentValue(env: NodeJS.ProcessEnv, name: string): SuppliedValue { const value = env[name] - return value === undefined ? {supplied: false, occurrences: 0} : {supplied: true, value, occurrences: 1} + return value === undefined + ? {supplied: false, occurrences: 0, values: []} + : {supplied: true, value, occurrences: 1, values: [value]} } function flagString(flags: FlagValues, name: string): string | undefined { @@ -74,11 +82,13 @@ function rawSelections(flags: FlagValues, argv: string[], env: NodeJS.ProcessEnv 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'), @@ -160,6 +170,65 @@ 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 @@ -174,14 +243,8 @@ export function resolveSingleAirlockTarget(options: { allowRememberedCandidate: boolean } { const selections = rawSelections(options.flags, options.argv, options.env) - assertNoRepeatedSelection(selections.cliStore, '--store') + const {cliStore, environmentStore} = validateStoreSelectionSources(options) assertNoRepeatedSelection(selections.cliEnvironment, '--environment') - 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) assertSelectionHasValue(selections.cliEnvironment, '--environment', 'unknown-environment') assertSelectionHasValue(selections.environmentName, 'SHOPIFY_FLAG_ENVIRONMENT', 'unknown-environment') diff --git a/packages/theme/src/cli/utilities/theme-command.test.ts b/packages/theme/src/cli/utilities/theme-command.test.ts index 0196779c1e3..ea70b4efa2e 100644 --- a/packages/theme/src/cli/utilities/theme-command.test.ts +++ b/packages/theme/src/cli/utilities/theme-command.test.ts @@ -253,6 +253,99 @@ 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'}) @@ -289,10 +382,9 @@ describe('ThemeCommand', () => { const error = await command.run().catch((error) => error) expect(error).toMatchObject({ - reason: 'invalid-batch', - message: expect.stringContaining('trusted-a'), + reason: 'invalid-store', + message: 'Invalid store value for --store: invalid/store.', }) - expect(error.message).toContain('No files were uploaded.') expect(ensureThemeStore).not.toHaveBeenCalled() expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() expect(command.preflightTargets).toHaveLength(0) diff --git a/packages/theme/src/cli/utilities/theme-command.ts b/packages/theme/src/cli/utilities/theme-command.ts index e159da8c756..90d91864e7f 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -1,6 +1,11 @@ import {ensureThemeStore} from './theme-store.js' import {loadThemeProjectTrust} from './theme-airlock/config.js' -import {resolveBatchAirlockTargets, resolveSingleAirlockTarget} from './theme-airlock/resolver.js' +import { + resolveBatchAirlockTargets, + resolveSingleAirlockTarget, + validateAirlockBatchEnvironmentSelections, + validateAirlockStoreSelectionSources, +} from './theme-airlock/resolver.js' import {bootstrapThemeAirlock, interactiveBootstrapUI} from './theme-airlock/bootstrap.js' import {renderAirlockPreflight} from './theme-airlock/preflight.js' import {ThemeAirlockError} from './theme-airlock/types.js' @@ -45,6 +50,7 @@ interface ValidEnvironment { storeAuthSession?: AdminSession } type EnvironmentName = string + /** * Flags required to run a command in multiple environments * @@ -91,7 +97,8 @@ 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 = @@ -100,6 +107,10 @@ export default abstract class ThemeCommand extends Command { const airlockPolicy = this.airlockPolicy() + if (airlockPolicy) { + validateAirlockBatchEnvironmentSelections({flags, argv: this.argv}) + } + // Single environment or no environment if (environments.length <= 1) { if (!airlockPolicy && environments[0] && !flags.store && storeIsRequired) { @@ -143,6 +154,10 @@ 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() From 86bf01b8d09cf9e5a074e7ada07c9e94a3caaa60 Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Wed, 22 Jul 2026 11:01:54 -0400 Subject: [PATCH 16/26] Propagate protected batch errors --- .../src/cli/utilities/theme-command.test.ts | 42 +++++++++++++++++++ .../theme/src/cli/utilities/theme-command.ts | 9 ++-- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/theme/src/cli/utilities/theme-command.test.ts b/packages/theme/src/cli/utilities/theme-command.test.ts index ea70b4efa2e..1a68ca8faa0 100644 --- a/packages/theme/src/cli/utilities/theme-command.test.ts +++ b/packages/theme/src/cli/utilities/theme-command.test.ts @@ -87,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' + } } } @@ -796,6 +800,44 @@ describe('ThemeCommand', () => { 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'}) diff --git a/packages/theme/src/cli/utilities/theme-command.ts b/packages/theme/src/cli/utilities/theme-command.ts index 90d91864e7f..555aaf3a2d6 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -558,11 +558,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]}) - if (throwOnActionError) actionErrors.push(error) - } + 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) } }, })), From a910bbec0cc09eabf0a4c2711388983b5a6965b4 Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Wed, 22 Jul 2026 11:01:54 -0400 Subject: [PATCH 17/26] Add Theme Airlock changeset --- .changeset/theme-airlock-trust-preflight.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/theme-airlock-trust-preflight.md diff --git a/.changeset/theme-airlock-trust-preflight.md b/.changeset/theme-airlock-trust-preflight.md new file mode 100644 index 00000000000..ae588c8c14c --- /dev/null +++ b/.changeset/theme-airlock-trust-preflight.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme': minor +--- + +Add Theme Airlock trust checks and preflight output to theme push and dev, with an explicit store trust command. From a0289ae60c35e301e0a82ce867888502d653ff30 Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Wed, 22 Jul 2026 14:04:33 -0400 Subject: [PATCH 18/26] Add interactive Airlock bootstrap UI --- .../cli/utilities/theme-airlock/bootstrap.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/theme/src/cli/utilities/theme-airlock/bootstrap.ts b/packages/theme/src/cli/utilities/theme-airlock/bootstrap.ts index a4628bd2df3..949a0742162 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/bootstrap.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/bootstrap.ts @@ -1,9 +1,12 @@ 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 @@ -36,6 +39,28 @@ function requiredBootstrapValue(value: string | undefined, message: string): str 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 : undefined + }, + promptEnvironment: async () => { + const environment = await renderTextPrompt({message: 'Enter a name for this theme environment'}) + return hasValue(environment) ? environment : undefined + }, + } +} + export async function bootstrapThemeAirlock( options: BootstrapOptions, ): Promise<{target: AirlockTarget; session: TSession; configurationPath: string}> { From 465037a47426d5d72d71a5ee15bd542bbb80915f Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Wed, 22 Jul 2026 14:05:09 -0400 Subject: [PATCH 19/26] Refresh generated CLI documentation --- packages/cli/README.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/cli/README.md b/packages/cli/README.md index 798716d6e56..5020a1c90c6 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. From 7b7cbc03cc4942288e8f23682308cd1704981d22 Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Wed, 22 Jul 2026 14:20:16 -0400 Subject: [PATCH 20/26] Test interactive Airlock bootstrap prompts --- .../utilities/theme-airlock/bootstrap.test.ts | 56 ++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/theme/src/cli/utilities/theme-airlock/bootstrap.test.ts b/packages/theme/src/cli/utilities/theme-airlock/bootstrap.test.ts index 447396a37f5..7081f2a132b 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/bootstrap.test.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/bootstrap.test.ts @@ -1,9 +1,12 @@ -import {bootstrapThemeAirlock} from './bootstrap.js' +import {bootstrapThemeAirlock, interactiveBootstrapUI} from './bootstrap.js' import {ThemeAirlockError} from './types.js' import {configurationFileName} from '../../constants.js' -import {describe, expect, test} from 'vitest' +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 @@ -36,6 +39,55 @@ function optionsFor(themePath: string, overrides: Partial { + 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 non-empty input unchanged', async ({prompt, message}) => { + const input = ' response ' + vi.mocked(renderTextPrompt).mockResolvedValue(input) + + const result = await interactiveBootstrapUI()[prompt]() + + expect(renderTextPrompt).toHaveBeenCalledWith({message}) + expect(result).toBe(input) + }) + + 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) => { From 1ceeb2a48819cdbd7c7ff25b7aadd1fbaa4576f2 Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Wed, 22 Jul 2026 14:39:33 -0400 Subject: [PATCH 21/26] Trim interactive Airlock bootstrap input --- .../theme/src/cli/utilities/theme-airlock/bootstrap.test.ts | 4 ++-- packages/theme/src/cli/utilities/theme-airlock/bootstrap.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/theme/src/cli/utilities/theme-airlock/bootstrap.test.ts b/packages/theme/src/cli/utilities/theme-airlock/bootstrap.test.ts index 7081f2a132b..c3221a241f4 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/bootstrap.test.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/bootstrap.test.ts @@ -66,14 +66,14 @@ describe('interactiveBootstrapUI', () => { prompt: 'promptEnvironment' as const, message: 'Enter a name for this theme environment', }, - ])('presents the $name prompt and returns non-empty input unchanged', async ({prompt, message}) => { + ])('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(input) + expect(result).toBe('response') }) test.each([ diff --git a/packages/theme/src/cli/utilities/theme-airlock/bootstrap.ts b/packages/theme/src/cli/utilities/theme-airlock/bootstrap.ts index 949a0742162..2982d54c386 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/bootstrap.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/bootstrap.ts @@ -52,11 +52,11 @@ export function interactiveBootstrapUI(): BootstrapUI { }), promptStore: async () => { const store = await renderTextPrompt({message: 'Enter the Shopify store to trust'}) - return hasValue(store) ? store : undefined + return hasValue(store) ? store.trim() : undefined }, promptEnvironment: async () => { const environment = await renderTextPrompt({message: 'Enter a name for this theme environment'}) - return hasValue(environment) ? environment : undefined + return hasValue(environment) ? environment.trim() : undefined }, } } From 6ee4b2d710f13c34d563031ace9e4a9b994b058a Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Wed, 22 Jul 2026 14:39:33 -0400 Subject: [PATCH 22/26] Refresh generated Shopify documentation --- .../generated/generated_docs_data_v2.json | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) 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", From e2b6546b0838a233fb51c12dd2aa3c82a6ce1644 Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Thu, 23 Jul 2026 12:48:04 -0400 Subject: [PATCH 23/26] Coordinate protected theme command execution --- .../theme-airlock/coordinator.test.ts | 375 ++++++++++++++++++ .../utilities/theme-airlock/coordinator.ts | 201 ++++++++++ .../theme/src/cli/utilities/theme-command.ts | 220 +++------- 3 files changed, 622 insertions(+), 174 deletions(-) create mode 100644 packages/theme/src/cli/utilities/theme-airlock/coordinator.test.ts create mode 100644 packages/theme/src/cli/utilities/theme-airlock/coordinator.ts 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..243e1504dac --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/coordinator.test.ts @@ -0,0 +1,375 @@ +import {ThemeAirlockCoordinator} from './coordinator.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 createConfiguredTheme(root: string): Promise { + const themePath = joinPath(root, 'theme') + await mkdir(themePath) + await writeFile(joinPath(themePath, configurationFileName), `[environments.default]\nstore = "${trustedStore}"\n`) + return themePath +} + +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, + } +} + +describe('ThemeAirlockCoordinator', () => { + describe('runSingle', () => { + 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..6f6cd82a73e --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-airlock/coordinator.ts @@ -0,0 +1,201 @@ +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 +} + +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( + 'This theme project is not configured. Run `theme airlock add` before running this command without a terminal.', + '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-command.ts b/packages/theme/src/cli/utilities/theme-command.ts index 555aaf3a2d6..3f7e1817afb 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -1,12 +1,9 @@ import {ensureThemeStore} from './theme-store.js' -import {loadThemeProjectTrust} from './theme-airlock/config.js' +import {ThemeAirlockCoordinator} from './theme-airlock/coordinator.js' import { - resolveBatchAirlockTargets, - resolveSingleAirlockTarget, validateAirlockBatchEnvironmentSelections, validateAirlockStoreSelectionSources, } from './theme-airlock/resolver.js' -import {bootstrapThemeAirlock, interactiveBootstrapUI} from './theme-airlock/bootstrap.js' import {renderAirlockPreflight} from './theme-airlock/preflight.js' import {ThemeAirlockError} from './theme-airlock/types.js' import {configurationFileName} from '../constants.js' @@ -39,6 +36,7 @@ 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' @@ -48,6 +46,7 @@ interface ValidEnvironment { flags: FlagValues requiresAuth: boolean storeAuthSession?: AdminSession + session?: AdminSession } type EnvironmentName = string @@ -135,16 +134,20 @@ export default abstract class ThemeCommand extends Command { return } - const {flags: airlockFlags, target, session} = await this.resolveAirlockSingle(flags, commandRequiresAuth) - const commandName = this.constructor.name.toLowerCase() - recordEvent(`theme-command:${commandName}:single-env:authenticated`) - this.airlockPreflight([target]) - - try { - await this.command(airlockFlags, session, false, args) - } finally { - await this.logAnalyticsData(session) - } + 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 } @@ -172,23 +175,20 @@ export default abstract class ThemeCommand extends Command { ) if (airlockPolicy) { - const protectedValidation = await this.validateProtectedEnvironments( - environmentsMap, - requiredFlags, - commandRequiresAuth, - ) const commandAllowsForceFlag = 'force' in klass.flags - if (commandAllowsForceFlag && !flags.force) { - const confirmed = await this.showConfirmation(this.constructor.name, requiredFlags, { - valid: protectedValidation.valid, - invalid: [], - }) - if (!confirmed) return - } - - await this.authenticateProtectedEnvironments(protectedValidation.valid) - this.airlockPreflight(protectedValidation.targets) - await this.runConcurrent(protectedValidation.valid, true) + 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 } @@ -216,49 +216,19 @@ export default abstract class ThemeCommand extends Command { renderAirlockPreflight(this.constructor.name.toLowerCase(), targets) } - private async resolveAirlockSingle( - flags: FlagValues, - commandRequiresAuth: boolean, - ): Promise<{flags: FlagValues; target: AirlockTarget; session?: AdminSession}> { - 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.argv, env: process.env}) - - if ('bootstrap' in resolution) { - if (!terminalSupportsPrompting()) { - throw new ThemeAirlockError( - 'This theme project is not configured. Run `theme airlock add` before running this command without a terminal.', - 'unconfigured-project', - ) - } - - const rememberedStore = resolution.allowRememberedCandidate ? getThemeStore() : 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.createSession({...flags, store}, undefined, false), - }) - const mutableFlags = {...flags, store: bootstrap.target.store} - return { - flags: mutableFlags, - target: bootstrap.target, - session: commandRequiresAuth ? bootstrap.session : undefined, - } - } - - const mutableFlags = {...flags, store: resolution.store} - const session = commandRequiresAuth ? await this.createSession(mutableFlags, undefined, false) : undefined - return {flags: mutableFlags, target: resolution, session} + 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), + }) } /** @@ -320,98 +290,6 @@ export default abstract class ThemeCommand extends Command { 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 async validateProtectedEnvironments( - environmentMap: Map, - requiredFlags: Exclude, - requiresAuth: boolean, - ): Promise<{valid: ValidEnvironment[]; targets: AirlockTarget[]}> { - const valid: ValidEnvironment[] = [] - const targets: AirlockTarget[] = [] - const validatedEnvironments: { - environmentName: EnvironmentName - environment: {flags: FlagValues; validationFlags: FlagValues} - target: AirlockTarget - }[] = [] - - for (const [environmentName, environment] of environmentMap.entries()) { - const themePath = typeof environment.flags.path === 'string' ? environment.flags.path : cwd() - if (!fileExistsSync(themePath)) { - throw new ThemeAirlockError( - `Invalid batch environment "${environmentName}": 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: environmentName, store: environment.flags.store as string}], - }) - if (!target) { - throw new ThemeAirlockError( - `Invalid batch environment "${environmentName}": no trust target resolved.`, - 'invalid-batch', - ) - } - - validatedEnvironments.push({environmentName, environment, target}) - } - - // Do not project cached sessions until all effective paths and trust targets have been validated. - const storeAuthSessionsByStore = requiresAuth - ? this.storeAuthSessionsForTheme(validatedEnvironments.map(({environment}) => environment.validationFlags)) - : new Map() - - for (const {environmentName, environment, target} of validatedEnvironments) { - const storeAuthSession = this.storeAuthSessionFromCache(environment.validationFlags, storeAuthSessionsByStore) - const missingFlags = this.missingRequiredFlags(environment.validationFlags, requiredFlags, storeAuthSession) - if (missingFlags.length > 0) { - throw new ThemeAirlockError( - `Invalid batch environment "${environmentName}": missing required flags: ${missingFlags.join(', ')}.`, - 'invalid-batch', - ) - } - - const mutableFlags = {...environment.flags, store: target.store} - valid.push({environment: environmentName, flags: mutableFlags, requiresAuth, storeAuthSession}) - targets.push(target) - } - - return {valid, targets} - } - - private async authenticateProtectedEnvironments(validEnvironments: ValidEnvironment[]): Promise { - const authenticatedSessionsByStore = new Map>() - - for (const environment of validEnvironments) { - 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 cachedSession = sessionsByPassword.get(password ?? '') - if (cachedSession) { - environment.storeAuthSession = cachedSession - continue - } - - // eslint-disable-next-line no-await-in-loop - const session = await this.createSession(environment.flags, environment.storeAuthSession, false) - environment.storeAuthSession = session - sessionsByPassword.set(password ?? '', session) - authenticatedSessionsByStore.set(store, sessionsByPassword) - } - } - private missingRequiredFlags( environmentFlags: FlagValues, requiredFlags: Exclude, @@ -531,20 +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 () => { - let session: AdminSession | undefined - if (requiresAuth) { - if (this.airlockPolicy() === undefined) { - session = await this.createSession(flags, storeAuthSession) - } else { - session = storeAuthSession ?? (await this.createSession(flags, undefined, false)) - } - } + const session = + requiresAuth && !suppliedSession ? await this.createSession(flags, storeAuthSession) : suppliedSession const commandName = this.constructor.name.toLowerCase() recordEvent(`theme-command:${commandName}:multi-env:authenticated`) From f2ebb82b671f008f071df130fe58bc2fc8aca18a Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Thu, 23 Jul 2026 13:31:02 -0400 Subject: [PATCH 24/26] Test trusted theme command execution --- .../theme/src/cli/commands/theme/dev.test.ts | 183 ++++++++- .../theme/src/cli/commands/theme/push.test.ts | 346 +++++++++++++++++- 2 files changed, 503 insertions(+), 26 deletions(-) diff --git a/packages/theme/src/cli/commands/theme/dev.test.ts b/packages/theme/src/cli/commands/theme/dev.test.ts index b1ef88ad5c8..3ca34fe5012 100644 --- a/packages/theme/src/cli/commands/theme/dev.test.ts +++ b/packages/theme/src/cli/commands/theme/dev.test.ts @@ -2,19 +2,36 @@ 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 {setThemeStore} from '../../services/local-storage.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' -const setThemeStoreMock = vi.hoisted(() => vi.fn()) -const findOrCreate = vi.hoisted(() => vi.fn()) -const developmentThemeManagerConstructor = vi.hoisted(() => vi.fn()) +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') @@ -24,29 +41,91 @@ 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('../../utilities/development-theme-manager.js', () => ({ - DevelopmentThemeManager: developmentThemeManagerConstructor, -})) vi.mock('../../services/local-storage.js', async () => { const actual = await vi.importActual( '../../services/local-storage.js', ) - return {...actual, setThemeStore: setThemeStoreMock} + + 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 adminSession = {token: 'test-token', storeFqdn: 'test-store.myshopify.com'} +const trustedStore = 'trusted-store.myshopify.com' +const adminSession = {token: 'test-token', storeFqdn: trustedStore} +const developmentTheme = {id: 1, createdAtRuntime: false} -async function run() { +async function run(args: string[]) { await CommandConfig.load() - const command = new Dev(['--store=test-store.myshopify.com'], CommandConfig) + 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(() => { - developmentThemeManagerConstructor.mockImplementation(() => ({findOrCreate})) - findOrCreate.mockResolvedValue({id: 1, createdAtRuntime: false}) + vi.spyOn(DevelopmentThemeManager.prototype, 'findOrCreate').mockResolvedValue(developmentTheme as never) vi.mocked(loadThemeProjectTrust).mockRejectedValue( new ThemeAirlockError('Theme project trust is blocked', 'unconfigured-project'), ) @@ -59,7 +138,7 @@ describe('theme dev', () => { }) test('blocks before development lifecycle', async () => { - await expect(run()).rejects.toMatchObject({reason: 'unconfigured-project'}) + await expect(run(['--store=test-store.myshopify.com'])).rejects.toMatchObject({reason: 'unconfigured-project'}) expect(loadThemeProjectTrust).toHaveBeenCalledOnce() expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() @@ -67,10 +146,82 @@ describe('theme dev', () => { expect(setThemeStore).not.toHaveBeenCalled() expect(dev).not.toHaveBeenCalled() expect(findOrSelectTheme).not.toHaveBeenCalled() - expect(developmentThemeManagerConstructor).not.toHaveBeenCalled() - expect(findOrCreate).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/push.test.ts b/packages/theme/src/cli/commands/theme/push.test.ts index d47f7ab87e1..a91d02e1212 100644 --- a/packages/theme/src/cli/commands/theme/push.test.ts +++ b/packages/theme/src/cli/commands/theme/push.test.ts @@ -1,44 +1,173 @@ 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 {push} from '../../services/push.js' -import {setThemeStore} from '../../services/local-storage.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 {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' -import {renderConcurrent} from '@shopify/cli-kit/node/ui' +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' -const setThemeStoreMock = vi.hoisted(() => vi.fn()) +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') +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', ) - return {...actual, setThemeStore: setThemeStoreMock} + + 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 adminSession = {token: 'test-token', storeFqdn: 'test-store.myshopify.com'} +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(['--store=test-store.myshopify.com', ...args], CommandConfig) + 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) }) @@ -46,7 +175,9 @@ describe('theme push', () => { {name: 'without force', args: []}, {name: 'with force', args: ['--force']}, ])('blocks before upload lifecycle $name', async ({args}) => { - await expect(run(args)).rejects.toMatchObject({reason: 'unconfigured-project'}) + await expect(run(['--store=test-store.myshopify.com', ...args])).rejects.toMatchObject({ + reason: 'unconfigured-project', + }) expect(loadThemeProjectTrust).toHaveBeenCalledOnce() expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() @@ -55,4 +186,199 @@ describe('theme push', () => { 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() + }) + }) }) From c6aae2f35c92fac2c4b0f51deb0d0d432dbf8faa Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Thu, 23 Jul 2026 13:45:52 -0400 Subject: [PATCH 25/26] Extract the Airlock trust service --- .../cli/commands/theme/airlock/add.test.ts | 322 +++++------------- .../src/cli/commands/theme/airlock/add.ts | 26 +- .../cli/services/theme-airlock-add.test.ts | 182 ++++++++++ .../src/cli/services/theme-airlock-add.ts | 39 +++ 4 files changed, 319 insertions(+), 250 deletions(-) create mode 100644 packages/theme/src/cli/services/theme-airlock-add.test.ts create mode 100644 packages/theme/src/cli/services/theme-airlock-add.ts diff --git a/packages/theme/src/cli/commands/theme/airlock/add.test.ts b/packages/theme/src/cli/commands/theme/airlock/add.test.ts index a451746bfb5..80e1360871a 100644 --- a/packages/theme/src/cli/commands/theme/airlock/add.test.ts +++ b/packages/theme/src/cli/commands/theme/airlock/add.test.ts @@ -1,18 +1,49 @@ import Add from './add.js' -import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' -import {renderSuccess} from '@shopify/cli-kit/node/ui' +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 {fileExistsSync, inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' +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 {describe, expect, test, vi, beforeEach} from 'vitest' +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 session = {token: 'test-token', storeFqdn: 'trusted-store.myshopify.com'} -const configurationFileName = 'shopify.theme.toml' +const serviceResult = { + environment: 'preview', + store: 'trusted-store.myshopify.com', + configurationPath: '/theme/shopify.theme.toml', +} async function runCommand(argv: string[]) { await CommandConfig.load() @@ -20,272 +51,95 @@ async function runCommand(argv: string[]) { return command.run() } -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 -} - describe('theme airlock add', () => { beforeEach(() => { - vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) + vi.mocked(themeAirlockAdd).mockResolvedValue(serviceResult) }) - test('rejects a missing store argument without authenticating or writing', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const themePath = await createTheme(tmpDir) - - await expect(runCommand(['--path', themePath, '--environment', 'preview'])).rejects.toThrow() + test('rejects a missing store argument before calling the service', async () => { + await expect(runCommand(['--path', '/theme', '--environment', 'preview'])).rejects.toThrow() - expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() - expect(fileExistsSync(joinPath(themePath, configurationFileName))).toBe(false) - }) + expect(themeAirlockAdd).not.toHaveBeenCalled() + expect(renderSuccess).not.toHaveBeenCalled() }) - test('rejects a missing environment flag without authenticating or writing', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const themePath = await createTheme(tmpDir) - - await expect(runCommand(['trusted-store', '--path', themePath])).rejects.toThrow() + test('rejects a missing environment flag before calling the service', async () => { + await expect(runCommand(['trusted-store', '--path', '/theme'])).rejects.toThrow() - expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() - expect(fileExistsSync(joinPath(themePath, configurationFileName))).toBe(false) - }) + expect(themeAirlockAdd).not.toHaveBeenCalled() + expect(renderSuccess).not.toHaveBeenCalled() }) - test('rejects an empty environment before authenticating or writing', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const themePath = await createTheme(tmpDir) - - await expect(runCommand(['trusted-store', '--path', themePath, '--environment', ' \t'])).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('normalizes the store before authentication and writing', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const themePath = await createTheme(tmpDir) - + 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 ', - ]) - - expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('trusted-store.myshopify.com', undefined) - await expect(readFile(joinPath(themePath, configurationFileName))).resolves.toContain( - '[environments.preview]\nstore = "trusted-store.myshopify.com"', - ) - }) - }) - - test('authenticates before creating a new configuration file', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const themePath = await createTheme(tmpDir) - const configurationPath = joinPath(themePath, configurationFileName) - vi.mocked(ensureAuthenticatedThemes).mockImplementation(async () => { - expect(fileExistsSync(configurationPath)).toBe(false) - return session - }) - - await runCommand(['trusted-store', '--path', themePath, '--environment', 'preview']) - - expect(fileExistsSync(configurationPath)).toBe(true) - }) - }) - - test('passes the password to authentication exactly', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const themePath = await createTheme(tmpDir) - - await runCommand([ - 'trusted-store', - '--path', - themePath, - '--environment', - 'preview', '--password', 'shptka_secret', ]) - expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('trusted-store.myshopify.com', 'shptka_secret') + expect(themeAirlockAdd).toHaveBeenCalledOnce() + expect(themeAirlockAdd).toHaveBeenCalledWith({ + themePath, + environment: ' preview ', + store: 'https://TRUSTED-STORE.myshopify.com/admin/', + password: 'shptka_secret', + }) }) }) - test('does not create a file when authentication fails', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const themePath = await createTheme(tmpDir) - vi.mocked(ensureAuthenticatedThemes).mockRejectedValue(new Error('authentication failed')) - - await expect(runCommand(['trusted-store', '--path', themePath, '--environment', 'preview'])).rejects.toThrow( - 'authentication failed', - ) + test('renders success from the service result', async () => { + await inTemporaryDirectory(async (themePath) => { + await runCommand(['trusted-store', '--path', themePath, '--environment', 'preview']) - expect(fileExistsSync(joinPath(themePath, configurationFileName))).toBe(false) - expect(renderSuccess).not.toHaveBeenCalled() + expect(renderSuccess).toHaveBeenCalledWith({ + headline: 'Store added to Theme Airlock.', + body: [ + 'Environment: preview', + 'Store: trusted-store.myshopify.com', + 'Configuration: /theme/shopify.theme.toml', + ], + }) }) }) - test('preserves an existing file byte-for-byte when authentication fails', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const themePath = await createTheme(tmpDir) - const original = '# Existing trust\nname = "theme"\n' - const configurationPath = await writeConfiguration(themePath, original) - vi.mocked(ensureAuthenticatedThemes).mockRejectedValue(new Error('authentication failed')) - - await expect(runCommand(['trusted-store', '--path', themePath, '--environment', 'preview'])).rejects.toThrow( - 'authentication failed', + 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', ) - - await expect(readFile(configurationPath)).resolves.toBe(original) - expect(renderSuccess).not.toHaveBeenCalled() - }) - }) - - test('creates a new trust configuration after authentication', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const themePath = await createTheme(tmpDir) + 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"', ) - expect(renderSuccess).toHaveBeenCalledOnce() - }) - }) - - test('preserves comments and unrelated keys when patching an existing file', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const themePath = await createTheme(tmpDir) - const configurationPath = await writeConfiguration( - tmpDir, - '# Keep this comment\nname = "theme"\n\n[other]\nvalue = true\n', - ) - - await runCommand(['trusted-store', '--path', themePath, '--environment', 'preview']) - - 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 = "trusted-store.myshopify.com"') - }) - }) - - test('is idempotent for the same environment and store after authentication', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const themePath = await createTheme(tmpDir) - const configurationPath = await writeConfiguration( - themePath, - '[environments.preview]\nstore = "trusted-store.myshopify.com"\n', - ) - const original = await readFile(configurationPath) - - await runCommand(['trusted-store', '--path', themePath, '--environment', 'preview']) - - expect(ensureAuthenticatedThemes).toHaveBeenCalledOnce() - await expect(readFile(configurationPath)).resolves.toBe(original) - }) - }) - - test('propagates an environment conflict after authentication without changing bytes', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const themePath = await createTheme(tmpDir) - const configurationPath = await writeConfiguration( - themePath, - '[environments.preview]\nstore = "other-store.myshopify.com"\n', - ) - const original = await readFile(configurationPath) - - await expect( - runCommand(['trusted-store', '--path', themePath, '--environment', 'preview']), - ).rejects.toMatchObject({reason: 'environment-conflict'}) - - expect(ensureAuthenticatedThemes).toHaveBeenCalledOnce() - await expect(readFile(configurationPath)).resolves.toBe(original) - expect(renderSuccess).not.toHaveBeenCalled() - }) - }) - - test('propagates a normalized store conflict after authentication without changing bytes', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const themePath = await createTheme(tmpDir) - const configurationPath = await writeConfiguration( - themePath, - '[environments.default]\nstore = "trusted-store.myshopify.com"\n', - ) - const original = await readFile(configurationPath) - - await expect( - runCommand(['https://TRUSTED-STORE.myshopify.com/', '--path', themePath, '--environment', 'preview']), - ).rejects.toMatchObject({reason: 'store-conflict'}) - - expect(ensureAuthenticatedThemes).toHaveBeenCalledOnce() - await expect(readFile(configurationPath)).resolves.toBe(original) - expect(renderSuccess).not.toHaveBeenCalled() - }) - }) - - test('uses the effective path and nearest existing 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') - - await runCommand(['trusted-store', '--path', themePath, '--environment', 'preview']) - - await expect(readFile(configurationPath)).resolves.toContain('[environments.preview]') - expect(fileExistsSync(joinPath(themePath, configurationFileName))).toBe(false) - }) - }) - - test('renders normalized store, environment, and configuration path after writing', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const themePath = await createTheme(tmpDir) - const configurationPath = joinPath(themePath, configurationFileName) - - await runCommand(['https://TRUSTED-STORE.myshopify.com/', '--path', themePath, '--environment', ' preview ']) - - expect(renderSuccess).toHaveBeenCalledWith({ - headline: 'Store added to Theme Airlock.', - body: expect.arrayContaining([ - expect.stringContaining('preview'), - expect.stringContaining('trusted-store.myshopify.com'), - expect.stringContaining(configurationPath), - ]), - }) }) }) test('exposes the standard auth-alias base flag', () => { expect(Add.baseFlags).toBe(authAliasFlag) }) - - test('does not declare bypass, force, or yes flags', () => { - expect(Add.flags).not.toHaveProperty('bypass') - expect(Add.flags).not.toHaveProperty('force') - expect(Add.flags).not.toHaveProperty('yes') - }) - - test('does not import or call global store utilities', async () => { - const source = await readFile(joinPath(__dirname, 'add.ts')) - - expect(source).not.toMatch(/ensureThemeStore|getThemeStore|setThemeStore/) - expect(source).not.toMatch(/node\/store/) - }) }) diff --git a/packages/theme/src/cli/commands/theme/airlock/add.ts b/packages/theme/src/cli/commands/theme/airlock/add.ts index b46759732b2..12cbe45933f 100644 --- a/packages/theme/src/cli/commands/theme/airlock/add.ts +++ b/packages/theme/src/cli/commands/theme/airlock/add.ts @@ -1,11 +1,8 @@ -import {addTrustedThemeEnvironment} from '../../../utilities/theme-airlock/writer.js' -import {ThemeAirlockError} from '../../../utilities/theme-airlock/types.js' +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 {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' -import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {renderSuccess} from '@shopify/cli-kit/node/ui' export default class Add extends BaseCommand { @@ -33,23 +30,20 @@ export default class Add extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(Add) - const environment = flags.environment.trim() - if (!environment) { - throw new ThemeAirlockError("Environment name to add can't be empty.", 'invalid-environment') - } - - const store = normalizeStoreFqdn(args.store) - await ensureAuthenticatedThemes(store, flags.password) - - const result = await addTrustedThemeEnvironment({ + const result = await themeAirlockAdd({ themePath: flags.path, - environment, - store, + environment: flags.environment, + store: args.store, + password: flags.password, }) renderSuccess({ headline: 'Store added to Theme Airlock.', - body: [`Environment: ${environment}`, `Store: ${result.store}`, `Configuration: ${result.path}`], + body: [ + `Environment: ${result.environment}`, + `Store: ${result.store}`, + `Configuration: ${result.configurationPath}`, + ], }) } } 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, + } +} From d270f1eb181f316f67ae526ab6bf61ae83a4b572 Mon Sep 17 00:00:00 2001 From: Nick Hayden Date: Thu, 23 Jul 2026 13:56:55 -0400 Subject: [PATCH 26/26] Document the Airlock trust migration --- .changeset/theme-airlock-trust-preflight.md | 2 +- UPGRADE_GUIDE.md | 19 ++ packages/cli/README.md | 6 + packages/cli/oclif.manifest.json | 8 +- packages/theme/src/cli/commands/theme/dev.ts | 2 + packages/theme/src/cli/commands/theme/push.ts | 2 + .../theme-airlock/coordinator.test.ts | 198 +++++++++++++++++- .../utilities/theme-airlock/coordinator.ts | 19 +- 8 files changed, 245 insertions(+), 11 deletions(-) diff --git a/.changeset/theme-airlock-trust-preflight.md b/.changeset/theme-airlock-trust-preflight.md index ae588c8c14c..a5bde1e1ab3 100644 --- a/.changeset/theme-airlock-trust-preflight.md +++ b/.changeset/theme-airlock-trust-preflight.md @@ -2,4 +2,4 @@ '@shopify/theme': minor --- -Add Theme Airlock trust checks and preflight output to theme push and dev, with an explicit store trust command. +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/packages/cli/README.md b/packages/cli/README.md index 5020a1c90c6..643824eaefc 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4283,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 @@ -5115,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 20e47981a87..5e994ec7814 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -7728,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": { @@ -9189,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/src/cli/commands/theme/dev.ts b/packages/theme/src/cli/commands/theme/dev.ts index 6890fe6ec57..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. diff --git a/packages/theme/src/cli/commands/theme/push.ts b/packages/theme/src/cli/commands/theme/push.ts index cf78b750662..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). diff --git a/packages/theme/src/cli/utilities/theme-airlock/coordinator.test.ts b/packages/theme/src/cli/utilities/theme-airlock/coordinator.test.ts index 243e1504dac..caf02d9758c 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/coordinator.test.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/coordinator.test.ts @@ -1,4 +1,5 @@ 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' @@ -14,13 +15,22 @@ const session: AdminSession = {token: 'theme-token', storeFqdn: trustedStore} type CoordinatorOptions = ConstructorParameters[0] -async function createConfiguredTheme(root: string): Promise { +async function createUnconfiguredTheme(root: string): Promise { const themePath = joinPath(root, 'theme') await mkdir(themePath) - await writeFile(joinPath(themePath, configurationFileName), `[environments.default]\nstore = "${trustedStore}"\n`) 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) @@ -62,8 +72,192 @@ function environment(name: string, path: string, store: string, password?: strin } } +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) diff --git a/packages/theme/src/cli/utilities/theme-airlock/coordinator.ts b/packages/theme/src/cli/utilities/theme-airlock/coordinator.ts index 6f6cd82a73e..0534eee853b 100644 --- a/packages/theme/src/cli/utilities/theme-airlock/coordinator.ts +++ b/packages/theme/src/cli/utilities/theme-airlock/coordinator.ts @@ -35,6 +35,20 @@ interface RunSingleOptions { 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 @@ -79,10 +93,7 @@ export class ThemeAirlockCoordinator { if ('bootstrap' in resolution) { if (!this.options.supportsPrompting()) { - throw new ThemeAirlockError( - 'This theme project is not configured. Run `theme airlock add` before running this command without a terminal.', - 'unconfigured-project', - ) + throw new ThemeAirlockError(nonInteractiveSetupGuidance(resolution), 'unconfigured-project') } const rememberedStore = resolution.allowRememberedCandidate ? this.options.rememberedStore() : undefined