@shopify/cli-hydrogen 4.1.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/commands/hydrogen/codegen-unstable.d.ts +14 -0
  2. package/dist/commands/hydrogen/codegen-unstable.js +64 -0
  3. package/dist/commands/hydrogen/dev.d.ts +5 -0
  4. package/dist/commands/hydrogen/dev.js +48 -6
  5. package/dist/commands/hydrogen/env/list.d.ts +19 -0
  6. package/dist/commands/hydrogen/env/list.js +96 -0
  7. package/dist/commands/hydrogen/env/list.test.d.ts +1 -0
  8. package/dist/commands/hydrogen/env/list.test.js +151 -0
  9. package/dist/commands/hydrogen/env/pull.d.ts +23 -0
  10. package/dist/commands/hydrogen/env/pull.js +68 -0
  11. package/dist/commands/hydrogen/env/pull.test.d.ts +1 -0
  12. package/dist/commands/hydrogen/env/pull.test.js +112 -0
  13. package/dist/commands/hydrogen/init.js +3 -1
  14. package/dist/commands/hydrogen/link.d.ts +24 -0
  15. package/dist/commands/hydrogen/link.js +102 -0
  16. package/dist/commands/hydrogen/link.test.d.ts +1 -0
  17. package/dist/commands/hydrogen/link.test.js +137 -0
  18. package/dist/commands/hydrogen/list.d.ts +21 -0
  19. package/dist/commands/hydrogen/list.js +83 -0
  20. package/dist/commands/hydrogen/list.test.d.ts +1 -0
  21. package/dist/commands/hydrogen/list.test.js +116 -0
  22. package/dist/commands/hydrogen/unlink.d.ts +17 -0
  23. package/dist/commands/hydrogen/unlink.js +29 -0
  24. package/dist/commands/hydrogen/unlink.test.d.ts +1 -0
  25. package/dist/commands/hydrogen/unlink.test.js +36 -0
  26. package/dist/generator-templates/routes/[robots.txt].tsx +111 -19
  27. package/dist/generator-templates/routes/collections/index.tsx +102 -0
  28. package/dist/lib/admin-session.d.ts +5 -0
  29. package/dist/lib/admin-session.js +16 -0
  30. package/dist/lib/admin-session.test.d.ts +1 -0
  31. package/dist/lib/admin-session.test.js +27 -0
  32. package/dist/lib/admin-urls.d.ts +8 -0
  33. package/dist/lib/admin-urls.js +18 -0
  34. package/dist/lib/codegen.d.ts +25 -0
  35. package/dist/lib/codegen.js +128 -0
  36. package/dist/lib/colors.d.ts +3 -0
  37. package/dist/lib/colors.js +4 -1
  38. package/dist/lib/combined-environment-variables.d.ts +8 -0
  39. package/dist/lib/combined-environment-variables.js +74 -0
  40. package/dist/lib/combined-environment-variables.test.d.ts +1 -0
  41. package/dist/lib/combined-environment-variables.test.js +111 -0
  42. package/dist/lib/flags.d.ts +2 -0
  43. package/dist/lib/flags.js +13 -0
  44. package/dist/lib/graphql/admin/link-storefront.d.ts +11 -0
  45. package/dist/lib/graphql/admin/link-storefront.js +11 -0
  46. package/dist/lib/graphql/admin/list-environments.d.ts +20 -0
  47. package/dist/lib/graphql/admin/list-environments.js +18 -0
  48. package/dist/lib/graphql/admin/list-storefronts.d.ts +17 -0
  49. package/dist/lib/graphql/admin/list-storefronts.js +16 -0
  50. package/dist/lib/graphql/admin/pull-variables.d.ts +16 -0
  51. package/dist/lib/graphql/admin/pull-variables.js +15 -0
  52. package/dist/lib/graphql.d.ts +21 -0
  53. package/dist/lib/graphql.js +18 -0
  54. package/dist/lib/graphql.test.d.ts +1 -0
  55. package/dist/lib/graphql.test.js +15 -0
  56. package/dist/lib/mini-oxygen.d.ts +4 -1
  57. package/dist/lib/mini-oxygen.js +7 -3
  58. package/dist/lib/missing-storefronts.d.ts +5 -0
  59. package/dist/lib/missing-storefronts.js +18 -0
  60. package/dist/lib/pull-environment-variables.d.ts +19 -0
  61. package/dist/lib/pull-environment-variables.js +67 -0
  62. package/dist/lib/pull-environment-variables.test.d.ts +1 -0
  63. package/dist/lib/pull-environment-variables.test.js +174 -0
  64. package/dist/lib/render-errors.d.ts +16 -0
  65. package/dist/lib/render-errors.js +37 -0
  66. package/dist/lib/shop.d.ts +7 -0
  67. package/dist/lib/shop.js +32 -0
  68. package/dist/lib/shop.test.d.ts +1 -0
  69. package/dist/lib/shop.test.js +78 -0
  70. package/dist/lib/shopify-config.d.ts +35 -0
  71. package/dist/lib/shopify-config.js +86 -0
  72. package/dist/lib/shopify-config.test.d.ts +1 -0
  73. package/dist/lib/shopify-config.test.js +209 -0
  74. package/oclif.manifest.json +1 -1
  75. package/package.json +7 -5
