diff --git a/.changeset/ten-singers-rhyme.md b/.changeset/ten-singers-rhyme.md new file mode 100644 index 0000000000..867ce6d0eb --- /dev/null +++ b/.changeset/ten-singers-rhyme.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-hydrogen': minor +--- + +Support scaffolding projects based on examples in Hydrogen repo using the `--template` flag. Example: `npm create @shopify/hydrogen@latest -- --template multipass`. diff --git a/examples/optimistic-cart-ui/app/components/Cart.tsx b/examples/optimistic-cart-ui/app/components/Cart.tsx index e75f1fc581..d98bf0a487 100644 --- a/examples/optimistic-cart-ui/app/components/Cart.tsx +++ b/examples/optimistic-cart-ui/app/components/Cart.tsx @@ -2,7 +2,7 @@ import {CartForm, Image, Money} from '@shopify/hydrogen'; import type {CartLineUpdateInput} from '@shopify/hydrogen/storefront-api-types'; import {Link} from '@remix-run/react'; import type {CartApiQueryFragment} from 'storefrontapi.generated'; -import {useVariantUrl} from '~/utils'; +import {useVariantUrl} from '~/lib/variants'; // 1. Import the OptimisticInput and useOptimisticData hooks from @shopify/hydrogen import {OptimisticInput, useOptimisticData} from '@shopify/hydrogen'; diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index d171d133a5..ff2b9dfee3 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -405,7 +405,7 @@ "template": { "name": "template", "type": "option", - "description": "Sets the template to use. Pass `demo-store` for a fully-featured store template or `hello-world` for a barebones project.", + "description": "Scaffolds project based on an existing template or example from the Hydrogen repository.", "multiple": false }, "install-deps": { diff --git a/packages/cli/src/commands/hydrogen/init.test.ts b/packages/cli/src/commands/hydrogen/init.test.ts index 30f4764350..f98530d04c 100644 --- a/packages/cli/src/commands/hydrogen/init.test.ts +++ b/packages/cli/src/commands/hydrogen/init.test.ts @@ -3,6 +3,7 @@ import {describe, it, expect, vi, beforeEach} from 'vitest'; import {runInit} from './init.js'; import {exec} from '@shopify/cli-kit/node/system'; import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'; +import {readAndParsePackageJson} from '@shopify/cli-kit/node/node-package-manager'; import { fileExists, isDirectory, @@ -33,6 +34,9 @@ vi.mock('../../lib/template-downloader.js', async () => ({ templatesDir: fileURLToPath( new URL('../../../../../templates', import.meta.url), ), + examplesDir: fileURLToPath( + new URL('../../../../../examples', import.meta.url), + ), }), })); @@ -131,6 +135,10 @@ describe('init', () => { describe('remote templates', () => { it('throws for unknown templates', async () => { + const processExit = vi + .spyOn(process, 'exit') + .mockImplementationOnce((() => {}) as any); + await inTemporaryDirectory(async (tmpDir) => { await expect( runInit({ @@ -139,8 +147,13 @@ describe('init', () => { language: 'ts', template: 'https://github.com/some/repo', }), - ).rejects.toThrow('supported'); + ).resolves; }); + + expect(outputMock.error()).toMatch('--template'); + expect(processExit).toHaveBeenCalledWith(1); + + processExit.mockRestore(); }); it('creates basic projects', async () => { @@ -152,24 +165,24 @@ describe('init', () => { template: 'hello-world', }); - const helloWorldFiles = await glob('**/*', { + const templateFiles = await glob('**/*', { cwd: getSkeletonSourceDir().replace('skeleton', 'hello-world'), ignore: ['**/node_modules/**', '**/dist/**'], }); - const projectFiles = await glob('**/*', {cwd: tmpDir}); - const nonAppFiles = helloWorldFiles.filter( + const resultFiles = await glob('**/*', {cwd: tmpDir}); + const nonAppFiles = templateFiles.filter( (item) => !item.startsWith('app/'), ); - expect(projectFiles).toEqual(expect.arrayContaining(nonAppFiles)); + expect(resultFiles).toEqual(expect.arrayContaining(nonAppFiles)); - expect(projectFiles).toContain('app/root.tsx'); - expect(projectFiles).toContain('app/entry.client.tsx'); - expect(projectFiles).toContain('app/entry.server.tsx'); - expect(projectFiles).not.toContain('app/components/Layout.tsx'); + expect(resultFiles).toContain('app/root.tsx'); + expect(resultFiles).toContain('app/entry.client.tsx'); + expect(resultFiles).toContain('app/entry.server.tsx'); + expect(resultFiles).not.toContain('app/components/Layout.tsx'); // Skip routes: - expect(projectFiles).not.toContain('app/routes/_index.tsx'); + expect(resultFiles).not.toContain('app/routes/_index.tsx'); await expect(readFile(`${tmpDir}/package.json`)).resolves.toMatch( `"name": "hello-world"`, @@ -189,6 +202,77 @@ describe('init', () => { }); }); + it('applies diff for examples', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const exampleName = 'third-party-queries-caching'; + + await runInit({ + path: tmpDir, + git: false, + language: 'ts', + template: exampleName, + }); + + const templatePath = getSkeletonSourceDir(); + const examplePath = templatePath + .replace('templates', 'examples') + .replace('skeleton', exampleName); + + // --- Test file diff + const ignore = ['**/node_modules/**', '**/dist/**']; + const resultFiles = await glob('**/*', {ignore, cwd: tmpDir}); + const templateFiles = await glob('**/*', {ignore, cwd: templatePath}); + const exampleFiles = await glob('**/*', {ignore, cwd: examplePath}); + + expect(resultFiles).toEqual( + expect.arrayContaining([ + ...new Set([...templateFiles, ...exampleFiles]), + ]), + ); + + // --- Test package.json merge + const templatePkgJson = await readAndParsePackageJson( + `${templatePath}/package.json`, + ); + const examplePkgJson = await readAndParsePackageJson( + `${examplePath}/package.json`, + ); + const resultPkgJson = await readAndParsePackageJson( + `${tmpDir}/package.json`, + ); + + expect(resultPkgJson.name).toMatch(exampleName); + + expect(resultPkgJson.scripts).toEqual( + expect.objectContaining(templatePkgJson.scripts), + ); + + expect(resultPkgJson.dependencies).toEqual( + expect.objectContaining({ + ...templatePkgJson.dependencies, + ...examplePkgJson.dependencies, + }), + ); + expect(resultPkgJson.devDependencies).toEqual( + expect.objectContaining({ + ...templatePkgJson.devDependencies, + ...examplePkgJson.devDependencies, + }), + ); + expect(resultPkgJson.peerDependencies).toEqual( + expect.objectContaining({ + ...templatePkgJson.peerDependencies, + ...examplePkgJson.peerDependencies, + }), + ); + + // --- Keeps original tsconfig.json + expect(await readFile(joinPath(templatePath, 'tsconfig.json'))).toEqual( + await readFile(joinPath(tmpDir, 'tsconfig.json')), + ); + }); + }); + it('transpiles projects to JS', async () => { await inTemporaryDirectory(async (tmpDir) => { await runInit({ @@ -198,15 +282,15 @@ describe('init', () => { template: 'hello-world', }); - const helloWorldFiles = await glob('**/*', { + const templateFiles = await glob('**/*', { cwd: getSkeletonSourceDir().replace('skeleton', 'hello-world'), ignore: ['**/node_modules/**', '**/dist/**'], }); - const projectFiles = await glob('**/*', {cwd: tmpDir}); + const resultFiles = await glob('**/*', {cwd: tmpDir}); - expect(projectFiles).toEqual( + expect(resultFiles).toEqual( expect.arrayContaining( - helloWorldFiles + templateFiles .filter((item) => !item.endsWith('.d.ts')) .map((item) => item @@ -239,24 +323,24 @@ describe('init', () => { mockShop: true, }); - const skeletonFiles = await glob('**/*', { + const templateFiles = await glob('**/*', { cwd: getSkeletonSourceDir(), ignore: ['**/node_modules/**', '**/dist/**'], }); - const projectFiles = await glob('**/*', {cwd: tmpDir}); - const nonAppFiles = skeletonFiles.filter( + const resultFiles = await glob('**/*', {cwd: tmpDir}); + const nonAppFiles = templateFiles.filter( (item) => !item.startsWith('app/'), ); - expect(projectFiles).toEqual(expect.arrayContaining(nonAppFiles)); + expect(resultFiles).toEqual(expect.arrayContaining(nonAppFiles)); - expect(projectFiles).toContain('app/root.tsx'); - expect(projectFiles).toContain('app/entry.client.tsx'); - expect(projectFiles).toContain('app/entry.server.tsx'); - expect(projectFiles).toContain('app/components/Layout.tsx'); + expect(resultFiles).toContain('app/root.tsx'); + expect(resultFiles).toContain('app/entry.client.tsx'); + expect(resultFiles).toContain('app/entry.server.tsx'); + expect(resultFiles).toContain('app/components/Layout.tsx'); // Skip routes: - expect(projectFiles).not.toContain('app/routes/_index.tsx'); + expect(resultFiles).not.toContain('app/routes/_index.tsx'); // Not modified: await expect(readFile(`${tmpDir}/server.ts`)).resolves.toEqual( @@ -292,14 +376,14 @@ describe('init', () => { await inTemporaryDirectory(async (tmpDir) => { await runInit({path: tmpDir, git: false, routes: true, language: 'ts'}); - const skeletonFiles = await glob('**/*', { + const templateFiles = await glob('**/*', { cwd: getSkeletonSourceDir(), ignore: ['**/node_modules/**', '**/dist/**'], }); - const projectFiles = await glob('**/*', {cwd: tmpDir}); + const resultFiles = await glob('**/*', {cwd: tmpDir}); - expect(projectFiles).toEqual(expect.arrayContaining(skeletonFiles)); - expect(projectFiles).toContain('app/routes/_index.tsx'); + expect(resultFiles).toEqual(expect.arrayContaining(templateFiles)); + expect(resultFiles).toContain('app/routes/_index.tsx'); // Not modified: await expect(readFile(`${tmpDir}/server.ts`)).resolves.toEqual( @@ -321,15 +405,15 @@ describe('init', () => { await inTemporaryDirectory(async (tmpDir) => { await runInit({path: tmpDir, git: false, routes: true, language: 'js'}); - const skeletonFiles = await glob('**/*', { + const templateFiles = await glob('**/*', { cwd: getSkeletonSourceDir(), ignore: ['**/node_modules/**', '**/dist/**'], }); - const projectFiles = await glob('**/*', {cwd: tmpDir}); + const resultFiles = await glob('**/*', {cwd: tmpDir}); - expect(projectFiles).toEqual( + expect(resultFiles).toEqual( expect.arrayContaining( - skeletonFiles + templateFiles .filter((item) => !item.endsWith('.d.ts')) .map((item) => item @@ -339,7 +423,7 @@ describe('init', () => { ), ); - expect(projectFiles).toContain('app/routes/_index.jsx'); + expect(resultFiles).toContain('app/routes/_index.jsx'); // No types but JSDocs: await expect(readFile(`${tmpDir}/server.js`)).resolves.toMatch( @@ -454,8 +538,8 @@ describe('init', () => { routes: true, }); - const projectFiles = await glob('**/*', {cwd: tmpDir}); - expect(projectFiles).toContain('app/routes/_index.tsx'); + const resultFiles = await glob('**/*', {cwd: tmpDir}); + expect(resultFiles).toContain('app/routes/_index.tsx'); // Injects styles in Root const serverFile = await readFile(`${tmpDir}/server.ts`); @@ -479,8 +563,8 @@ describe('init', () => { routes: true, }); - const projectFiles = await glob('**/*', {cwd: tmpDir}); - expect(projectFiles).toContain('app/routes/_index.tsx'); + const resultFiles = await glob('**/*', {cwd: tmpDir}); + expect(resultFiles).toContain('app/routes/_index.tsx'); // Injects styles in Root const serverFile = await readFile(`${tmpDir}/server.ts`); @@ -504,9 +588,9 @@ describe('init', () => { routes: true, }); - const projectFiles = await glob('**/*', {cwd: tmpDir}); + const resultFiles = await glob('**/*', {cwd: tmpDir}); // Adds locale to the path - expect(projectFiles).toContain('app/routes/($locale)._index.tsx'); + expect(resultFiles).toContain('app/routes/($locale)._index.tsx'); // Injects styles in Root const serverFile = await readFile(`${tmpDir}/server.ts`); diff --git a/packages/cli/src/commands/hydrogen/init.ts b/packages/cli/src/commands/hydrogen/init.ts index b908d2ad9e..be6eb5f510 100644 --- a/packages/cli/src/commands/hydrogen/init.ts +++ b/packages/cli/src/commands/hydrogen/init.ts @@ -40,7 +40,7 @@ export default class Init extends Command { }), template: Flags.string({ description: - 'Sets the template to use. Pass `demo-store` for a fully-featured store template or `hello-world` for a barebones project.', + 'Scaffolds project based on an existing template or example from the Hydrogen repository.', env: 'SHOPIFY_HYDROGEN_FLAG_TEMPLATE', }), 'install-deps': commonFlags.installDeps, diff --git a/packages/cli/src/lib/onboarding/common.ts b/packages/cli/src/lib/onboarding/common.ts index 29a3c82655..a57dfc777e 100644 --- a/packages/cli/src/lib/onboarding/common.ts +++ b/packages/cli/src/lib/onboarding/common.ts @@ -690,12 +690,15 @@ export async function renderProjectReady( export function createAbortHandler( controller: AbortController, - project: {directory: string}, + project?: {directory: string}, ) { return async function abort(error: AbortError): Promise { controller.abort(); - if (typeof project !== 'undefined') { + // Give time to hide prompts before showing error + await Promise.resolve(); + + if (project?.directory) { await rmdir(project!.directory, {force: true}).catch(() => {}); } @@ -710,6 +713,8 @@ export function createAbortHandler( console.error(error); } + // This code runs asynchronously so throwing here + // turns into an unhandled rejection. Exit process instead: process.exit(1); }; } diff --git a/packages/cli/src/lib/onboarding/local.ts b/packages/cli/src/lib/onboarding/local.ts index 0b0ea2eef9..d7188af75a 100644 --- a/packages/cli/src/lib/onboarding/local.ts +++ b/packages/cli/src/lib/onboarding/local.ts @@ -37,7 +37,7 @@ import {ALIAS_NAME, getCliCommand} from '../shell.js'; import {CSS_STRATEGY_NAME_MAP} from '../setups/css/index.js'; /** - * Flow for setting up a project from the locally bundled starter template (hello-world). + * Flow for setting up a project from the locally bundled starter template (skeleton). */ export async function setupLocalStarterTemplate( options: InitOptions, diff --git a/packages/cli/src/lib/onboarding/remote.ts b/packages/cli/src/lib/onboarding/remote.ts index c97f2b5927..86467cc80a 100644 --- a/packages/cli/src/lib/onboarding/remote.ts +++ b/packages/cli/src/lib/onboarding/remote.ts @@ -1,9 +1,11 @@ import {AbortError} from '@shopify/cli-kit/node/error'; import {AbortController} from '@shopify/cli-kit/node/abort'; -import {copyFile} from '@shopify/cli-kit/node/fs'; +import {copyFile, fileExists} from '@shopify/cli-kit/node/fs'; +import {readAndParsePackageJson} from '@shopify/cli-kit/node/node-package-manager'; import {joinPath} from '@shopify/cli-kit/node/path'; import {renderInfo, renderTasks} from '@shopify/cli-kit/node/ui'; import {getLatestTemplates} from '../template-downloader.js'; +import {applyTemplateDiff} from '../template-diff.js'; import { commitAll, createAbortHandler, @@ -23,44 +25,74 @@ export async function setupRemoteTemplate( options: InitOptions, controller: AbortController, ) { - const isOfficialTemplate = - options.template === 'demo-store' || options.template === 'hello-world'; - - if (!isOfficialTemplate) { - // TODO: support GitHub repos as templates - throw new AbortError( - 'Only `demo-store` and `hello-world` are supported in --template flag for now.', - 'Skip the --template flag to run the setup flow.', - ); - } - + // TODO: support GitHub repos as templates const appTemplate = options.template!; + let abort = createAbortHandler(controller); // Start downloading templates early. const backgroundDownloadPromise = getLatestTemplates({ signal: controller.signal, - }).catch((error) => { - throw abort(error); // Throw to fix TS error - }); + }) + .then(async ({templatesDir, examplesDir}) => { + const templatePath = joinPath(templatesDir, appTemplate); + const examplePath = joinPath(examplesDir, appTemplate); + + if (await fileExists(templatePath)) { + return {templatesDir, sourcePath: templatePath}; + } + + if (await fileExists(examplePath)) { + return {templatesDir, sourcePath: examplePath}; + } + + throw new AbortError( + 'Unknown value in --template flag.', + 'Skip the --template flag or provide the name of a template or example in the Hydrogen repository.', + ); + }) + .catch(abort); const project = await handleProjectLocation({...options, controller}); if (!project) return; - const abort = createAbortHandler(controller, project); + abort = createAbortHandler(controller, project); - let backgroundWorkPromise = backgroundDownloadPromise.then(({templatesDir}) => - copyFile(joinPath(templatesDir, appTemplate), project.directory).catch( - abort, - ), - ); + let backgroundWorkPromise = backgroundDownloadPromise + .then(async (result) => { + // Result is undefined in certain tests, + // do not continue if it's already aborted + if (controller.signal.aborted) return; + + const {sourcePath, templatesDir} = result; + + const pkgJson = await readAndParsePackageJson( + joinPath(sourcePath, 'package.json'), + ); + + if (pkgJson.scripts?.dev?.includes('--diff')) { + return applyTemplateDiff( + project.directory, + sourcePath, + joinPath(templatesDir, 'skeleton'), + ); + } - const {language, transpileProject} = await handleLanguage( - project.directory, - controller, - options.language, + return copyFile(sourcePath, project.directory); + }) + .catch(abort); + + if (controller.signal.aborted) return; + + const {sourcePath} = await backgroundDownloadPromise; + const supportsTranspilation = await fileExists( + joinPath(sourcePath, 'tsconfig.json'), ); + const {language, transpileProject} = supportsTranspilation + ? await handleLanguage(project.directory, controller, options.language) + : {language: 'js' as const, transpileProject: () => Promise.resolve()}; + backgroundWorkPromise = backgroundWorkPromise .then(() => transpileProject().catch(abort)) .then(() => @@ -110,6 +142,8 @@ export async function setupRemoteTemplate( }); } + if (controller.signal.aborted) return; + await renderTasks(tasks); if (options.git) { @@ -118,16 +152,14 @@ export async function setupRemoteTemplate( await renderProjectReady(project, setupSummary); - if (isOfficialTemplate) { - renderInfo({ - headline: `Your project will display inventory from ${ - options.template === 'demo-store' - ? 'the Hydrogen Demo Store' - : 'Mock.shop' - }.`, - body: `To connect this project to your Shopify store’s inventory, update \`${project.name}/.env\` with your store ID and Storefront API key.`, - }); - } + renderInfo({ + headline: `Your project will display inventory from ${ + options.template === 'demo-store' + ? 'the Hydrogen Demo Store' + : 'Mock.shop' + }.`, + body: `To connect this project to your Shopify store’s inventory, update \`${project.name}/.env\` with your store ID and Storefront API key.`, + }); return { ...project, diff --git a/packages/cli/src/lib/template-downloader.ts b/packages/cli/src/lib/template-downloader.ts index 89cd78616e..29c1aa10d3 100644 --- a/packages/cli/src/lib/template-downloader.ts +++ b/packages/cli/src/lib/template-downloader.ts @@ -63,8 +63,8 @@ async function downloadTarball( filter: (name) => { name = name.replace(storageDir, ''); return ( - !name.startsWith(path.normalize('/templates/')) || - name.startsWith(path.normalize('/templates/skeleton/')) + !name.startsWith(path.normalize('/templates/')) && + !name.startsWith(path.normalize('/examples/')) ); }, }), @@ -92,6 +92,7 @@ export async function getLatestTemplates({ return { version, templatesDir: path.join(templateStorageVersionPath, 'templates'), + examplesDir: path.join(templateStorageVersionPath, 'examples'), }; } catch (e) { const error = e as AbortError;