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);