From 5530d98756503878fbf5ac013e2103259ffc0443 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Wed, 28 Jun 2023 14:00:54 +0900 Subject: [PATCH] Add `login` and `logout` commands (#1022) * Add login and logout commands * Refactor graphql client and requests, add unit tests * Update shopify-config methods and tests * Update renderErrors to use cli Command * Rework env-pull to call login and remove silent param * Rework link command to call login and remove --shop flag * Update list command to call login and remove --shop flag * Update env-list command to call login and remove --shop flag * Oclif manifest * Cleanup * Rework log replacer * Mute cli-kit auth logs * Fix mock * Merge main branch * Fix tests * Minor changes * Update package-lock * Skip writing config when no directory is passed to login * Improve unit tests * Changesets --- .changeset/brave-suns-hammer.md | 5 + .changeset/brown-parrots-tell.md | 5 + .changeset/thin-tigers-raise.md | 5 + .changeset/tough-seahorses-tan.md | 5 + .changeset/twelve-suits-dance.md | 5 + package-lock.json | 32 +-- packages/cli/oclif.manifest.json | 2 +- packages/cli/src/commands/hydrogen/dev.ts | 16 +- .../src/commands/hydrogen/env/list.test.ts | 86 ++++--- .../cli/src/commands/hydrogen/env/list.ts | 49 ++-- .../src/commands/hydrogen/env/pull.test.ts | 184 +++++++++++---- .../cli/src/commands/hydrogen/env/pull.ts | 87 +++++--- .../cli/src/commands/hydrogen/link.test.ts | 178 +++++++-------- packages/cli/src/commands/hydrogen/link.ts | 163 +++++++------- .../cli/src/commands/hydrogen/list.test.ts | 101 +++++---- packages/cli/src/commands/hydrogen/list.ts | 18 +- packages/cli/src/commands/hydrogen/login.ts | 55 +++++ packages/cli/src/commands/hydrogen/logout.ts | 27 +++ packages/cli/src/lib/admin-session.test.ts | 37 --- packages/cli/src/lib/admin-session.ts | 18 -- packages/cli/src/lib/auth.test.ts | 99 ++++++++ packages/cli/src/lib/auth.ts | 56 +++++ .../combined-environment-variables.test.ts | 94 ++++---- .../src/lib/combined-environment-variables.ts | 26 +-- packages/cli/src/lib/flags.ts | 8 - .../src/lib/{graphql.test.ts => gid.test.ts} | 4 +- packages/cli/src/lib/gid.ts | 14 ++ .../{graphql.ts => graphql/admin/client.ts} | 24 +- .../lib/graphql/admin/create-storefront.ts | 21 +- .../cli/src/lib/graphql/admin/fetch-job.ts | 11 +- .../lib/graphql/admin/link-storefront.test.ts | 44 ++++ .../src/lib/graphql/admin/link-storefront.ts | 21 +- .../graphql/admin/list-environments.test.ts | 54 +++++ .../lib/graphql/admin/list-environments.ts | 7 +- .../graphql/admin/list-storefronts.test.ts | 53 +++++ .../src/lib/graphql/admin/list-storefronts.ts | 21 +- .../lib/graphql/admin/pull-variables.test.ts | 47 ++++ .../src/lib/graphql/admin/pull-variables.ts | 7 +- packages/cli/src/lib/log.ts | 75 ++++++- .../lib/pull-environment-variables.test.ts | 211 ------------------ .../cli/src/lib/pull-environment-variables.ts | 88 -------- packages/cli/src/lib/render-errors.ts | 38 ++-- packages/cli/src/lib/shop.test.ts | 97 -------- packages/cli/src/lib/shop.ts | 46 ---- packages/cli/src/lib/shopify-config.test.ts | 98 +++----- packages/cli/src/lib/shopify-config.ts | 10 + 46 files changed, 1237 insertions(+), 1115 deletions(-) create mode 100644 .changeset/brave-suns-hammer.md create mode 100644 .changeset/brown-parrots-tell.md create mode 100644 .changeset/thin-tigers-raise.md create mode 100644 .changeset/tough-seahorses-tan.md create mode 100644 .changeset/twelve-suits-dance.md create mode 100644 packages/cli/src/commands/hydrogen/login.ts create mode 100644 packages/cli/src/commands/hydrogen/logout.ts delete mode 100644 packages/cli/src/lib/admin-session.test.ts delete mode 100644 packages/cli/src/lib/admin-session.ts create mode 100644 packages/cli/src/lib/auth.test.ts create mode 100644 packages/cli/src/lib/auth.ts rename packages/cli/src/lib/{graphql.test.ts => gid.test.ts} (91%) create mode 100644 packages/cli/src/lib/gid.ts rename packages/cli/src/lib/{graphql.ts => graphql/admin/client.ts} (53%) create mode 100644 packages/cli/src/lib/graphql/admin/link-storefront.test.ts create mode 100644 packages/cli/src/lib/graphql/admin/list-environments.test.ts create mode 100644 packages/cli/src/lib/graphql/admin/list-storefronts.test.ts create mode 100644 packages/cli/src/lib/graphql/admin/pull-variables.test.ts delete mode 100644 packages/cli/src/lib/pull-environment-variables.test.ts delete mode 100644 packages/cli/src/lib/pull-environment-variables.ts delete mode 100644 packages/cli/src/lib/shop.test.ts delete mode 100644 packages/cli/src/lib/shop.ts diff --git a/.changeset/brave-suns-hammer.md b/.changeset/brave-suns-hammer.md new file mode 100644 index 0000000000..fd175d345b --- /dev/null +++ b/.changeset/brave-suns-hammer.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-hydrogen': patch +--- + +Add more context on MiniOxygen local dev server startup diff --git a/.changeset/brown-parrots-tell.md b/.changeset/brown-parrots-tell.md new file mode 100644 index 0000000000..6b718a27ea --- /dev/null +++ b/.changeset/brown-parrots-tell.md @@ -0,0 +1,5 @@ +--- +'@shopify/hydrogen-react': patch +--- + +Add JSDoc examples to and useMoney diff --git a/.changeset/thin-tigers-raise.md b/.changeset/thin-tigers-raise.md new file mode 100644 index 0000000000..52c49fed81 --- /dev/null +++ b/.changeset/thin-tigers-raise.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-hydrogen': patch +--- + +Fix `dev --codegen-unstable` flag, which was removed by mistake in the previous release. diff --git a/.changeset/tough-seahorses-tan.md b/.changeset/tough-seahorses-tan.md new file mode 100644 index 0000000000..cc5f546c38 --- /dev/null +++ b/.changeset/tough-seahorses-tan.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-hydrogen': minor +--- + +Add `login` and `logout` commands. Rework how other commands interact with auth. diff --git a/.changeset/twelve-suits-dance.md b/.changeset/twelve-suits-dance.md new file mode 100644 index 0000000000..789b0aa918 --- /dev/null +++ b/.changeset/twelve-suits-dance.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-hydrogen': minor +--- + +Support creating new storefronts from the `link` command. diff --git a/package-lock.json b/package-lock.json index 46687b2da2..c659bcd800 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32729,7 +32729,7 @@ }, "packages/cli": { "name": "@shopify/cli-hydrogen", - "version": "5.0.1", + "version": "5.0.2", "license": "MIT", "dependencies": { "@graphql-codegen/cli": "3.3.1", @@ -32766,7 +32766,7 @@ }, "peerDependencies": { "@remix-run/react": "^1.17.1", - "@shopify/hydrogen-react": "^2023.4.4", + "@shopify/hydrogen-react": "^2023.4.5", "@shopify/remix-oxygen": "^1.1.1" } }, @@ -35472,7 +35472,7 @@ "version": "4.1.3", "license": "MIT", "dependencies": { - "@shopify/cli-hydrogen": "^5.0.1" + "@shopify/cli-hydrogen": "^5.0.2" }, "bin": { "create-hydrogen": "dist/create-app.js" @@ -35480,10 +35480,10 @@ }, "packages/hydrogen": { "name": "@shopify/hydrogen", - "version": "2023.4.5", + "version": "2023.4.6", "license": "MIT", "dependencies": { - "@shopify/hydrogen-react": "2023.4.4", + "@shopify/hydrogen-react": "2023.4.5", "react": "^18.2.0" }, "devDependencies": { @@ -35518,7 +35518,7 @@ }, "packages/hydrogen-react": { "name": "@shopify/hydrogen-react", - "version": "2023.4.4", + "version": "2023.4.5", "license": "MIT", "dependencies": { "@google/model-viewer": "^1.12.1", @@ -35871,8 +35871,8 @@ "dependencies": { "@remix-run/react": "1.17.1", "@shopify/cli": "3.45.0", - "@shopify/cli-hydrogen": "^5.0.1", - "@shopify/hydrogen": "^2023.4.5", + "@shopify/cli-hydrogen": "^5.0.2", + "@shopify/hydrogen": "^2023.4.6", "@shopify/remix-oxygen": "^1.1.1", "graphql": "^16.6.0", "graphql-tag": "^2.12.6", @@ -35901,8 +35901,8 @@ "dependencies": { "@remix-run/react": "1.17.1", "@shopify/cli": "3.45.0", - "@shopify/cli-hydrogen": "^5.0.1", - "@shopify/hydrogen": "^2023.4.5", + "@shopify/cli-hydrogen": "^5.0.2", + "@shopify/hydrogen": "^2023.4.6", "@shopify/remix-oxygen": "^1.1.1", "graphql": "^16.6.0", "graphql-tag": "^2.12.6", @@ -43279,7 +43279,7 @@ "@shopify/create-hydrogen": { "version": "file:packages/create-hydrogen", "requires": { - "@shopify/cli-hydrogen": "^5.0.1" + "@shopify/cli-hydrogen": "^5.0.2" } }, "@shopify/eslint-plugin": { @@ -43347,7 +43347,7 @@ "version": "file:packages/hydrogen", "requires": { "@shopify/generate-docs": "0.10.7", - "@shopify/hydrogen-react": "2023.4.4", + "@shopify/hydrogen-react": "2023.4.5", "@testing-library/react": "^14.0.0", "happy-dom": "^8.9.0", "react": "^18.2.0", @@ -49653,8 +49653,8 @@ "@remix-run/dev": "1.17.1", "@remix-run/react": "1.17.1", "@shopify/cli": "3.45.0", - "@shopify/cli-hydrogen": "^5.0.1", - "@shopify/hydrogen": "^2023.4.5", + "@shopify/cli-hydrogen": "^5.0.2", + "@shopify/hydrogen": "^2023.4.6", "@shopify/oxygen-workers-types": "^3.17.2", "@shopify/prettier-config": "^1.1.2", "@shopify/remix-oxygen": "^1.1.1", @@ -56844,8 +56844,8 @@ "@remix-run/dev": "1.17.1", "@remix-run/react": "1.17.1", "@shopify/cli": "3.45.0", - "@shopify/cli-hydrogen": "^5.0.1", - "@shopify/hydrogen": "^2023.4.5", + "@shopify/cli-hydrogen": "^5.0.2", + "@shopify/hydrogen": "^2023.4.6", "@shopify/oxygen-workers-types": "^3.17.2", "@shopify/prettier-config": "^1.1.2", "@shopify/remix-oxygen": "^1.1.1", diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 61d4b40a44..2dc17922ff 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1 +1 @@ -{"version":"5.0.2","commands":{"hydrogen:build":{"id":"hydrogen:build","description":"Builds a Hydrogen storefront for production.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false},"sourcemap":{"name":"sourcemap","type":"boolean","description":"Generate sourcemaps for the build.","allowNo":false},"disable-route-warning":{"name":"disable-route-warning","type":"boolean","description":"Disable warning about missing standard routes.","allowNo":false},"codegen-unstable":{"name":"codegen-unstable","type":"boolean","description":"Generate types for the Storefront API queries found in your project.","required":false,"allowNo":false},"codegen-config-path":{"name":"codegen-config-path","type":"option","description":"Specify a path to a codegen configuration file. Defaults to `/codegen.ts` if it exists.","required":false,"multiple":false,"dependsOn":["codegen-unstable"]},"base":{"name":"base","type":"option","hidden":true,"multiple":false},"entry":{"name":"entry","type":"option","hidden":true,"multiple":false},"target":{"name":"target","type":"option","hidden":true,"multiple":false}},"args":[]},"hydrogen:check":{"id":"hydrogen:check","description":"Returns diagnostic information about a Hydrogen storefront.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false}},"args":[{"name":"resource","description":"The resource to check. Currently only 'routes' is supported.","required":true,"options":["routes"]}]},"hydrogen:codegen-unstable":{"id":"hydrogen:codegen-unstable","description":"Generate types for the Storefront API queries found in your project.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false},"codegen-config-path":{"name":"codegen-config-path","type":"option","description":"Specify a path to a codegen configuration file. Defaults to `/codegen.ts` if it exists.","required":false,"multiple":false},"force-sfapi-version":{"name":"force-sfapi-version","type":"option","description":"Force generating Storefront API types for a specific version instead of using the one provided in Hydrogen. A token can also be provided with this format: `:`.","hidden":true,"multiple":false},"watch":{"name":"watch","type":"boolean","description":"Watch the project for changes to update types on file save.","required":false,"allowNo":false}},"args":[]},"hydrogen:dev":{"id":"hydrogen:dev","description":"Runs Hydrogen storefront in an Oxygen worker for development.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false},"port":{"name":"port","type":"option","description":"Port to run the server on.","multiple":false,"default":3000},"codegen-unstable":{"name":"codegen-unstable","type":"boolean","description":"Generate types for the Storefront API queries found in your project. It updates the types on file save.","required":false,"allowNo":false},"codegen-config-path":{"name":"codegen-config-path","type":"option","description":"Specify a path to a codegen configuration file. Defaults to `/codegen.ts` if it exists.","required":false,"multiple":false,"dependsOn":["codegen-unstable"]},"sourcemap":{"name":"sourcemap","type":"boolean","description":"Generate sourcemaps for the build.","allowNo":true},"disable-virtual-routes":{"name":"disable-virtual-routes","type":"boolean","description":"Disable rendering fallback routes when a route file doesn't exist.","allowNo":false},"shop":{"name":"shop","type":"option","char":"s","description":"Shop URL. It can be the shop prefix (janes-apparel) or the full myshopify.com URL (janes-apparel.myshopify.com, https://janes-apparel.myshopify.com).","multiple":false},"debug":{"name":"debug","type":"boolean","description":"Attaches a Node inspector","allowNo":false},"host":{"name":"host","type":"option","hidden":true,"multiple":false},"env-branch":{"name":"env-branch","type":"option","char":"e","description":"Specify an environment's branch name when using remote environment variables.","multiple":false}},"args":[]},"hydrogen:g":{"id":"hydrogen:g","description":"Shortcut for `hydrogen generate`. See `hydrogen generate --help` for more information.","strict":false,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","hidden":true,"aliases":[],"flags":{},"args":[]},"hydrogen:init":{"id":"hydrogen:init","description":"Creates a new Hydrogen storefront.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"force":{"name":"force","type":"boolean","char":"f","description":"Overwrite the destination directory and files if they already exist.","allowNo":false},"path":{"name":"path","type":"option","description":"The path to the directory of the new Hydrogen storefront.","multiple":false},"language":{"name":"language","type":"option","description":"Sets the template language to use. One of `js` or `ts`.","multiple":false},"template":{"name":"template","type":"option","description":"Sets the template to use. One of `demo-store` or `hello-world`.","multiple":false},"install-deps":{"name":"install-deps","type":"boolean","description":"Auto install dependencies using the active package manager","allowNo":true}},"args":[]},"hydrogen:link":{"id":"hydrogen:link","description":"Link a local project to one of your shop's Hydrogen storefronts.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"force":{"name":"force","type":"boolean","char":"f","description":"Overwrite the destination directory and files if they already exist.","allowNo":false},"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false},"shop":{"name":"shop","type":"option","char":"s","description":"Shop URL. It can be the shop prefix (janes-apparel) or the full myshopify.com URL (janes-apparel.myshopify.com, https://janes-apparel.myshopify.com).","multiple":false},"storefront":{"name":"storefront","type":"option","description":"The name of a Hydrogen Storefront (e.g. \"Jane's Apparel\")","multiple":false}},"args":[]},"hydrogen:list":{"id":"hydrogen:list","description":"Returns a list of Hydrogen storefronts available on a given shop.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false},"shop":{"name":"shop","type":"option","char":"s","description":"Shop URL. It can be the shop prefix (janes-apparel) or the full myshopify.com URL (janes-apparel.myshopify.com, https://janes-apparel.myshopify.com).","multiple":false}},"args":[]},"hydrogen:preview":{"id":"hydrogen:preview","description":"Runs a Hydrogen storefront in an Oxygen worker for production.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false},"port":{"name":"port","type":"option","description":"Port to run the server on.","multiple":false,"default":3000}},"args":[]},"hydrogen:shortcut":{"id":"hydrogen:shortcut","description":"Creates a global `h2` shortcut for the Hydrogen CLI","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{},"args":[]},"hydrogen:unlink":{"id":"hydrogen:unlink","description":"Unlink a local project from a Hydrogen storefront.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false}},"args":[]},"hydrogen:env:list":{"id":"hydrogen:env:list","description":"List the environments on your linked Hydrogen storefront.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false},"shop":{"name":"shop","type":"option","char":"s","description":"Shop URL. It can be the shop prefix (janes-apparel) or the full myshopify.com URL (janes-apparel.myshopify.com, https://janes-apparel.myshopify.com).","multiple":false}},"args":[]},"hydrogen:env:pull":{"id":"hydrogen:env:pull","description":"Populate your .env with variables from your Hydrogen storefront.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"env-branch":{"name":"env-branch","type":"option","char":"e","description":"Specify an environment's branch name when using remote environment variables.","multiple":false},"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false},"shop":{"name":"shop","type":"option","char":"s","description":"Shop URL. It can be the shop prefix (janes-apparel) or the full myshopify.com URL (janes-apparel.myshopify.com, https://janes-apparel.myshopify.com).","multiple":false},"force":{"name":"force","type":"boolean","char":"f","description":"Overwrite the destination directory and files if they already exist.","allowNo":false}},"args":[]},"hydrogen:generate:route":{"id":"hydrogen:generate:route","description":"Generates a standard Shopify route.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"adapter":{"name":"adapter","type":"option","description":"Remix adapter used in the route. The default is `@shopify/remix-oxygen`.","multiple":false},"typescript":{"name":"typescript","type":"boolean","description":"Generate TypeScript files","allowNo":false},"force":{"name":"force","type":"boolean","char":"f","description":"Overwrite the destination directory and files if they already exist.","allowNo":false},"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false}},"args":[{"name":"route","description":"The route to generate. One of home,page,cart,products,collections,policies,robots,sitemap,account,all.","required":true,"options":["home","page","cart","products","collections","policies","robots","sitemap","account","all"]}]},"hydrogen:generate:routes":{"id":"hydrogen:generate:routes","description":"Generates all supported standard shopify routes.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"adapter":{"name":"adapter","type":"option","description":"Remix adapter used in the route. The default is `@shopify/remix-oxygen`.","multiple":false},"typescript":{"name":"typescript","type":"boolean","description":"Generate TypeScript files","allowNo":false},"force":{"name":"force","type":"boolean","char":"f","description":"Overwrite the destination directory and files if they already exist.","allowNo":false},"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false}},"args":[]}}} \ No newline at end of file +{"version":"5.0.2","commands":{"hydrogen:build":{"id":"hydrogen:build","description":"Builds a Hydrogen storefront for production.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false},"sourcemap":{"name":"sourcemap","type":"boolean","description":"Generate sourcemaps for the build.","allowNo":false},"disable-route-warning":{"name":"disable-route-warning","type":"boolean","description":"Disable warning about missing standard routes.","allowNo":false},"codegen-unstable":{"name":"codegen-unstable","type":"boolean","description":"Generate types for the Storefront API queries found in your project.","required":false,"allowNo":false},"codegen-config-path":{"name":"codegen-config-path","type":"option","description":"Specify a path to a codegen configuration file. Defaults to `/codegen.ts` if it exists.","required":false,"multiple":false,"dependsOn":["codegen-unstable"]},"base":{"name":"base","type":"option","hidden":true,"multiple":false},"entry":{"name":"entry","type":"option","hidden":true,"multiple":false},"target":{"name":"target","type":"option","hidden":true,"multiple":false}},"args":[]},"hydrogen:check":{"id":"hydrogen:check","description":"Returns diagnostic information about a Hydrogen storefront.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false}},"args":[{"name":"resource","description":"The resource to check. Currently only 'routes' is supported.","required":true,"options":["routes"]}]},"hydrogen:codegen-unstable":{"id":"hydrogen:codegen-unstable","description":"Generate types for the Storefront API queries found in your project.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false},"codegen-config-path":{"name":"codegen-config-path","type":"option","description":"Specify a path to a codegen configuration file. Defaults to `/codegen.ts` if it exists.","required":false,"multiple":false},"force-sfapi-version":{"name":"force-sfapi-version","type":"option","description":"Force generating Storefront API types for a specific version instead of using the one provided in Hydrogen. A token can also be provided with this format: `:`.","hidden":true,"multiple":false},"watch":{"name":"watch","type":"boolean","description":"Watch the project for changes to update types on file save.","required":false,"allowNo":false}},"args":[]},"hydrogen:dev":{"id":"hydrogen:dev","description":"Runs Hydrogen storefront in an Oxygen worker for development.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false},"port":{"name":"port","type":"option","description":"Port to run the server on.","multiple":false,"default":3000},"codegen-unstable":{"name":"codegen-unstable","type":"boolean","description":"Generate types for the Storefront API queries found in your project. It updates the types on file save.","required":false,"allowNo":false},"codegen-config-path":{"name":"codegen-config-path","type":"option","description":"Specify a path to a codegen configuration file. Defaults to `/codegen.ts` if it exists.","required":false,"multiple":false,"dependsOn":["codegen-unstable"]},"sourcemap":{"name":"sourcemap","type":"boolean","description":"Generate sourcemaps for the build.","allowNo":true},"disable-virtual-routes":{"name":"disable-virtual-routes","type":"boolean","description":"Disable rendering fallback routes when a route file doesn't exist.","allowNo":false},"debug":{"name":"debug","type":"boolean","description":"Attaches a Node inspector","allowNo":false},"host":{"name":"host","type":"option","hidden":true,"multiple":false},"env-branch":{"name":"env-branch","type":"option","char":"e","description":"Specify an environment's branch name when using remote environment variables.","multiple":false}},"args":[]},"hydrogen:g":{"id":"hydrogen:g","description":"Shortcut for `hydrogen generate`. See `hydrogen generate --help` for more information.","strict":false,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","hidden":true,"aliases":[],"flags":{},"args":[]},"hydrogen:init":{"id":"hydrogen:init","description":"Creates a new Hydrogen storefront.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"force":{"name":"force","type":"boolean","char":"f","description":"Overwrite the destination directory and files if they already exist.","allowNo":false},"path":{"name":"path","type":"option","description":"The path to the directory of the new Hydrogen storefront.","multiple":false},"language":{"name":"language","type":"option","description":"Sets the template language to use. One of `js` or `ts`.","multiple":false},"template":{"name":"template","type":"option","description":"Sets the template to use. One of `demo-store` or `hello-world`.","multiple":false},"install-deps":{"name":"install-deps","type":"boolean","description":"Auto install dependencies using the active package manager","allowNo":true}},"args":[]},"hydrogen:link":{"id":"hydrogen:link","description":"Link a local project to one of your shop's Hydrogen storefronts.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"force":{"name":"force","type":"boolean","char":"f","description":"Overwrite the destination directory and files if they already exist.","allowNo":false},"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false},"storefront":{"name":"storefront","type":"option","description":"The name of a Hydrogen Storefront (e.g. \"Jane's Apparel\")","multiple":false}},"args":[]},"hydrogen:list":{"id":"hydrogen:list","description":"Returns a list of Hydrogen storefronts available on a given shop.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false}},"args":[]},"hydrogen:login":{"id":"hydrogen:login","description":"Login to your Shopify account.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false},"shop":{"name":"shop","type":"option","char":"s","description":"Shop URL. It can be the shop prefix (janes-apparel) or the full myshopify.com URL (janes-apparel.myshopify.com, https://janes-apparel.myshopify.com).","multiple":false}},"args":[]},"hydrogen:logout":{"id":"hydrogen:logout","description":"Logout of your local session.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false}},"args":[]},"hydrogen:preview":{"id":"hydrogen:preview","description":"Runs a Hydrogen storefront in an Oxygen worker for production.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false},"port":{"name":"port","type":"option","description":"Port to run the server on.","multiple":false,"default":3000}},"args":[]},"hydrogen:shortcut":{"id":"hydrogen:shortcut","description":"Creates a global `h2` shortcut for the Hydrogen CLI","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{},"args":[]},"hydrogen:unlink":{"id":"hydrogen:unlink","description":"Unlink a local project from a Hydrogen storefront.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false}},"args":[]},"hydrogen:env:list":{"id":"hydrogen:env:list","description":"List the environments on your linked Hydrogen storefront.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false}},"args":[]},"hydrogen:env:pull":{"id":"hydrogen:env:pull","description":"Populate your .env with variables from your Hydrogen storefront.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"env-branch":{"name":"env-branch","type":"option","char":"e","description":"Specify an environment's branch name when using remote environment variables.","multiple":false},"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false},"force":{"name":"force","type":"boolean","char":"f","description":"Overwrite the destination directory and files if they already exist.","allowNo":false}},"args":[]},"hydrogen:generate:route":{"id":"hydrogen:generate:route","description":"Generates a standard Shopify route.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"adapter":{"name":"adapter","type":"option","description":"Remix adapter used in the route. The default is `@shopify/remix-oxygen`.","multiple":false},"typescript":{"name":"typescript","type":"boolean","description":"Generate TypeScript files","allowNo":false},"force":{"name":"force","type":"boolean","char":"f","description":"Overwrite the destination directory and files if they already exist.","allowNo":false},"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false}},"args":[{"name":"route","description":"The route to generate. One of home,page,cart,products,collections,policies,robots,sitemap,account,all.","required":true,"options":["home","page","cart","products","collections","policies","robots","sitemap","account","all"]}]},"hydrogen:generate:routes":{"id":"hydrogen:generate:routes","description":"Generates all supported standard shopify routes.","strict":true,"pluginName":"@shopify/cli-hydrogen","pluginAlias":"@shopify/cli-hydrogen","pluginType":"core","aliases":[],"flags":{"adapter":{"name":"adapter","type":"option","description":"Remix adapter used in the route. The default is `@shopify/remix-oxygen`.","multiple":false},"typescript":{"name":"typescript","type":"boolean","description":"Generate TypeScript files","allowNo":false},"force":{"name":"force","type":"boolean","char":"f","description":"Overwrite the destination directory and files if they already exist.","allowNo":false},"path":{"name":"path","type":"option","description":"The path to the directory of the Hydrogen storefront. The default is the current directory.","multiple":false}},"args":[]}}} \ No newline at end of file diff --git a/packages/cli/src/commands/hydrogen/dev.ts b/packages/cli/src/commands/hydrogen/dev.ts index a38bd3b176..a2dc18651a 100644 --- a/packages/cli/src/commands/hydrogen/dev.ts +++ b/packages/cli/src/commands/hydrogen/dev.ts @@ -44,7 +44,6 @@ export default class Dev extends Command { env: 'SHOPIFY_HYDROGEN_FLAG_DISABLE_VIRTUAL_ROUTES', default: false, }), - shop: commonFlags.shop, debug: Flags.boolean({ description: 'Attaches a Node inspector', env: 'SHOPIFY_HYDROGEN_FLAG_DEBUG', @@ -72,7 +71,6 @@ async function runDev({ useCodegen = false, codegenConfigPath, disableVirtualRoutes, - shop, envBranch, debug = false, sourcemap = true, @@ -82,7 +80,6 @@ async function runDev({ useCodegen?: boolean; codegenConfigPath?: string; disableVirtualRoutes?: boolean; - shop?: string; envBranch?: string; debug?: false; sourcemap?: boolean; @@ -111,14 +108,11 @@ async function runDev({ const serverBundleExists = () => fileExists(buildPathWorkerFile); - const hasLinkedStorefront = !!(await getConfig(root))?.storefront?.id; - const environmentVariables = hasLinkedStorefront - ? await combinedEnvironmentVariables({ - root, - shop, - envBranch, - }) - : undefined; + const {shop, storefront} = await getConfig(root); + const environmentVariables = + !!shop && !!storefront?.id + ? await combinedEnvironmentVariables({root, shop, envBranch}) + : undefined; const [{watch}, {createFileWatchCache}] = await Promise.all([ import('@remix-run/dev/dist/compiler/watch.js'), diff --git a/packages/cli/src/commands/hydrogen/env/list.test.ts b/packages/cli/src/commands/hydrogen/env/list.test.ts index af1342c7be..278289f66f 100644 --- a/packages/cli/src/commands/hydrogen/env/list.test.ts +++ b/packages/cli/src/commands/hydrogen/env/list.test.ts @@ -1,5 +1,4 @@ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; -import type {AdminSession} from '@shopify/cli-kit/node/session'; import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'; import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs'; import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui'; @@ -8,14 +7,13 @@ import { getStorefrontEnvironments, type Environment, } from '../../../lib/graphql/admin/list-environments.js'; -import {getAdminSession} from '../../../lib/admin-session.js'; -import {getConfig} from '../../../lib/shopify-config.js'; +import {type AdminSession, login} from '../../../lib/auth.js'; import { renderMissingLink, renderMissingStorefront, } from '../../../lib/render-errors.js'; import {linkStorefront} from '../link.js'; -import {listEnvironments} from './list.js'; +import {runEnvList} from './list.js'; const SHOP = 'my-shop'; @@ -29,15 +27,11 @@ vi.mock('@shopify/cli-kit/node/ui', async () => { }; }); vi.mock('../link.js'); -vi.mock('../../../lib/admin-session.js'); +vi.mock('../../../lib/auth.js'); vi.mock('../../../lib/shopify-config.js'); vi.mock('../../../lib/render-errors.js'); -vi.mock('../../../lib/graphql/admin/list-environments.js', () => { - return {getStorefrontEnvironments: vi.fn()}; -}); -vi.mock('../../../lib/shop.js', () => ({ - getHydrogenShop: () => SHOP, -})); +vi.mock('../../../lib/graphql/admin/list-environments.js'); +vi.mock('../../../lib/shell.js', () => ({getCliCommand: () => 'h2'})); describe('listEnvironments', () => { const ADMIN_SESSION: AdminSession = { @@ -45,6 +39,13 @@ describe('listEnvironments', () => { storeFqdn: SHOP, }; + const SHOPIFY_CONFIG = { + storefront: { + id: 'gid://shopify/HydrogenStorefront/1', + title: 'Existing Link', + }, + }; + const PRODUCTION_ENVIRONMENT: Environment = { id: 'gid://shopify/HydrogenStorefrontEnvironment/1', branch: 'main', @@ -73,23 +74,19 @@ describe('listEnvironments', () => { }; beforeEach(async () => { - vi.mocked(getAdminSession).mockResolvedValue(ADMIN_SESSION); - vi.mocked(getConfig).mockResolvedValue({ - storefront: { - id: 'gid://shopify/HydrogenStorefront/1', - title: 'Existing Link', - }, + vi.mocked(login).mockResolvedValue({ + session: ADMIN_SESSION, + config: SHOPIFY_CONFIG, }); + vi.mocked(getStorefrontEnvironments).mockResolvedValue({ - storefront: { - id: 'gid://shopify/HydrogenStorefront/1', - productionUrl: 'https://example.com', - environments: [ - PRODUCTION_ENVIRONMENT, - CUSTOM_ENVIRONMENT, - PREVIEW_ENVIRONMENT, - ], - }, + id: 'gid://shopify/HydrogenStorefront/1', + productionUrl: 'https://example.com', + environments: [ + PRODUCTION_ENVIRONMENT, + CUSTOM_ENVIRONMENT, + PREVIEW_ENVIRONMENT, + ], }); }); @@ -98,13 +95,13 @@ describe('listEnvironments', () => { mockAndCaptureOutput().clear(); }); - it('makes a GraphQL call to fetch environment variables', async () => { + it('fetchs environment variables', async () => { await inTemporaryDirectory(async (tmpDir) => { - await listEnvironments({path: tmpDir}); + await runEnvList({path: tmpDir}); expect(getStorefrontEnvironments).toHaveBeenCalledWith( ADMIN_SESSION, - 'gid://shopify/HydrogenStorefront/1', + SHOPIFY_CONFIG.storefront.id, ); }); }); @@ -113,10 +110,10 @@ describe('listEnvironments', () => { await inTemporaryDirectory(async (tmpDir) => { const output = mockAndCaptureOutput(); - await listEnvironments({path: tmpDir}); + await runEnvList({path: tmpDir}); expect(output.info()).toMatch( - /Showing 3 environments for the Hydrogen storefront Existing Link/, + /Showing 3 environments for the Hydrogen storefront Existing Link/i, ); expect(output.info()).toMatch(/Production \(Branch: main\)/); @@ -129,14 +126,15 @@ describe('listEnvironments', () => { describe('when there is no linked storefront', () => { beforeEach(() => { - vi.mocked(getConfig).mockResolvedValue({ - storefront: undefined, + vi.mocked(login).mockResolvedValue({ + session: ADMIN_SESSION, + config: {}, }); }); it('calls renderMissingLink', async () => { await inTemporaryDirectory(async (tmpDir) => { - await listEnvironments({path: tmpDir}); + await runEnvList({path: tmpDir}); expect(renderMissingLink).toHaveBeenCalledOnce(); }); @@ -146,30 +144,30 @@ describe('listEnvironments', () => { vi.mocked(renderConfirmationPrompt).mockResolvedValue(true); await inTemporaryDirectory(async (tmpDir) => { - await listEnvironments({path: tmpDir}); + await runEnvList({path: tmpDir}); expect(renderConfirmationPrompt).toHaveBeenCalledWith({ - message: expect.stringMatching(/Run .*npx shopify hydrogen link.*\?/), + message: expect.arrayContaining([{command: 'h2 link'}]), }); - expect(linkStorefront).toHaveBeenCalledWith({ - path: tmpDir, - silent: true, - }); + expect(linkStorefront).toHaveBeenCalledWith( + tmpDir, + ADMIN_SESSION, + {}, + expect.anything(), + ); }); }); }); describe('when there is no matching storefront in the shop', () => { beforeEach(() => { - vi.mocked(getStorefrontEnvironments).mockResolvedValue({ - storefront: null, - }); + vi.mocked(getStorefrontEnvironments).mockResolvedValue(null); }); it('calls renderMissingStorefront', async () => { await inTemporaryDirectory(async (tmpDir) => { - await listEnvironments({path: tmpDir}); + await runEnvList({path: tmpDir}); expect(renderMissingStorefront).toHaveBeenCalledOnce(); }); diff --git a/packages/cli/src/commands/hydrogen/env/list.ts b/packages/cli/src/commands/hydrogen/env/list.ts index 7f9bcd40c9..01cb79dd20 100644 --- a/packages/cli/src/commands/hydrogen/env/list.ts +++ b/packages/cli/src/commands/hydrogen/env/list.ts @@ -6,19 +6,17 @@ import colors from '@shopify/cli-kit/node/colors'; import { outputContent, outputInfo, - outputToken, outputNewline, } from '@shopify/cli-kit/node/output'; import {linkStorefront} from '../link.js'; import {commonFlags} from '../../../lib/flags.js'; -import {getHydrogenShop} from '../../../lib/shop.js'; -import {getAdminSession} from '../../../lib/admin-session.js'; import {getStorefrontEnvironments} from '../../../lib/graphql/admin/list-environments.js'; -import {getConfig} from '../../../lib/shopify-config.js'; import { renderMissingLink, renderMissingStorefront, } from '../../../lib/render-errors.js'; +import {login} from '../../../lib/auth.js'; +import {getCliCommand} from '../../../lib/shell.js'; export default class EnvList extends Command { static description = @@ -26,55 +24,56 @@ export default class EnvList extends Command { static flags = { path: commonFlags.path, - shop: commonFlags.shop, }; async run(): Promise { const {flags} = await this.parse(EnvList); - await listEnvironments(flags); + await runEnvList(flags); } } interface Flags { path?: string; - shop?: string; } -export async function listEnvironments({path, shop: flagShop}: Flags) { - const shop = await getHydrogenShop({path, shop: flagShop}); - const adminSession = await getAdminSession(shop); - const actualPath = path ?? process.cwd(); - let configStorefront = (await getConfig(actualPath)).storefront; +export async function runEnvList({path: root = process.cwd()}: Flags) { + const [{session, config}, cliCommand] = await Promise.all([ + login(root, true), + getCliCommand(), + ]); + + let configStorefront = config.storefront; if (!configStorefront?.id) { - renderMissingLink({adminSession}); + renderMissingLink({session, cliCommand}); const runLink = await renderConfirmationPrompt({ - message: outputContent`Run ${outputToken.genericShellCommand( - `npx shopify hydrogen link`, - )}?`.value, + message: ['Run', {command: `${cliCommand} link`}, '?'], }); if (!runLink) { return; } - await linkStorefront({path, shop: flagShop, silent: true}); + configStorefront = await linkStorefront(root, session, config, { + cliCommand, + }); } - configStorefront = (await getConfig(actualPath)).storefront; + if (!configStorefront) return; - if (!configStorefront) { - return; - } - - const {storefront} = await getStorefrontEnvironments( - adminSession, + const storefront = await getStorefrontEnvironments( + session, configStorefront.id, ); if (!storefront) { - renderMissingStorefront({adminSession, storefront: configStorefront}); + renderMissingStorefront({ + session, + storefront: configStorefront, + cliCommand, + }); + return; } diff --git a/packages/cli/src/commands/hydrogen/env/pull.test.ts b/packages/cli/src/commands/hydrogen/env/pull.test.ts index d2d55c7f9a..1fbcea859e 100644 --- a/packages/cli/src/commands/hydrogen/env/pull.test.ts +++ b/packages/cli/src/commands/hydrogen/env/pull.test.ts @@ -1,5 +1,4 @@ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; -import type {AdminSession} from '@shopify/cli-kit/node/session'; import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'; import { fileExists, @@ -10,11 +9,15 @@ import { import {joinPath} from '@shopify/cli-kit/node/path'; import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui'; -import {getAdminSession} from '../../../lib/admin-session.js'; -import {pullRemoteEnvironmentVariables} from '../../../lib/pull-environment-variables.js'; -import {getConfig} from '../../../lib/shopify-config.js'; +import {type AdminSession, login} from '../../../lib/auth.js'; +import {getStorefrontEnvVariables} from '../../../lib/graphql/admin/pull-variables.js'; -import {pullVariables} from './pull.js'; +import {runEnvPull} from './pull.js'; +import { + renderMissingLink, + renderMissingStorefront, +} from '../../../lib/render-errors.js'; +import {linkStorefront} from '../link.js'; vi.mock('@shopify/cli-kit/node/ui', async () => { const original = await vi.importActual< @@ -26,12 +29,9 @@ vi.mock('@shopify/cli-kit/node/ui', async () => { }; }); vi.mock('../link.js'); -vi.mock('../../../lib/admin-session.js'); -vi.mock('../../../lib/shopify-config.js'); -vi.mock('../../../lib/pull-environment-variables.js'); -vi.mock('../../../lib/shop.js', () => ({ - getHydrogenShop: () => 'my-shop', -})); +vi.mock('../../../lib/auth.js'); +vi.mock('../../../lib/render-errors.js'); +vi.mock('../../../lib/graphql/admin/pull-variables.js'); describe('pullVariables', () => { const ADMIN_SESSION: AdminSession = { @@ -39,28 +39,36 @@ describe('pullVariables', () => { storeFqdn: 'my-shop', }; + const SHOPIFY_CONFIG = { + storefront: { + id: 'gid://shopify/HydrogenStorefront/2', + title: 'Existing Link', + }, + }; + beforeEach(async () => { - vi.mocked(getAdminSession).mockResolvedValue(ADMIN_SESSION); - vi.mocked(getConfig).mockResolvedValue({ - storefront: { - id: 'gid://shopify/HydrogenStorefront/2', - title: 'Existing Link', - }, - }); - vi.mocked(pullRemoteEnvironmentVariables).mockResolvedValue([ - { - id: 'gid://shopify/HydrogenStorefrontEnvironmentVariable/1', - key: 'PUBLIC_API_TOKEN', - value: 'abc123', - isSecret: false, - }, - { - id: 'gid://shopify/HydrogenStorefrontEnvironmentVariable/2', - key: 'PRIVATE_API_TOKEN', - value: '', - isSecret: true, - }, - ]); + vi.mocked(login).mockResolvedValue({ + session: ADMIN_SESSION, + config: SHOPIFY_CONFIG, + }); + + vi.mocked(getStorefrontEnvVariables).mockResolvedValue({ + id: SHOPIFY_CONFIG.storefront.id, + environmentVariables: [ + { + id: 'gid://shopify/HydrogenStorefrontEnvironmentVariable/1', + key: 'PUBLIC_API_TOKEN', + value: 'abc123', + isSecret: false, + }, + { + id: 'gid://shopify/HydrogenStorefrontEnvironmentVariable/2', + key: 'PRIVATE_API_TOKEN', + value: '', + isSecret: true, + }, + ], + }); }); afterEach(() => { @@ -68,14 +76,15 @@ describe('pullVariables', () => { mockAndCaptureOutput().clear(); }); - it('calls pullRemoteEnvironmentVariables', async () => { + it('calls getStorefrontEnvVariables', async () => { await inTemporaryDirectory(async (tmpDir) => { - await pullVariables({path: tmpDir, envBranch: 'staging'}); + await runEnvPull({path: tmpDir, envBranch: 'staging'}); - expect(pullRemoteEnvironmentVariables).toHaveBeenCalledWith({ - root: tmpDir, - envBranch: 'staging', - }); + expect(getStorefrontEnvVariables).toHaveBeenCalledWith( + ADMIN_SESSION, + SHOPIFY_CONFIG.storefront.id, + 'staging', + ); }); }); @@ -85,7 +94,7 @@ describe('pullVariables', () => { expect(await fileExists(filePath)).toBeFalsy(); - await pullVariables({path: tmpDir}); + await runEnvPull({path: tmpDir}); expect(await readFile(filePath)).toStrictEqual( 'PUBLIC_API_TOKEN=abc123\n' + 'PRIVATE_API_TOKEN=""', @@ -97,7 +106,7 @@ describe('pullVariables', () => { await inTemporaryDirectory(async (tmpDir) => { const outputMock = mockAndCaptureOutput(); - await pullVariables({path: tmpDir}); + await runEnvPull({path: tmpDir}); expect(outputMock.warn()).toMatch( /Existing Link contains environment variables marked as secret, so their/, @@ -110,7 +119,7 @@ describe('pullVariables', () => { await inTemporaryDirectory(async (tmpDir) => { const outputMock = mockAndCaptureOutput(); - await pullVariables({path: tmpDir}); + await runEnvPull({path: tmpDir}); expect(outputMock.info()).toMatch( /Changes have been made to your \.env file/, @@ -118,6 +127,95 @@ describe('pullVariables', () => { }); }); + describe('when environment variables are empty', () => { + beforeEach(() => { + vi.mocked(getStorefrontEnvVariables).mockResolvedValue({ + id: 'gid://shopify/HydrogenStorefront/1', + environmentVariables: [], + }); + }); + + it('renders a message', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const outputMock = mockAndCaptureOutput(); + + await runEnvPull({path: tmpDir}); + + expect(outputMock.info()).toMatch(/No environment variables found\./); + }); + }); + }); + + describe('when there is no linked storefront', () => { + beforeEach(async () => { + vi.mocked(login).mockResolvedValue({ + session: ADMIN_SESSION, + config: {}, + }); + }); + + it('calls renderMissingLink', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await runEnvPull({path: tmpDir}); + + expect(renderMissingLink).toHaveBeenCalledOnce(); + }); + }); + + it('prompts the user to create a link', async () => { + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true); + + await inTemporaryDirectory(async (tmpDir) => { + await runEnvPull({path: tmpDir}); + + expect(renderConfirmationPrompt).toHaveBeenCalledWith({ + message: expect.stringMatching(/Run .* link.*\?/i), + }); + + expect(linkStorefront).toHaveBeenCalledWith( + tmpDir, + ADMIN_SESSION, + {}, + expect.anything(), + ); + }); + }); + + it('ends without requesting variables', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await runEnvPull({path: tmpDir}); + + expect(getStorefrontEnvVariables).not.toHaveBeenCalled(); + }); + }); + + describe('and the user does not create a new link', () => { + it('ends without requesting variables', async () => { + vi.mocked(renderConfirmationPrompt).mockResolvedValue(false); + + await inTemporaryDirectory(async (tmpDir) => { + await runEnvPull({path: tmpDir}); + + expect(getStorefrontEnvVariables).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('when there is no matching storefront in the shop', () => { + beforeEach(() => { + vi.mocked(getStorefrontEnvVariables).mockResolvedValue(null); + }); + + it('renders missing storefronts message and ends', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await runEnvPull({path: tmpDir}); + + expect(renderMissingStorefront).toHaveBeenCalledOnce(); + }); + }); + }); + describe('when a .env file already exists', () => { beforeEach(() => { vi.mocked(renderConfirmationPrompt).mockResolvedValue(true); @@ -128,7 +226,7 @@ describe('pullVariables', () => { const filePath = joinPath(tmpDir, '.env'); await writeFile(filePath, 'EXISTING_TOKEN=1'); - await pullVariables({path: tmpDir}); + await runEnvPull({path: tmpDir}); expect(renderConfirmationPrompt).toHaveBeenCalledWith({ confirmationMessage: `Yes, confirm changes`, @@ -146,7 +244,7 @@ describe('pullVariables', () => { const filePath = joinPath(tmpDir, '.env'); await writeFile(filePath, 'EXISTING_TOKEN=1'); - await pullVariables({path: tmpDir, force: true}); + await runEnvPull({path: tmpDir, force: true}); expect(renderConfirmationPrompt).not.toHaveBeenCalled(); }); diff --git a/packages/cli/src/commands/hydrogen/env/pull.ts b/packages/cli/src/commands/hydrogen/env/pull.ts index 2c9ba62911..0aee7974ae 100644 --- a/packages/cli/src/commands/hydrogen/env/pull.ts +++ b/packages/cli/src/commands/hydrogen/env/pull.ts @@ -7,14 +7,24 @@ import { renderWarning, renderSuccess, } from '@shopify/cli-kit/node/ui'; -import {outputContent, outputToken} from '@shopify/cli-kit/node/output'; +import { + outputContent, + outputInfo, + outputToken, +} from '@shopify/cli-kit/node/output'; import {fileExists, readFile, writeFile} from '@shopify/cli-kit/node/fs'; import {resolvePath} from '@shopify/cli-kit/node/path'; import {patchEnvFile} from '@shopify/cli-kit/node/dot-env'; import colors from '@shopify/cli-kit/node/colors'; import {commonFlags, flagsToCamelObject} from '../../../lib/flags.js'; -import {pullRemoteEnvironmentVariables} from '../../../lib/pull-environment-variables.js'; -import {getConfig} from '../../../lib/shopify-config.js'; +import {login} from '../../../lib/auth.js'; +import {getCliCommand} from '../../../lib/shell.js'; +import { + renderMissingLink, + renderMissingStorefront, +} from '../../../lib/render-errors.js'; +import {linkStorefront} from '../link.js'; +import {getStorefrontEnvVariables} from '../../../lib/graphql/admin/pull-variables.js'; export default class EnvPull extends Command { static description = @@ -23,13 +33,12 @@ export default class EnvPull extends Command { static flags = { ['env-branch']: commonFlags['env-branch'], path: commonFlags.path, - shop: commonFlags.shop, force: commonFlags.force, }; async run(): Promise { const {flags} = await this.parse(EnvPull); - await pullVariables({...flagsToCamelObject(flags)}); + await runEnvPull({...flagsToCamelObject(flags)}); } } @@ -37,33 +46,65 @@ interface Flags { envBranch?: string; force?: boolean; path?: string; - shop?: string; } -export async function pullVariables({ +export async function runEnvPull({ envBranch, + path: root = process.cwd(), force, - path, - shop: flagShop, }: Flags) { - const actualPath = path ?? process.cwd(); + const [{session, config}, cliCommand] = await Promise.all([ + login(root, true), + getCliCommand(), + ]); + + if (!config.storefront?.id) { + renderMissingLink({session, cliCommand}); + + const runLink = await renderConfirmationPrompt({ + message: outputContent`Run ${outputToken.genericShellCommand( + `${cliCommand} link`, + )}?`.value, + }); + + if (!runLink) return; + + config.storefront = await linkStorefront(root, session, config, { + cliCommand, + }); + } + + if (!config.storefront?.id) return; - const environmentVariables = await pullRemoteEnvironmentVariables({ - root: actualPath, - flagShop, + const storefront = await getStorefrontEnvVariables( + session, + config.storefront.id, envBranch, - }); + ); + + if (!storefront) { + renderMissingStorefront({ + session, + storefront: config.storefront, + cliCommand, + }); - if (!environmentVariables.length) { return; } - const fileName = colors.whiteBright(`.env`); + if (!storefront.environmentVariables.length) { + outputInfo(`No environment variables found.`); + return; + } - const dotEnvPath = resolvePath(actualPath, '.env'); + const variables = storefront.environmentVariables; + if (!variables.length) return; + const fileName = colors.whiteBright(`.env`); + const dotEnvPath = resolvePath(root, '.env'); const fetchedEnv: Record = {}; - environmentVariables.forEach(({isSecret, key, value}) => { + + variables.forEach(({isSecret, key, value}) => { // We need to force an empty string for secret variables, otherwise // patchEnvFile will treat them as new values even if they already exist. fetchedEnv[key] = isSecret ? `""` : value; @@ -101,17 +142,11 @@ Continue?`.value, await writeFile(dotEnvPath, newEnv); } - const hasSecretVariables = environmentVariables.some( - ({isSecret}) => isSecret, - ); + const hasSecretVariables = variables.some(({isSecret}) => isSecret); if (hasSecretVariables) { - const {storefront: configStorefront} = await getConfig(actualPath); - renderWarning({ - body: `${ - configStorefront!.title - } contains environment variables marked as secret, so their values weren’t pulled.`, + body: `${config.storefront.title} contains environment variables marked as secret, so their values weren’t pulled.`, }); } diff --git a/packages/cli/src/commands/hydrogen/link.test.ts b/packages/cli/src/commands/hydrogen/link.test.ts index 7fa353cb6a..95dca72f3e 100644 --- a/packages/cli/src/commands/hydrogen/link.test.ts +++ b/packages/cli/src/commands/hydrogen/link.test.ts @@ -1,26 +1,16 @@ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; -import type {AdminSession} from '@shopify/cli-kit/node/session'; import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'; import { renderConfirmationPrompt, renderSelectPrompt, renderTextPrompt, } from '@shopify/cli-kit/node/ui'; - -import {adminRequest} from '../../lib/graphql.js'; +import {type AdminSession, login} from '../../lib/auth.js'; import {getStorefronts} from '../../lib/graphql/admin/link-storefront.js'; +import {runLink} from './link.js'; import {createStorefront} from '../../lib/graphql/admin/create-storefront.js'; import {waitForJob} from '../../lib/graphql/admin/fetch-job.js'; -import {getConfig, setStorefront} from '../../lib/shopify-config.js'; -import {renderError, renderUserErrors} from '../../lib/user-errors.js'; - -import {linkStorefront} from './link.js'; - -const SHOP = 'my-shop'; -const ADMIN_SESSION: AdminSession = { - token: 'abc123', - storeFqdn: SHOP, -}; +import {setStorefront} from '../../lib/shopify-config.js'; vi.mock('@shopify/cli-kit/node/ui', async () => { const original = await vi.importActual< @@ -34,35 +24,49 @@ vi.mock('@shopify/cli-kit/node/ui', async () => { renderTextPrompt: vi.fn(), }; }); -vi.mock('../../lib/graphql.js'); +vi.mock('../../lib/auth.js'); vi.mock('../../lib/shopify-config.js'); vi.mock('../../lib/graphql/admin/link-storefront.js'); vi.mock('../../lib/graphql/admin/create-storefront.js'); vi.mock('../../lib/graphql/admin/fetch-job.js'); -vi.mock('../../lib/user-errors.js'); -vi.mock('../../lib/shop.js', () => ({ - getHydrogenShop: () => SHOP, -})); -vi.mock('../../lib/shell.js', () => ({ - getCliCommand: () => 'h2', -})); +vi.mock('../../lib/shell.js', () => ({getCliCommand: () => 'h2'})); describe('link', () => { const outputMock = mockAndCaptureOutput(); + const ADMIN_SESSION: AdminSession = { + token: 'abc123', + storeFqdn: 'my-shop.myshopify.com', + }; + + const FULL_SHOPIFY_CONFIG = { + shop: 'my-shop.myshopify.com', + storefront: { + id: 'gid://shopify/HydrogenStorefront/1', + title: 'Hydrogen', + }, + }; + + const UNLINKED_SHOPIFY_CONFIG = { + // Logged in, not linked + shop: FULL_SHOPIFY_CONFIG.shop, + }; + beforeEach(async () => { - vi.mocked(getStorefronts).mockResolvedValue({ - adminSession: ADMIN_SESSION, - storefronts: [ - { - id: 'gid://shopify/HydrogenStorefront/1', - parsedId: '1', - title: 'Hydrogen', - productionUrl: 'https://example.com', - }, - ], + vi.mocked(login).mockResolvedValue({ + session: ADMIN_SESSION, + config: UNLINKED_SHOPIFY_CONFIG, }); - vi.mocked(getConfig).mockResolvedValue({}); + + vi.mocked(getStorefronts).mockResolvedValue([ + { + ...FULL_SHOPIFY_CONFIG.storefront, + parsedId: '1', + productionUrl: 'https://example.com', + }, + ]); + + vi.mocked(renderSelectPrompt).mockResolvedValue(FULL_SHOPIFY_CONFIG.shop); }); afterEach(() => { @@ -70,10 +74,23 @@ describe('link', () => { outputMock.clear(); }); - it('makes a GraphQL call to fetch the storefronts', async () => { - await linkStorefront({}); + it('fetches the storefronts', async () => { + await runLink({}); + + expect(getStorefronts).toHaveBeenCalledWith(ADMIN_SESSION); + }); + + it('renders a list of choices and forwards the selection to setStorefront', async () => { + vi.mocked(renderSelectPrompt).mockResolvedValue( + FULL_SHOPIFY_CONFIG.storefront.id, + ); - expect(getStorefronts).toHaveBeenCalledWith(SHOP); + await runLink({path: 'my-path'}); + + expect(setStorefront).toHaveBeenCalledWith( + 'my-path', + expect.objectContaining(FULL_SHOPIFY_CONFIG.storefront), + ); }); describe('when you want to link an existing Hydrogen storefront', () => { @@ -84,24 +101,27 @@ describe('link', () => { }); it('renders a list of choices and forwards the selection to setStorefront', async () => { - await linkStorefront({path: 'my-path'}); + vi.mocked(renderSelectPrompt).mockResolvedValue( + FULL_SHOPIFY_CONFIG.storefront.id, + ); + + await runLink({path: 'my-path'}); expect(setStorefront).toHaveBeenCalledWith( 'my-path', - expect.objectContaining({ - id: 'gid://shopify/HydrogenStorefront/1', - title: 'Hydrogen', - }), + expect.objectContaining(FULL_SHOPIFY_CONFIG.storefront), ); }); it('renders a success message', async () => { - await linkStorefront({path: 'my-path'}); - - expect(outputMock.info()).toMatch(/Hydrogen is now linked/g); - expect(outputMock.info()).toMatch( - /Run `h2 dev` to start your local development server and start building/g, + vi.mocked(renderSelectPrompt).mockResolvedValue( + FULL_SHOPIFY_CONFIG.storefront.id, ); + + await runLink({path: 'my-path'}); + + expect(outputMock.info()).toMatch(/is now linked/i); + expect(outputMock.info()).toMatch(/Run `h2 dev`/i); }); }); @@ -110,7 +130,7 @@ describe('link', () => { const expectedJobId = 'gid://shopify/Job/1'; beforeEach(async () => { - vi.mocked(renderSelectPrompt).mockResolvedValue('NEW_STOREFRONT'); + vi.mocked(renderSelectPrompt).mockResolvedValue(null); vi.mocked(createStorefront).mockResolvedValue({ adminSession: ADMIN_SESSION, @@ -125,7 +145,7 @@ describe('link', () => { }); it('chooses to create a new storefront given the directory path', async () => { - await linkStorefront({path: 'my-path'}); + await runLink({path: 'my-path'}); expect(renderTextPrompt).toHaveBeenCalledWith({ message: expect.stringMatching(/name/i), @@ -133,19 +153,10 @@ describe('link', () => { }); }); - it('chooses to create a new storefront without directory path', async () => { - await linkStorefront({}); - - expect(renderTextPrompt).toHaveBeenCalledWith({ - message: expect.stringMatching(/name/i), - defaultValue: 'Hydrogen Storefront', - }); - }); - it('handles the successful creation of the storefront on Admin', async () => { - await linkStorefront({}); + await runLink({}); - expect(waitForJob).toHaveBeenCalledWith(SHOP, expectedJobId); + expect(waitForJob).toHaveBeenCalledWith(ADMIN_SESSION, expectedJobId); expect(outputMock.info()).toContain( `${expectedStorefrontName} is now linked`, @@ -168,67 +179,46 @@ describe('link', () => { jobId: undefined, }); - await linkStorefront({}); - + await expect(runLink({})).rejects.toThrow('Bad thing happend.'); expect(waitForJob).not.toHaveBeenCalled(); - expect(renderUserErrors).toHaveBeenCalledWith(expectedUserErrors); }); it('handles the job errors when creating the storefront on Admin', async () => { vi.mocked(waitForJob).mockRejectedValue(undefined); - await linkStorefront({}); - - expect(renderError).toHaveBeenCalled(); + await expect(runLink({})).rejects.toThrow(Error); }); }); describe('when there are no Hydrogen storefronts', () => { it('renders a message and returns early', async () => { - vi.mocked(getStorefronts).mockResolvedValue({ - adminSession: ADMIN_SESSION, - storefronts: [], - }); + vi.mocked(getStorefronts).mockResolvedValue([]); - await linkStorefront({}); + await runLink({}); - expect(outputMock.info()).toMatch( - /There are no Hydrogen storefronts on your Shop/g, - ); + expect(outputMock.info()).toMatch(/no Hydrogen storefronts/i); expect(renderSelectPrompt).not.toHaveBeenCalled(); expect(setStorefront).not.toHaveBeenCalled(); }); }); - describe('when no storefront gets selected', () => { - it('does not call setStorefront', async () => { - vi.mocked(renderSelectPrompt).mockResolvedValue(''); - - await linkStorefront({}); - - expect(setStorefront).not.toHaveBeenCalled(); - }); - }); - describe('when a linked storefront already exists', () => { beforeEach(() => { - vi.mocked(getConfig).mockResolvedValue({ - storefront: { - id: 'gid://shopify/HydrogenStorefront/2', - title: 'Existing Link', - }, + vi.mocked(login).mockResolvedValue({ + session: ADMIN_SESSION, + config: FULL_SHOPIFY_CONFIG, }); }); it('prompts the user to confirm', async () => { vi.mocked(renderConfirmationPrompt).mockResolvedValue(true); - await linkStorefront({}); + await runLink({}); expect(renderConfirmationPrompt).toHaveBeenCalledWith({ message: expect.stringMatching( - /Do you want to link to a different Hydrogen storefront on Shopify\?/, + /link to a different Hydrogen storefront/i, ), }); }); @@ -237,16 +227,16 @@ describe('link', () => { it('returns early', async () => { vi.mocked(renderConfirmationPrompt).mockResolvedValue(false); - await linkStorefront({}); + await runLink({}); - expect(adminRequest).not.toHaveBeenCalled(); + expect(getStorefronts).not.toHaveBeenCalled(); expect(setStorefront).not.toHaveBeenCalled(); }); }); describe('and the --force flag is provided', () => { it('does not prompt the user to confirm', async () => { - await linkStorefront({force: true}); + await runLink({force: true}); expect(renderConfirmationPrompt).not.toHaveBeenCalled(); }); @@ -255,7 +245,7 @@ describe('link', () => { describe('when the --storefront flag is provided', () => { it('does not prompt the user to make a selection', async () => { - await linkStorefront({path: 'my-path', storefront: 'Hydrogen'}); + await runLink({path: 'my-path', storefront: 'Hydrogen'}); expect(renderSelectPrompt).not.toHaveBeenCalled(); expect(setStorefront).toHaveBeenCalledWith( @@ -271,7 +261,7 @@ describe('link', () => { it('renders a warning message and returns early', async () => { const outputMock = mockAndCaptureOutput(); - await linkStorefront({storefront: 'Does not exist'}); + await runLink({storefront: 'Does not exist'}); expect(setStorefront).not.toHaveBeenCalled(); diff --git a/packages/cli/src/commands/hydrogen/link.ts b/packages/cli/src/commands/hydrogen/link.ts index ab729a5364..ed5ac84441 100644 --- a/packages/cli/src/commands/hydrogen/link.ts +++ b/packages/cli/src/commands/hydrogen/link.ts @@ -10,17 +10,18 @@ import { renderTextPrompt, renderWarning, } from '@shopify/cli-kit/node/ui'; +import {AbortError} from '@shopify/cli-kit/node/error'; import {commonFlags} from '../../lib/flags.js'; -import {getHydrogenShop} from '../../lib/shop.js'; import {getStorefronts} from '../../lib/graphql/admin/link-storefront.js'; +import {setStorefront, type ShopifyConfig} from '../../lib/shopify-config.js'; import {createStorefront} from '../../lib/graphql/admin/create-storefront.js'; import {waitForJob} from '../../lib/graphql/admin/fetch-job.js'; -import {getConfig, setStorefront} from '../../lib/shopify-config.js'; import {logMissingStorefronts} from '../../lib/missing-storefronts.js'; import {titleize} from '../../lib/string.js'; import {getCliCommand} from '../../lib/shell.js'; -import {renderError, renderUserErrors} from '../../lib/user-errors.js'; +import {login} from '../../lib/auth.js'; +import type {AdminSession} from '../../lib/auth.js'; export default class Link extends Command { static description = @@ -29,7 +30,6 @@ export default class Link extends Command { static flags = { force: commonFlags.force, path: commonFlags.path, - shop: commonFlags.shop, storefront: Flags.string({ description: 'The name of a Hydrogen Storefront (e.g. "Jane\'s Apparel")', env: 'SHOPIFY_HYDROGEN_STOREFRONT', @@ -38,16 +38,14 @@ export default class Link extends Command { async run(): Promise { const {flags} = await this.parse(Link); - await linkStorefront(flags); + await runLink(flags); } } export interface LinkStorefrontArguments { force?: boolean; path?: string; - shop?: string; storefront?: string; - silent?: boolean; } interface HydrogenStorefront { @@ -56,21 +54,53 @@ interface HydrogenStorefront { productionUrl: string; } -const CREATE_NEW_STOREFRONT_ID = 'NEW_STOREFRONT'; - -export async function linkStorefront({ +export async function runLink({ force, - path, - shop: flagShop, + path: root = process.cwd(), storefront: flagStorefront, - silent = false, }: LinkStorefrontArguments) { - const shop = await getHydrogenShop({path, shop: flagShop}); - const {storefront: configStorefront} = await getConfig(path ?? process.cwd()); + const [{session, config}, cliCommand] = await Promise.all([ + login(root, true), + getCliCommand(), + ]); + + const linkedStore = await linkStorefront(root, session, config, { + force, + flagStorefront, + cliCommand, + }); + + if (!linkedStore) return; + + renderSuccess({ + body: [{userInput: linkedStore.title}, 'is now linked'], + nextSteps: [ + [ + 'Run', + {command: `${cliCommand} dev`}, + 'to start your local development server and start building', + ], + ], + }); +} - if (configStorefront && !force) { +export async function linkStorefront( + root: string, + session: AdminSession, + config: ShopifyConfig, + { + force = false, + flagStorefront, + cliCommand, + }: {force?: boolean; flagStorefront?: string; cliCommand: string}, +) { + if (!config.shop) { + throw new AbortError('No shop found in local config, login first.'); + } + + if (config.storefront?.id && !force) { const overwriteLink = await renderConfirmationPrompt({ - message: `Your project is currently linked to ${configStorefront.title}. Do you want to link to a different Hydrogen storefront on Shopify?`, + message: `Your project is currently linked to ${config.storefront.title}. Do you want to link to a different Hydrogen storefront on Shopify?`, }); if (!overwriteLink) { @@ -78,16 +108,14 @@ export async function linkStorefront({ } } - const {storefronts, adminSession} = await getStorefronts(shop); + const storefronts = await getStorefronts(session); if (storefronts.length === 0) { - logMissingStorefronts(adminSession); + logMissingStorefronts(session); return; } let selectedStorefront: HydrogenStorefront | undefined; - let selectCreateNewStorefront = false; - const cliCommand = await getCliCommand(); if (flagStorefront) { selectedStorefront = storefronts.find( @@ -101,7 +129,7 @@ export async function linkStorefront({ "There's no storefront matching", {userInput: flagStorefront}, 'on your', - {userInput: shop}, + {userInput: config.shop}, 'shop. To see all available Hydrogen storefronts, run', { command: `${cliCommand} list`, @@ -112,52 +140,42 @@ export async function linkStorefront({ return; } } else { - const choices = storefronts.map(({id, title, productionUrl}) => ({ - value: id, - label: `${title} (${productionUrl})`, - })); - - // choices.unshift({ - // value: CREATE_NEW_STOREFRONT_ID, - // label: 'Create a new storefront', - // }); + const choices = [ + { + label: 'Create a new storefront', + value: null, + }, + ...storefronts.map(({id, title, productionUrl}) => ({ + label: `${title} (${productionUrl})`, + value: id, + })), + ]; const storefrontId = await renderSelectPrompt({ message: 'Choose a Hydrogen storefront to link', choices, }); - if (storefrontId === CREATE_NEW_STOREFRONT_ID) { - selectCreateNewStorefront = true; - } else { + if (storefrontId) { selectedStorefront = storefronts.find(({id}) => id === storefrontId); + } else { + selectedStorefront = await createNewStorefront(root, session); } } - if (selectCreateNewStorefront) { - const storefront = await createNewStorefront(path, shop); - - if (!storefront) { - return; - } - - selectedStorefront = storefront; - } - if (selectedStorefront) { - await linkExistingStorefront(path, selectedStorefront, silent, cliCommand); + await setStorefront(root, selectedStorefront); } + + return selectedStorefront; } -async function createNewStorefront( - path: string | undefined, - shop: string, -): Promise { - const projectDirectory = path && basename(path); +async function createNewStorefront(root: string, session: AdminSession) { + const projectDirectory = basename(root); const projectName = await renderTextPrompt({ - message: 'What do you want to name the Hydrogen storefront on Shopify?', - defaultValue: titleize(projectDirectory) || 'Hydrogen Storefront', + message: 'New storefront name', + defaultValue: titleize(projectDirectory), }); let storefront: HydrogenStorefront | undefined; @@ -167,13 +185,17 @@ async function createNewStorefront( { title: 'Creating storefront', task: async () => { - const result = await createStorefront(shop, projectName); + const result = await createStorefront(session, projectName); storefront = result.storefront; jobId = result.jobId; if (result.userErrors.length > 0) { - renderUserErrors(result.userErrors); + const errorMessages = result.userErrors + .map(({message}) => message) + .join(', '); + + throw new AbortError('Could not create storefront: ' + errorMessages); } }, }, @@ -181,39 +203,20 @@ async function createNewStorefront( title: 'Creating API tokens', task: async () => { try { - await waitForJob(shop, jobId!); + await waitForJob(session, jobId!); } catch (_err) { storefront = undefined; - renderError( - 'Please try again or contact support if the error persists.', - ); } }, skip: () => !jobId, }, ]); - return storefront; -} - -async function linkExistingStorefront( - path: string | undefined, - selectedStorefront: HydrogenStorefront, - silent: boolean, - cliCommand: string, -) { - await setStorefront(path ?? process.cwd(), selectedStorefront); - - if (!silent) { - renderSuccess({ - body: [{userInput: selectedStorefront.title}, 'is now linked'], - nextSteps: [ - [ - 'Run', - {command: `${cliCommand} dev`}, - 'to start your local development server and start building', - ], - ], - }); + if (!storefront) { + throw new AbortError( + 'Unknown error ocurred. Please try again or contact support if the error persists.', + ); } + + return storefront; } diff --git a/packages/cli/src/commands/hydrogen/list.test.ts b/packages/cli/src/commands/hydrogen/list.test.ts index 6a0f0149e1..7890ea8218 100644 --- a/packages/cli/src/commands/hydrogen/list.test.ts +++ b/packages/cli/src/commands/hydrogen/list.test.ts @@ -1,28 +1,34 @@ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; -import type {AdminSession} from '@shopify/cli-kit/node/session'; +import type {AdminSession} from '../../lib/auth.js'; import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'; import {getStorefrontsWithDeployment} from '../../lib/graphql/admin/list-storefronts.js'; -import {formatDeployment, listStorefronts} from './list.js'; +import {formatDeployment, runList} from './list.js'; +import {login} from '../../lib/auth.js'; -const SHOP_NAME = 'my-shop'; -vi.mock('../../lib/graphql/admin/list-storefronts.js', async () => { - return {getStorefrontsWithDeployment: vi.fn()}; -}); -vi.mock('../../lib/shop.js', () => ({ - getHydrogenShop: () => SHOP_NAME, -})); +vi.mock('../../lib/auth.js'); +vi.mock('../../lib/graphql/admin/list-storefronts.js'); describe('list', () => { const ADMIN_SESSION: AdminSession = { token: 'abc123', - storeFqdn: SHOP_NAME, + storeFqdn: 'my-shop', + }; + + const SHOPIFY_CONFIG = { + shop: 'my-shop.myshopify.com', + storefront: { + id: 'gid://shopify/HydrogenStorefront/1', + title: 'Hydrogen', + }, }; beforeEach(async () => { - vi.mocked(getStorefrontsWithDeployment).mockResolvedValue({ - adminSession: ADMIN_SESSION, - storefronts: [], + vi.mocked(login).mockResolvedValue({ + session: ADMIN_SESSION, + config: SHOPIFY_CONFIG, }); + + vi.mocked(getStorefrontsWithDeployment).mockResolvedValue([]); }); afterEach(() => { @@ -30,52 +36,49 @@ describe('list', () => { mockAndCaptureOutput().clear(); }); - it('makes a GraphQL call to fetch the storefronts', async () => { - await listStorefronts({}); + it('fetches the storefronts', async () => { + await runList({}); - expect(getStorefrontsWithDeployment).toHaveBeenCalledWith(SHOP_NAME); + expect(getStorefrontsWithDeployment).toHaveBeenCalledWith(ADMIN_SESSION); }); describe('and there are storefronts', () => { beforeEach(() => { - vi.mocked(getStorefrontsWithDeployment).mockResolvedValue({ - adminSession: ADMIN_SESSION, - storefronts: [ - { - id: 'gid://shopify/HydrogenStorefront/1', - parsedId: '1', - title: 'Hydrogen', - productionUrl: 'https://example.com', - currentProductionDeployment: null, - }, - { - id: 'gid://shopify/HydrogenStorefront/2', - parsedId: '2', - title: 'Demo Store', - productionUrl: 'https://demo.example.com', - currentProductionDeployment: { - id: 'gid://shopify/HydrogenStorefrontDeployment/1', - createdAt: '2023-03-22T22:28:38Z', - commitMessage: 'Update README.md', - }, + vi.mocked(getStorefrontsWithDeployment).mockResolvedValue([ + { + id: 'gid://shopify/HydrogenStorefront/1', + parsedId: '1', + title: 'Hydrogen', + productionUrl: 'https://example.com', + currentProductionDeployment: null, + }, + { + id: 'gid://shopify/HydrogenStorefront/2', + parsedId: '2', + title: 'Demo Store', + productionUrl: 'https://demo.example.com', + currentProductionDeployment: { + id: 'gid://shopify/HydrogenStorefrontDeployment/1', + createdAt: '2023-03-22T22:28:38Z', + commitMessage: 'Update README.md', }, - ], - }); + }, + ]); }); it('renders a list of storefronts', async () => { const outputMock = mockAndCaptureOutput(); - await listStorefronts({}); + await runList({}); expect(outputMock.info()).toMatch( - /Showing 2 Hydrogen storefronts for the store my-shop/g, + /Showing 2 Hydrogen storefronts for the store my-shop/i, ); - expect(outputMock.info()).toMatch(/Hydrogen \(id: 1\)/g); - expect(outputMock.info()).toMatch(/https:\/\/example.com/g); - expect(outputMock.info()).toMatch(/Demo Store \(id: 2\)/g); - expect(outputMock.info()).toMatch(/https:\/\/demo.example.com/g); - expect(outputMock.info()).toMatch(/3\/22\/2023, Update README.md/g); + expect(outputMock.info()).toMatch(/Hydrogen \(id: 1\)/); + expect(outputMock.info()).toMatch(/https:\/\/example.com/); + expect(outputMock.info()).toMatch(/Demo Store \(id: 2\)/); + expect(outputMock.info()).toMatch(/https:\/\/demo.example.com/); + expect(outputMock.info()).toMatch(/3\/22\/2023, Update README.md/); }); }); @@ -83,14 +86,14 @@ describe('list', () => { it('prompts the user to create a storefront', async () => { const outputMock = mockAndCaptureOutput(); - await listStorefronts({}); + await runList({}); expect(outputMock.info()).toMatch( - /There are no Hydrogen storefronts on your Shop\./g, + /There are no Hydrogen storefronts on your Shop\./i, ); - expect(outputMock.info()).toMatch(/Create a new Hydrogen storefront/g); + expect(outputMock.info()).toMatch(/Create a new Hydrogen storefront/i); expect(outputMock.info()).toMatch( - /https:\/\/my\-shop\/admin\/custom_storefronts\/new/g, + /https:\/\/my\-shop\/admin\/custom_storefronts\/new/, ); }); }); diff --git a/packages/cli/src/commands/hydrogen/list.ts b/packages/cli/src/commands/hydrogen/list.ts index 7e39f4c99e..e2191849cb 100644 --- a/packages/cli/src/commands/hydrogen/list.ts +++ b/packages/cli/src/commands/hydrogen/list.ts @@ -7,14 +7,14 @@ import { outputNewline, } from '@shopify/cli-kit/node/output'; import {commonFlags} from '../../lib/flags.js'; -import {getHydrogenShop} from '../../lib/shop.js'; -import {parseGid} from '../../lib/graphql.js'; +import {parseGid} from '../../lib/gid.js'; import { type Deployment, type HydrogenStorefront, getStorefrontsWithDeployment, } from '../../lib/graphql/admin/list-storefronts.js'; import {logMissingStorefronts} from '../../lib/missing-storefronts.js'; +import {login} from '../../lib/auth.js'; export default class List extends Command { static description = @@ -22,24 +22,22 @@ export default class List extends Command { static flags = { path: commonFlags.path, - shop: commonFlags.shop, }; async run(): Promise { const {flags} = await this.parse(List); - await listStorefronts(flags); + await runList(flags); } } interface Flags { path?: string; - shop?: string; } -export async function listStorefronts({path, shop: flagShop}: Flags) { - const shop = await getHydrogenShop({path, shop: flagShop}); +export async function runList({path: root = process.cwd()}: Flags) { + const {session} = await login(root, true); - const {storefronts, adminSession} = await getStorefrontsWithDeployment(shop); + const storefronts = await getStorefrontsWithDeployment(session); if (storefronts.length > 0) { outputNewline(); @@ -47,7 +45,7 @@ export async function listStorefronts({path, shop: flagShop}: Flags) { outputInfo( pluralizedStorefronts({ storefronts, - shop, + shop: session.storeFqdn, }).toString(), ); @@ -77,7 +75,7 @@ export async function listStorefronts({path, shop: flagShop}: Flags) { }, ); } else { - logMissingStorefronts(adminSession); + logMissingStorefronts(session); } } diff --git a/packages/cli/src/commands/hydrogen/login.ts b/packages/cli/src/commands/hydrogen/login.ts new file mode 100644 index 0000000000..5138496bde --- /dev/null +++ b/packages/cli/src/commands/hydrogen/login.ts @@ -0,0 +1,55 @@ +import {Flags} from '@oclif/core'; +import Command from '@shopify/cli-kit/node/base-command'; +import {renderSuccess} from '@shopify/cli-kit/node/ui'; +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'; + +import {commonFlags} from '../../lib/flags.js'; +import {getCliCommand} from '../../lib/shell.js'; +import {login} from '../../lib/auth.js'; + +export default class Login extends Command { + static description = 'Login to your Shopify account.'; + + static flags = { + path: commonFlags.path, + shop: Flags.string({ + char: 's', + description: + 'Shop URL. It can be the shop prefix (janes-apparel)' + + ' or the full myshopify.com URL (janes-apparel.myshopify.com, https://janes-apparel.myshopify.com).', + env: 'SHOPIFY_SHOP', + parse: async (input) => normalizeStoreFqdn(input), + }), + }; + + async run(): Promise { + const {flags} = await this.parse(Login); + await runLogin(flags); + } +} + +interface LoginArguments { + path?: string; + shop?: string; +} + +async function runLogin({ + path: root = process.cwd(), + shop: shopFlag, +}: LoginArguments) { + const [{session}, cliCommand] = await Promise.all([ + login(root, shopFlag), + getCliCommand(), + ]); + + renderSuccess({ + body: ['You are logged in to', {userInput: session.storeFqdn}], + nextSteps: [ + [ + 'Run', + {command: `${cliCommand} link`}, + 'to link your store to this project.', + ], + ], + }); +} diff --git a/packages/cli/src/commands/hydrogen/logout.ts b/packages/cli/src/commands/hydrogen/logout.ts new file mode 100644 index 0000000000..a7396ccafe --- /dev/null +++ b/packages/cli/src/commands/hydrogen/logout.ts @@ -0,0 +1,27 @@ +import Command from '@shopify/cli-kit/node/base-command'; +import {renderSuccess} from '@shopify/cli-kit/node/ui'; + +import {commonFlags} from '../../lib/flags.js'; +import {logout} from '../../lib/auth.js'; + +export default class Logout extends Command { + static description = 'Logout of your local session.'; + + static flags = { + path: commonFlags.path, + }; + + async run(): Promise { + const {flags} = await this.parse(Logout); + await runLogout(flags); + } +} + +interface LogoutArguments { + path?: string; +} + +async function runLogout({path: root = process.cwd()}: LogoutArguments) { + await logout(root); + renderSuccess({body: 'You are logged out from Shopify.'}); +} diff --git a/packages/cli/src/lib/admin-session.test.ts b/packages/cli/src/lib/admin-session.test.ts deleted file mode 100644 index a0b9b6bea3..0000000000 --- a/packages/cli/src/lib/admin-session.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {describe, it, expect, vi, afterEach} from 'vitest'; -import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'; -import type {AdminSession} from '@shopify/cli-kit/node/session'; -import {AbortError} from '@shopify/cli-kit/node/error'; - -import {getAdminSession} from './admin-session.js'; - -describe('list', () => { - vi.mock('@shopify/cli-kit/node/session'); - - const ADMIN_SESSION: AdminSession = { - token: 'abc123', - storeFqdn: 'my-shop', - }; - - afterEach(() => { - vi.resetAllMocks(); - }); - - it('returns the admin session', async () => { - vi.mocked(ensureAuthenticatedAdmin).mockResolvedValue(ADMIN_SESSION); - - const adminSession = await getAdminSession('my-shop'); - - expect(ensureAuthenticatedAdmin).toHaveBeenCalledWith('my-shop'); - - expect(adminSession).toStrictEqual(ADMIN_SESSION); - }); - - describe('when it fails to authenticate', () => { - it('throws an error', async () => { - vi.mocked(ensureAuthenticatedAdmin).mockRejectedValue({}); - - await expect(getAdminSession('my-shop')).rejects.toThrow(AbortError); - }); - }); -}); diff --git a/packages/cli/src/lib/admin-session.ts b/packages/cli/src/lib/admin-session.ts deleted file mode 100644 index 2fbd1366d9..0000000000 --- a/packages/cli/src/lib/admin-session.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {AbortError} from '@shopify/cli-kit/node/error'; -import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'; -import type {AdminSession} from '@shopify/cli-kit/node/session'; - -export {type AdminSession} from '@shopify/cli-kit/node/session'; - -export async function getAdminSession(shop: string): Promise { - let adminSession; - try { - adminSession = await ensureAuthenticatedAdmin(shop); - } catch { - throw new AbortError('Unable to authenticate with Shopify', undefined, [ - `Ensure the shop that you specified is correct (you are trying to use: ${shop})`, - ]); - } - - return adminSession; -} diff --git a/packages/cli/src/lib/auth.test.ts b/packages/cli/src/lib/auth.test.ts new file mode 100644 index 0000000000..17de498b3e --- /dev/null +++ b/packages/cli/src/lib/auth.test.ts @@ -0,0 +1,99 @@ +import {describe, it, expect, vi, afterEach, beforeEach} from 'vitest'; +import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'; +import {renderTextPrompt} from '@shopify/cli-kit/node/ui'; +import {AbortError} from '@shopify/cli-kit/node/error'; +import {login} from './auth.js'; +import {getConfig, setShop} from './shopify-config.js'; + +vi.mock('@shopify/cli-kit/node/session'); +vi.mock('@shopify/cli-kit/node/ui'); +vi.mock('./shopify-config.js'); + +describe('auth', () => { + const SHOP = 'my-shop'; + const SHOP_DOMAIN = SHOP + '.myshopify.com'; + const TOKEN = 'abc123'; + const ROOT = 'path/to/project'; + + beforeEach(() => { + vi.mocked(setShop).mockImplementation((root, shop) => + Promise.resolve({shop}), + ); + vi.mocked(ensureAuthenticatedAdmin).mockImplementation((shop) => + Promise.resolve({token: TOKEN, storeFqdn: shop}), + ); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('login', () => { + it('throws an error when it fails to authenticate', async () => { + vi.mocked(ensureAuthenticatedAdmin).mockRejectedValue({}); + + await expect(login(ROOT, SHOP)).rejects.toThrow(AbortError); + }); + + it('reads shop from local config when passing boolean', async () => { + vi.mocked(getConfig).mockResolvedValue({shop: SHOP_DOMAIN}); + const result = await login(ROOT, true); + + expect(getConfig).toHaveBeenCalledWith(ROOT); + expect(ensureAuthenticatedAdmin).toHaveBeenCalledWith(SHOP_DOMAIN); + expect(setShop).toHaveBeenCalledWith(ROOT, SHOP_DOMAIN); + + expect(result).toStrictEqual({ + config: {shop: SHOP_DOMAIN}, + session: {token: TOKEN, storeFqdn: SHOP_DOMAIN}, + }); + }); + + it('writes shop to local config and returns it with the admin session', async () => { + const result = await login(ROOT, SHOP); + + expect(ensureAuthenticatedAdmin).toHaveBeenCalledWith(SHOP_DOMAIN); + expect(setShop).toHaveBeenCalledWith(ROOT, SHOP_DOMAIN); + + expect(result).toStrictEqual({ + config: {shop: SHOP_DOMAIN}, + session: {token: TOKEN, storeFqdn: SHOP_DOMAIN}, + }); + }); + + it('prompts for shop when not passed in the arguments', async () => { + vi.mocked(renderTextPrompt).mockResolvedValue(SHOP); + + const result = await login(ROOT); + + expect(ensureAuthenticatedAdmin).toHaveBeenCalledWith(SHOP_DOMAIN); + expect(setShop).toHaveBeenCalledWith(ROOT, SHOP_DOMAIN); + + expect(result).toStrictEqual({ + config: {shop: SHOP_DOMAIN}, + session: { + token: TOKEN, + storeFqdn: SHOP_DOMAIN, + }, + }); + }); + + it('skips config steps when root argument is not passed', async () => { + vi.mocked(renderTextPrompt).mockResolvedValue(SHOP); + + const result = await login(); + + expect(ensureAuthenticatedAdmin).toHaveBeenCalledWith(SHOP_DOMAIN); + expect(getConfig).not.toHaveBeenCalled(); + expect(setShop).not.toHaveBeenCalled(); + + expect(result).toStrictEqual({ + config: {shop: SHOP_DOMAIN}, + session: { + token: TOKEN, + storeFqdn: SHOP_DOMAIN, + }, + }); + }); + }); +}); diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts new file mode 100644 index 0000000000..b4638ad2b0 --- /dev/null +++ b/packages/cli/src/lib/auth.ts @@ -0,0 +1,56 @@ +import {renderTextPrompt} from '@shopify/cli-kit/node/ui'; +import {AbortError} from '@shopify/cli-kit/node/error'; +import { + logout as adminLogout, + ensureAuthenticatedAdmin, + type AdminSession, +} from '@shopify/cli-kit/node/session'; +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'; +import {getConfig, resetConfig, setShop} from './shopify-config.js'; +import {muteAuthLogs} from './log.js'; + +export type {AdminSession}; + +/** + * Logs out of the currently authenticated shop and resets the local config. + * @param root Root of the project to read and write config to. + */ +export async function logout(root: string) { + await adminLogout(); + await resetConfig(root); +} + +/** + * Logs in to the specified shop and saves the shop domain to the project. + * @param root Root of the project to read and write config to. + * @param shop Shop string to use for authentication or `true` to read + * from the local Shopify config. If not provided, the user will be + * prompted to enter a shop domain. + */ +export async function login(root?: string, shop?: string | true) { + if (typeof shop !== 'string') { + if (shop === true) shop = root ? (await getConfig(root!)).shop : undefined; + + if (!shop) { + shop = await renderTextPrompt({ + message: + 'Specify which Store you would like to use (e.g. {store}.myshopify.com)', + allowEmpty: false, + }); + } + } + + shop = await normalizeStoreFqdn(shop); + + muteAuthLogs(); + + const session = await ensureAuthenticatedAdmin(shop).catch(() => { + throw new AbortError('Unable to authenticate with Shopify', undefined, [ + `Ensure the shop that you specified is correct (you are trying to use: ${shop})`, + ]); + }); + + const config = root ? await setShop(root, session.storeFqdn) : {shop}; + + return {session, config}; +} diff --git a/packages/cli/src/lib/combined-environment-variables.test.ts b/packages/cli/src/lib/combined-environment-variables.test.ts index 29ecd2a6de..0e792b6b90 100644 --- a/packages/cli/src/lib/combined-environment-variables.test.ts +++ b/packages/cli/src/lib/combined-environment-variables.test.ts @@ -1,31 +1,45 @@ -import {describe, test, expect, beforeEach, afterEach, vi} from 'vitest'; +import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs'; import {joinPath} from '@shopify/cli-kit/node/path'; import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'; import {combinedEnvironmentVariables} from './combined-environment-variables.js'; -import {pullRemoteEnvironmentVariables} from './pull-environment-variables.js'; -import {getConfig} from './shopify-config.js'; +import {getStorefrontEnvVariables} from './graphql/admin/pull-variables.js'; +import {login} from './auth.js'; -vi.mock('./shopify-config.js'); -vi.mock('./pull-environment-variables.js'); +vi.mock('./auth.js'); +vi.mock('./graphql/admin/pull-variables.js'); describe('combinedEnvironmentVariables()', () => { + const ADMIN_SESSION = { + token: 'abc123', + storeFqdn: 'my-shop', + }; + + const SHOPIFY_CONFIG = { + storefront: { + id: 'gid://shopify/HydrogenStorefront/1', + title: 'Hydrogen', + }, + }; + beforeEach(() => { - vi.mocked(getConfig).mockResolvedValue({ - storefront: { - id: 'gid://shopify/HydrogenStorefront/1', - title: 'Hydrogen', - }, + vi.mocked(login).mockResolvedValue({ + session: ADMIN_SESSION, + config: SHOPIFY_CONFIG, + }); + + vi.mocked(getStorefrontEnvVariables).mockResolvedValue({ + id: SHOPIFY_CONFIG.storefront.id, + environmentVariables: [ + { + id: 'gid://shopify/HydrogenStorefrontEnvironmentVariable/1', + key: 'PUBLIC_API_TOKEN', + value: 'abc123', + isSecret: false, + }, + ], }); - vi.mocked(pullRemoteEnvironmentVariables).mockResolvedValue([ - { - id: 'gid://shopify/HydrogenStorefrontEnvironmentVariable/1', - key: 'PUBLIC_API_TOKEN', - value: 'abc123', - isSecret: false, - }, - ]); }); afterEach(() => { @@ -33,7 +47,7 @@ describe('combinedEnvironmentVariables()', () => { mockAndCaptureOutput().clear(); }); - test('calls pullRemoteEnvironmentVariables', async () => { + it('calls pullRemoteEnvironmentVariables', async () => { await inTemporaryDirectory(async (tmpDir) => { await combinedEnvironmentVariables({ envBranch: 'main', @@ -41,16 +55,15 @@ describe('combinedEnvironmentVariables()', () => { shop: 'my-shop', }); - expect(pullRemoteEnvironmentVariables).toHaveBeenCalledWith({ - envBranch: 'main', - root: tmpDir, - flagShop: 'my-shop', - silent: true, - }); + expect(getStorefrontEnvVariables).toHaveBeenCalledWith( + ADMIN_SESSION, + SHOPIFY_CONFIG.storefront.id, + 'main', + ); }); }); - test('renders a message about injection', async () => { + it('renders a message about injection', async () => { await inTemporaryDirectory(async (tmpDir) => { const outputMock = mockAndCaptureOutput(); @@ -62,7 +75,7 @@ describe('combinedEnvironmentVariables()', () => { }); }); - test('lists all of the variables being used', async () => { + it('lists all of the variables being used', async () => { await inTemporaryDirectory(async (tmpDir) => { const outputMock = mockAndCaptureOutput(); @@ -74,17 +87,20 @@ describe('combinedEnvironmentVariables()', () => { describe('when one of the variables is a secret', () => { beforeEach(() => { - vi.mocked(pullRemoteEnvironmentVariables).mockResolvedValue([ - { - id: 'gid://shopify/HydrogenStorefrontEnvironmentVariable/1', - key: 'PUBLIC_API_TOKEN', - value: '', - isSecret: true, - }, - ]); + vi.mocked(getStorefrontEnvVariables).mockResolvedValue({ + id: SHOPIFY_CONFIG.storefront.id, + environmentVariables: [ + { + id: 'gid://shopify/HydrogenStorefrontEnvironmentVariable/1', + key: 'PUBLIC_API_TOKEN', + value: '', + isSecret: true, + }, + ], + }); }); - test('uses special messaging to alert the user', async () => { + it('uses special messaging to alert the user', async () => { await inTemporaryDirectory(async (tmpDir) => { const outputMock = mockAndCaptureOutput(); @@ -98,21 +114,21 @@ describe('combinedEnvironmentVariables()', () => { }); describe('when there are local variables', () => { - test('includes local variables in the list', async () => { + it('includes local variables in the list', async () => { await inTemporaryDirectory(async (tmpDir) => { const filePath = joinPath(tmpDir, '.env'); await writeFile(filePath, 'LOCAL_TOKEN=1'); const outputMock = mockAndCaptureOutput(); - await combinedEnvironmentVariables({root: tmpDir}); + await combinedEnvironmentVariables({root: tmpDir, shop: 'my-shop'}); expect(outputMock.info()).toMatch(/LOCAL_TOKEN\s+from local \.env/); }); }); describe('and they overwrite remote variables', () => { - test('uses special messaging to alert the user', async () => { + it('uses special messaging to alert the user', async () => { await inTemporaryDirectory(async (tmpDir) => { const filePath = joinPath(tmpDir, '.env'); await writeFile(filePath, 'PUBLIC_API_TOKEN=abc'); diff --git a/packages/cli/src/lib/combined-environment-variables.ts b/packages/cli/src/lib/combined-environment-variables.ts index d7eb946624..204adcf34b 100644 --- a/packages/cli/src/lib/combined-environment-variables.ts +++ b/packages/cli/src/lib/combined-environment-variables.ts @@ -4,26 +4,26 @@ import {linesToColumns} from '@shopify/cli-kit/common/string'; import {outputInfo} from '@shopify/cli-kit/node/output'; import {readAndParseDotEnv} from '@shopify/cli-kit/node/dot-env'; import colors from '@shopify/cli-kit/node/colors'; -import {pullRemoteEnvironmentVariables} from './pull-environment-variables.js'; +import {getStorefrontEnvVariables} from './graphql/admin/pull-variables.js'; +import {login} from './auth.js'; interface Arguments { envBranch?: string; root: string; - shop?: string; + shop: string; } export async function combinedEnvironmentVariables({ envBranch, root, shop, }: Arguments) { - const remoteEnvironmentVariables = await pullRemoteEnvironmentVariables({ - root, - flagShop: shop, - silent: true, - envBranch, - }); + const {session, config} = await login(root, shop); + + const remoteVariables = + (await getStorefrontEnvVariables(session, config.storefront!.id, envBranch)) + ?.environmentVariables || []; - const formattedRemoteVariables = remoteEnvironmentVariables?.reduce( + const formattedRemoteVariables = remoteVariables?.reduce( (a, v) => ({...a, [v.key]: v.value}), {}, ); @@ -33,9 +33,7 @@ export async function combinedEnvironmentVariables({ ? (await readAndParseDotEnv(dotEnvPath)).variables : {}; - const remoteKeys = new Set( - remoteEnvironmentVariables.map((variable) => variable.key), - ); + const remoteKeys = new Set(remoteVariables.map((variable) => variable.key)); const localKeys = new Set(Object.keys(localEnvironmentVariables)); @@ -45,7 +43,7 @@ export async function combinedEnvironmentVariables({ let rows: [string, string][] = []; - remoteEnvironmentVariables + remoteVariables .filter(({isSecret}) => !isSecret) .forEach(({key}) => { if (!localKeys.has(key)) { @@ -58,7 +56,7 @@ export async function combinedEnvironmentVariables({ }); // Ensure secret variables always get added to the bottom of the list - remoteEnvironmentVariables + remoteVariables .filter(({isSecret}) => isSecret) .forEach(({key}) => { if (!localKeys.has(key)) { diff --git a/packages/cli/src/lib/flags.ts b/packages/cli/src/lib/flags.ts index 9cb83eb847..d1d895a803 100644 --- a/packages/cli/src/lib/flags.ts +++ b/packages/cli/src/lib/flags.ts @@ -21,14 +21,6 @@ export const commonFlags = { env: 'SHOPIFY_HYDROGEN_FLAG_FORCE', char: 'f', }), - shop: Flags.string({ - char: 's', - description: - 'Shop URL. It can be the shop prefix (janes-apparel)' + - ' or the full myshopify.com URL (janes-apparel.myshopify.com, https://janes-apparel.myshopify.com).', - env: 'SHOPIFY_SHOP', - parse: async (input) => normalizeStoreFqdn(input), - }), ['env-branch']: Flags.string({ description: "Specify an environment's branch name when using remote environment variables.", diff --git a/packages/cli/src/lib/graphql.test.ts b/packages/cli/src/lib/gid.test.ts similarity index 91% rename from packages/cli/src/lib/graphql.test.ts rename to packages/cli/src/lib/gid.test.ts index c8925acda7..670684fee9 100644 --- a/packages/cli/src/lib/graphql.test.ts +++ b/packages/cli/src/lib/gid.test.ts @@ -1,11 +1,13 @@ import {describe, it, expect} from 'vitest'; import {AbortError} from '@shopify/cli-kit/node/error'; -import {parseGid} from './graphql.js'; +import {parseGid} from './gid.js'; + describe('parseGid', () => { it('returns an ID', () => { const id = parseGid('gid://shopify/HydrogenStorefront/324'); expect(id).toStrictEqual('324'); }); + describe('when the global ID is invalid', () => { it('throws an error', () => { expect(() => parseGid('321asd')).toThrow(AbortError); diff --git a/packages/cli/src/lib/gid.ts b/packages/cli/src/lib/gid.ts new file mode 100644 index 0000000000..d4b74f161e --- /dev/null +++ b/packages/cli/src/lib/gid.ts @@ -0,0 +1,14 @@ +import {AbortError} from '@shopify/cli-kit/node/error'; + +const GID_REGEXP = /gid:\/\/shopify\/\w*\/(\d+)/; +/** + * @param gid a Global ID to parse (e.g. 'gid://shopify/HydrogenStorefront/1') + * @returns the ID of the record (e.g. '1') + */ +export function parseGid(gid: string): string { + const matches = GID_REGEXP.exec(gid); + if (matches && matches[1] !== undefined) { + return matches[1]; + } + throw new AbortError(`Invalid Global ID: ${gid}`); +} diff --git a/packages/cli/src/lib/graphql.ts b/packages/cli/src/lib/graphql/admin/client.ts similarity index 53% rename from packages/cli/src/lib/graphql.ts rename to packages/cli/src/lib/graphql/admin/client.ts index 62dddae248..8bd79db684 100644 --- a/packages/cli/src/lib/graphql.ts +++ b/packages/cli/src/lib/graphql/admin/client.ts @@ -1,7 +1,10 @@ -import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql'; -import type {GraphQLVariables} from '@shopify/cli-kit/node/api/graphql'; -import type {AdminSession} from '@shopify/cli-kit/node/session'; -import {AbortError} from '@shopify/cli-kit/node/error'; +import { + graphqlRequest, + type GraphQLVariables, +} from '@shopify/cli-kit/node/api/graphql'; +import type {AdminSession} from '../../auth.js'; + +export type {AdminSession}; /** * This is a temporary workaround until cli-kit includes a way to specify @@ -22,16 +25,3 @@ export async function adminRequest( const url = `https://${session.storeFqdn}/admin/api/unstable/graphql.json`; return graphqlRequest({query, api, url, token: session.token, variables}); } - -const GID_REGEXP = /gid:\/\/shopify\/\w*\/(\d+)/; -/** - * @param gid a Global ID to parse (e.g. 'gid://shopify/HydrogenStorefront/1') - * @returns the ID of the record (e.g. '1') - */ -export function parseGid(gid: string): string { - const matches = GID_REGEXP.exec(gid); - if (matches && matches[1] !== undefined) { - return matches[1]; - } - throw new AbortError(`Invalid Global ID: ${gid}`); -} diff --git a/packages/cli/src/lib/graphql/admin/create-storefront.ts b/packages/cli/src/lib/graphql/admin/create-storefront.ts index 19cc2b0d6c..ab4a4b819d 100644 --- a/packages/cli/src/lib/graphql/admin/create-storefront.ts +++ b/packages/cli/src/lib/graphql/admin/create-storefront.ts @@ -1,6 +1,4 @@ -import {adminRequest} from '../../graphql.js'; -import {getAdminSession} from '../../admin-session.js'; -import {type UserError} from '../../user-errors.js'; +import {adminRequest, type AdminSession} from './client.js'; export const CreateStorefrontMutation = `#graphql mutation CreateStorefront($title: String!) { @@ -26,6 +24,12 @@ interface HydrogenStorefront { productionUrl: string; } +interface UserError { + code: string | undefined; + field: string[]; + message: string; +} + interface CreateStorefrontSchema { hydrogenStorefrontCreate: { hydrogenStorefront: HydrogenStorefront | undefined; @@ -34,15 +38,14 @@ interface CreateStorefrontSchema { }; } -export async function createStorefront(shop: string, title: string) { - const adminSession = await getAdminSession(shop); - +export async function createStorefront( + adminSession: AdminSession, + title: string, +) { const {hydrogenStorefrontCreate} = await adminRequest( CreateStorefrontMutation, adminSession, - { - title: title, - }, + {title: title}, ); return { diff --git a/packages/cli/src/lib/graphql/admin/fetch-job.ts b/packages/cli/src/lib/graphql/admin/fetch-job.ts index 7d9f52ef0f..616cf0d021 100644 --- a/packages/cli/src/lib/graphql/admin/fetch-job.ts +++ b/packages/cli/src/lib/graphql/admin/fetch-job.ts @@ -1,5 +1,4 @@ -import {adminRequest} from '../../graphql.js'; -import {getAdminSession} from '../../admin-session.js'; +import {adminRequest, type AdminSession} from './client.js'; export const FetchJobQuery = `#graphql query FetchJob($id: ID!) { @@ -27,9 +26,7 @@ export interface JobSchema { }; } -export async function fetchJob(shop: string, jobId: string) { - const adminSession = await getAdminSession(shop); - +export async function fetchJob(adminSession: AdminSession, jobId: string) { const {hydrogenStorefrontJob} = await adminRequest( FetchJobQuery, adminSession, @@ -46,10 +43,10 @@ export async function fetchJob(shop: string, jobId: string) { }; } -export function waitForJob(shop: string, jobId: string) { +export function waitForJob(adminSession: AdminSession, jobId: string) { return new Promise((resolve, reject) => { const interval = setInterval(async () => { - const job = await fetchJob(shop, jobId); + const job = await fetchJob(adminSession, jobId); if (job.errors.length > 0) { clearInterval(interval); diff --git a/packages/cli/src/lib/graphql/admin/link-storefront.test.ts b/packages/cli/src/lib/graphql/admin/link-storefront.test.ts new file mode 100644 index 0000000000..a2eeb10f65 --- /dev/null +++ b/packages/cli/src/lib/graphql/admin/link-storefront.test.ts @@ -0,0 +1,44 @@ +import {describe, it, expect, vi, afterEach} from 'vitest'; +import {adminRequest} from './client.js'; +import {getStorefronts, type LinkStorefrontSchema} from './link-storefront.js'; + +vi.mock('./client.js'); + +describe('getStorefrontsWithDeployment', () => { + const ADMIN_SESSION = { + token: 'abc123', + storeFqdn: 'my-shop.myshopify.com', + }; + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('calls the graphql client and returns Hydrogen storefronts', async () => { + const mockedResponse: LinkStorefrontSchema = { + hydrogenStorefronts: [ + { + id: 'gid://shopify/HydrogenStorefront/123', + title: 'title', + productionUrl: 'https://...', + }, + ], + }; + + vi.mocked(adminRequest).mockResolvedValue( + mockedResponse, + ); + + await expect(getStorefronts(ADMIN_SESSION)).resolves.toStrictEqual([ + { + ...mockedResponse.hydrogenStorefronts[0], + parsedId: '123', + }, + ]); + + expect(adminRequest).toHaveBeenCalledWith( + expect.stringMatching(/^#graphql.+query.+hydrogenStorefronts\s*{/s), + ADMIN_SESSION, + ); + }); +}); diff --git a/packages/cli/src/lib/graphql/admin/link-storefront.ts b/packages/cli/src/lib/graphql/admin/link-storefront.ts index 41173e0bf2..a3917b964c 100644 --- a/packages/cli/src/lib/graphql/admin/link-storefront.ts +++ b/packages/cli/src/lib/graphql/admin/link-storefront.ts @@ -1,5 +1,5 @@ -import {adminRequest, parseGid} from '../../graphql.js'; -import {getAdminSession} from '../../admin-session.js'; +import {adminRequest, type AdminSession} from './client.js'; +import {parseGid} from '../../gid.js'; export const LinkStorefrontQuery = `#graphql query LinkStorefront { @@ -17,23 +17,18 @@ interface HydrogenStorefront { productionUrl: string; } -interface LinkStorefrontSchema { +export interface LinkStorefrontSchema { hydrogenStorefronts: HydrogenStorefront[]; } -export async function getStorefronts(shop: string) { - const adminSession = await getAdminSession(shop); - +export async function getStorefronts(adminSession: AdminSession) { const {hydrogenStorefronts} = await adminRequest( LinkStorefrontQuery, adminSession, ); - return { - adminSession, - storefronts: hydrogenStorefronts.map((storefront) => ({ - ...storefront, - parsedId: parseGid(storefront.id), - })), - }; + return hydrogenStorefronts.map((storefront) => ({ + ...storefront, + parsedId: parseGid(storefront.id), + })); } diff --git a/packages/cli/src/lib/graphql/admin/list-environments.test.ts b/packages/cli/src/lib/graphql/admin/list-environments.test.ts new file mode 100644 index 0000000000..0f89dcdbd8 --- /dev/null +++ b/packages/cli/src/lib/graphql/admin/list-environments.test.ts @@ -0,0 +1,54 @@ +import {describe, it, expect, vi, afterEach} from 'vitest'; +import {adminRequest} from './client.js'; +import { + getStorefrontEnvironments, + type ListEnvironmentsSchema, +} from './list-environments.js'; + +vi.mock('./client.js'); + +describe('getStorefrontEnvironments', () => { + const ADMIN_SESSION = { + token: 'abc123', + storeFqdn: 'my-shop.myshopify.com', + }; + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('calls the graphql client and returns Hydrogen storefronts', async () => { + const mockedResponse: ListEnvironmentsSchema = { + hydrogenStorefront: { + id: 'gid://shopify/HydrogenStorefront/123', + productionUrl: 'https://...', + environments: [ + { + createdAt: '2021-01-01T00:00:00Z', + id: 'e123', + name: 'Staging', + type: 'CUSTOM', + branch: 'staging', + url: 'https://...', + }, + ], + }, + }; + + vi.mocked(adminRequest).mockResolvedValue( + mockedResponse, + ); + + const id = '123'; + + await expect( + getStorefrontEnvironments(ADMIN_SESSION, id), + ).resolves.toStrictEqual(mockedResponse.hydrogenStorefront); + + expect(adminRequest).toHaveBeenCalledWith( + expect.stringMatching(/^#graphql.+query.+hydrogenStorefront\(/s), + ADMIN_SESSION, + {id}, + ); + }); +}); diff --git a/packages/cli/src/lib/graphql/admin/list-environments.ts b/packages/cli/src/lib/graphql/admin/list-environments.ts index d16b1ce7df..ed7fa52ed7 100644 --- a/packages/cli/src/lib/graphql/admin/list-environments.ts +++ b/packages/cli/src/lib/graphql/admin/list-environments.ts @@ -1,5 +1,4 @@ -import {type AdminSession} from '../../admin-session.js'; -import {adminRequest} from '../../graphql.js'; +import {adminRequest, type AdminSession} from './client.js'; const ListEnvironmentsQuery = `#graphql query ListStorefronts($id: ID!) { @@ -35,7 +34,7 @@ interface HydrogenStorefront { productionUrl: string; } -interface ListEnvironmentsSchema { +export interface ListEnvironmentsSchema { hydrogenStorefront: HydrogenStorefront | null; } @@ -49,5 +48,5 @@ export async function getStorefrontEnvironments( {id: storefrontId}, ); - return {storefront: hydrogenStorefront}; + return hydrogenStorefront; } diff --git a/packages/cli/src/lib/graphql/admin/list-storefronts.test.ts b/packages/cli/src/lib/graphql/admin/list-storefronts.test.ts new file mode 100644 index 0000000000..fd3c7a9c43 --- /dev/null +++ b/packages/cli/src/lib/graphql/admin/list-storefronts.test.ts @@ -0,0 +1,53 @@ +import {describe, it, expect, vi, afterEach} from 'vitest'; +import {adminRequest} from './client.js'; +import { + getStorefrontsWithDeployment, + type ListStorefrontsSchema, +} from './list-storefronts.js'; + +vi.mock('./client.js'); + +describe('getStorefrontsWithDeployment', () => { + const ADMIN_SESSION = { + token: 'abc123', + storeFqdn: 'my-shop.myshopify.com', + }; + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('calls the graphql client and returns Hydrogen storefronts', async () => { + const mockedResponse: ListStorefrontsSchema = { + hydrogenStorefronts: [ + { + id: 'gid://shopify/HydrogenStorefront/123', + title: 'title', + currentProductionDeployment: { + id: 'd123', + createdAt: '2021-01-01T00:00:00Z', + commitMessage: null, + }, + }, + ], + }; + + vi.mocked(adminRequest).mockResolvedValue( + mockedResponse, + ); + + await expect( + getStorefrontsWithDeployment(ADMIN_SESSION), + ).resolves.toStrictEqual([ + { + ...mockedResponse.hydrogenStorefronts[0], + parsedId: '123', + }, + ]); + + expect(adminRequest).toHaveBeenCalledWith( + expect.stringMatching(/^#graphql.+query.+hydrogenStorefronts\s*{/s), + ADMIN_SESSION, + ); + }); +}); diff --git a/packages/cli/src/lib/graphql/admin/list-storefronts.ts b/packages/cli/src/lib/graphql/admin/list-storefronts.ts index 989ca11185..dbc74274c0 100644 --- a/packages/cli/src/lib/graphql/admin/list-storefronts.ts +++ b/packages/cli/src/lib/graphql/admin/list-storefronts.ts @@ -1,5 +1,5 @@ -import {adminRequest, parseGid} from '../../graphql.js'; -import {getAdminSession} from '../../admin-session.js'; +import {adminRequest, type AdminSession} from './client.js'; +import {parseGid} from '../../gid.js'; const ListStorefrontsQuery = `#graphql query ListStorefronts { @@ -29,23 +29,18 @@ export interface HydrogenStorefront { currentProductionDeployment: Deployment | null; } -interface ListStorefrontsSchema { +export interface ListStorefrontsSchema { hydrogenStorefronts: HydrogenStorefront[]; } -export async function getStorefrontsWithDeployment(shop: string) { - const adminSession = await getAdminSession(shop); - +export async function getStorefrontsWithDeployment(adminSession: AdminSession) { const {hydrogenStorefronts} = await adminRequest( ListStorefrontsQuery, adminSession, ); - return { - adminSession, - storefronts: hydrogenStorefronts.map((storefront) => ({ - ...storefront, - parsedId: parseGid(storefront.id), - })), - }; + return hydrogenStorefronts.map((storefront) => ({ + ...storefront, + parsedId: parseGid(storefront.id), + })); } diff --git a/packages/cli/src/lib/graphql/admin/pull-variables.test.ts b/packages/cli/src/lib/graphql/admin/pull-variables.test.ts new file mode 100644 index 0000000000..e7a427d204 --- /dev/null +++ b/packages/cli/src/lib/graphql/admin/pull-variables.test.ts @@ -0,0 +1,47 @@ +import {describe, it, expect, vi, afterEach} from 'vitest'; +import {adminRequest} from './client.js'; +import { + getStorefrontEnvVariables, + type PullVariablesSchema, +} from './pull-variables.js'; + +vi.mock('./client.js'); + +describe('getStorefrontEnvVariables', () => { + const ADMIN_SESSION = { + token: 'abc123', + storeFqdn: 'my-shop.myshopify.com', + }; + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('calls the graphql client and returns Hydrogen storefronts', async () => { + const mockedResponse: PullVariablesSchema = { + hydrogenStorefront: { + id: '123', + environmentVariables: [ + {id: '123', isSecret: false, key: 'key', value: 'value'}, + ], + }, + }; + + vi.mocked(adminRequest).mockResolvedValue( + mockedResponse, + ); + + const id = '123'; + const branch = 'staging'; + + await expect( + getStorefrontEnvVariables(ADMIN_SESSION, id, branch), + ).resolves.toStrictEqual(mockedResponse.hydrogenStorefront); + + expect(adminRequest).toHaveBeenCalledWith( + expect.stringMatching(/^#graphql.+query.+hydrogenStorefront\(/s), + ADMIN_SESSION, + {id, branch}, + ); + }); +}); diff --git a/packages/cli/src/lib/graphql/admin/pull-variables.ts b/packages/cli/src/lib/graphql/admin/pull-variables.ts index b3855491e3..aa5d80a9ad 100644 --- a/packages/cli/src/lib/graphql/admin/pull-variables.ts +++ b/packages/cli/src/lib/graphql/admin/pull-variables.ts @@ -1,7 +1,6 @@ -import {type AdminSession} from '../../admin-session.js'; -import {adminRequest} from '../../graphql.js'; +import {adminRequest, type AdminSession} from './client.js'; -export const PullVariablesQuery = `#graphql +const PullVariablesQuery = `#graphql query PullVariables($id: ID!, $branch: String) { hydrogenStorefront(id: $id) { id @@ -45,5 +44,5 @@ export async function getStorefrontEnvVariables( }, ); - return {storefront: hydrogenStorefront}; + return hydrogenStorefront; } diff --git a/packages/cli/src/lib/log.ts b/packages/cli/src/lib/log.ts index e9ad494581..e3e310a001 100644 --- a/packages/cli/src/lib/log.ts +++ b/packages/cli/src/lib/log.ts @@ -1,25 +1,80 @@ /* eslint-disable no-console */ -let isFirstWorkerReload = true; +type ConsoleMethod = 'log' | 'warn' | 'error' | 'debug' | 'info'; +const originalConsole = {...console}; +const methodsReplaced = new Set(); +type Matcher = (args: Array) => boolean; +type Replacer = (args: Array) => void | string[]; +const messageReplacers: Array<[Matcher, Replacer]> = []; + +function injectLogReplacer(method: ConsoleMethod) { + if (!methodsReplaced.has(method)) { + methodsReplaced.add(method); + console[method] = (...args: unknown[]) => { + const replacer = messageReplacers.find(([matcher]) => matcher(args))?.[1]; + if (!replacer) return originalConsole[method](...args); + + const result = replacer(args); + if (result) return originalConsole[method](...result); + }; + } +} + +injectLogReplacer('log'); +injectLogReplacer('info'); + +let devMuted = false; export function muteDevLogs({workerReload}: {workerReload?: boolean} = {}) { - const log = console.log.bind(console); - console.log = (first, ...rest) => { - // Miniflare logs - if (typeof first === 'string' && first.includes('[mf:')) { + if (devMuted) return; + else devMuted = true; + + let isFirstWorkerReload = true; + messageReplacers.push([ + ([first]) => typeof first === 'string' && first.includes('[mf:'), + (args: string[]) => { + const first = args[0] as string; + if (workerReload !== false && first.includes('Worker reloaded')) { - if (isFirstWorkerReload) isFirstWorkerReload = false; - else return log(first.replace('[mf:inf] ', '🔄 ') + '\n', ...rest); + if (isFirstWorkerReload) { + isFirstWorkerReload = false; + // return args as string[]; + return; + } + + return [first.replace('[mf:inf] ', '🔄 ') + '\n', ...args.slice(1)]; } if (!first.includes('[mf:err]')) { // Hide logs except errors return; } - } + }, + ]); +} + +let authMuted = false; +export function muteAuthLogs() { + if (authMuted) return; + else authMuted = true; - return log(first, ...rest); - }; + messageReplacers.push( + [ + ([first]) => typeof first === 'string' && first.includes('Auto-open'), + ([first]) => { + return [first.replace(' to Shopify Partners', '')]; + }, + ], + [ + ([first]) => + typeof first === 'string' && + (first.includes('Shopify Partners') || first.includes('Logged in')), + () => { + // Hide logs + return; + }, + ], + ); } const warnings = new Set(); diff --git a/packages/cli/src/lib/pull-environment-variables.test.ts b/packages/cli/src/lib/pull-environment-variables.test.ts deleted file mode 100644 index 9baa4d36d0..0000000000 --- a/packages/cli/src/lib/pull-environment-variables.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; -import type {AdminSession} from '@shopify/cli-kit/node/session'; -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'; -import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs'; -import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui'; - -import { - PullVariablesQuery, - PullVariablesSchema, -} from './graphql/admin/pull-variables.js'; -import {getAdminSession} from './admin-session.js'; -import {adminRequest} from './graphql.js'; -import {getConfig} from './shopify-config.js'; -import {renderMissingLink, renderMissingStorefront} from './render-errors.js'; -import {linkStorefront} from '../commands/hydrogen/link.js'; - -import {pullRemoteEnvironmentVariables} from './pull-environment-variables.js'; - -vi.mock('@shopify/cli-kit/node/ui', async () => { - const original = await vi.importActual< - typeof import('@shopify/cli-kit/node/ui') - >('@shopify/cli-kit/node/ui'); - return { - ...original, - renderConfirmationPrompt: vi.fn(), - }; -}); -vi.mock('../commands/hydrogen/link.js'); -vi.mock('./admin-session.js'); -vi.mock('./shopify-config.js'); -vi.mock('./render-errors.js'); -vi.mock('./graphql.js', async () => { - const original = await vi.importActual( - './graphql.js', - ); - return { - ...original, - adminRequest: vi.fn(), - }; -}); -vi.mock('./shop.js', () => ({ - getHydrogenShop: () => 'my-shop', -})); - -describe('pullRemoteEnvironmentVariables', () => { - const ENVIRONMENT_VARIABLES_RESPONSE = [ - { - id: 'gid://shopify/HydrogenStorefrontEnvironmentVariable/1', - key: 'PUBLIC_API_TOKEN', - value: 'abc123', - isSecret: false, - }, - ]; - - const ADMIN_SESSION: AdminSession = { - token: 'abc123', - storeFqdn: 'my-shop', - }; - - beforeEach(async () => { - vi.mocked(getAdminSession).mockResolvedValue(ADMIN_SESSION); - vi.mocked(getConfig).mockResolvedValue({ - storefront: { - id: 'gid://shopify/HydrogenStorefront/2', - title: 'Existing Link', - }, - }); - vi.mocked(adminRequest).mockResolvedValue({ - hydrogenStorefront: { - id: 'gid://shopify/HydrogenStorefront/1', - environmentVariables: ENVIRONMENT_VARIABLES_RESPONSE, - }, - }); - }); - - afterEach(() => { - vi.resetAllMocks(); - mockAndCaptureOutput().clear(); - }); - - it('makes a GraphQL call to fetch environment variables', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await pullRemoteEnvironmentVariables({ - root: tmpDir, - envBranch: 'staging', - }); - - expect(adminRequest).toHaveBeenCalledWith( - PullVariablesQuery, - ADMIN_SESSION, - { - id: 'gid://shopify/HydrogenStorefront/2', - branch: 'staging', - }, - ); - }); - }); - - it('returns environment variables', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const environmentVariables = await pullRemoteEnvironmentVariables({ - root: tmpDir, - }); - - expect(environmentVariables).toBe(ENVIRONMENT_VARIABLES_RESPONSE); - }); - }); - - describe('when environment variables are empty', () => { - beforeEach(() => { - vi.mocked(adminRequest).mockResolvedValue({ - hydrogenStorefront: { - id: 'gid://shopify/HydrogenStorefront/1', - environmentVariables: [], - }, - }); - }); - - it('renders a message', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const outputMock = mockAndCaptureOutput(); - - await pullRemoteEnvironmentVariables({root: tmpDir}); - - expect(outputMock.info()).toMatch(/No environment variables found\./); - }); - }); - - it('returns an empty array', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const environmentVariables = await pullRemoteEnvironmentVariables({ - root: tmpDir, - }); - - expect(environmentVariables).toStrictEqual([]); - }); - }); - }); - - describe('when there is no linked storefront', () => { - beforeEach(() => { - vi.mocked(getConfig).mockResolvedValue({ - storefront: undefined, - }); - }); - - it('calls renderMissingLink', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await pullRemoteEnvironmentVariables({root: tmpDir}); - - expect(renderMissingLink).toHaveBeenCalledOnce(); - }); - }); - - it('prompts the user to create a link', async () => { - vi.mocked(renderConfirmationPrompt).mockResolvedValue(true); - - await inTemporaryDirectory(async (tmpDir) => { - await pullRemoteEnvironmentVariables({root: tmpDir}); - - expect(renderConfirmationPrompt).toHaveBeenCalledWith({ - message: expect.stringMatching(/Run .*npx shopify hydrogen link.*\?/), - }); - - expect(linkStorefront).toHaveBeenCalledWith({ - path: tmpDir, - }); - }); - }); - - describe('and the user does not create a new link', () => { - it('returns an empty array', async () => { - vi.mocked(renderConfirmationPrompt).mockResolvedValue(false); - - await inTemporaryDirectory(async (tmpDir) => { - const environmentVariables = await pullRemoteEnvironmentVariables({ - root: tmpDir, - }); - - expect(environmentVariables).toStrictEqual([]); - }); - }); - }); - }); - - describe('when there is no matching storefront in the shop', () => { - beforeEach(() => { - vi.mocked(adminRequest).mockResolvedValue({ - hydrogenStorefront: null, - }); - }); - - it('calls renderMissingStorefront', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await pullRemoteEnvironmentVariables({root: tmpDir}); - - expect(renderMissingStorefront).toHaveBeenCalledOnce(); - }); - }); - - it('returns an empty array', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const environmentVariables = await pullRemoteEnvironmentVariables({ - root: tmpDir, - }); - - expect(environmentVariables).toStrictEqual([]); - }); - }); - }); -}); diff --git a/packages/cli/src/lib/pull-environment-variables.ts b/packages/cli/src/lib/pull-environment-variables.ts deleted file mode 100644 index e0bfe06a1c..0000000000 --- a/packages/cli/src/lib/pull-environment-variables.ts +++ /dev/null @@ -1,88 +0,0 @@ -import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui'; -import { - outputContent, - outputInfo, - outputToken, -} from '@shopify/cli-kit/node/output'; - -import {linkStorefront} from '../commands/hydrogen/link.js'; - -import {getHydrogenShop} from './shop.js'; -import {getAdminSession} from './admin-session.js'; -import {getConfig} from './shopify-config.js'; -import {renderMissingLink, renderMissingStorefront} from './render-errors.js'; - -import {getStorefrontEnvVariables} from './graphql/admin/pull-variables.js'; - -interface Arguments { - envBranch?: string; - root: string; - /** - * Optional shop override that developers would have passed using the --shop - * flag. - */ - flagShop?: string; - /** - * Does not prompt the user to fix any errors that are encountered (e.g. no - * linked storefront) - */ - silent?: boolean; -} - -export async function pullRemoteEnvironmentVariables({ - envBranch, - root, - flagShop, - silent, -}: Arguments) { - const shop = await getHydrogenShop({path: root, shop: flagShop}); - const adminSession = await getAdminSession(shop); - let configStorefront = (await getConfig(root)).storefront; - - if (!configStorefront?.id) { - if (!silent) { - renderMissingLink({adminSession}); - - const runLink = await renderConfirmationPrompt({ - message: outputContent`Run ${outputToken.genericShellCommand( - `npx shopify hydrogen link`, - )}?`.value, - }); - - if (!runLink) { - return []; - } - - await linkStorefront({path: root, shop: flagShop, silent}); - } - } - - configStorefront = (await getConfig(root)).storefront; - - if (!configStorefront) { - return []; - } - - const {storefront} = await getStorefrontEnvVariables( - adminSession, - configStorefront.id, - envBranch, - ); - - if (!storefront) { - if (!silent) { - renderMissingStorefront({adminSession, storefront: configStorefront}); - } - - return []; - } - - if (!storefront.environmentVariables.length) { - if (!silent) { - outputInfo(`No environment variables found.`); - } - return []; - } - - return storefront.environmentVariables; -} diff --git a/packages/cli/src/lib/render-errors.ts b/packages/cli/src/lib/render-errors.ts index e703a1cde3..79f2691336 100644 --- a/packages/cli/src/lib/render-errors.ts +++ b/packages/cli/src/lib/render-errors.ts @@ -1,20 +1,20 @@ import {renderFatalError} from '@shopify/cli-kit/node/ui'; import {outputContent, outputToken} from '@shopify/cli-kit/node/output'; -import type {AdminSession} from '@shopify/cli-kit/node/session'; +import type {AdminSession} from './auth.js'; import {hydrogenStorefrontsUrl} from './admin-urls.js'; -import {parseGid} from './graphql.js'; +import {parseGid} from './gid.js'; interface MissingStorefront { - adminSession: AdminSession; - storefront: { - id: string; - title: string; - }; + session: AdminSession; + storefront: {id: string; title: string}; + cliCommand: string; } + export function renderMissingStorefront({ - adminSession, + session, storefront, + cliCommand, }: MissingStorefront) { renderFatalError({ name: 'NoStorefrontError', @@ -25,27 +25,29 @@ export function renderMissingStorefront({ tryMessage: outputContent`Couldn’t find ${storefront.title} (ID: ${parseGid( storefront.id, )}) on ${ - adminSession.storeFqdn + session.storeFqdn }. Check that the storefront exists and run ${outputToken.genericShellCommand( - `npx shopify hydrogen link`, + `${cliCommand} link`, )} to link this project to it.\n\n${outputToken.link( 'Hydrogen Storefronts Admin', - hydrogenStorefrontsUrl(adminSession), + hydrogenStorefrontsUrl(session), )}`.value, }); } interface MissingLink { - adminSession: AdminSession; + session: AdminSession; + cliCommand: string; } -export function renderMissingLink({adminSession}: MissingLink) { + +export function renderMissingLink({session, cliCommand}: MissingLink) { renderFatalError({ name: 'NoLinkedStorefrontError', type: 0, - message: `No linked Hydrogen storefront on ${adminSession.storeFqdn}`, - tryMessage: - outputContent`To pull environment variables, link this project to a Hydrogen storefront. To select a storefront to link, run ${outputToken.genericShellCommand( - `npx shopify hydrogen link`, - )}.`.value, + message: `No linked Hydrogen storefront on ${session.storeFqdn}`, + tryMessage: [ + 'To pull environment variables, link this project to a Hydrogen storefront. To select a storefront to link, run', + {command: `${cliCommand} link`}, + ], }); } diff --git a/packages/cli/src/lib/shop.test.ts b/packages/cli/src/lib/shop.test.ts deleted file mode 100644 index cf31622e9f..0000000000 --- a/packages/cli/src/lib/shop.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; -import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs'; -import {AbortError} from '@shopify/cli-kit/node/error'; -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'; -import {renderTextPrompt} from '@shopify/cli-kit/node/ui'; - -import {getHydrogenShop} from './shop.js'; -import {getConfig, setShop} from './shopify-config.js'; - -vi.mock('@shopify/cli-kit/node/ui'); -vi.mock('./shopify-config.js'); - -describe('getHydrogenShop()', () => { - beforeEach(() => { - vi.mocked(getConfig).mockResolvedValue({}); - }); - - afterEach(() => { - mockAndCaptureOutput().clear(); - vi.clearAllMocks(); - }); - - describe('when a shop is passed via flag', () => { - it('returns the shop', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const shop = await getHydrogenShop({shop: 'my-shop', path: tmpDir}); - - expect(shop).toBe('my-shop'); - }); - }); - }); - - describe('when a shop is not provided via flag', () => { - describe('and there is no existing SHOP_NAME file', () => { - it('prompts the user to enter a new name', async () => { - await inTemporaryDirectory(async (tmpDir) => { - vi.mocked(renderTextPrompt).mockResolvedValue('my-prompted-shop'); - - const shop = await getHydrogenShop({path: tmpDir}); - - expect(renderTextPrompt).toHaveBeenCalledWith({ - message: - 'Specify which Shop you would like to use (e.g. janes-goods.myshopify.com)', - allowEmpty: false, - }); - expect(shop).toBe('my-prompted-shop'); - }); - }); - - describe('and the user does not enter a value', () => { - it('throws an error', async () => { - await inTemporaryDirectory(async (tmpDir) => { - vi.mocked(renderTextPrompt).mockResolvedValue(''); - - await expect(getHydrogenShop({path: tmpDir})).rejects.toThrow( - AbortError, - ); - }); - }); - }); - }); - - describe('and there is an existing shop from the config file', () => { - it('returns the shop', async () => { - vi.mocked(getConfig).mockResolvedValue({shop: 'previous-shop'}); - - await inTemporaryDirectory(async (tmpDir) => { - const shop = await getHydrogenShop({path: tmpDir}); - - expect(shop).toBe('previous-shop'); - }); - }); - }); - }); - - describe('when the SHOP_NAME file does not exist', () => { - it('gets created', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await getHydrogenShop({shop: 'new-shop', path: tmpDir}); - - expect(setShop).toHaveBeenCalledWith(tmpDir, 'new-shop'); - }); - }); - }); - - describe('when the shop is different from the value in SHOP_NAME', () => { - it('overwrites SHOP_NAME with the new value', async () => { - vi.mocked(getConfig).mockResolvedValue({shop: 'previous-shop'}); - - await inTemporaryDirectory(async (tmpDir) => { - await getHydrogenShop({shop: 'new-shop', path: tmpDir}); - - expect(setShop).toHaveBeenCalledWith(tmpDir, 'new-shop'); - }); - }); - }); -}); diff --git a/packages/cli/src/lib/shop.ts b/packages/cli/src/lib/shop.ts deleted file mode 100644 index 73f276da82..0000000000 --- a/packages/cli/src/lib/shop.ts +++ /dev/null @@ -1,46 +0,0 @@ -import {renderTextPrompt} from '@shopify/cli-kit/node/ui'; -import {AbortError} from '@shopify/cli-kit/node/error'; -import {outputContent, outputToken} from '@shopify/cli-kit/node/output'; - -import {getConfig, setShop} from './shopify-config.js'; - -interface Flags { - shop?: string; - path?: string; -} - -export async function getHydrogenShop(flags: Flags): Promise { - const {shop: flagShop, path: flagPath} = flags; - const targetPath = flagPath ?? process.cwd(); - const {shop: configShop} = await getConfig(targetPath); - - let promptShop; - if (!flagShop && !configShop) { - promptShop = await renderTextPrompt({ - message: - 'Specify which Shop you would like to use (e.g. janes-goods.myshopify.com)', - allowEmpty: false, - }); - } - - const shop = flagShop || configShop || promptShop; - - if (!shop) { - throw new AbortError( - 'A shop is required', - `Specify the shop passing ${ - outputContent`${outputToken.genericShellCommand( - `--shop={your_shop_url}}`, - )}`.value - } or set the ${ - outputContent`${outputToken.genericShellCommand('SHOPIFY_SHOP')}`.value - } environment variable.`, - ); - } - - if (!configShop || (flagShop && flagShop != configShop)) { - await setShop(targetPath, shop); - } - - return shop; -} diff --git a/packages/cli/src/lib/shopify-config.test.ts b/packages/cli/src/lib/shopify-config.test.ts index 616ffa3b73..5dc904cfa0 100644 --- a/packages/cli/src/lib/shopify-config.test.ts +++ b/packages/cli/src/lib/shopify-config.test.ts @@ -11,6 +11,7 @@ import {joinPath, dirname} from '@shopify/cli-kit/node/path'; import { getConfig, setShop, + resetConfig, setStorefront, unsetStorefront, ensureShopifyGitIgnore, @@ -19,6 +20,24 @@ import { } from './shopify-config.js'; import type {ShopifyConfig} from './shopify-config.js'; +async function writeExistingConfig(dir: string, config?: ShopifyConfig) { + const existingConfig: ShopifyConfig = config ?? { + shop: 'previous-shop', + storefront: { + id: 'gid://shopify/HydrogenStorefront/1', + title: 'Hydrogen', + }, + }; + + const filePath = joinPath(dir, SHOPIFY_DIR, SHOPIFY_DIR_PROJECT); + await mkdir(dirname(filePath)); + await writeFile(filePath, JSON.stringify(existingConfig)); + + expect(JSON.parse(await readFile(filePath))).toStrictEqual(existingConfig); + + return {existingConfig, filePath}; +} + describe('getConfig()', () => { describe('when no config exists', () => { it('returns an empty object', async () => { @@ -48,6 +67,18 @@ describe('getConfig()', () => { }); }); +describe('resetConfig()', () => { + it('writes an empty object', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await writeExistingConfig(tmpDir); + await resetConfig(tmpDir); + + const config = await getConfig(tmpDir); + expect(config).toStrictEqual({}); + }); + }); +}); + describe('setShop()', () => { describe('when no config exists', () => { it('creates a new config file', async () => { @@ -76,20 +107,7 @@ describe('setShop()', () => { describe('when a config exists', () => { it('updates the config file', async () => { await inTemporaryDirectory(async (tmpDir) => { - const existingConfig: ShopifyConfig = { - shop: 'previous-shop', - storefront: { - id: 'gid://shopify/HydrogenStorefront/1', - title: 'Hydrogen', - }, - }; - const filePath = joinPath(tmpDir, SHOPIFY_DIR, SHOPIFY_DIR_PROJECT); - await mkdir(dirname(filePath)); - await writeFile(filePath, JSON.stringify(existingConfig)); - - expect(JSON.parse(await readFile(filePath))).toStrictEqual( - existingConfig, - ); + const {existingConfig, filePath} = await writeExistingConfig(tmpDir); await setShop(tmpDir, 'new-shop'); @@ -102,16 +120,7 @@ describe('setShop()', () => { it('returns the new config', async () => { await inTemporaryDirectory(async (tmpDir) => { - const existingConfig: ShopifyConfig = { - shop: 'previous-shop', - storefront: { - id: 'gid://shopify/HydrogenStorefront/1', - title: 'Hydrogen', - }, - }; - const filePath = joinPath(tmpDir, SHOPIFY_DIR, SHOPIFY_DIR_PROJECT); - await mkdir(dirname(filePath)); - await writeFile(filePath, JSON.stringify(existingConfig)); + const {existingConfig} = await writeExistingConfig(tmpDir); const config = await setShop(tmpDir, 'new-shop'); @@ -127,20 +136,7 @@ describe('setShop()', () => { describe('setStorefront()', () => { it('updates the config file', async () => { await inTemporaryDirectory(async (tmpDir) => { - const existingConfig: ShopifyConfig = { - shop: 'previous-shop', - storefront: { - id: 'gid://shopify/HydrogenStorefront/1', - title: 'Hydrogen', - }, - }; - const filePath = joinPath(tmpDir, SHOPIFY_DIR, SHOPIFY_DIR_PROJECT); - await mkdir(dirname(filePath)); - await writeFile(filePath, JSON.stringify(existingConfig)); - - expect(JSON.parse(await readFile(filePath))).toStrictEqual( - existingConfig, - ); + const {existingConfig, filePath} = await writeExistingConfig(tmpDir); const newStorefront = { id: 'gid://shopify/HydrogenStorefront/2', @@ -158,16 +154,7 @@ describe('setStorefront()', () => { it('returns the new config', async () => { await inTemporaryDirectory(async (tmpDir) => { - const existingConfig: ShopifyConfig = { - shop: 'previous-shop', - storefront: { - id: 'gid://shopify/HydrogenStorefront/1', - title: 'Hydrogen', - }, - }; - const filePath = joinPath(tmpDir, SHOPIFY_DIR, SHOPIFY_DIR_PROJECT); - await mkdir(dirname(filePath)); - await writeFile(filePath, JSON.stringify(existingConfig)); + const {existingConfig} = await writeExistingConfig(tmpDir); const newStorefront = { id: 'gid://shopify/HydrogenStorefront/2', @@ -187,20 +174,7 @@ describe('setStorefront()', () => { describe('unsetStorefront()', () => { it('removes the storefront configuration and returns the config', async () => { await inTemporaryDirectory(async (tmpDir) => { - const existingConfig: ShopifyConfig = { - shop: 'previous-shop', - storefront: { - id: 'gid://shopify/HydrogenStorefront/1', - title: 'Hydrogen', - }, - }; - const filePath = joinPath(tmpDir, SHOPIFY_DIR, SHOPIFY_DIR_PROJECT); - await mkdir(dirname(filePath)); - await writeFile(filePath, JSON.stringify(existingConfig)); - - expect(JSON.parse(await readFile(filePath))).toStrictEqual( - existingConfig, - ); + const {filePath} = await writeExistingConfig(tmpDir); const config = await unsetStorefront(tmpDir); diff --git a/packages/cli/src/lib/shopify-config.ts b/packages/cli/src/lib/shopify-config.ts index ded072d7cf..b4bd9ab215 100644 --- a/packages/cli/src/lib/shopify-config.ts +++ b/packages/cli/src/lib/shopify-config.ts @@ -16,6 +16,16 @@ export interface ShopifyConfig { storefront?: Storefront; } +export async function resetConfig(root: string): Promise { + const filePath = resolvePath(root, SHOPIFY_DIR, SHOPIFY_DIR_PROJECT); + + if (!(await fileExists(filePath))) { + return; + } + + await writeFile(filePath, JSON.stringify({})); +} + export async function getConfig(root: string): Promise { const filePath = resolvePath(root, SHOPIFY_DIR, SHOPIFY_DIR_PROJECT);