@@ -0,0 +1,14 @@
1
+ import * as _oclif_core_lib_interfaces_parser_js from '@oclif/core/lib/interfaces/parser.js';
2
+ import Command from '@shopify/cli-kit/node/base-command';
3
+
4
+ declare class Codegen extends Command {
5
+ static description: string;
6
+ static flags: {
7
+ path: _oclif_core_lib_interfaces_parser_js.OptionFlag<string | undefined, _oclif_core_lib_interfaces_parser_js.CustomOptions>;
8
+ "codegen-config-path": _oclif_core_lib_interfaces_parser_js.OptionFlag<string | undefined, _oclif_core_lib_interfaces_parser_js.CustomOptions>;
9
+ watch: _oclif_core_lib_interfaces_parser_js.BooleanFlag<boolean>;
10
+ };
11
+ run(): Promise<void>;
12
+ }
13
+
14
+ export { Codegen as default };
@@ -0,0 +1,64 @@
1
+ import path from 'path';
2
+ import Command from '@shopify/cli-kit/node/base-command';
3
+ import { AbortError } from '@shopify/cli-kit/node/error';
4
+ import { renderSuccess } from '@shopify/cli-kit/node/ui';
5
+ import { Flags } from '@oclif/core';
6
+ import { getProjectPaths, getRemixConfig } from '../../lib/config.js';
7
+ import { commonFlags, flagsToCamelObject } from '../../lib/flags.js';
8
+ import { patchGqlPluck, generateTypes, normalizeCodegenError } from '../../lib/codegen.js';
9
+
10
+ class Codegen extends Command {
11
+ static description = "Generate types for the Storefront API queries found in your project.";
12
+ static flags = {
13
+ path: commonFlags.path,
14
+ ["codegen-config-path"]: Flags.string({
15
+ description: "Specify a path to a codegen configuration file. Defaults to `<root>/codegen.ts` if it exists.",
16
+ required: false
17
+ }),
18
+ watch: Flags.boolean({
19
+ description: "Watch the project for changes to update types on file save.",
20
+ required: false,
21
+ default: false
22
+ })
23
+ };
24
+ async run() {
25
+ const { flags } = await this.parse(Codegen);
26
+ const directory = flags.path ? path.resolve(flags.path) : process.cwd();
27
+ await runCodegen({
28
+ ...flagsToCamelObject(flags),
29
+ path: directory
30
+ });
31
+ }
32
+ }
33
+ async function runCodegen({
34
+ path: appPath,
35
+ codegenConfigPath,
36
+ watch
37
+ }) {
38
+ const { root } = getProjectPaths(appPath);
39
+ const remixConfig = await getRemixConfig(root);
40
+ await patchGqlPluck();
41
+ try {
42
+ const generatedFiles = await generateTypes({
43
+ ...remixConfig,
44
+ configFilePath: codegenConfigPath,
45
+ watch
46
+ });
47
+ if (!watch) {
48
+ console.log("");
49
+ renderSuccess({
50
+ headline: "Generated types for GraphQL:",
51
+ body: generatedFiles.map((file) => `- ${file}`).join("\n")
52
+ });
53
+ }
54
+ } catch (error) {
55
+ const { message, details } = normalizeCodegenError(
56
+ error.message,
57
+ remixConfig.rootDirectory
58
+ );
59
+ console.log("");
60
+ throw new AbortError(message, details);
61
+ }
62
+ }
63
+
64
+ export { Codegen as default };
@@ -6,8 +6,13 @@ declare class Dev extends Command {
6
6
  static flags: {
7
7
  path: _oclif_core_lib_interfaces_parser_js.OptionFlag<string | undefined, _oclif_core_lib_interfaces_parser_js.CustomOptions>;
8
8
  port: _oclif_core_lib_interfaces_parser_js.OptionFlag<number, _oclif_core_lib_interfaces_parser_js.CustomOptions>;
9
+ "codegen-unstable": _oclif_core_lib_interfaces_parser_js.BooleanFlag<boolean>;
10
+ "codegen-config-path": _oclif_core_lib_interfaces_parser_js.OptionFlag<string | undefined, _oclif_core_lib_interfaces_parser_js.CustomOptions>;
9
11
  "disable-virtual-routes": _oclif_core_lib_interfaces_parser_js.BooleanFlag<boolean>;
12
+ shop: _oclif_core_lib_interfaces_parser_js.OptionFlag<string | undefined, _oclif_core_lib_interfaces_parser_js.CustomOptions>;
13
+ debug: _oclif_core_lib_interfaces_parser_js.BooleanFlag<boolean>;
10
14
  host: _oclif_core_lib_interfaces_parser_js.OptionFlag<unknown, _oclif_core_lib_interfaces_parser_js.CustomOptions>;
15
+ "env-branch": _oclif_core_lib_interfaces_parser_js.OptionFlag<string | undefined, _oclif_core_lib_interfaces_parser_js.CustomOptions>;
11
16
  };
12
17
  run(): Promise<void>;
13
18
  }
