diff --git a/.changeset/olive-pugs-shave.md b/.changeset/olive-pugs-shave.md new file mode 100644 index 0000000000..e5291c265b --- /dev/null +++ b/.changeset/olive-pugs-shave.md @@ -0,0 +1,6 @@ +--- +'@shopify/cli-hydrogen': patch +'@shopify/create-hydrogen': patch +--- + +Fix Hydrogen monorepo detection when the repository path contains spaces or other characters that are percent-encoded in URLs. The path is now decoded before it is used to look for the skeleton template. diff --git a/packages/cli/src/lib/build.test.ts b/packages/cli/src/lib/build.test.ts new file mode 100644 index 0000000000..911cdd87d1 --- /dev/null +++ b/packages/cli/src/lib/build.test.ts @@ -0,0 +1,52 @@ +import {describe, it, expect} from 'vitest'; +import {pathToFileURL} from 'node:url'; +import {inTemporaryDirectory, mkdir} from '@shopify/cli-kit/node/fs'; +import {joinPath} from '@shopify/cli-kit/node/path'; +import {detectHydrogenMonorepo, getMonorepoPackagesPath} from './build.js'; + +// Mirrors the location of this file inside the monorepo, since monorepo +// detection resolves `packages/` relative to the module that asks for it. +const MODULE_PATH_IN_REPO = ['packages', 'cli', 'src', 'lib', 'build.ts']; + +function moduleUrlFor(repoRoot: string) { + return pathToFileURL(joinPath(repoRoot, ...MODULE_PATH_IN_REPO)); +} + +async function createFakeMonorepo(repoRoot: string) { + await mkdir(joinPath(repoRoot, ...MODULE_PATH_IN_REPO.slice(0, -1))); + await mkdir(joinPath(repoRoot, 'templates', 'skeleton')); +} + +describe('monorepo detection', () => { + it('detects the monorepo when the path contains a space', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // The space is what regressed: `URL.pathname` percent-encodes it, and + // the encoded path does not exist on disk. + const repoRoot = joinPath(tmpDir, 'Open Source', 'hydrogen'); + await createFakeMonorepo(repoRoot); + + expect(detectHydrogenMonorepo(moduleUrlFor(repoRoot))).toBe(true); + expect(getMonorepoPackagesPath(moduleUrlFor(repoRoot))).toContain( + 'Open Source', + ); + }); + }); + + it('detects the monorepo when the path contains no space', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const repoRoot = joinPath(tmpDir, 'hydrogen'); + await createFakeMonorepo(repoRoot); + + expect(detectHydrogenMonorepo(moduleUrlFor(repoRoot))).toBe(true); + }); + }); + + it('does not detect a monorepo without templates/skeleton', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const repoRoot = joinPath(tmpDir, 'Open Source', 'not-hydrogen'); + await mkdir(joinPath(repoRoot, ...MODULE_PATH_IN_REPO.slice(0, -1))); + + expect(detectHydrogenMonorepo(moduleUrlFor(repoRoot))).toBe(false); + }); + }); +}); diff --git a/packages/cli/src/lib/build.ts b/packages/cli/src/lib/build.ts index 6aeeb37c5d..99b1505972 100644 --- a/packages/cli/src/lib/build.ts +++ b/packages/cli/src/lib/build.ts @@ -5,17 +5,22 @@ import {AbortError} from '@shopify/cli-kit/node/error'; import {dirname, joinPath} from '@shopify/cli-kit/node/path'; import {execAsync} from './process.js'; -// Avoid using fileURLToPath here to prevent backslashes nightmare on Windows -const monorepoPackagesPath = new URL('../../..', import.meta.url).pathname; +// Resolve the `packages/` directory that contains this file. +// Avoid using fileURLToPath here to prevent backslashes nightmare on Windows. +// URL pathnames are percent-encoded, so decode to support directories that +// contain spaces or other reserved characters. +export function getMonorepoPackagesPath(moduleUrl: string | URL) { + return decodeURIComponent(new URL('../../..', moduleUrl).pathname); +} // Check if we're in the Hydrogen monorepo by checking the path structure // This was the original logic before PR #3074 that worked for over a year // Check if we're in the monorepo by looking for the templates/skeleton directory // This works both in development and when built as npm package -export const isHydrogenMonorepo = (() => { +export function detectHydrogenMonorepo(moduleUrl: string | URL) { try { const skeletonPath = joinPath( - dirname(monorepoPackagesPath), + dirname(getMonorepoPackagesPath(moduleUrl)), 'templates', 'skeleton', ); @@ -25,7 +30,11 @@ export const isHydrogenMonorepo = (() => { } catch { return false; } -})(); +} + +const monorepoPackagesPath = getMonorepoPackagesPath(import.meta.url); + +export const isHydrogenMonorepo = detectHydrogenMonorepo(import.meta.url); export const hydrogenPackagesPath = isHydrogenMonorepo ? monorepoPackagesPath : undefined;