@@ -11,6 +11,9 @@ import { Flags } from '@oclif/core';
11
11
  import { startMiniOxygen } from '../../lib/mini-oxygen.js';
12
12
  import { checkHydrogenVersion } from '../../lib/check-version.js';
13
13
  import { addVirtualRoutes } from '../../lib/virtual-routes.js';
14
+ import { spawnCodegenProcess } from '../../lib/codegen.js';
15
+ import { combinedEnvironmentVariables } from '../../lib/combined-environment-variables.js';
16
+ import { getConfig } from '../../lib/shopify-config.js';
14
17
 
15
18
  const LOG_INITIAL_BUILD = "\n\u{1F3C1} Initial build";
16
19
  const LOG_REBUILDING = "\u{1F9F1} Rebuilding...";
@@ -20,27 +23,55 @@ class Dev extends Command {
20
23
  static flags = {
21
24
  path: commonFlags.path,
22
25
  port: commonFlags.port,
26
+ ["codegen-unstable"]: Flags.boolean({
27
+ description: "Generate types for the Storefront API queries found in your project. It updates the types on file save.",
28
+ required: false,
29
+ default: false
30
+ }),
31
+ ["codegen-config-path"]: Flags.string({
32
+ description: "Specify a path to a codegen configuration file. Defaults to `<root>/codegen.ts` if it exists.",
33
+ required: false,
34
+ dependsOn: ["codegen-unstable"]
35
+ }),
23
36
  ["disable-virtual-routes"]: Flags.boolean({
24
- description: "Disable rendering fallback routes when a route file doesn't exist",
37
+ description: "Disable rendering fallback routes when a route file doesn't exist.",
25
38
  env: "SHOPIFY_HYDROGEN_FLAG_DISABLE_VIRTUAL_ROUTES",
26
39
  default: false
27
40
  }),
28
- host: deprecated("--host")()
41
+ shop: commonFlags.shop,
42
+ debug: Flags.boolean({
43
+ description: "Attaches a Node inspector",
44
+ env: "SHOPIFY_HYDROGEN_FLAG_DEBUG",
45
+ default: false
46
+ }),
47
+ host: deprecated("--host")(),
48
+ ["env-branch"]: commonFlags["env-branch"]
29
49
  };
30
50
  async run() {
31
51
  const { flags } = await this.parse(Dev);
32
52
  const directory = flags.path ? path.resolve(flags.path) : process.cwd();
33
- await runDev({ ...flagsToCamelObject(flags), path: directory });
53
+ await runDev({
54
+ ...flagsToCamelObject(flags),
55
+ codegen: flags["codegen-unstable"],
56
+ path: directory
57
+ });
34
58
  }
35
59
  }
36
60
  async function runDev({
37
61
  port,
38
62
  path: appPath,
39
- disableVirtualRoutes
63
+ codegen = false,
64
+ codegenConfigPath,
65
+ disableVirtualRoutes,
66
+ shop,
67
+ envBranch,
68
+ debug = false
40
69
  }) {
41
70
  if (!process.env.NODE_ENV)
42
71
  process.env.NODE_ENV = "development";
43
72
  muteDevLogs();
73
+ if (debug)
74
+ (await import('node:inspector')).open();
44
75
  console.time(LOG_INITIAL_BUILD);
45
76
  const { root, publicPath, buildPathClient, buildPathWorkerFile } = getProjectPaths(appPath);
46
77
  const checkingHydrogenVersion = checkHydrogenVersion(root);
@@ -54,6 +85,12 @@ async function runDev({
54
85
  return [fileRelative, path.resolve(root, fileRelative)];
55
86
  };
56
87
  const serverBundleExists = () => fileExists(buildPathWorkerFile);
88
+ const hasLinkedStorefront = !!(await getConfig(root))?.storefront?.id;
89
+ const environmentVariables = hasLinkedStorefront ? await combinedEnvironmentVariables({
90
+ root,
91
+ shop,
92
+ envBranch
93
+ }) : void 0;
57
94
  let miniOxygenStarted = false;
58
95
  async function safeStartMiniOxygen() {
59
96
  if (miniOxygenStarted)
@@ -63,7 +100,8 @@ async function runDev({
63
100
  port,
64
101
  watch: true,
65
102
  buildPathWorkerFile,
66
- buildPathClient
103
+ buildPathClient,
104
+ environmentVariables
67
105
  });
68
106
  miniOxygenStarted = true;
69
107
  const showUpgrade = await checkingHydrogenVersion;
@@ -71,7 +109,11 @@ async function runDev({
71
109
  showUpgrade();
72
110
  }
73
111
  const { watch } = await import('@remix-run/dev/dist/compiler/watch.js');
74
- await watch(await reloadConfig(), {
112
+ const remixConfig = await reloadConfig();
113
+ if (codegen) {
114
+ spawnCodegenProcess({ ...remixConfig, configFilePath: codegenConfigPath });
115
+ }
116
+ await watch(remixConfig, {
75
117
  reloadConfig,
76
118
  mode: process.env.NODE_ENV,
77
119
  async onInitialBuild() {
@@ -0,0 +1,19 @@
1
+ import * as _oclif_core_lib_interfaces_parser_js from '@oclif/core/lib/interfaces/parser.js';
2
+ import Command from '@shopify/cli-kit/node/base-command';
3
+
4
+ declare class List extends Command {
5
+ static description: string;
6
+ static hidden: boolean;
7
+ static flags: {
8
+ path: _oclif_core_lib_interfaces_parser_js.OptionFlag<string | undefined, _oclif_core_lib_interfaces_parser_js.CustomOptions>;
9
+ shop: _oclif_core_lib_interfaces_parser_js.OptionFlag<string | undefined, _oclif_core_lib_interfaces_parser_js.CustomOptions>;
10
+ };
11
+ run(): Promise<void>;
12
+ }
13
+ interface Flags {
14
+ path?: string;
15
+ shop?: string;
16
+ }
17
+ declare function listEnvironments({ path, shop: flagShop }: Flags): Promise<void>;
18
+
19
+ export { List as default, listEnvironments };
@@ -0,0 +1,96 @@
1
+ import Command from '@shopify/cli-kit/node/base-command';
2
+ import { renderConfirmationPrompt, renderTable } from '@shopify/cli-kit/node/ui';
3
+ import { outputContent, outputToken, outputNewline } from '@shopify/cli-kit/node/output';
4
+ import { linkStorefront } from '../link.js';
5
+ import { adminRequest } from '../../../lib/graphql.js';
6
+ import { commonFlags } from '../../../lib/flags.js';
7
+ import { getHydrogenShop } from '../../../lib/shop.js';
8
+ import { getAdminSession } from '../../../lib/admin-session.js';
9
+ import { ListEnvironmentsQuery } from '../../../lib/graphql/admin/list-environments.js';
10
+ import { getConfig } from '../../../lib/shopify-config.js';
11
+ import { renderMissingLink, renderMissingStorefront } from '../../../lib/render-errors.js';
12
+
13
+ class List extends Command {
14
+ static description = "List the environments on your Hydrogen storefront.";
15
+ static hidden = true;
16
+ static flags = {
17
+ path: commonFlags.path,
18
+ shop: commonFlags.shop
19
+ };
20
+ async run() {
21
+ const { flags } = await this.parse(List);
22
+ await listEnvironments(flags);
23
+ }
24
+ }
25
+ async function listEnvironments({ path, shop: flagShop }) {
26
+ const shop = await getHydrogenShop({ path, shop: flagShop });
27
+ const adminSession = await getAdminSession(shop);
28
+ const actualPath = path ?? process.cwd();
29
+ let configStorefront = (await getConfig(actualPath)).storefront;
30
+ if (!configStorefront?.id) {
31
+ renderMissingLink({ adminSession });
32
+ const runLink = await renderConfirmationPrompt({
33
+ message: outputContent`Run ${outputToken.genericShellCommand(
34
+ `npx shopify hydrogen link`
35
+ )}?`.value
36
+ });
37
+ if (!runLink) {
38
+ return;
39
+ }
40
+ await linkStorefront({ path, shop: flagShop, silent: true });
41
+ }
42
+ configStorefront = (await getConfig(actualPath)).storefront;
43
+ if (!configStorefront) {
44
+ return;
45
+ }
46
+ const result = await adminRequest(
47
+ ListEnvironmentsQuery,
48
+ adminSession,
49
+ {
50
+ id: configStorefront.id
51
+ }
52
+ );
53
+ const hydrogenStorefront = result.hydrogenStorefront;
54
+ if (!hydrogenStorefront) {
55
+ renderMissingStorefront({ adminSession, storefront: configStorefront });
56
+ return;
57
+ }
58
+ const previewEnvironmentIndex = hydrogenStorefront.environments.findIndex(
59
+ (env) => env.type === "PREVIEW"
60
+ );
61
+ const previewEnvironment = hydrogenStorefront.environments.splice(
62
+ previewEnvironmentIndex,
63
+ 1
64
+ );
65
+ hydrogenStorefront.environments.push(previewEnvironment[0]);
66
+ const rows = hydrogenStorefront.environments.map(
67
+ ({ branch, name, url, type }) => {
68
+ const environmentUrl = type === "PRODUCTION" ? hydrogenStorefront.productionUrl : url;
69
+ return {
70
+ name,
71
+ branch: branch ? branch : "-",
72
+ url: environmentUrl ? environmentUrl : "-"
73
+ };
74
+ }
75
+ );
76
+ outputNewline();
77
+ renderTable({
78
+ rows,
79
+ columns: {
80
+ name: {
81
+ header: "Name",
82
+ color: "whiteBright"
83
+ },
84
+ branch: {
85
+ header: "Branch",
86
+ color: "yellow"
87
+ },
88
+ url: {
89
+ header: "URL",
90
+ color: "green"
91
+ }
92
+ }
93
+ });
94
+ }
95
+
96
+ export { List as default, listEnvironments };
@@ -0,0 +1,151 @@
1
+ import { vi, describe, beforeEach, afterEach, it, expect } from 'vitest';
2
+ import { mockAndCaptureOutput } from '@shopify/cli-kit/node/testing/output';
3
+ import { inTemporaryDirectory } from '@shopify/cli-kit/node/fs';
4
+ import { renderConfirmationPrompt } from '@shopify/cli-kit/node/ui';
5
+ import { ListEnvironmentsQuery } from '../../../lib/graphql/admin/list-environments.js';
6
+ import { getAdminSession } from '../../../lib/admin-session.js';
7
+ import { adminRequest } from '../../../lib/graphql.js';
8
+ import { getConfig } from '../../../lib/shopify-config.js';
9
+ import { renderMissingLink, renderMissingStorefront } from '../../../lib/render-errors.js';
10
+ import { linkStorefront } from '../link.js';
11
+ import { listEnvironments } from './list.js';
12
+
13
+ vi.mock("@shopify/cli-kit/node/ui", async () => {
14
+ const original = await vi.importActual("@shopify/cli-kit/node/ui");
15
+ return {
16
+ ...original,
17
+ renderConfirmationPrompt: vi.fn()
18
+ };
19
+ });
20
+ vi.mock("../link.js");
21
+ vi.mock("../../../lib/admin-session.js");
22
+ vi.mock("../../../lib/shopify-config.js");
23
+ vi.mock("../../../lib/render-errors.js");
24
+ vi.mock("../../../lib/graphql.js", async () => {
25
+ const original = await vi.importActual("../../../lib/graphql.js");
26
+ return {
27
+ ...original,
28
+ adminRequest: vi.fn()
29
+ };
30
+ });
31
+ vi.mock("../../../lib/shop.js", () => ({
32
+ getHydrogenShop: () => "my-shop"
33
+ }));
34
+ describe("listEnvironments", () => {
35
+ const ADMIN_SESSION = {
36
+ token: "abc123",
37
+ storeFqdn: "my-shop"
38
+ };
39
+ const PRODUCTION_ENVIRONMENT = {
40
+ id: "gid://shopify/HydrogenStorefrontEnvironment/1",
41
+ branch: "main",
42
+ type: "PRODUCTION",
43
+ name: "Production",
44
+ createdAt: "2023-02-16T22:35:42Z",
45
+ url: "https://oxygen-123.example.com"
46
+ };
47
+ const CUSTOM_ENVIRONMENT = {
48
+ id: "gid://shopify/HydrogenStorefrontEnvironment/3",
49
+ branch: "staging",
50
+ type: "CUSTOM",
51
+ name: "Staging",
52
+ createdAt: "2023-05-08T20:52:29Z",
53
+ url: "https://oxygen-456.example.com"
54
+ };
55
+ const PREVIEW_ENVIRONMENT = {
56
+ id: "gid://shopify/HydrogenStorefrontEnvironment/2",
57
+ branch: null,
58
+ type: "PREVIEW",
59
+ name: "Preview",
60
+ createdAt: "2023-02-16T22:35:42Z",
61
+ url: null
62
+ };
63
+ beforeEach(async () => {
64
+ vi.mocked(getAdminSession).mockResolvedValue(ADMIN_SESSION);
65
+ vi.mocked(getConfig).mockResolvedValue({
66
+ storefront: {
67
+ id: "gid://shopify/HydrogenStorefront/1",
68
+ title: "Existing Link"
69
+ }
70
+ });
71
+ vi.mocked(adminRequest).mockResolvedValue({
72
+ hydrogenStorefront: {
73
+ id: "gid://shopify/HydrogenStorefront/1",
74
+ productionUrl: "https://example.com",
75
+ environments: [
76
+ PRODUCTION_ENVIRONMENT,
77
+ CUSTOM_ENVIRONMENT,
78
+ PREVIEW_ENVIRONMENT
79
+ ]
80
+ }
81
+ });
82
+ });
83
+ afterEach(() => {
84
+ vi.resetAllMocks();
85
+ mockAndCaptureOutput().clear();
86
+ });
87
+ it("makes a GraphQL call to fetch environment variables", async () => {
88
+ await inTemporaryDirectory(async (tmpDir) => {
89
+ await listEnvironments({ path: tmpDir });
90
+ expect(adminRequest).toHaveBeenCalledWith(
91
+ ListEnvironmentsQuery,
92
+ ADMIN_SESSION,
93
+ {
94
+ id: "gid://shopify/HydrogenStorefront/1"
95
+ }
96
+ );
97
+ });
98
+ });
99
+ it("lists the environments", async () => {
100
+ await inTemporaryDirectory(async (tmpDir) => {
101
+ const output = mockAndCaptureOutput();
102
+ await listEnvironments({ path: tmpDir });
103
+ expect(output.info()).toMatch(
104
+ /Production\s*main\s*https:\/\/example\.com/
105
+ );
106
+ expect(output.info()).toMatch(
107
+ /Staging\s*staging\s*https:\/\/oxygen-456\.example\.com/
108
+ );
109
+ expect(output.info()).toMatch(/Preview\s*-\s*-/);
110
+ });
111
+ });
112
+ describe("when there is no linked storefront", () => {
113
+ beforeEach(() => {
114
+ vi.mocked(getConfig).mockResolvedValue({
115
+ storefront: void 0
116
+ });
117
+ });
118
+ it("calls renderMissingLink", async () => {
119
+ await inTemporaryDirectory(async (tmpDir) => {
120
+ await listEnvironments({ path: tmpDir });
121
+ expect(renderMissingLink).toHaveBeenCalledOnce();
122
+ });
123
+ });
124
+ it("prompts the user to create a link", async () => {
125
+ vi.mocked(renderConfirmationPrompt).mockResolvedValue(true);
126
+ await inTemporaryDirectory(async (tmpDir) => {
127
+ await listEnvironments({ path: tmpDir });
128
+ expect(renderConfirmationPrompt).toHaveBeenCalledWith({
129
+ message: expect.stringMatching(/Run .*npx shopify hydrogen link.*\?/)
130
+ });
131
+ expect(linkStorefront).toHaveBeenCalledWith({
132
+ path: tmpDir,
133
+ silent: true
134
+ });
135
+ });
136
+ });
137
+ });
138
+ describe("when there is no matching storefront in the shop", () => {
139
+ beforeEach(() => {
140
+ vi.mocked(adminRequest).mockResolvedValue({
141
+ hydrogenStorefront: null
142
+ });
143
+ });
144
+ it("calls renderMissingStorefront", async () => {
145
+ await inTemporaryDirectory(async (tmpDir) => {
146
+ await listEnvironments({ path: tmpDir });
147
+ expect(renderMissingStorefront).toHaveBeenCalledOnce();
148
+ });
149
+ });
150
+ });
151
+ });
@@ -0,0 +1,23 @@
1
+ import * as _oclif_core_lib_interfaces_parser_js from '@oclif/core/lib/interfaces/parser.js';
2
+ import Command from '@shopify/cli-kit/node/base-command';
3
+
4
+ declare class Pull extends Command {
5
+ static description: string;
6
+ static hidden: boolean;
7
+ static flags: {
8
+ "env-branch": _oclif_core_lib_interfaces_parser_js.OptionFlag<string | undefined, _oclif_core_lib_interfaces_parser_js.CustomOptions>;
9
+ path: _oclif_core_lib_interfaces_parser_js.OptionFlag<string | undefined, _oclif_core_lib_interfaces_parser_js.CustomOptions>;
10
+ shop: _oclif_core_lib_interfaces_parser_js.OptionFlag<string | undefined, _oclif_core_lib_interfaces_parser_js.CustomOptions>;
11
+ force: _oclif_core_lib_interfaces_parser_js.BooleanFlag<boolean>;
12
+ };
13
+ run(): Promise<void>;
14
+ }
15
+ interface Flags {
16
+ envBranch?: string;
17
+ force?: boolean;
18
+ path?: string;
19
+ shop?: string;
20
+ }
21
+ declare function pullVariables({ envBranch, force, path, shop: flagShop, }: Flags): Promise<void>;
22
+
23
+ export { Pull as default, pullVariables };
@@ -0,0 +1,68 @@
1
+ import Command from '@shopify/cli-kit/node/base-command';
2
+ import { renderConfirmationPrompt } from '@shopify/cli-kit/node/ui';
3
+ import { outputWarn, outputSuccess } from '@shopify/cli-kit/node/output';
4
+ import { fileExists, writeFile } from '@shopify/cli-kit/node/fs';
5
+ import { resolvePath } from '@shopify/cli-kit/node/path';
6
+ import { commonFlags, flagsToCamelObject } from '../../../lib/flags.js';
7
+ import { pullRemoteEnvironmentVariables } from '../../../lib/pull-environment-variables.js';
8
+ import { getConfig } from '../../../lib/shopify-config.js';
9
+
10
+ class Pull extends Command {
11
+ static description = "Populate your .env with variables from your Hydrogen storefront.";
12
+ static hidden = true;
13
+ static flags = {
14
+ ["env-branch"]: commonFlags["env-branch"],
15
+ path: commonFlags.path,
16
+ shop: commonFlags.shop,
17
+ force: commonFlags.force
18
+ };
19
+ async run() {
20
+ const { flags } = await this.parse(Pull);
21
+ await pullVariables({ ...flagsToCamelObject(flags) });
22
+ }
23
+ }
24
+ async function pullVariables({
25
+ envBranch,
26
+ force,
27
+ path,
28
+ shop: flagShop
29
+ }) {
30
+ const actualPath = path ?? process.cwd();
31
+ const environmentVariables = await pullRemoteEnvironmentVariables({
32
+ root: actualPath,
33
+ flagShop,
34
+ envBranch
35
+ });
36
+ if (!environmentVariables.length) {
37
+ return;
38
+ }
39
+ const dotEnvPath = resolvePath(actualPath, ".env");
40
+ if (await fileExists(dotEnvPath) && !force) {
41
+ const overwrite = await renderConfirmationPrompt({
42
+ message: "Warning: .env file already exists. Do you want to overwrite it?"
43
+ });
44
+ if (!overwrite) {
45
+ return;
46
+ }
47
+ }
48
+ let hasSecretVariables = false;
49
+ const contents = environmentVariables.map(({ key, value, isSecret }) => {
50
+ let line = `${key}="${value}"`;
51
+ if (isSecret) {
52
+ hasSecretVariables = true;
53
+ line = `# ${key} is marked as secret and its value is hidden
54
+ ` + line;
55
+ }
56
+ return line;
57
+ }).join("\n") + "\n";
58
+ if (hasSecretVariables) {
59
+ const { storefront: configStorefront } = await getConfig(actualPath);
60
+ outputWarn(
61
+ `${configStorefront.title} contains environment variables marked as secret, so their values weren\u2019t pulled.`
62
+ );
63
+ }
64
+ await writeFile(dotEnvPath, contents);
65
+ outputSuccess("Updated .env");
66
+ }
67
+
68
+ export { Pull as default, pullVariables };
@@ -0,0 +1,112 @@
1
+ import { vi, describe, beforeEach, afterEach, it, expect } from 'vitest';
2
+ import { mockAndCaptureOutput } from '@shopify/cli-kit/node/testing/output';
3
+ import { inTemporaryDirectory, fileExists, readFile, writeFile } from '@shopify/cli-kit/node/fs';
4
+ import { joinPath } from '@shopify/cli-kit/node/path';
5
+ import { renderConfirmationPrompt } from '@shopify/cli-kit/node/ui';
6
+ import { getAdminSession } from '../../../lib/admin-session.js';
7
+ import { pullRemoteEnvironmentVariables } from '../../../lib/pull-environment-variables.js';
8
+ import { getConfig } from '../../../lib/shopify-config.js';
9
+ import { pullVariables } from './pull.js';
10
+
11
+ vi.mock("@shopify/cli-kit/node/ui", async () => {
12
+ const original = await vi.importActual("@shopify/cli-kit/node/ui");
13
+ return {
14
+ ...original,
15
+ renderConfirmationPrompt: vi.fn()
16
+ };
17
+ });
18
+ vi.mock("../link.js");
19
+ vi.mock("../../../lib/admin-session.js");
20
+ vi.mock("../../../lib/shopify-config.js");
21
+ vi.mock("../../../lib/pull-environment-variables.js");
22
+ vi.mock("../../../lib/shop.js", () => ({
23
+ getHydrogenShop: () => "my-shop"
24
+ }));
25
+ describe("pullVariables", () => {
26
+ const ADMIN_SESSION = {
27
+ token: "abc123",
28
+ storeFqdn: "my-shop"
29
+ };
30
+ beforeEach(async () => {
31
+ vi.mocked(getAdminSession).mockResolvedValue(ADMIN_SESSION);
32
+ vi.mocked(getConfig).mockResolvedValue({
33
+ storefront: {
34
+ id: "gid://shopify/HydrogenStorefront/2",
35
+ title: "Existing Link"
36
+ }
37
+ });
38
+ vi.mocked(pullRemoteEnvironmentVariables).mockResolvedValue([
39
+ {
40
+ id: "gid://shopify/HydrogenStorefrontEnvironmentVariable/1",
41
+ key: "PUBLIC_API_TOKEN",
42
+ value: "abc123",
43
+ isSecret: false
44
+ },
45
+ {
46
+ id: "gid://shopify/HydrogenStorefrontEnvironmentVariable/2",
47
+ key: "PRIVATE_API_TOKEN",
48
+ value: "",
49
+ isSecret: true
50
+ }
51
+ ]);
52
+ });
53
+ afterEach(() => {
54
+ vi.resetAllMocks();
55
+ mockAndCaptureOutput().clear();
56
+ });
57
+ it("calls pullRemoteEnvironmentVariables", async () => {
58
+ await inTemporaryDirectory(async (tmpDir) => {
59
+ await pullVariables({ path: tmpDir, envBranch: "staging" });
60
+ expect(pullRemoteEnvironmentVariables).toHaveBeenCalledWith({
61
+ root: tmpDir,
62
+ envBranch: "staging"
63
+ });
64
+ });
65
+ });
66
+ it("writes environment variables to a .env file", async () => {
67
+ await inTemporaryDirectory(async (tmpDir) => {
68
+ const filePath = joinPath(tmpDir, ".env");
69
+ expect(await fileExists(filePath)).toBeFalsy();
70
+ await pullVariables({ path: tmpDir });
71
+ expect(await readFile(filePath)).toStrictEqual(
72
+ 'PUBLIC_API_TOKEN="abc123"\n# PRIVATE_API_TOKEN is marked as secret and its value is hidden\nPRIVATE_API_TOKEN=""\n'
73
+ );
74
+ });
75
+ });
76
+ it("warns about secret environment variables", async () => {
77
+ await inTemporaryDirectory(async (tmpDir) => {
78
+ const outputMock = mockAndCaptureOutput();
79
+ await pullVariables({ path: tmpDir });
80
+ expect(outputMock.warn()).toStrictEqual(
81
+ "Existing Link contains environment variables marked as secret, so their values weren\u2019t pulled."
82
+ );
83
+ });
84
+ });
85
+ describe("when a .env file already exists", () => {
86
+ beforeEach(() => {
87
+ vi.mocked(renderConfirmationPrompt).mockResolvedValue(true);
88
+ });
89
+ it("prompts the user to confirm", async () => {
90
+ await inTemporaryDirectory(async (tmpDir) => {
91
+ const filePath = joinPath(tmpDir, ".env");
92
+ await writeFile(filePath, "EXISTING_TOKEN=1");
93
+ await pullVariables({ path: tmpDir });
94
+ expect(renderConfirmationPrompt).toHaveBeenCalledWith({
95
+ message: expect.stringMatching(
96
+ /Warning: \.env file already exists\. Do you want to overwrite it\?/
97
+ )
98
+ });
99
+ });
100
+ });
101
+ describe("and --force is enabled", () => {
102
+ it("does not prompt the user to confirm", async () => {
103
+ await inTemporaryDirectory(async (tmpDir) => {
104
+ const filePath = joinPath(tmpDir, ".env");
105
+ await writeFile(filePath, "EXISTING_TOKEN=1");
106
+ await pullVariables({ path: tmpDir, force: true });
107
+ expect(renderConfirmationPrompt).not.toHaveBeenCalled();
108
+ });
109
+ });
110
+ });
111
+ });
112
+ });